Skip to content

Commit

Permalink
Merge pull request #43 from BKWLD/ada-video-controls
Browse files Browse the repository at this point in the history
Add accessibility controls
  • Loading branch information
weotch authored Aug 14, 2024
2 parents 1b9d7c4 + 109c1e3 commit 8f76125
Show file tree
Hide file tree
Showing 10 changed files with 498 additions and 135 deletions.
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

0 comments on commit 8f76125

Please sign in to comment.