Skip to content

Commit 440fdbc

Browse files
authored
fix: firefox accessibility test (#1258)
Closes muxinc/elements#1040 This PR enhances accessibility of the media-chrome components. ## Changes - Changed `MediaCaptionsButton` from `role="switch"` to `role="button"` to avoid Firefox's "visible text label" requirement for form elements, while maintaining toggle semantics via `aria-checked`. - Added visible (but off-screen) label to `MediaChromeRange` to satisfy Firefox's requirement for visible labels on form elements (`<input type="range">`). The label is positioned off-screen with proper contrast to meet accessibility standards without affecting the visual design. - Added `aria-hidden="true"` to the segments SVG element in `MediaChromeRange` to prevent accessibility warnings about unlabeled graphics. - Added `aria-label` attribute to `MediaErrorDialog`. - `MediaTimeDisplay` dynamically manages `role` and `tabindex` based on interactivity state (`noToggle` and `disabled` attributes). When interactive, it uses `role="button"` with `tabindex="0"`; when non-interactive, it removes the role and sets `tabindex="-1"`. Event listeners are now dynamically added/removed based on interactivity. When checking the Accessibility tab in Firefox's developer tools, there should be no warnings related to media-chrome components.
1 parent 4b1f5a9 commit 440fdbc

File tree

6 files changed

+84
-18
lines changed

6 files changed

+84
-18
lines changed

src/js/media-captions-button.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ class MediaCaptionsButton extends MediaChromeButton {
7777

7878
connectedCallback(): void {
7979
super.connectedCallback();
80-
this.setAttribute('role', 'switch');
80+
this.setAttribute('role', 'button');
8181
this.setAttribute('aria-label', t('closed captions'));
8282
updateAriaChecked(this);
8383
}

src/js/media-chrome-range.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,14 @@ function getTemplateHTML(_attrs: Record<string, string>) {
227227
transform: var(--media-range-segment-transform, scaleY(1));
228228
transform-origin: center;
229229
}
230+
231+
/* Visible label for accessibility - positioned off-screen but technically visible (Firefox requires visible labels) */
232+
#range-label {
233+
position: absolute;
234+
left: -10000px;
235+
background: var(--media-control-background, var(--media-secondary-color, rgb(20 20 30 / .7)));
236+
pointer-events: none;
237+
}
230238
</style>
231239
<div id="leftgap"></div>
232240
<div id="container">
@@ -240,9 +248,10 @@ function getTemplateHTML(_attrs: Record<string, string>) {
240248
<slot name="thumb">
241249
<div id="thumb" part="thumb"></div>
242250
</slot>
243-
<svg id="segments"><clipPath id="segments-clipping"></clipPath></svg>
251+
<svg id="segments" aria-hidden="true"><clipPath id="segments-clipping"></clipPath></svg>
244252
</div>
245-
<input id="range" type="range" min="0" max="1" step="any" value="0">
253+
<input id="range" type="range" min="0" max="1" step="any" value="0">
254+
<label for="range" id="range-label"></label>
246255
247256
${this.getContainerTemplateHTML(_attrs)}
248257
</div>

src/js/media-container.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,22 @@ class MediaContainer extends globalThis.HTMLElement {
424424
await globalThis.customElements.whenDefined(media.localName);
425425
}
426426

427+
const setVideoAccessibility = (videoEl: HTMLVideoElement) => {
428+
if (!videoEl.hasAttribute('aria-hidden')) {
429+
videoEl.setAttribute('aria-hidden', 'true');
430+
}
431+
};
432+
433+
if (media instanceof HTMLVideoElement || media.tagName === 'VIDEO') {
434+
setVideoAccessibility(media as HTMLVideoElement);
435+
} else if (media.localName?.includes('-')) {
436+
// If `media` is a custom media element search in its shadow DOM
437+
const videoElement = media.shadowRoot?.querySelector('video') as HTMLVideoElement | null;
438+
if (videoElement) {
439+
setVideoAccessibility(videoElement);
440+
}
441+
}
442+
427443
// Even if we are not connected to the DOM after this await still call mediaSetCallback
428444
// so the media state is already computed once, then when the container is connected
429445
// to the DOM mediaSetCallback is called again to attach the root node event listeners.

src/js/media-error-dialog.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ class MediaErrorDialog extends MediaChromeDialog {
9191
if (this.open) {
9292
this.shadowRoot.querySelector('slot').name = `error-${this.mediaErrorCode}`;
9393
this.shadowRoot.querySelector('#content').innerHTML = this.formatErrorMessage(mediaError);
94+
95+
if (!this.hasAttribute('aria-label')) {
96+
const { title } = formatError(mediaError);
97+
if (title) this.setAttribute('aria-label', title);
98+
}
9499
}
95100
}
96101

src/js/media-live-button.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const { MEDIA_TIME_IS_LIVE, MEDIA_PAUSED } = MediaUIAttributes;
88
const { MEDIA_SEEK_TO_LIVE_REQUEST, MEDIA_PLAY_REQUEST } = MediaUIEvents;
99

1010
const indicatorSVG =
11-
'<svg viewBox="0 0 6 12"><circle cx="3" cy="6" r="2"></circle></svg>';
11+
'<svg viewBox="0 0 6 12" aria-hidden="true"><circle cx="3" cy="6" r="2"></circle></svg>';
1212

1313
function getSlotTemplateHTML(_attrs: Record<string, string>) {
1414
return /*html*/ `

src/js/media-time-display.ts

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ class MediaTimeDisplay extends MediaTextDisplay {
108108
static getSlotTemplateHTML = getSlotTemplateHTML;
109109

110110
#slot: HTMLSlotElement;
111+
#keyUpHandler: ((evt: KeyboardEvent) => void) | null = null;
111112

112113
static get observedAttributes(): string[] {
113114
return [...super.observedAttributes, ...CombinedAttributes, 'disabled'];
@@ -131,35 +132,61 @@ class MediaTimeDisplay extends MediaTextDisplay {
131132
'var(--media-control-hover-background, rgba(50 50 70 / .7))'
132133
);
133134

134-
if (!this.hasAttribute('disabled')) {
135-
this.enable();
136-
}
137-
138-
this.setAttribute('role', 'progressbar');
139135
this.setAttribute('aria-label', t('playback time'));
136+
this.#makeInteractive();
137+
138+
super.connectedCallback();
139+
}
140140

141-
const keyUpHandler = (evt) => {
141+
#setupEventListeners(): void {
142+
if (this.#keyUpHandler) {
143+
return;
144+
}
145+
146+
this.#keyUpHandler = (evt: KeyboardEvent) => {
142147
const { key } = evt;
143148
if (!ButtonPressedKeys.includes(key)) {
144-
this.removeEventListener('keyup', keyUpHandler);
149+
this.removeEventListener('keyup', this.#keyUpHandler!);
145150
return;
146151
}
147152

148153
this.toggleTimeDisplay();
149154
};
150155

151-
this.addEventListener('keydown', (evt) => {
156+
this.addEventListener('keydown', (evt: KeyboardEvent) => {
152157
const { metaKey, altKey, key } = evt;
153158
if (metaKey || altKey || !ButtonPressedKeys.includes(key)) {
154-
this.removeEventListener('keyup', keyUpHandler);
159+
this.removeEventListener('keyup', this.#keyUpHandler!);
155160
return;
156161
}
157-
this.addEventListener('keyup', keyUpHandler);
162+
this.addEventListener('keyup', this.#keyUpHandler!);
158163
});
159164

160165
this.addEventListener('click', this.toggleTimeDisplay);
166+
}
161167

162-
super.connectedCallback();
168+
#removeEventListeners(): void {
169+
if (this.#keyUpHandler) {
170+
this.removeEventListener('keyup', this.#keyUpHandler);
171+
this.removeEventListener('click', this.toggleTimeDisplay);
172+
this.#keyUpHandler = null;
173+
}
174+
}
175+
176+
// Makes element clickable and focusable only when not disabled and noToggle is not present
177+
#makeInteractive(): void {
178+
if (!this.noToggle && !this.hasAttribute('disabled')) {
179+
this.setAttribute('role', 'button');
180+
this.enable();
181+
this.#setupEventListeners();
182+
}
183+
}
184+
185+
// Removes interactivity from the element, making it neither clickable nor focusable
186+
#makeNonInteractive(): void {
187+
this.removeAttribute('role');
188+
this.disable();
189+
this.#removeEventListeners();
163190
}
164191

165192
toggleTimeDisplay(): void {
@@ -188,17 +215,26 @@ class MediaTimeDisplay extends MediaTextDisplay {
188215
this.update();
189216
} else if (attrName === 'disabled' && newValue !== oldValue) {
190217
if (newValue == null) {
191-
this.enable();
218+
this.#makeInteractive();
192219
} else {
193-
this.disable();
220+
this.#makeNonInteractive();
221+
}
222+
} else if (attrName === Attributes.NO_TOGGLE && newValue !== oldValue) {
223+
if (this.noToggle) {
224+
this.#makeNonInteractive();
225+
} else {
226+
this.#makeInteractive();
194227
}
195228
}
196229

197230
super.attributeChangedCallback(attrName, oldValue, newValue);
198231
}
199232

200233
enable(): void {
201-
this.tabIndex = 0;
234+
235+
if (!this.noToggle) {
236+
this.tabIndex = 0;
237+
}
202238
}
203239

204240
disable(): void {

0 commit comments

Comments
 (0)