Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add accessibility controls #43

Merged
merged 4 commits into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/contentful/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Renders Contentful images and videos into a container. Features:

- Automatically defines a loader functions for generating srcsets
- Supports responsive image and video assets
- Adds play/pause toggle for videos for [ADA compliance](https://www.w3.org/WAI/WCAG21/Understanding/pause-stop-hide.html)

## Install

Expand Down Expand Up @@ -112,13 +113,18 @@ For more examples, read [the Cypress component tests](./cypress/component).
| Prop | Type | Description
| -- | -- | --
| `paused` | `boolean` | Disables autoplay of videos. This prop is reactive, unlike the `paused` property of the html `<video>` tag. You can set it to `true` to pause a playing video or set it to `false` to play a paused video.

| `onPause` | `Function` | Invoked whenever the video fires a [pause event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/pause_event).
| `onPlay` | `Function` | Invoked whenever the video fires a [play event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play_event).
| `playIcon` | `ComponentType` | Replace the play icon used with accessibility controls.
| `pauseIcon` | `ComponentType` | Replace the pause icon used with accessibility controls.

### Accessibility

| Prop | Type | Description
| -- | -- | --
| `alt` | `string` | Sets the alt attribute or aria-label value, depending on asset type.
| `alt` | `string` | Sets the alt attribute or aria-label value, depending on asset type.
| `hideAccessibilityControls` | `boolean` | Removes the play/pause toggle on videos.
| `accessibilityControlsPosition` | [`PositionOption`](https://github.com/BKWLD/react-visual/blob/eaf2d150efa1187033ba732a350a4db20f260435/packages/react/src/types/reactVisualTypes.ts#L61-L70) | Controls the position of the accessibility controls. Defaults to `bottom left`.

### Theming

Expand Down
12 changes: 9 additions & 3 deletions packages/next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Renders images and videos into a container. Features:
- Uses `next/image` to render images
- Easily render assets using aspect ratios
- Videos are lazyloaded (unless `priority` flag is set)
- Adds play/pause toggle for videos for [ADA compliance](https://www.w3.org/WAI/WCAG21/Understanding/pause-stop-hide.html)

## Install

Expand Down Expand Up @@ -60,20 +61,25 @@ For more examples, read [the Cypress component tests](./cypress/component).
| -- | -- | --
| `priority` | `boolean` | Sets [`next/image`'s `priority`](https://nextjs.org/docs/pages/api-reference/components/image#priority) and videos to not lazy load.
| `sizes` | `string` | Sets [`next/image`'s `sizes`](https://nextjs.org/docs/pages/api-reference/components/image#sizes) prop.
| `imageLoader` | `Function` | This is passed through [to `next/image`'s `loader` prop](https://nextjs.org/docs/app/api-reference/components/image#loader).
| `imageLoader` | [`ImageLoader`](https://github.com/BKWLD/react-visual/blob/eaf2d150efa1187033ba732a350a4db20f260435/packages/react/src/types/reactVisualTypes.ts#L38-L44) | This is passed through [to `next/image`'s `loader` prop](https://nextjs.org/docs/app/api-reference/components/image#loader).

### Video

| Prop | Type | Description
| -- | -- | --
| `paused` | `boolean` | Disables autoplay of videos. This prop is reactive, unlike the `paused` property of the html `<video>` tag. You can set it to `true` to pause a playing video or set it to `false` to play a paused video.

| `onPause` | `Function` | Invoked whenever the video fires a [pause event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/pause_event).
| `onPlay` | `Function` | Invoked whenever the video fires a [play event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play_event).
| `playIcon` | `ComponentType` | Replace the play icon used with accessibility controls.
| `pauseIcon` | `ComponentType` | Replace the pause icon used with accessibility controls.

### Accessibility

| Prop | Type | Description
| -- | -- | --
| `alt` | `string` | Sets the alt attribute or aria-label value, depending on asset type.
| `alt` | `string` | Sets the alt attribute or aria-label value, depending on asset type.
| `hideAccessibilityControls` | `boolean` | Removes the play/pause toggle on videos.
| `accessibilityControlsPosition` | [`PositionOption`](https://github.com/BKWLD/react-visual/blob/eaf2d150efa1187033ba732a350a4db20f260435/packages/react/src/types/reactVisualTypes.ts#L61-L70) | Controls the position of the accessibility controls. Defaults to `bottom left`.

### Theming

Expand Down
20 changes: 13 additions & 7 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Renders images and videos into a container. Features:
- Creates `<source>` tags for different MIME types and media queries
- Easily render assets using aspect ratios
- Videos are lazyloaded (unless `priority` flag is set)
- Adds play/pause toggle for videos for [ADA compliance](https://www.w3.org/WAI/WCAG21/Understanding/pause-stop-hide.html)

## Install

Expand Down Expand Up @@ -179,7 +180,7 @@ For more examples, read [the Cypress component tests](./cypress/component).
| Prop | Type | Description
| -- | -- | --
| `expand` | `boolean` | Make the Visual fill it's container via CSS using absolute positioning.
| `aspect` | `number`, `function` | Force the Visual to a specific aspect ratio.
| `aspect` | `number`, [`AspectCalculator`](https://github.com/BKWLD/react-visual/blob/eaf2d150efa1187033ba732a350a4db20f260435/packages/react/src/types/reactVisualTypes.ts#L52-L57) | Force the Visual to a specific aspect ratio.
| `width` | `number`, `string` | A CSS dimension value or a px number.
| `height` | `number`, `string` | A CSS dimension value or a px number.
| `fit` | `string` | An [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) value that is applied to the assets. Defaults to `cover`.
Expand All @@ -191,23 +192,28 @@ For more examples, read [the Cypress component tests](./cypress/component).
| -- | -- | --
| `priority` | `boolean` | Disables [`<img loading="lazy>"`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#loading) and prevents videos from lazy loading based on IntersectionObserver.
| `sizes` | `string` | Sets the `<img sizes>` attribute.
| `sourceTypes` | `string[]` | Specify image MIME types that will be passed to the `imageLoader` and used to create additional `<source>` tags. Use this to create `webp` or `avif` sources with a CDN like Contentful.
| `sourceMedia` | `string[]` | Specify media queries that will be passed to the `imageLoader` and used to create additional `<source>` tags.
| `imageLoader` | `Function` | Uses syntax that is similar [to `next/image`'s `loader` prop](https://nextjs.org/docs/app/api-reference/components/image#loader). A srcset is built with a hardcoded list of widths.
| `videoLoader` | `Function` | Like `imageLoader` but is only passed the `src` and `media` properties.
| `sourceTypes` | [`SourceType[]`](https://github.com/BKWLD/react-visual/blob/eaf2d150efa1187033ba732a350a4db20f260435/packages/react/src/types/reactVisualTypes.ts#L72-L78) | Specify image MIME types that will be passed to the `imageLoader` and used to create additional `<source>` tags. Use this to create `webp` or `avif` sources with a CDN like Contentful.
| `sourceMedia` | [`SourceType[]`](https://github.com/BKWLD/react-visual/blob/eaf2d150efa1187033ba732a350a4db20f260435/packages/react/src/types/reactVisualTypes.ts#L80-L83) | Specify media queries that will be passed to the `imageLoader` and used to create additional `<source>` tags.
| `imageLoader` | [`ImageLoader`](https://github.com/BKWLD/react-visual/blob/eaf2d150efa1187033ba732a350a4db20f260435/packages/react/src/types/reactVisualTypes.ts#L38-L44) | Uses syntax that is similar [to `next/image`'s `loader` prop](https://nextjs.org/docs/app/api-reference/components/image#loader). A srcset is built with a hardcoded list of widths.
| `videoLoader` | [`VideoLoader`](https://github.com/BKWLD/react-visual/blob/eaf2d150efa1187033ba732a350a4db20f260435/packages/react/src/types/reactVisualTypes.ts#L46-L50) | Like `imageLoader` but is only passed the `src` and `media` properties.

### Video

| Prop | Type | Description
| -- | -- | --
| `paused` | `boolean` | Disables autoplay of videos. This prop is reactive, unlike the `paused` property of the html `<video>` tag. You can set it to `true` to pause a playing video or set it to `false` to play a paused video.

| `onPause` | `Function` | Invoked whenever the video fires a [pause event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/pause_event).
| `onPlay` | `Function` | Invoked whenever the video fires a [play event](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play_event).
| `playIcon` | `ComponentType` | Replace the play icon used with accessibility controls.
| `pauseIcon` | `ComponentType` | Replace the pause icon used with accessibility controls.

### Accessibility

| Prop | Type | Description
| -- | -- | --
| `alt` | `string` | Sets the alt attribute or aria-label value, depending on asset type.
| `alt` | `string` | Sets the alt attribute or aria-label value, depending on asset type.
| `hideAccessibilityControls` | `boolean` | Removes the play/pause toggle on videos.
| `accessibilityControlsPosition` | [`PositionOption`](https://github.com/BKWLD/react-visual/blob/eaf2d150efa1187033ba732a350a4db20f260435/packages/react/src/types/reactVisualTypes.ts#L61-L70) | Controls the position of the accessibility controls. Defaults to `bottom left`.

### Theming

Expand Down
75 changes: 75 additions & 0 deletions packages/react/cypress/component/LazyVideo.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,78 @@ describe('responsive video', () => {
})

})

describe('Accessibility controls', () => {

it('renders ada controls by default', () => {
cy.mount(
<LazyVideo
src="https://placehold.co/300x200.mp4"
alt="Accessibility controls test"
/>
);
cy.get("button").should("have.css", "bottom");
cy.get("button").and("have.css", "left");
})

it('controls affect playback', () => {

const onPauseSpy = cy.spy().as("onPauseSpy")
const onPlaySpy = cy.spy().as("onPlaySpy");

cy.mount(
<LazyVideo
src="https://placehold.co/300x200.mp4"
alt="Accessibility controls test"
onPause={onPauseSpy}
onPlay={onPlaySpy}
/>
);

cy.get("video").isPlaying();
cy.get("[aria-label=Pause]").click();
cy.get("video").isPaused();
cy.get("[aria-label=Play]").click();
cy.get("video").isPlaying(); // The second time

cy.get("@onPauseSpy").should("have.been.calledOnce");
cy.get("@onPlaySpy").should("have.been.calledTwice");

})

it("allows a different position to be set", () => {
cy.mount(
<LazyVideo
src="https://placehold.co/300x200.mp4"
alt="Accessibility controls test"
accessibilityControlsPosition='top right'
/>
);
cy.get("[aria-label=Pause]").should("have.css", "top")
cy.get("[aria-label=Pause]").and("have.css", "right");
});

it('allows the controls to be hidden', () => {
cy.mount(
<LazyVideo
src="https://placehold.co/300x200.mp4"
alt="Accessibility controls test"
hideAccessibilityControls
/>
);
cy.get("[aria-label=Pause]").should("not.exist");
})

it('can have custom icons', () => {
cy.mount(
<LazyVideo
src="https://placehold.co/300x200.mp4"
alt="Accessibility controls test"
playIcon={() => <span>Play</span>}
pauseIcon={() => <span>Pause</span>}
/>
);
cy.get("[aria-label=Pause]").contains("Pause");
})

})
151 changes: 151 additions & 0 deletions packages/react/src/LazyVideo/AccessibilityControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { LazyVideoProps } from '../types/lazyVideoTypes'
import {
CSSProperties,
type ReactElement,
} from 'react';
import { PositionOption } from '../types/reactVisualTypes'

// How big to make the button. Can't be too small and still be ADA friendly
// https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html
const minAccessibleBtnSize = 24

// How far from the edge to position the button
const positionGutter = '1em'

type AccessibilityControlsProps = Pick<
LazyVideoProps,
| "playIcon"
| "pauseIcon"
| "hideAccessibilityControls"
| "accessibilityControlsPosition"
> & {
isVideoPaused: boolean;
play: () => void;
pause: () => void;
};

// Adds a simple pause/play UI for accessibility use cases
export default function AccessibilityControls({
play,
pause,
isVideoPaused,
playIcon,
pauseIcon,
hideAccessibilityControls,
accessibilityControlsPosition,
}: AccessibilityControlsProps): ReactElement | null {
// If hidden, return nothing
if (hideAccessibilityControls) return null;

// Determine the icon to display
const Icon = isVideoPaused ? playIcon || PlayIcon : pauseIcon || PauseIcon;

return (
<button
onClick={isVideoPaused ? play : pause}
aria-pressed={!isVideoPaused}
aria-label={isVideoPaused ? "Play" : "Pause"}
style={{
// Clear default sizes
appearance: "none",
border: "none",
lineHeight: 0,
padding: 0,

// Make it look clickable
cursor: "pointer",

// Position the button
position: "absolute",
...makePosition(accessibilityControlsPosition),
}}
>
<Icon />
</button>
);
}

// Make the styles for positioning the button
function makePosition(position: PositionOption = 'bottom left'): CSSProperties {
switch (position) {
case 'center':
return {
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
};
case 'left':
return {
top: "50%",
left: positionGutter,
transform: "translate(0, -50%)",
};
case 'top left':
return { top: positionGutter, left: positionGutter };
case 'top':
return {
top: positionGutter,
left: "50%",
transform: "translate(-50%, 0)",
};
case 'top right':
return { top: positionGutter, right: positionGutter };
case 'right':
return {
top: "50%",
right: positionGutter,
transform: "translate(0, -50%)",
};
case 'bottom right':
return { bottom: positionGutter, right: positionGutter };
case 'bottom':
return {
bottom: positionGutter,
left: "50%",
transform: "translate(-50%, 0)",
};
case 'bottom left':
default:
return { bottom: positionGutter, left: positionGutter };
}
}


function PauseIcon() {
return (
<svg
width={minAccessibleBtnSize}
height={minAccessibleBtnSize}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
style={iconStyle}
>
<rect x="6" y="4" width="4" height="16" fill="currentColor" />
<rect x="14" y="4" width="4" height="16" fill="currentColor" />
</svg>
);
}

function PlayIcon() {
return (
<svg
width={minAccessibleBtnSize}
height={minAccessibleBtnSize}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
style={iconStyle}
>
<polygon points="9,4 19,12 9,20" fill="currentColor" />
</svg>
);
}

// Make the default icons white on a semi-transparent black background
// https://chatgpt.com/share/1050ddc4-5d2f-4a50-a5f6-623b7b679184
const iconStyle = {
background: `rgba(0, 0, 0, 0.25)`,
color: "white",
borderRadius: "2px",
};
Loading
Loading