Skip to content

Commit a5b6ef9

Browse files
authored
Add alt text for screen readers to describe video content (#784)
* [rdar://120903895] Render a hidden description with unique id for AX purposes if video provides an alt text * [rdar://120903895] Add a description reference to Control Button in ReplayableVideoAsset * [rdar://120903895] Add a description reference to video in VideoAsset * [rdar://120903895] Update license headers * [rdar://120903895] Replace id || null with alt ? id : null * [rdar://120903895] Add `aria-label`, `aria-roledescription` and `aria-controls` to the video * [rdar://120903895] Address feedback * [rdar://120903895] Fix failing test * [rdar://120903895] Move description * [rdar://120903895] Change text for custom-controls * [rdar://120903895] Delete wrong test
1 parent 99584d6 commit a5b6ef9

File tree

7 files changed

+156
-56
lines changed

7 files changed

+156
-56
lines changed

src/components/Asset.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<!--
22
This source file is part of the Swift.org open source project
33
4-
Copyright (c) 2021-2023 Apple Inc. and the Swift project authors
4+
Copyright (c) 2021-2024 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66
77
See https://swift.org/LICENSE.txt for license information
@@ -105,11 +105,13 @@ export default {
105105
videoProps() {
106106
return {
107107
variants: this.asset.variants,
108-
showsControls: this.showsVideoControls,
108+
showsDefaultControls: this.showsVideoControls,
109109
muted: this.videoMuted,
110110
autoplays: this.prefersReducedMotion ? false : this.videoAutoplays,
111111
posterVariants: this.videoPoster ? this.videoPoster.variants : [],
112112
deviceFrame: this.deviceFrame,
113+
alt: this.asset.alt,
114+
id: this.identifier,
113115
};
114116
},
115117
assetListeners() {

src/components/ReplayableVideoAsset.vue

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<!--
22
This source file is part of the Swift.org open source project
33
4-
Copyright (c) 2021-2023 Apple Inc. and the Swift project authors
4+
Copyright (c) 2021-2024 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66
77
See https://swift.org/LICENSE.txt for license information
@@ -14,18 +14,21 @@
1414
ref="asset"
1515
:variants="variants"
1616
:autoplays="autoplays"
17-
:showsControls="showsControls"
17+
:showsDefaultControls="showsDefaultControls"
1818
:muted="muted"
1919
:posterVariants="posterVariants"
2020
:deviceFrame="deviceFrame"
21+
:alt="alt"
22+
:id="id"
2123
@pause="onPause"
2224
@playing="onVideoPlaying"
2325
@ended="onVideoEnd"
2426
/>
2527
<a
26-
v-if="!showsControls"
28+
v-if="!showsDefaultControls"
2729
class="control-button"
2830
href="#"
31+
:aria-controls="id"
2932
@click.prevent="togglePlayStatus"
3033
>
3134
{{ text }}
@@ -55,7 +58,15 @@ export default {
5558
type: Array,
5659
required: true,
5760
},
58-
showsControls: {
61+
alt: {
62+
type: String,
63+
required: false,
64+
},
65+
id: {
66+
type: String,
67+
required: true,
68+
},
69+
showsDefaultControls: {
5970
type: Boolean,
6071
default: () => false,
6172
},
@@ -110,7 +121,7 @@ export default {
110121
onPause() {
111122
const { video } = this.$refs.asset.$refs;
112123
// if the video pauses, and we are hiding the controls, show the replay button
113-
if (!this.showsControls && this.isPlaying) {
124+
if (!this.showsDefaultControls && this.isPlaying) {
114125
this.isPlaying = false;
115126
}
116127
this.videoEnded = video.ended;

src/components/VideoAsset.vue

Lines changed: 47 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<!--
22
This source file is part of the Swift.org open source project
33
4-
Copyright (c) 2021-2023 Apple Inc. and the Swift project authors
4+
Copyright (c) 2021-2024 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66
77
See https://swift.org/LICENSE.txt for license information
@@ -15,29 +15,42 @@
1515
:should-wrap="!!deviceFrame"
1616
:device="deviceFrame"
1717
>
18-
<video
19-
ref="video"
20-
:key="videoAttributes.url"
21-
:controls="showsControls"
22-
:data-orientation="orientation"
23-
:autoplay="autoplays"
24-
:poster="normalisedPosterPath"
25-
:muted="muted"
26-
:width="optimalWidth"
27-
playsinline
28-
@loadedmetadata="setOrientation"
29-
@playing="$emit('playing')"
30-
@pause="$emit('pause')"
31-
@ended="$emit('ended')"
32-
>
33-
<!--
34-
Many browsers do not support the `media` attribute for `<source>` tags
35-
within a video specifically, so this implementation for dark theme assets
36-
is handled with JavaScript media query listeners unlike the `<source>`
37-
based implementation being used for image assets.
38-
-->
39-
<source :src="normalizePath(videoAttributes.url)">
40-
</video>
18+
<div>
19+
<video
20+
ref="video"
21+
:key="videoAttributes.url"
22+
:id="id"
23+
:controls="showsDefaultControls"
24+
:data-orientation="orientation"
25+
:autoplay="autoplays"
26+
:poster="normalisedPosterPath"
27+
:muted="muted"
28+
:width="optimalWidth"
29+
:aria-roledescription="$t('video.title')"
30+
:aria-label="!showsDefaultControls ? $t('video.custom-controls') : null"
31+
:aria-describedby="alt ? altTextId : null"
32+
playsinline
33+
@loadedmetadata="setOrientation"
34+
@playing="$emit('playing')"
35+
@pause="$emit('pause')"
36+
@ended="$emit('ended')"
37+
>
38+
<!--
39+
Many browsers do not support the `media` attribute for `<source>` tags
40+
within a video specifically, so this implementation for dark theme assets
41+
is handled with JavaScript media query listeners unlike the `<source>`
42+
based implementation being used for image assets.
43+
-->
44+
<source :src="normalizePath(videoAttributes.url)">
45+
</video>
46+
<span
47+
v-if="alt"
48+
:id="altTextId"
49+
class="visuallyhidden"
50+
>
51+
{{ $t('video.description', { alt }) }}
52+
</span>
53+
</div>
4154
</ConditionalWrapper>
4255
</template>
4356

@@ -62,7 +75,7 @@ export default {
6275
type: Array,
6376
required: true,
6477
},
65-
showsControls: {
78+
showsDefaultControls: {
6679
type: Boolean,
6780
default: () => false,
6881
},
@@ -83,6 +96,14 @@ export default {
8396
type: String,
8497
required: false,
8598
},
99+
alt: {
100+
type: String,
101+
required: false,
102+
},
103+
id: {
104+
type: String,
105+
required: true,
106+
},
86107
},
87108
data: () => ({
88109
appState: AppStore.state,
@@ -93,6 +114,7 @@ export default {
93114
DeviceFrameComponent: () => DeviceFrame,
94115
preferredColorScheme: ({ appState }) => appState.preferredColorScheme,
95116
systemColorScheme: ({ appState }) => appState.systemColorScheme,
117+
altTextId: ({ id }) => `${id}-alt`,
96118
userPrefersDark: ({
97119
preferredColorScheme,
98120
systemColorScheme,

src/lang/locales/en-US.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
"continue-viewing": "Continue viewing in English",
44
"language": "Language",
55
"video": {
6+
"title": "Video",
67
"replay": "Replay",
78
"play": "Play",
89
"pause": "Pause",
9-
"watch": "Watch intro video"
10+
"watch": "Watch intro video",
11+
"description": "Content description of this video: {alt}",
12+
"custom-controls": "Custom controls are available below"
1013
},
1114
"tutorials": {
1215
"title": "Tutorial | Tutorials",

tests/unit/components/Asset.spec.js

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* This source file is part of the Swift.org open source project
33
*
4-
* Copyright (c) 2021-2023 Apple Inc. and the Swift project authors
4+
* Copyright (c) 2021-2024 Apple Inc. and the Swift project authors
55
* Licensed under Apache License v2.0 with Runtime Library Exception
66
*
77
* See https://swift.org/LICENSE.txt for license information
@@ -17,6 +17,7 @@ import ReplayableVideoAsset from 'docc-render/components/ReplayableVideoAsset.vu
1717
const video = {
1818
type: 'video',
1919
poster: 'image',
20+
alt: 'Text describing this video',
2021
variants: [
2122
{
2223
url: 'foo.mp4',
@@ -93,7 +94,7 @@ describe('Asset', () => {
9394
const videoAsset = wrapper.find(ReplayableVideoAsset);
9495
expect(videoAsset.props('variants')).toBe(video.variants);
9596
expect(videoAsset.props('posterVariants')).toBe(image.variants);
96-
expect(videoAsset.props('showsControls')).toBe(false);
97+
expect(videoAsset.props('showsDefaultControls')).toBe(false);
9798
expect(videoAsset.props('autoplays')).toBe(false);
9899

99100
// Check that 'ended' events emitted by a `VideoAsset` are re-emitted.
@@ -102,9 +103,12 @@ describe('Asset', () => {
102103
});
103104

104105
it('renders a `ReplayableVideoAsset` without poster variants', () => {
105-
const videoAsset = mountAsset('video', { video }).find(ReplayableVideoAsset);
106+
const identifier = 'video';
107+
const videoAsset = mountAsset(identifier, { video }).find(ReplayableVideoAsset);
106108
expect(videoAsset.props('variants')).toBe(video.variants);
107109
expect(videoAsset.props('posterVariants')).toEqual([]);
110+
expect(videoAsset.props('id')).toBe(identifier);
111+
expect(videoAsset.props('alt')).toBe(video.alt);
108112
});
109113

110114
it('passes down `deviceFrame` to `ReplayableVideoAsset`', () => {
@@ -119,29 +123,37 @@ describe('Asset', () => {
119123
});
120124

121125
it('renders a `VideoAsset` for video with `showsReplayButton=false`', () => {
126+
const identifier = 'video';
127+
122128
const wrapper = shallowMount(Asset, {
123129
propsData: {
124-
identifier: 'video',
130+
identifier,
125131
showsReplayButton: false,
126132
showsVideoControls: true,
127133
},
128134
provide: {
129135
store: {
130-
state: { references: { video } },
136+
state: { references: { [identifier]: video } },
131137
},
132138
},
133139
});
134140

135141
const videoAsset = wrapper.find(VideoAsset);
136-
expect(videoAsset.props('variants')).toBe(video.variants);
137-
expect(videoAsset.props('showsControls')).toBe(true);
138-
expect(videoAsset.props('muted')).toBe(false);
142+
expect(videoAsset.props()).toEqual(expect.objectContaining({
143+
variants: video.variants,
144+
showsDefaultControls: true,
145+
muted: false,
146+
id: identifier,
147+
alt: video.alt,
148+
}));
139149
});
140150

141151
it('renders a `ReplayableVideoAsset` with deviceFrame', () => {
152+
const identifier = 'video';
153+
142154
const wrapper = shallowMount(Asset, {
143155
propsData: {
144-
identifier: 'video',
156+
identifier,
145157
showsReplayButton: true,
146158
showsVideoControls: true,
147159
deviceFrame: 'phone',
@@ -159,8 +171,10 @@ describe('Asset', () => {
159171
deviceFrame: 'phone',
160172
muted: false,
161173
posterVariants: [],
162-
showsControls: true,
174+
showsDefaultControls: true,
163175
variants: video.variants,
176+
id: identifier,
177+
alt: video.alt,
164178
});
165179
});
166180

@@ -181,7 +195,7 @@ describe('Asset', () => {
181195

182196
const videoAsset = wrapper.find(ReplayableVideoAsset);
183197
expect(videoAsset.props('variants')).toBe(video.variants);
184-
expect(videoAsset.props('showsControls')).toBe(true);
198+
expect(videoAsset.props('showsDefaultControls')).toBe(true);
185199
expect(videoAsset.props('muted')).toBe(false);
186200
expect(videoAsset.props('autoplays')).toBe(true);
187201
});
@@ -202,7 +216,7 @@ describe('Asset', () => {
202216
});
203217

204218
const videoAsset = wrapper.find(ReplayableVideoAsset);
205-
expect(videoAsset.props('showsControls')).toBe(true);
219+
expect(videoAsset.props('showsDefaultControls')).toBe(true);
206220
expect(videoAsset.props('muted')).toBe(false);
207221
});
208222

tests/unit/components/ReplayableVideoAsset.spec.js

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* This source file is part of the Swift.org open source project
33
*
4-
* Copyright (c) 2021-2023 Apple Inc. and the Swift project authors
4+
* Copyright (c) 2021-2024 Apple Inc. and the Swift project authors
55
* Licensed under Apache License v2.0 with Runtime Library Exception
66
*
77
* See https://swift.org/LICENSE.txt for license information
@@ -22,7 +22,9 @@ const posterVariants = [{ traits: ['dark', '1x'], url: 'https://www.example.com/
2222
const propsData = {
2323
variants,
2424
posterVariants,
25-
showsControls: false,
25+
showsDefaultControls: false,
26+
alt: 'Text describing this video',
27+
id: 'video.mp4',
2628
};
2729
describe('ReplayableVideoAsset', () => {
2830
const mountWithProps = props => shallowMount(ReplayableVideoAsset, {
@@ -64,20 +66,34 @@ describe('ReplayableVideoAsset', () => {
6466
const video = wrapper.find(VideoAsset);
6567
expect(video.props('variants')).toBe(variants);
6668
expect(video.props('posterVariants')).toBe(posterVariants);
67-
expect(video.props('showsControls')).toBe(false);
69+
expect(video.props('showsDefaultControls')).toBe(false);
6870
expect(video.props('autoplays')).toBe(false);
71+
expect(video.props('id')).toBe(propsData.id);
72+
expect(video.props('alt')).toBe(propsData.alt);
6973
expect(wrapper.find('.control-button').exists()).toBe(true);
7074
});
7175

72-
it('does not show the `.control-button` if `showsControls` is `true`', () => {
76+
it('does not show the `.control-button` if `showsDefaultControls` is `true`', () => {
7377
const wrapper = mountWithProps({
74-
showsControls: true,
78+
showsDefaultControls: true,
7579
});
7680
const video = wrapper.find(VideoAsset);
77-
expect(video.props('showsControls')).toBe(true);
81+
expect(video.props('showsDefaultControls')).toBe(true);
7882
expect(wrapper.find('.control-button').exists()).toBe(false);
7983
});
8084

85+
it('adds an aria-controls referring to the video id in `.control-button`', () => {
86+
const wrapper = mountWithProps();
87+
const controlButton = wrapper.find('.control-button');
88+
expect(controlButton.attributes('aria-controls')).toBe(propsData.id);
89+
});
90+
91+
it('does not add a description reference to `.control-button` if alt is not provided', () => {
92+
const wrapper = mountWithProps({ alt: null });
93+
const controlButton = wrapper.find('.control-button');
94+
expect(controlButton.attributes()).not.toHaveProperty('aria-describedby');
95+
});
96+
8197
it('changes the control button text, while the video is changing states', async () => {
8298
const wrapper = mountWithProps();
8399

0 commit comments

Comments
 (0)