From f2b93d8c98ffc1fd2d780bdc617c055c92fcdb03 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Fri, 5 Jun 2026 11:25:07 +1000 Subject: [PATCH 01/11] feat(core): add status announcer state updates --- .../input-feedback/status-announcer-core.ts | 147 +++++++++++++++++- .../core/src/core/ui/input-feedback/status.ts | 27 +++- .../tests/status-indicator-core.test.ts | 146 +++++++++++++++++ packages/core/src/dom/index.ts | 3 + packages/core/src/dom/ui/container-attrs.ts | 19 +++ packages/core/src/dom/ui/input-action.ts | 3 + packages/core/src/dom/ui/slider-focus.ts | 3 + .../src/dom/ui/tests/input-action.test.ts | 5 + packages/core/src/dom/ui/visually-hidden.ts | 17 ++ packages/html/src/media/container-element.ts | 6 +- .../src/media/tests/container-element.test.ts | 52 +++++++ .../tests/input-indicators.test.ts | 118 +++++++++++++- .../status-announcer-element.ts | 58 ++++--- packages/react/src/player/context.tsx | 31 +++- .../react/src/player/tests/context.test.tsx | 42 +++++ .../tests/input-indicators.test.tsx | 94 ++++++++++- .../ui/status-announcer/status-announcer.tsx | 24 ++- 17 files changed, 745 insertions(+), 50 deletions(-) create mode 100644 packages/core/src/dom/ui/container-attrs.ts create mode 100644 packages/core/src/dom/ui/slider-focus.ts create mode 100644 packages/core/src/dom/ui/visually-hidden.ts create mode 100644 packages/html/src/media/tests/container-element.test.ts diff --git a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts index e7f042b71..45147b7f3 100644 --- a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts +++ b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts @@ -3,15 +3,22 @@ import { createState } from '@videojs/store'; import type { IndicatorCoreProps } from './indicator-lifecycle'; import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle'; import { - DEFAULT_INPUT_INDICATOR_LABELS, + DEFAULT_STATUS_ANNOUNCER_LABELS, deriveAnnouncerLabel, + formatPlaybackRateAnnouncerLabel, + formatSeekAnnouncerLabel, + formatVolumeValue, type InputActionEvent, - type InputIndicatorLabels, type MediaSnapshot, + type StatusAnnouncerLabels, } from './status'; +const SNAPSHOT_ANNOUNCER_DEBOUNCE = 200; + export interface StatusAnnouncerProps extends IndicatorCoreProps { - labels?: Partial | undefined; + labels?: Partial | undefined; + shouldAnnounceSeek?: ((snapshot: MediaSnapshot) => boolean) | undefined; + shouldAnnounceVolume?: ((snapshot: MediaSnapshot) => boolean) | undefined; } export interface StatusAnnouncerState { @@ -22,6 +29,11 @@ export class StatusAnnouncerCore { readonly state = createState({ label: null }); #props: StatusAnnouncerProps = {}; + #snapshot: MediaSnapshot | null = null; + #seekStartTime: number | null = null; + #seekTargetTime: number | null = null; + #seekTimer: ReturnType | null = null; + #volumeTimer: ReturnType | null = null; #close = new IndicatorCloseController( () => this.state.patch({ label: null }), () => getIndicatorCloseDelay(this.#props) @@ -32,23 +44,150 @@ export class StatusAnnouncerCore { } destroy(): void { + this.#clearSeekTimer(); + this.#clearVolumeTimer(); this.#close.destroy(); } processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean { const label = deriveAnnouncerLabel(event, snapshot, { - ...DEFAULT_INPUT_INDICATOR_LABELS, + ...DEFAULT_STATUS_ANNOUNCER_LABELS, ...this.#props.labels, }); if (!label) return false; + this.#announce(label); + return true; + } + + processSnapshot(snapshot: MediaSnapshot): boolean { + const previous = this.#snapshot; + this.#snapshot = snapshot; + + if (!previous) return false; + + const labels = this.#getLabels(); + let accepted = false; + + if (hasChanged(previous.paused, snapshot.paused)) { + accepted = this.#announce(snapshot.paused ? labels.paused : labels.playing); + } + + if (hasChanged(previous.subtitlesShowing, snapshot.subtitlesShowing) && snapshot.subtitlesAvailable !== false) { + accepted = this.#announce(snapshot.subtitlesShowing ? labels.captionsOn : labels.captionsOff); + } + + if (hasChanged(previous.fullscreen, snapshot.fullscreen)) { + accepted = this.#announce(snapshot.fullscreen ? labels.fullscreen : labels.exitFullscreen); + } + + if (hasChanged(previous.pip, snapshot.pip)) { + accepted = this.#announce(snapshot.pip ? labels.pictureInPicture : labels.exitPictureInPicture); + } + + if (hasChanged(previous.playbackRate, snapshot.playbackRate)) { + accepted = this.#announce(formatPlaybackRateAnnouncerLabel(snapshot.playbackRate, labels)); + } + + if (this.#processVolumeSnapshot(previous, snapshot, labels)) { + accepted = true; + } + + if (this.#processSeekSnapshot(previous, snapshot, labels)) { + accepted = true; + } + + return accepted; + } + + #getLabels(): StatusAnnouncerLabels { + return { + ...DEFAULT_STATUS_ANNOUNCER_LABELS, + ...this.#props.labels, + }; + } + + #announce(label: string): boolean { this.state.patch({ label }); this.#close.arm(); return true; } + + #processVolumeSnapshot(previous: MediaSnapshot, snapshot: MediaSnapshot, labels: StatusAnnouncerLabels): boolean { + if (!hasChanged(previous.volume, snapshot.volume) && !hasChanged(previous.muted, snapshot.muted)) return false; + if (this.#props.shouldAnnounceVolume?.(snapshot) === false) return false; + + const volume = snapshot.volume ?? previous.volume; + const muted = snapshot.muted ?? previous.muted; + + if (volume === undefined && muted === undefined) return false; + + const label = muted || (volume ?? 0) <= 0 ? labels.muted : `${labels.volume} ${formatVolumeValue(volume ?? 0)}`; + this.#scheduleVolumeAnnouncement(label); + return true; + } + + #processSeekSnapshot(previous: MediaSnapshot, snapshot: MediaSnapshot, labels: StatusAnnouncerLabels): boolean { + if (previous.seeking !== true && snapshot.seeking === true) { + this.#seekStartTime = previous.currentTime ?? null; + this.#seekTargetTime = snapshot.currentTime ?? null; + this.#clearSeekTimer(); + return false; + } + + if (snapshot.seeking === true) { + this.#seekTargetTime = snapshot.currentTime ?? this.#seekTargetTime; + return false; + } + + if (previous.seeking !== true || snapshot.seeking !== false) return false; + + const targetTime = snapshot.currentTime ?? this.#seekTargetTime; + const startTime = this.#seekStartTime; + this.#seekStartTime = null; + this.#seekTargetTime = null; + + if (targetTime === undefined || targetTime === null || Object.is(targetTime, startTime)) return false; + if (this.#props.shouldAnnounceSeek?.(snapshot) === false) return false; + + this.#scheduleSeekAnnouncement(formatSeekAnnouncerLabel(targetTime, labels)); + return true; + } + + #scheduleVolumeAnnouncement(label: string): void { + this.#clearVolumeTimer(); + this.#volumeTimer = setTimeout(() => { + this.#volumeTimer = null; + this.#announce(label); + }, SNAPSHOT_ANNOUNCER_DEBOUNCE); + } + + #scheduleSeekAnnouncement(label: string): void { + this.#clearSeekTimer(); + this.#seekTimer = setTimeout(() => { + this.#seekTimer = null; + this.#announce(label); + }, SNAPSHOT_ANNOUNCER_DEBOUNCE); + } + + #clearSeekTimer(): void { + if (!this.#seekTimer) return; + clearTimeout(this.#seekTimer); + this.#seekTimer = null; + } + + #clearVolumeTimer(): void { + if (!this.#volumeTimer) return; + clearTimeout(this.#volumeTimer); + this.#volumeTimer = null; + } } export namespace StatusAnnouncerCore { export type Props = StatusAnnouncerProps; export type State = StatusAnnouncerState; } + +function hasChanged(previous: Value | undefined, next: Value | undefined): next is Value { + return previous !== undefined && next !== undefined && !Object.is(previous, next); +} diff --git a/packages/core/src/core/ui/input-feedback/status.ts b/packages/core/src/core/ui/input-feedback/status.ts index aa6d346d4..3f4f016bc 100644 --- a/packages/core/src/core/ui/input-feedback/status.ts +++ b/packages/core/src/core/ui/input-feedback/status.ts @@ -1,5 +1,5 @@ import { clamp } from '@videojs/utils/number'; -import { formatTime } from '@videojs/utils/time'; +import { formatTime, formatTimeAsPhrase } from '@videojs/utils/time'; export type InputActionSource = 'gesture' | 'hotkey'; @@ -44,6 +44,7 @@ export interface MediaSnapshot { paused?: boolean | undefined; volume?: number | undefined; muted?: boolean | undefined; + playbackRate?: number | undefined; fullscreen?: boolean | undefined; subtitlesShowing?: boolean | undefined; /** When false, caption toggles are unavailable and status feedback is suppressed. */ @@ -51,6 +52,7 @@ export interface MediaSnapshot { pip?: boolean | undefined; currentTime?: number | undefined; duration?: number | undefined; + seeking?: boolean | undefined; } export interface InputIndicatorLabels { @@ -66,6 +68,11 @@ export interface InputIndicatorLabels { exitPictureInPicture: string; } +export interface StatusAnnouncerLabels extends InputIndicatorLabels { + seekedTo: string; + playbackRate: string; +} + export interface StatusDetails { status: IndicatorStatus; label: string; @@ -86,6 +93,12 @@ export const DEFAULT_INPUT_INDICATOR_LABELS: InputIndicatorLabels = { exitPictureInPicture: 'Exit picture in picture', }; +export const DEFAULT_STATUS_ANNOUNCER_LABELS: StatusAnnouncerLabels = { + ...DEFAULT_INPUT_INDICATOR_LABELS, + seekedTo: 'Seeked to', + playbackRate: 'Playback rate', +}; + export function isVolumeIndicatorAction(action: string | null | undefined): action is 'toggleMuted' | 'volumeStep' { return action === 'toggleMuted' || action === 'volumeStep'; } @@ -169,10 +182,22 @@ export function formatVolumeValue(volume: number): string { return `${Math.round(clamp(volume, 0, 1) * 100)}%`; } +export function formatPlaybackRateValue(rate: number): string { + return `${rate}x`; +} + export function formatCurrentTime(snapshot: MediaSnapshot): string { return formatTime(snapshot.currentTime ?? 0, snapshot.duration); } +export function formatSeekAnnouncerLabel(time: number, labels: StatusAnnouncerLabels): string { + return `${labels.seekedTo} ${formatTimeAsPhrase(time)}`; +} + +export function formatPlaybackRateAnnouncerLabel(rate: number, labels: StatusAnnouncerLabels): string { + return `${labels.playbackRate} ${formatPlaybackRateValue(rate)}`; +} + export function getStatusIndicatorDisplayValue(state: { value: string | null; label: string | null }): string { return state.value ?? state.label ?? ''; } diff --git a/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts b/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts index c74a67884..24b1d1764 100644 --- a/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts +++ b/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts @@ -51,4 +51,150 @@ describe('StatusAnnouncerCore', () => { vi.advanceTimersByTime(100); expect(core.state.current.label).toBeNull(); }); + + it('uses the first snapshot as a baseline only', () => { + const core = new StatusAnnouncerCore(); + + expect(core.processSnapshot({ paused: false, volume: 0.5, muted: false })).toBe(false); + expect(core.state.current.label).toBeNull(); + }); + + it('announces confirmed playback, captions, fullscreen, pip, and playback-rate changes', () => { + const core = new StatusAnnouncerCore(); + let snapshot = { + paused: true, + subtitlesShowing: false, + subtitlesAvailable: true, + fullscreen: false, + pip: false, + playbackRate: 1, + }; + const process = (partial: Partial) => { + snapshot = { ...snapshot, ...partial }; + return core.processSnapshot(snapshot); + }; + + core.processSnapshot(snapshot); + + expect(process({ paused: false })).toBe(true); + expect(core.state.current.label).toBe('Playing'); + + expect(process({ subtitlesShowing: true })).toBe(true); + expect(core.state.current.label).toBe('Captions on'); + + expect(process({ fullscreen: true })).toBe(true); + expect(core.state.current.label).toBe('Fullscreen'); + + expect(process({ pip: true })).toBe(true); + expect(core.state.current.label).toBe('Picture in picture'); + + expect(process({ playbackRate: 1.5 })).toBe(true); + expect(core.state.current.label).toBe('Playback rate 1.5x'); + }); + + it('does not announce captions when captions are unavailable', () => { + const core = new StatusAnnouncerCore(); + + core.processSnapshot({ subtitlesShowing: false, subtitlesAvailable: false }); + + expect(core.processSnapshot({ subtitlesShowing: true, subtitlesAvailable: false })).toBe(false); + expect(core.state.current.label).toBeNull(); + }); + + it('debounces volume snapshot announcements to the final value', () => { + const core = new StatusAnnouncerCore(); + let snapshot = { volume: 0.5, muted: false }; + const process = (partial: Partial) => { + snapshot = { ...snapshot, ...partial }; + return core.processSnapshot(snapshot); + }; + + core.processSnapshot(snapshot); + + expect(process({ volume: 0.55 })).toBe(true); + expect(process({ volume: 0.6 })).toBe(true); + expect(core.state.current.label).toBeNull(); + + vi.advanceTimersByTime(199); + expect(core.state.current.label).toBeNull(); + + vi.advanceTimersByTime(1); + expect(core.state.current.label).toBe('Volume 60%'); + }); + + it('announces muted volume snapshots', () => { + const core = new StatusAnnouncerCore(); + let snapshot = { volume: 0.5, muted: false }; + const process = (partial: Partial) => { + snapshot = { ...snapshot, ...partial }; + return core.processSnapshot(snapshot); + }; + + core.processSnapshot(snapshot); + process({ muted: true }); + + vi.advanceTimersByTime(200); + + expect(core.state.current.label).toBe('Muted'); + }); + + it('ignores regular currentTime updates and announces completed seeks once', () => { + const core = new StatusAnnouncerCore(); + let snapshot = { currentTime: 10, duration: 120, seeking: false }; + const process = (partial: Partial) => { + snapshot = { ...snapshot, ...partial }; + return core.processSnapshot(snapshot); + }; + + core.processSnapshot(snapshot); + + expect(process({ currentTime: 11 })).toBe(false); + expect(core.state.current.label).toBeNull(); + + expect(process({ currentTime: 40, seeking: true })).toBe(false); + expect(process({ currentTime: 45 })).toBe(false); + expect(process({ seeking: false })).toBe(true); + expect(core.state.current.label).toBeNull(); + + vi.advanceTimersByTime(200); + + expect(core.state.current.label).toBe('Seeked to 45 seconds'); + }); + + it('allows seek announcements to be suppressed by callers', () => { + const core = new StatusAnnouncerCore(); + core.setProps({ shouldAnnounceSeek: () => false }); + let snapshot = { currentTime: 10, duration: 120, seeking: false }; + const process = (partial: Partial) => { + snapshot = { ...snapshot, ...partial }; + return core.processSnapshot(snapshot); + }; + + core.processSnapshot(snapshot); + + process({ currentTime: 45, seeking: true }); + expect(process({ seeking: false })).toBe(false); + + vi.advanceTimersByTime(200); + + expect(core.state.current.label).toBeNull(); + }); + + it('allows volume announcements to be suppressed by callers', () => { + const core = new StatusAnnouncerCore(); + core.setProps({ shouldAnnounceVolume: () => false }); + let snapshot = { volume: 0.5, muted: false }; + const process = (partial: Partial) => { + snapshot = { ...snapshot, ...partial }; + return core.processSnapshot(snapshot); + }; + + core.processSnapshot(snapshot); + + expect(process({ volume: 0.75 })).toBe(false); + + vi.advanceTimersByTime(200); + + expect(core.state.current.label).toBeNull(); + }); }); diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index 9f3d99312..7c4c86f65 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -12,6 +12,7 @@ export * from './store/features'; export * from './store/selectors'; export * from './ui/alert-dialog'; export * from './ui/button'; +export * from './ui/container-attrs'; export * from './ui/dismiss-layer'; export * from './ui/event'; export * from './ui/input-action'; @@ -23,8 +24,10 @@ export * from './ui/popover/popover-positioning'; export * from './ui/popover/popup-group'; export * from './ui/slider'; export * from './ui/slider-css-vars'; +export * from './ui/slider-focus'; export * from './ui/thumbnail'; export * from './ui/tooltip/tooltip'; export * from './ui/transition'; +export * from './ui/visually-hidden'; export * from './ui/wheel-step'; export * from './utils'; diff --git a/packages/core/src/dom/ui/container-attrs.ts b/packages/core/src/dom/ui/container-attrs.ts new file mode 100644 index 000000000..5b4099451 --- /dev/null +++ b/packages/core/src/dom/ui/container-attrs.ts @@ -0,0 +1,19 @@ +export const DEFAULT_CONTAINER_LABEL = 'Media player'; +export const DEFAULT_CONTAINER_ROLE = 'group'; +export const DEFAULT_CONTAINER_TAB_INDEX = 0; + +export function applyContainerAttrs(element: HTMLElement): void { + if (!element.hasAttribute('role')) { + element.setAttribute('role', DEFAULT_CONTAINER_ROLE); + } + + // Provide a default label so screen readers don't announce the child element labels. + if (!element.hasAttribute('aria-label') && !element.hasAttribute('aria-labelledby')) { + element.setAttribute('aria-label', DEFAULT_CONTAINER_LABEL); + } + + // Make it focusable so keyboard events reach the hotkey coordinator's listener. + if (!element.hasAttribute('tabindex')) { + element.setAttribute('tabindex', String(DEFAULT_CONTAINER_TAB_INDEX)); + } +} diff --git a/packages/core/src/dom/ui/input-action.ts b/packages/core/src/dom/ui/input-action.ts index 743d183c7..c321c5cda 100644 --- a/packages/core/src/dom/ui/input-action.ts +++ b/packages/core/src/dom/ui/input-action.ts @@ -10,6 +10,7 @@ import { selectFullscreen, selectPiP, selectPlayback, + selectPlaybackRate, selectTextTrack, selectTime, selectVolume, @@ -42,12 +43,14 @@ export function getMediaSnapshot(store: MediaSnapshotStore | undefined): MediaSn paused: selectPlayback(state)?.paused, volume: selectVolume(state)?.volume, muted: selectVolume(state)?.muted, + playbackRate: selectPlaybackRate(state)?.playbackRate, fullscreen: selectFullscreen(state)?.fullscreen, subtitlesShowing: textTrack?.subtitlesShowing, subtitlesAvailable: textTrack ? (textTrack.textTrackList ?? []).some(isCaptionOrSubtitleTrack) : undefined, pip: selectPiP(state)?.pip, currentTime: time?.currentTime, duration: time?.duration, + seeking: time?.seeking, }; } diff --git a/packages/core/src/dom/ui/slider-focus.ts b/packages/core/src/dom/ui/slider-focus.ts new file mode 100644 index 000000000..8740982c8 --- /dev/null +++ b/packages/core/src/dom/ui/slider-focus.ts @@ -0,0 +1,3 @@ +export function isSliderFocused(root: Document = document): boolean { + return root.activeElement?.getAttribute('role') === 'slider'; +} diff --git a/packages/core/src/dom/ui/tests/input-action.test.ts b/packages/core/src/dom/ui/tests/input-action.test.ts index afd912286..34781123a 100644 --- a/packages/core/src/dom/ui/tests/input-action.test.ts +++ b/packages/core/src/dom/ui/tests/input-action.test.ts @@ -36,24 +36,29 @@ describe('input-action', () => { paused: true, volume: 0.5, muted: false, + playbackRates: [1, 1.5], + playbackRate: 1.5, fullscreen: true, subtitlesShowing: true, textTrackList: [{ kind: 'captions', label: 'English', language: 'en', mode: 'showing' }], pip: false, currentTime: 30, duration: 120, + seeking: true, }) ) ).toEqual({ paused: true, volume: 0.5, muted: false, + playbackRate: 1.5, fullscreen: true, subtitlesShowing: true, subtitlesAvailable: true, pip: false, currentTime: 30, duration: 120, + seeking: true, }); }); diff --git a/packages/core/src/dom/ui/visually-hidden.ts b/packages/core/src/dom/ui/visually-hidden.ts new file mode 100644 index 000000000..25702e344 --- /dev/null +++ b/packages/core/src/dom/ui/visually-hidden.ts @@ -0,0 +1,17 @@ +import { applyStyles } from '@videojs/utils/dom'; + +export const visuallyHiddenStyle = { + position: 'absolute', + width: '1px', + height: '1px', + padding: '0', + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: '0', +} as const satisfies Record; + +export function applyVisuallyHiddenStyle(element: HTMLElement): void { + applyStyles(element, visuallyHiddenStyle); +} diff --git a/packages/html/src/media/container-element.ts b/packages/html/src/media/container-element.ts index a3eb22363..550a4ae67 100644 --- a/packages/html/src/media/container-element.ts +++ b/packages/html/src/media/container-element.ts @@ -1,3 +1,4 @@ +import { applyContainerAttrs } from '@videojs/core/dom'; import { listen } from '@videojs/utils/dom'; import { containerContext, playerContext } from '../player/context'; @@ -14,10 +15,7 @@ export class MediaContainerElement extends ContainerMixin(MediaElement) { override connectedCallback(): void { super.connectedCallback(); - // Make focusable so keyboard events reach hotkey listeners. - if (!this.hasAttribute('tabindex')) { - this.setAttribute('tabindex', '0'); - } + applyContainerAttrs(this); this.#disconnect = new AbortController(); listen(this, 'pointerup', this.#onPointerUp, { signal: this.#disconnect.signal }); diff --git a/packages/html/src/media/tests/container-element.test.ts b/packages/html/src/media/tests/container-element.test.ts new file mode 100644 index 000000000..70a635615 --- /dev/null +++ b/packages/html/src/media/tests/container-element.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { MediaContainerElement } from '../container-element'; + +let tagCounter = 0; + +function uniqueTag(base: string): string { + return `${base}-${tagCounter++}`; +} + +function createElement(Base: abstract new () => Element): Element { + const tag = uniqueTag('test-media-container'); + customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {}); + return document.createElement(tag) as Element; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('MediaContainerElement', () => { + it('provides default focus and accessibility attributes', () => { + const container = createElement(MediaContainerElement); + + document.body.append(container); + + expect(container.getAttribute('tabindex')).toBe('0'); + expect(container.getAttribute('role')).toBe('group'); + expect(container.getAttribute('aria-label')).toBe('Media player'); + }); + + it('preserves explicit role and aria-label', () => { + const container = createElement(MediaContainerElement); + container.setAttribute('role', 'region'); + container.setAttribute('aria-label', 'Video player'); + + document.body.append(container); + + expect(container.getAttribute('role')).toBe('region'); + expect(container.getAttribute('aria-label')).toBe('Video player'); + }); + + it('uses aria-labelledby instead of the default label when provided', () => { + const container = createElement(MediaContainerElement); + container.setAttribute('aria-labelledby', 'player-title'); + + document.body.append(container); + + expect(container.getAttribute('aria-labelledby')).toBe('player-title'); + expect(container.hasAttribute('aria-label')).toBe(false); + }); +}); diff --git a/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts b/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts index a523d8688..81b44133a 100644 --- a/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts +++ b/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts @@ -4,8 +4,11 @@ import { VolumeIndicatorCSSVars, VolumeIndicatorDataAttrs, } from '@videojs/core'; -import { getIndicatorVisibilityCoordinator } from '@videojs/core/dom'; +import { type AnyPlayerStore, getIndicatorVisibilityCoordinator } from '@videojs/core/dom'; +import { ContextProvider } from '@videojs/element/context'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { playerContext } from '../../../player/context'; +import { MediaElement } from '../../media-element'; import { SeekIndicatorElement } from '../../seek-indicator/seek-indicator-element'; import { SeekIndicatorValueElement } from '../../seek-indicator/seek-indicator-value-element'; import { StatusAnnouncerElement } from '../../status-announcer/status-announcer-element'; @@ -20,6 +23,45 @@ afterEach(() => { document.body.replaceChildren(); }); +function defineElement(tagName: string, Base: CustomElementConstructor): void { + if (!customElements.get(tagName)) customElements.define(tagName, Base); +} + +function createTestStore(initialState: Record = {}) { + let state = initialState; + const listeners = new Set<() => void>(); + const store = { + get state() { + return state; + }, + subscribe(callback: () => void) { + listeners.add(callback); + return () => listeners.delete(callback); + }, + } as unknown as AnyPlayerStore; + + const setState = (partial: Record) => { + state = { ...state, ...partial }; + for (const listener of listeners) listener(); + }; + + return { store, setState }; +} + +class TestStatusAnnouncerPlayerElement extends MediaElement { + store = createTestStore().store; + + readonly #provider = new ContextProvider(this, { context: playerContext }); + + override connectedCallback(): void { + this.#provider.setValue(this.store); + super.connectedCallback(); + } +} + +defineElement(StatusAnnouncerElement.tagName, StatusAnnouncerElement); +defineElement('test-status-announcer-player', TestStatusAnnouncerPlayerElement); + describe('input indicators', () => { it('exposes standalone indicator tag names', () => { expect(StatusIndicatorElement.tagName).toBe('media-status-indicator'); @@ -84,6 +126,80 @@ describe('input indicators', () => { expect(host.hasAttribute('data-level')).toBe(false); }); + it('updates StatusAnnouncerElement live text from store snapshots', async () => { + const { store, setState } = createTestStore({ paused: true }); + const provider = document.createElement('test-status-announcer-player') as TestStatusAnnouncerPlayerElement; + provider.store = store; + provider.innerHTML = ''; + document.body.append(provider); + await provider.updateComplete; + + const announcer = provider.querySelector('media-status-announcer')!; + expect(announcer.textContent).toBe(''); + + setState({ paused: false }); + await Promise.resolve(); + await (announcer as StatusAnnouncerElement).updateComplete; + + expect(announcer.textContent).toBe('Playing'); + }); + + it('does not announce completed seeks while a time slider is focused', async () => { + vi.useFakeTimers(); + const slider = document.createElement('button'); + slider.setAttribute('role', 'slider'); + document.body.append(slider); + slider.focus(); + + try { + const { store, setState } = createTestStore({ currentTime: 10, duration: 120, seeking: false }); + const provider = document.createElement('test-status-announcer-player') as TestStatusAnnouncerPlayerElement; + provider.store = store; + provider.innerHTML = ''; + document.body.append(provider); + await provider.updateComplete; + + const announcer = provider.querySelector('media-status-announcer')!; + setState({ currentTime: 45, seeking: true }); + await Promise.resolve(); + setState({ seeking: false }); + await Promise.resolve(); + vi.advanceTimersByTime(200); + await (announcer as StatusAnnouncerElement).updateComplete; + + expect(announcer.textContent).toBe(''); + } finally { + vi.useRealTimers(); + } + }); + + it('does not announce volume changes while a volume slider is focused', async () => { + vi.useFakeTimers(); + const slider = document.createElement('button'); + slider.setAttribute('role', 'slider'); + document.body.append(slider); + slider.focus(); + + try { + const { store, setState } = createTestStore({ volume: 0.5, muted: false }); + const provider = document.createElement('test-status-announcer-player') as TestStatusAnnouncerPlayerElement; + provider.store = store; + provider.innerHTML = ''; + document.body.append(provider); + await provider.updateComplete; + + const announcer = provider.querySelector('media-status-announcer')!; + setState({ volume: 0.75 }); + await Promise.resolve(); + vi.advanceTimersByTime(200); + await (announcer as StatusAnnouncerElement).updateComplete; + + expect(announcer.textContent).toBe(''); + } finally { + vi.useRealTimers(); + } + }); + it('shares a visibility coordinator per container', () => { const container = document.createElement('div'); const first = { close: vi.fn() }; diff --git a/packages/html/src/ui/status-announcer/status-announcer-element.ts b/packages/html/src/ui/status-announcer/status-announcer-element.ts index e409204c6..352699af9 100644 --- a/packages/html/src/ui/status-announcer/status-announcer-element.ts +++ b/packages/html/src/ui/status-announcer/status-announcer-element.ts @@ -1,10 +1,9 @@ import { StatusAnnouncerCore } from '@videojs/core'; -import { getMediaSnapshot, subscribeToInputActions } from '@videojs/core/dom'; +import { applyVisuallyHiddenStyle, getMediaSnapshot, isSliderFocused } from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextConsumer } from '@videojs/element/context'; -import { containerContext, playerContext } from '../../player/context'; -import { PlayerController } from '../../player/player-controller'; +import { playerContext } from '../../player/context'; import { MediaElement } from '../media-element'; export class StatusAnnouncerElement extends MediaElement { @@ -17,21 +16,22 @@ export class StatusAnnouncerElement extends MediaElement { closeDelay: number | undefined; readonly #core = new StatusAnnouncerCore(); - readonly #player = new PlayerController(this, playerContext); - readonly #container = new ContextConsumer(this, { - context: containerContext, + readonly #player = new ContextConsumer(this, { + context: playerContext, callback: () => this.#reconnect(), subscribe: true, }); #disconnect: AbortController | null = null; - #inputActionUnsubscribe: (() => void) | null = null; + #storeUnsubscribe: (() => void) | null = null; + #liveText: HTMLElement | null = null; override connectedCallback(): void { super.connectedCallback(); if (this.destroyed) return; this.setAttribute('role', 'status'); + this.#ensureLiveText(); this.#disconnect = new AbortController(); this.#core.state.subscribe(() => this.requestUpdate(), { signal: this.#disconnect.signal }); @@ -40,43 +40,55 @@ export class StatusAnnouncerElement extends MediaElement { override disconnectedCallback(): void { super.disconnectedCallback(); - this.#inputActionUnsubscribe?.(); - this.#inputActionUnsubscribe = null; + this.#storeUnsubscribe?.(); + this.#storeUnsubscribe = null; this.#disconnect?.abort(); this.#disconnect = null; } override destroyCallback(): void { - this.#inputActionUnsubscribe?.(); + this.#storeUnsubscribe?.(); this.#core.destroy(); super.destroyCallback(); } protected override willUpdate(changed: PropertyValues): void { super.willUpdate(changed); - this.#core.setProps({ closeDelay: this.closeDelay }); + this.#core.setProps({ + closeDelay: this.closeDelay, + shouldAnnounceSeek: () => !isSliderFocused(this.ownerDocument), + shouldAnnounceVolume: () => !isSliderFocused(this.ownerDocument), + }); } protected override update(changed: PropertyValues): void { super.update(changed); const label = this.#core.state.current.label; - if (label) { - this.setAttribute('aria-label', label); - } else { - this.removeAttribute('aria-label'); - } + this.#ensureLiveText().textContent = label ?? ''; } #reconnect(): void { - this.#inputActionUnsubscribe?.(); - this.#inputActionUnsubscribe = null; + this.#storeUnsubscribe?.(); + this.#storeUnsubscribe = null; - const container = this.#container.value?.container; - if (!container) return; + const store = this.#player.value; + if (!store) return; - this.#inputActionUnsubscribe = subscribeToInputActions(container, (event) => { - this.#core.processEvent(event, getMediaSnapshot(this.#player.value)); - }); + this.#core.processSnapshot(getMediaSnapshot(store)); + this.#storeUnsubscribe = store.subscribe(() => this.#core.processSnapshot(getMediaSnapshot(store))); + } + + #ensureLiveText(): HTMLElement { + if (this.#liveText?.isConnected) return this.#liveText; + + const existing = this.querySelector('[data-status-announcer-content]'); + this.#liveText = existing ?? document.createElement('span'); + this.#liveText.setAttribute('data-status-announcer-content', ''); + applyVisuallyHiddenStyle(this.#liveText); + + if (!existing) this.append(this.#liveText); + + return this.#liveText; } } diff --git a/packages/react/src/player/context.tsx b/packages/react/src/player/context.tsx index c72d005fc..39a523d8d 100644 --- a/packages/react/src/player/context.tsx +++ b/packages/react/src/player/context.tsx @@ -1,7 +1,13 @@ 'use client'; import type { Media } from '@videojs/core'; -import type { MediaContainer, PopupGroup } from '@videojs/core/dom'; +import { + DEFAULT_CONTAINER_LABEL, + DEFAULT_CONTAINER_ROLE, + DEFAULT_CONTAINER_TAB_INDEX, + type MediaContainer, + type PopupGroup, +} from '@videojs/core/dom'; import type { UnknownState, UnknownStore } from '@videojs/store'; import { useStore } from '@videojs/store/react'; import type { Dispatch, HTMLAttributes, ReactNode, PointerEvent as ReactPointerEvent, SetStateAction } from 'react'; @@ -118,7 +124,14 @@ export interface ContainerProps extends HTMLAttributes { } export const Container = forwardRef(function Container( - { children, tabIndex = 0, ...props }, + { + children, + tabIndex = DEFAULT_CONTAINER_TAB_INDEX, + role = DEFAULT_CONTAINER_ROLE, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + ...props + }, ref ) { const setContainer = useContainerAttach(); @@ -140,8 +153,20 @@ export const Container = forwardRef(function Con } }; + const accessibleNameProps = + ariaLabel !== undefined || ariaLabelledBy !== undefined + ? { 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy } + : { 'aria-label': DEFAULT_CONTAINER_LABEL }; + return ( -
+
{children}
); diff --git a/packages/react/src/player/tests/context.test.tsx b/packages/react/src/player/tests/context.test.tsx index 83ac48bf9..46910a30f 100644 --- a/packages/react/src/player/tests/context.test.tsx +++ b/packages/react/src/player/tests/context.test.tsx @@ -227,6 +227,48 @@ describe('Container', () => { expect(container.querySelector('span')).toBeTruthy(); }); + it('provides a default accessible name', () => { + const value = createContextValue(); + + const { container } = render( + + + + ); + + const el = container.firstElementChild; + expect(el?.getAttribute('role')).toBe('group'); + expect(el?.getAttribute('aria-label')).toBe('Media player'); + }); + + it('preserves explicit accessible naming', () => { + const value = createContextValue(); + + const { container } = render( + + + + ); + + const el = container.firstElementChild; + expect(el?.getAttribute('role')).toBe('region'); + expect(el?.getAttribute('aria-label')).toBe('Video player'); + }); + + it('uses aria-labelledby instead of the default label when provided', () => { + const value = createContextValue(); + + const { container } = render( + + + + ); + + const el = container.firstElementChild; + expect(el?.getAttribute('aria-labelledby')).toBe('player-title'); + expect(el?.hasAttribute('aria-label')).toBe(false); + }); + it('registers container element via setContainer', () => { const setContainer = vi.fn(); const value = createContextValue({ setContainer }); diff --git a/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx b/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx index a37d475d1..3d2c55605 100644 --- a/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx +++ b/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx @@ -1,5 +1,6 @@ -import { cleanup, fireEvent, render } from '@testing-library/react'; +import { act, cleanup, fireEvent, render } from '@testing-library/react'; import type { SeekIndicatorCore, StatusIndicatorCore, VolumeIndicatorCore } from '@videojs/core'; +import type { UnknownStore } from '@videojs/store'; import type { ReactNode } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -43,6 +44,67 @@ describe('input indicators', () => { expect(getByRole('status').textContent).toBe(''); }); + it('updates StatusAnnouncer live text from store snapshots', async () => { + const { store, setState } = createTestStore({ paused: true }); + const { getByRole } = renderWithPlayer(, store); + await act(async () => {}); + + expect(getByRole('status').textContent).toBe(''); + + setState({ paused: false }); + await act(async () => {}); + + expect(getByRole('status').textContent).toBe('Playing'); + }); + + it('does not announce completed seeks while a time slider is focused', async () => { + vi.useFakeTimers(); + const slider = document.createElement('button'); + slider.setAttribute('role', 'slider'); + document.body.append(slider); + slider.focus(); + + try { + const { store, setState } = createTestStore({ currentTime: 10, duration: 120, seeking: false }); + const { getByRole } = renderWithPlayer(, store); + await act(async () => {}); + + setState({ currentTime: 45, seeking: true }); + await act(async () => {}); + setState({ seeking: false }); + await act(async () => {}); + act(() => vi.advanceTimersByTime(200)); + + expect(getByRole('status').textContent).toBe(''); + } finally { + slider.remove(); + vi.useRealTimers(); + } + }); + + it('does not announce volume changes while a volume slider is focused', async () => { + vi.useFakeTimers(); + const slider = document.createElement('button'); + slider.setAttribute('role', 'slider'); + document.body.append(slider); + slider.focus(); + + try { + const { store, setState } = createTestStore({ volume: 0.5, muted: false }); + const { getByRole } = renderWithPlayer(, store); + await act(async () => {}); + + setState({ volume: 0.75 }); + await act(async () => {}); + act(() => vi.advanceTimersByTime(200)); + + expect(getByRole('status').textContent).toBe(''); + } finally { + slider.remove(); + vi.useRealTimers(); + } + }); + it('scopes the volume CSS variable to VolumeIndicator.Fill', () => { const state: VolumeIndicatorCore.State = { open: true, @@ -119,13 +181,33 @@ function VisibilityProbe({ close, id }: { close: () => void; id: string }) { ); } -function renderWithPlayer(ui: ReactNode) { +function createTestStore(initialState: Record = {}) { + let state = initialState; + const listeners = new Set<() => void>(); + const store = { + get state() { + return state; + }, + subscribe(callback: () => void) { + listeners.add(callback); + return () => listeners.delete(callback); + }, + } as unknown as UnknownStore; + + const setState = (partial: Record) => { + act(() => { + state = { ...state, ...partial }; + for (const listener of listeners) listener(); + }); + }; + + return { store, setState }; +} + +function renderWithPlayer(ui: ReactNode, store: UnknownStore = createTestStore().store) { const container = document.createElement('div'); const playerContextValue = { - store: { - state: {}, - subscribe: () => () => {}, - }, + store, media: null, setMedia: vi.fn(), container, diff --git a/packages/react/src/ui/status-announcer/status-announcer.tsx b/packages/react/src/ui/status-announcer/status-announcer.tsx index 4f8f6983c..a43b4e133 100644 --- a/packages/react/src/ui/status-announcer/status-announcer.tsx +++ b/packages/react/src/ui/status-announcer/status-announcer.tsx @@ -1,13 +1,14 @@ 'use client'; import { StatusAnnouncerCore } from '@videojs/core'; +import { getMediaSnapshot, isSliderFocused, visuallyHiddenStyle } from '@videojs/core/dom'; import type { ForwardedRef } from 'react'; -import { forwardRef, useState, useSyncExternalStore } from 'react'; +import { forwardRef, useEffect, useState, useSyncExternalStore } from 'react'; +import { usePlayer } from '../../player/context'; import type { UIComponentProps } from '../../utils/types'; import { useDestroy } from '../../utils/use-destroy'; import { renderElement } from '../../utils/use-render'; -import { useInputActionSubscription } from '../input-indicators/use-input-action-subscription'; export interface StatusAnnouncerProps extends UIComponentProps<'div', StatusAnnouncerCore.State>, @@ -19,13 +20,20 @@ export const StatusAnnouncer = forwardRef(function StatusAnnouncer( ) { const { render, className, style, closeDelay, labels, ...elementProps } = componentProps; const [core] = useState(() => new StatusAnnouncerCore()); + const store = usePlayer(); useDestroy(core); - core.setProps({ closeDelay, labels }); - - useInputActionSubscription((event, snapshot) => { - core.processEvent(event, snapshot); + core.setProps({ + closeDelay, + labels, + shouldAnnounceSeek: () => !isSliderFocused(), + shouldAnnounceVolume: () => !isSliderFocused(), }); + useEffect(() => { + core.processSnapshot(getMediaSnapshot(store)); + return store.subscribe(() => core.processSnapshot(getMediaSnapshot(store))); + }, [core, store]); + const state = useSyncExternalStore( (callback) => core.state.subscribe(callback), () => core.state.current, @@ -39,11 +47,11 @@ export const StatusAnnouncer = forwardRef(function StatusAnnouncer( state, ref: forwardedRef, props: [ + elementProps, { role: 'status', - 'aria-label': state.label ?? undefined, + children: {state.label ?? ''}, }, - elementProps, ], } ); From 3a52f3d7a4e0ea7a68b3295e8d42c9fb1628dd96 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Fri, 5 Jun 2026 11:29:34 +1000 Subject: [PATCH 02/11] fix(core): reset status announcer snapshots --- .../input-feedback/status-announcer-core.ts | 9 +++++ .../tests/status-indicator-core.test.ts | 25 +++++++++++++ .../tests/input-indicators.test.ts | 36 +++++++++++++++++-- .../status-announcer-element.ts | 1 + .../tests/input-indicators.test.tsx | 32 +++++++++++++++-- .../ui/status-announcer/status-announcer.tsx | 1 + 6 files changed, 98 insertions(+), 6 deletions(-) diff --git a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts index 45147b7f3..e6f6e82f2 100644 --- a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts +++ b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts @@ -43,6 +43,15 @@ export class StatusAnnouncerCore { this.#props = props; } + resetSnapshot(): void { + this.#snapshot = null; + this.#seekStartTime = null; + this.#seekTargetTime = null; + this.#clearSeekTimer(); + this.#clearVolumeTimer(); + this.#close.close(); + } + destroy(): void { this.#clearSeekTimer(); this.#clearVolumeTimer(); diff --git a/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts b/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts index 24b1d1764..f4295d454 100644 --- a/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts +++ b/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts @@ -59,6 +59,31 @@ describe('StatusAnnouncerCore', () => { expect(core.state.current.label).toBeNull(); }); + it('uses the first snapshot after reset as a baseline only', () => { + const core = new StatusAnnouncerCore(); + + core.processSnapshot({ paused: false, volume: 0.5, muted: false }); + core.resetSnapshot(); + + expect(core.processSnapshot({ paused: true, volume: 0.75, muted: true })).toBe(false); + + vi.advanceTimersByTime(200); + + expect(core.state.current.label).toBeNull(); + }); + + it('clears pending debounced announcements when reset', () => { + const core = new StatusAnnouncerCore(); + + core.processSnapshot({ volume: 0.5, muted: false }); + core.processSnapshot({ volume: 0.75, muted: false }); + core.resetSnapshot(); + + vi.advanceTimersByTime(200); + + expect(core.state.current.label).toBeNull(); + }); + it('announces confirmed playback, captions, fullscreen, pip, and playback-rate changes', () => { const core = new StatusAnnouncerCore(); let snapshot = { diff --git a/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts b/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts index 81b44133a..53b6a93bf 100644 --- a/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts +++ b/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts @@ -49,12 +49,20 @@ function createTestStore(initialState: Record = {}) { } class TestStatusAnnouncerPlayerElement extends MediaElement { - store = createTestStore().store; - readonly #provider = new ContextProvider(this, { context: playerContext }); + #store = createTestStore().store; + + get store(): AnyPlayerStore { + return this.#store; + } + + set store(store: AnyPlayerStore) { + this.#store = store; + if (this.isConnected) this.#provider.setValue(this.#store); + } override connectedCallback(): void { - this.#provider.setValue(this.store); + this.#provider.setValue(this.#store); super.connectedCallback(); } } @@ -144,6 +152,28 @@ describe('input indicators', () => { expect(announcer.textContent).toBe('Playing'); }); + it('uses the next store snapshot as baseline when StatusAnnouncerElement store changes', async () => { + const first = createTestStore({ paused: false }); + const second = createTestStore({ paused: false }); + const provider = document.createElement('test-status-announcer-player') as TestStatusAnnouncerPlayerElement; + provider.store = first.store; + provider.innerHTML = ''; + document.body.append(provider); + await provider.updateComplete; + + const announcer = provider.querySelector('media-status-announcer')!; + first.setState({ paused: true }); + await Promise.resolve(); + await (announcer as StatusAnnouncerElement).updateComplete; + expect(announcer.textContent).toBe('Paused'); + + provider.store = second.store; + await Promise.resolve(); + await (announcer as StatusAnnouncerElement).updateComplete; + + expect(announcer.textContent).toBe(''); + }); + it('does not announce completed seeks while a time slider is focused', async () => { vi.useFakeTimers(); const slider = document.createElement('button'); diff --git a/packages/html/src/ui/status-announcer/status-announcer-element.ts b/packages/html/src/ui/status-announcer/status-announcer-element.ts index 352699af9..049790f08 100644 --- a/packages/html/src/ui/status-announcer/status-announcer-element.ts +++ b/packages/html/src/ui/status-announcer/status-announcer-element.ts @@ -71,6 +71,7 @@ export class StatusAnnouncerElement extends MediaElement { #reconnect(): void { this.#storeUnsubscribe?.(); this.#storeUnsubscribe = null; + this.#core.resetSnapshot(); const store = this.#player.value; if (!store) return; diff --git a/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx b/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx index 3d2c55605..6de64d8b9 100644 --- a/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx +++ b/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx @@ -57,6 +57,30 @@ describe('input indicators', () => { expect(getByRole('status').textContent).toBe('Playing'); }); + it('uses the next store snapshot as baseline when StatusAnnouncer store changes', async () => { + const first = createTestStore({ paused: false }); + const second = createTestStore({ paused: false }); + const { getByRole, rerender } = render( + + + + ); + await act(async () => {}); + + first.setState({ paused: true }); + await act(async () => {}); + expect(getByRole('status').textContent).toBe('Paused'); + + rerender( + + + + ); + await act(async () => {}); + + expect(getByRole('status').textContent).toBe(''); + }); + it('does not announce completed seeks while a time slider is focused', async () => { vi.useFakeTimers(); const slider = document.createElement('button'); @@ -204,15 +228,17 @@ function createTestStore(initialState: Record = {}) { return { store, setState }; } -function renderWithPlayer(ui: ReactNode, store: UnknownStore = createTestStore().store) { +function createPlayerContextValue(store: UnknownStore): PlayerContextValue { const container = document.createElement('div'); - const playerContextValue = { + return { store, media: null, setMedia: vi.fn(), container, setContainer: vi.fn(), } as unknown as PlayerContextValue; +} - return render({ui}); +function renderWithPlayer(ui: ReactNode, store: UnknownStore = createTestStore().store) { + return render({ui}); } diff --git a/packages/react/src/ui/status-announcer/status-announcer.tsx b/packages/react/src/ui/status-announcer/status-announcer.tsx index a43b4e133..e9c55a794 100644 --- a/packages/react/src/ui/status-announcer/status-announcer.tsx +++ b/packages/react/src/ui/status-announcer/status-announcer.tsx @@ -30,6 +30,7 @@ export const StatusAnnouncer = forwardRef(function StatusAnnouncer( }); useEffect(() => { + core.resetSnapshot(); core.processSnapshot(getMediaSnapshot(store)); return store.subscribe(() => core.processSnapshot(getMediaSnapshot(store))); }, [core, store]); From 68aed9f418ee8ab163cc55585261a86896fe38de Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Fri, 5 Jun 2026 11:34:39 +1000 Subject: [PATCH 03/11] fix(core): cancel stale status announcer timers --- .../input-feedback/status-announcer-core.ts | 22 +++++++++++--- .../tests/status-indicator-core.test.ts | 29 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts index e6f6e82f2..4bdd7d8bc 100644 --- a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts +++ b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts @@ -98,11 +98,11 @@ export class StatusAnnouncerCore { accepted = this.#announce(formatPlaybackRateAnnouncerLabel(snapshot.playbackRate, labels)); } - if (this.#processVolumeSnapshot(previous, snapshot, labels)) { + if (this.#processVolumeSnapshot(previous, snapshot, labels, accepted)) { accepted = true; } - if (this.#processSeekSnapshot(previous, snapshot, labels)) { + if (this.#processSeekSnapshot(previous, snapshot, labels, accepted)) { accepted = true; } @@ -117,14 +117,22 @@ export class StatusAnnouncerCore { } #announce(label: string): boolean { + this.#clearSeekTimer(); + this.#clearVolumeTimer(); this.state.patch({ label }); this.#close.arm(); return true; } - #processVolumeSnapshot(previous: MediaSnapshot, snapshot: MediaSnapshot, labels: StatusAnnouncerLabels): boolean { + #processVolumeSnapshot( + previous: MediaSnapshot, + snapshot: MediaSnapshot, + labels: StatusAnnouncerLabels, + suppress: boolean + ): boolean { if (!hasChanged(previous.volume, snapshot.volume) && !hasChanged(previous.muted, snapshot.muted)) return false; if (this.#props.shouldAnnounceVolume?.(snapshot) === false) return false; + if (suppress) return false; const volume = snapshot.volume ?? previous.volume; const muted = snapshot.muted ?? previous.muted; @@ -136,7 +144,12 @@ export class StatusAnnouncerCore { return true; } - #processSeekSnapshot(previous: MediaSnapshot, snapshot: MediaSnapshot, labels: StatusAnnouncerLabels): boolean { + #processSeekSnapshot( + previous: MediaSnapshot, + snapshot: MediaSnapshot, + labels: StatusAnnouncerLabels, + suppress: boolean + ): boolean { if (previous.seeking !== true && snapshot.seeking === true) { this.#seekStartTime = previous.currentTime ?? null; this.#seekTargetTime = snapshot.currentTime ?? null; @@ -158,6 +171,7 @@ export class StatusAnnouncerCore { if (targetTime === undefined || targetTime === null || Object.is(targetTime, startTime)) return false; if (this.#props.shouldAnnounceSeek?.(snapshot) === false) return false; + if (suppress) return false; this.#scheduleSeekAnnouncement(formatSeekAnnouncerLabel(targetTime, labels)); return true; diff --git a/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts b/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts index f4295d454..641ce405c 100644 --- a/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts +++ b/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts @@ -84,6 +84,35 @@ describe('StatusAnnouncerCore', () => { expect(core.state.current.label).toBeNull(); }); + it('clears pending volume announcements when an immediate snapshot announcement wins', () => { + const core = new StatusAnnouncerCore(); + + core.processSnapshot({ paused: true, volume: 0.5, muted: false }); + core.processSnapshot({ paused: true, volume: 0.75, muted: false }); + core.processSnapshot({ paused: false, volume: 0.75, muted: false }); + + expect(core.state.current.label).toBe('Playing'); + + vi.advanceTimersByTime(200); + + expect(core.state.current.label).toBe('Playing'); + }); + + it('clears pending seek announcements when an immediate snapshot announcement wins', () => { + const core = new StatusAnnouncerCore(); + + core.processSnapshot({ playbackRate: 1, currentTime: 10, duration: 120, seeking: false }); + core.processSnapshot({ playbackRate: 1, currentTime: 45, duration: 120, seeking: true }); + core.processSnapshot({ playbackRate: 1, currentTime: 45, duration: 120, seeking: false }); + core.processSnapshot({ playbackRate: 1.25, currentTime: 45, duration: 120, seeking: false }); + + expect(core.state.current.label).toBe('Playback rate 1.25x'); + + vi.advanceTimersByTime(200); + + expect(core.state.current.label).toBe('Playback rate 1.25x'); + }); + it('announces confirmed playback, captions, fullscreen, pip, and playback-rate changes', () => { const core = new StatusAnnouncerCore(); let snapshot = { From e336fd4964a74f5478520d84b7b1107a01a1ecf5 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Fri, 5 Jun 2026 11:39:18 +1000 Subject: [PATCH 04/11] fix(core): guard status announcer debounce --- .../input-feedback/status-announcer-core.ts | 29 ++++++---- .../tests/status-indicator-core.test.ts | 53 +++++++++++++++++++ 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts index 4bdd7d8bc..a2021a516 100644 --- a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts +++ b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts @@ -77,25 +77,30 @@ export class StatusAnnouncerCore { const labels = this.#getLabels(); let accepted = false; + const immediateLabels: string[] = []; if (hasChanged(previous.paused, snapshot.paused)) { - accepted = this.#announce(snapshot.paused ? labels.paused : labels.playing); + immediateLabels.push(snapshot.paused ? labels.paused : labels.playing); } if (hasChanged(previous.subtitlesShowing, snapshot.subtitlesShowing) && snapshot.subtitlesAvailable !== false) { - accepted = this.#announce(snapshot.subtitlesShowing ? labels.captionsOn : labels.captionsOff); + immediateLabels.push(snapshot.subtitlesShowing ? labels.captionsOn : labels.captionsOff); } if (hasChanged(previous.fullscreen, snapshot.fullscreen)) { - accepted = this.#announce(snapshot.fullscreen ? labels.fullscreen : labels.exitFullscreen); + immediateLabels.push(snapshot.fullscreen ? labels.fullscreen : labels.exitFullscreen); } if (hasChanged(previous.pip, snapshot.pip)) { - accepted = this.#announce(snapshot.pip ? labels.pictureInPicture : labels.exitPictureInPicture); + immediateLabels.push(snapshot.pip ? labels.pictureInPicture : labels.exitPictureInPicture); } if (hasChanged(previous.playbackRate, snapshot.playbackRate)) { - accepted = this.#announce(formatPlaybackRateAnnouncerLabel(snapshot.playbackRate, labels)); + immediateLabels.push(formatPlaybackRateAnnouncerLabel(snapshot.playbackRate, labels)); + } + + if (immediateLabels.length > 0) { + accepted = this.#announce(formatAnnouncementLabels(immediateLabels)); } if (this.#processVolumeSnapshot(previous, snapshot, labels, accepted)) { @@ -140,7 +145,7 @@ export class StatusAnnouncerCore { if (volume === undefined && muted === undefined) return false; const label = muted || (volume ?? 0) <= 0 ? labels.muted : `${labels.volume} ${formatVolumeValue(volume ?? 0)}`; - this.#scheduleVolumeAnnouncement(label); + this.#scheduleVolumeAnnouncement(label, snapshot); return true; } @@ -173,22 +178,24 @@ export class StatusAnnouncerCore { if (this.#props.shouldAnnounceSeek?.(snapshot) === false) return false; if (suppress) return false; - this.#scheduleSeekAnnouncement(formatSeekAnnouncerLabel(targetTime, labels)); + this.#scheduleSeekAnnouncement(formatSeekAnnouncerLabel(targetTime, labels), snapshot); return true; } - #scheduleVolumeAnnouncement(label: string): void { + #scheduleVolumeAnnouncement(label: string, snapshot: MediaSnapshot): void { this.#clearVolumeTimer(); this.#volumeTimer = setTimeout(() => { this.#volumeTimer = null; + if (this.#props.shouldAnnounceVolume?.(snapshot) === false) return; this.#announce(label); }, SNAPSHOT_ANNOUNCER_DEBOUNCE); } - #scheduleSeekAnnouncement(label: string): void { + #scheduleSeekAnnouncement(label: string, snapshot: MediaSnapshot): void { this.#clearSeekTimer(); this.#seekTimer = setTimeout(() => { this.#seekTimer = null; + if (this.#props.shouldAnnounceSeek?.(snapshot) === false) return; this.#announce(label); }, SNAPSHOT_ANNOUNCER_DEBOUNCE); } @@ -214,3 +221,7 @@ export namespace StatusAnnouncerCore { function hasChanged(previous: Value | undefined, next: Value | undefined): next is Value { return previous !== undefined && next !== undefined && !Object.is(previous, next); } + +function formatAnnouncementLabels(labels: string[]): string { + return labels.join('. '); +} diff --git a/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts b/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts index 641ce405c..f3791b46f 100644 --- a/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts +++ b/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts @@ -113,6 +113,59 @@ describe('StatusAnnouncerCore', () => { expect(core.state.current.label).toBe('Playback rate 1.25x'); }); + it('rechecks volume suppression when the debounced announcement fires', () => { + let shouldAnnounce = true; + const core = new StatusAnnouncerCore(); + core.setProps({ shouldAnnounceVolume: () => shouldAnnounce }); + + core.processSnapshot({ volume: 0.5, muted: false }); + expect(core.processSnapshot({ volume: 0.75, muted: false })).toBe(true); + shouldAnnounce = false; + + vi.advanceTimersByTime(200); + + expect(core.state.current.label).toBeNull(); + }); + + it('rechecks seek suppression when the debounced announcement fires', () => { + let shouldAnnounce = true; + const core = new StatusAnnouncerCore(); + core.setProps({ shouldAnnounceSeek: () => shouldAnnounce }); + + core.processSnapshot({ currentTime: 10, duration: 120, seeking: false }); + core.processSnapshot({ currentTime: 45, duration: 120, seeking: true }); + expect(core.processSnapshot({ currentTime: 45, duration: 120, seeking: false })).toBe(true); + shouldAnnounce = false; + + vi.advanceTimersByTime(200); + + expect(core.state.current.label).toBeNull(); + }); + + it('combines multiple immediate snapshot announcements', () => { + const core = new StatusAnnouncerCore(); + + core.processSnapshot({ + paused: true, + subtitlesShowing: false, + subtitlesAvailable: true, + fullscreen: false, + playbackRate: 1, + }); + + expect( + core.processSnapshot({ + paused: false, + subtitlesShowing: true, + subtitlesAvailable: true, + fullscreen: true, + playbackRate: 1.25, + }) + ).toBe(true); + + expect(core.state.current.label).toBe('Playing. Captions on. Fullscreen. Playback rate 1.25x'); + }); + it('announces confirmed playback, captions, fullscreen, pip, and playback-rate changes', () => { const core = new StatusAnnouncerCore(); let snapshot = { From 056aa511c3a7cea5f104b16c785a7a28048b3614 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Fri, 5 Jun 2026 11:40:42 +1000 Subject: [PATCH 05/11] fix(core): rename status announcer queue --- .../ui/input-feedback/status-announcer-core.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts index a2021a516..5a04cbd04 100644 --- a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts +++ b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts @@ -77,30 +77,30 @@ export class StatusAnnouncerCore { const labels = this.#getLabels(); let accepted = false; - const immediateLabels: string[] = []; + const queue: string[] = []; if (hasChanged(previous.paused, snapshot.paused)) { - immediateLabels.push(snapshot.paused ? labels.paused : labels.playing); + queue.push(snapshot.paused ? labels.paused : labels.playing); } if (hasChanged(previous.subtitlesShowing, snapshot.subtitlesShowing) && snapshot.subtitlesAvailable !== false) { - immediateLabels.push(snapshot.subtitlesShowing ? labels.captionsOn : labels.captionsOff); + queue.push(snapshot.subtitlesShowing ? labels.captionsOn : labels.captionsOff); } if (hasChanged(previous.fullscreen, snapshot.fullscreen)) { - immediateLabels.push(snapshot.fullscreen ? labels.fullscreen : labels.exitFullscreen); + queue.push(snapshot.fullscreen ? labels.fullscreen : labels.exitFullscreen); } if (hasChanged(previous.pip, snapshot.pip)) { - immediateLabels.push(snapshot.pip ? labels.pictureInPicture : labels.exitPictureInPicture); + queue.push(snapshot.pip ? labels.pictureInPicture : labels.exitPictureInPicture); } if (hasChanged(previous.playbackRate, snapshot.playbackRate)) { - immediateLabels.push(formatPlaybackRateAnnouncerLabel(snapshot.playbackRate, labels)); + queue.push(formatPlaybackRateAnnouncerLabel(snapshot.playbackRate, labels)); } - if (immediateLabels.length > 0) { - accepted = this.#announce(formatAnnouncementLabels(immediateLabels)); + if (queue.length > 0) { + accepted = this.#announce(formatAnnouncementLabels(queue)); } if (this.#processVolumeSnapshot(previous, snapshot, labels, accepted)) { From b4a1c4d31a7a287daaa6fe1e2f953392f976ab78 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Fri, 5 Jun 2026 11:42:24 +1000 Subject: [PATCH 06/11] chore(core): simplify status announcer names --- .../input-feedback/status-announcer-core.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts index 5a04cbd04..c66f8a903 100644 --- a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts +++ b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts @@ -13,7 +13,7 @@ import { type StatusAnnouncerLabels, } from './status'; -const SNAPSHOT_ANNOUNCER_DEBOUNCE = 200; +const ANNOUNCEMENT_DEBOUNCE = 200; export interface StatusAnnouncerProps extends IndicatorCoreProps { labels?: Partial | undefined; @@ -76,7 +76,7 @@ export class StatusAnnouncerCore { if (!previous) return false; const labels = this.#getLabels(); - let accepted = false; + let handled = false; const queue: string[] = []; if (hasChanged(previous.paused, snapshot.paused)) { @@ -100,18 +100,18 @@ export class StatusAnnouncerCore { } if (queue.length > 0) { - accepted = this.#announce(formatAnnouncementLabels(queue)); + handled = this.#announce(formatAnnouncementQueue(queue)); } - if (this.#processVolumeSnapshot(previous, snapshot, labels, accepted)) { - accepted = true; + if (this.#processVolumeSnapshot(previous, snapshot, labels, handled)) { + handled = true; } - if (this.#processSeekSnapshot(previous, snapshot, labels, accepted)) { - accepted = true; + if (this.#processSeekSnapshot(previous, snapshot, labels, handled)) { + handled = true; } - return accepted; + return handled; } #getLabels(): StatusAnnouncerLabels { @@ -133,11 +133,11 @@ export class StatusAnnouncerCore { previous: MediaSnapshot, snapshot: MediaSnapshot, labels: StatusAnnouncerLabels, - suppress: boolean + alreadyHandled: boolean ): boolean { if (!hasChanged(previous.volume, snapshot.volume) && !hasChanged(previous.muted, snapshot.muted)) return false; if (this.#props.shouldAnnounceVolume?.(snapshot) === false) return false; - if (suppress) return false; + if (alreadyHandled) return false; const volume = snapshot.volume ?? previous.volume; const muted = snapshot.muted ?? previous.muted; @@ -153,7 +153,7 @@ export class StatusAnnouncerCore { previous: MediaSnapshot, snapshot: MediaSnapshot, labels: StatusAnnouncerLabels, - suppress: boolean + alreadyHandled: boolean ): boolean { if (previous.seeking !== true && snapshot.seeking === true) { this.#seekStartTime = previous.currentTime ?? null; @@ -176,7 +176,7 @@ export class StatusAnnouncerCore { if (targetTime === undefined || targetTime === null || Object.is(targetTime, startTime)) return false; if (this.#props.shouldAnnounceSeek?.(snapshot) === false) return false; - if (suppress) return false; + if (alreadyHandled) return false; this.#scheduleSeekAnnouncement(formatSeekAnnouncerLabel(targetTime, labels), snapshot); return true; @@ -188,7 +188,7 @@ export class StatusAnnouncerCore { this.#volumeTimer = null; if (this.#props.shouldAnnounceVolume?.(snapshot) === false) return; this.#announce(label); - }, SNAPSHOT_ANNOUNCER_DEBOUNCE); + }, ANNOUNCEMENT_DEBOUNCE); } #scheduleSeekAnnouncement(label: string, snapshot: MediaSnapshot): void { @@ -197,7 +197,7 @@ export class StatusAnnouncerCore { this.#seekTimer = null; if (this.#props.shouldAnnounceSeek?.(snapshot) === false) return; this.#announce(label); - }, SNAPSHOT_ANNOUNCER_DEBOUNCE); + }, ANNOUNCEMENT_DEBOUNCE); } #clearSeekTimer(): void { @@ -222,6 +222,6 @@ function hasChanged(previous: Value | undefined, next: Value | undefined) return previous !== undefined && next !== undefined && !Object.is(previous, next); } -function formatAnnouncementLabels(labels: string[]): string { - return labels.join('. '); +function formatAnnouncementQueue(queue: string[]): string { + return queue.join('. '); } From 25fec89466357355c6924f117633b25837468faa Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Fri, 5 Jun 2026 12:26:53 +1000 Subject: [PATCH 07/11] fix(packages): scope status announcer slider focus --- packages/core/src/dom/ui/slider-focus.ts | 10 ++- .../src/dom/ui/tests/input-action.test.ts | 28 +++++++++ packages/html/src/media/container-element.ts | 6 +- .../tests/input-indicators.test.ts | 38 +++++++++--- .../status-announcer-element.ts | 13 +++- packages/react/src/player/context.tsx | 5 +- .../tests/input-indicators.test.tsx | 62 ++++++++++++++++--- .../ui/status-announcer/status-announcer.tsx | 12 ++-- packages/utils/src/dom/focus.ts | 9 +++ packages/utils/src/dom/index.ts | 4 +- packages/utils/src/dom/predicates.ts | 8 +++ packages/utils/src/dom/tests/focus.test.ts | 18 ++++++ .../utils/src/dom/tests/predicates.test.ts | 28 ++++++++- packages/utils/src/dom/tests/tree.test.ts | 26 ++++++++ packages/utils/src/dom/tree.ts | 14 +++++ 15 files changed, 251 insertions(+), 30 deletions(-) create mode 100644 packages/utils/src/dom/focus.ts create mode 100644 packages/utils/src/dom/tests/focus.test.ts create mode 100644 packages/utils/src/dom/tests/tree.test.ts create mode 100644 packages/utils/src/dom/tree.ts diff --git a/packages/core/src/dom/ui/slider-focus.ts b/packages/core/src/dom/ui/slider-focus.ts index 8740982c8..f8ddabedd 100644 --- a/packages/core/src/dom/ui/slider-focus.ts +++ b/packages/core/src/dom/ui/slider-focus.ts @@ -1,3 +1,9 @@ -export function isSliderFocused(root: Document = document): boolean { - return root.activeElement?.getAttribute('role') === 'slider'; +import { containsComposed, getDeepActiveElement, isDocument } from '@videojs/utils/dom'; + +export function isSliderFocused(root: Document | Element = document): boolean { + const doc = isDocument(root) ? root : root.ownerDocument; + const active = getDeepActiveElement(doc); + + if (active?.getAttribute('role') !== 'slider') return false; + return isDocument(root) || containsComposed(root, active); } diff --git a/packages/core/src/dom/ui/tests/input-action.test.ts b/packages/core/src/dom/ui/tests/input-action.test.ts index 34781123a..78233993b 100644 --- a/packages/core/src/dom/ui/tests/input-action.test.ts +++ b/packages/core/src/dom/ui/tests/input-action.test.ts @@ -6,6 +6,7 @@ import { type MediaSnapshotStore, toInputActionEvent, } from '../input-action'; +import { isSliderFocused } from '../slider-focus'; function mockStore(state: Record): MediaSnapshotStore { return { state }; @@ -76,4 +77,31 @@ describe('input-action', () => { expect(first.close).toHaveBeenCalledOnce(); expect(second.close).not.toHaveBeenCalled(); }); + + it('detects focused sliders inside open shadow roots', () => { + const container = document.createElement('div'); + const host = document.createElement('div'); + const shadow = host.attachShadow({ mode: 'open' }); + const slider = document.createElement('button'); + slider.setAttribute('role', 'slider'); + shadow.append(slider); + container.append(host); + document.body.append(container); + + slider.focus(); + + expect(isSliderFocused(container)).toBe(true); + }); + + it('ignores focused sliders outside the scoped container', () => { + const container = document.createElement('div'); + const slider = document.createElement('button'); + slider.setAttribute('role', 'slider'); + document.body.append(container, slider); + + slider.focus(); + + expect(isSliderFocused(container)).toBe(false); + expect(isSliderFocused(document)).toBe(true); + }); }); diff --git a/packages/html/src/media/container-element.ts b/packages/html/src/media/container-element.ts index 550a4ae67..c430953d8 100644 --- a/packages/html/src/media/container-element.ts +++ b/packages/html/src/media/container-element.ts @@ -1,5 +1,5 @@ import { applyContainerAttrs } from '@videojs/core/dom'; -import { listen } from '@videojs/utils/dom'; +import { containsComposed, getDeepActiveElement, listen } from '@videojs/utils/dom'; import { containerContext, playerContext } from '../player/context'; import { createContainerMixin } from '../store/container-mixin'; @@ -28,9 +28,11 @@ export class MediaContainerElement extends ContainerMixin(MediaElement) { } #onPointerUp = (): void => { + const active = getDeepActiveElement(this.ownerDocument); + // If nothing inside the container has focus, grab it so keyboard // events reach the hotkey coordinator's listener. - if (!this.contains(document.activeElement) || document.activeElement === document.body) { + if (!active || active === this.ownerDocument.body || !containsComposed(this, active)) { this.focus({ preventScroll: true }); } }; diff --git a/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts b/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts index 53b6a93bf..44988b976 100644 --- a/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts +++ b/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts @@ -7,7 +7,7 @@ import { import { type AnyPlayerStore, getIndicatorVisibilityCoordinator } from '@videojs/core/dom'; import { ContextProvider } from '@videojs/element/context'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { playerContext } from '../../../player/context'; +import { containerContext, playerContext } from '../../../player/context'; import { MediaElement } from '../../media-element'; import { SeekIndicatorElement } from '../../seek-indicator/seek-indicator-element'; import { SeekIndicatorValueElement } from '../../seek-indicator/seek-indicator-value-element'; @@ -50,6 +50,7 @@ function createTestStore(initialState: Record = {}) { class TestStatusAnnouncerPlayerElement extends MediaElement { readonly #provider = new ContextProvider(this, { context: playerContext }); + readonly #containerProvider = new ContextProvider(this, { context: containerContext }); #store = createTestStore().store; get store(): AnyPlayerStore { @@ -63,6 +64,7 @@ class TestStatusAnnouncerPlayerElement extends MediaElement { override connectedCallback(): void { this.#provider.setValue(this.#store); + this.#containerProvider.setValue({ container: this, setContainer: vi.fn() }); super.connectedCallback(); } } @@ -176,19 +178,16 @@ describe('input indicators', () => { it('does not announce completed seeks while a time slider is focused', async () => { vi.useFakeTimers(); - const slider = document.createElement('button'); - slider.setAttribute('role', 'slider'); - document.body.append(slider); - slider.focus(); try { const { store, setState } = createTestStore({ currentTime: 10, duration: 120, seeking: false }); const provider = document.createElement('test-status-announcer-player') as TestStatusAnnouncerPlayerElement; provider.store = store; - provider.innerHTML = ''; + provider.innerHTML = ''; document.body.append(provider); await provider.updateComplete; + provider.querySelector('button')?.focus(); const announcer = provider.querySelector('media-status-announcer')!; setState({ currentTime: 45, seeking: true }); await Promise.resolve(); @@ -205,6 +204,30 @@ describe('input indicators', () => { it('does not announce volume changes while a volume slider is focused', async () => { vi.useFakeTimers(); + + try { + const { store, setState } = createTestStore({ volume: 0.5, muted: false }); + const provider = document.createElement('test-status-announcer-player') as TestStatusAnnouncerPlayerElement; + provider.store = store; + provider.innerHTML = ''; + document.body.append(provider); + await provider.updateComplete; + + provider.querySelector('button')?.focus(); + const announcer = provider.querySelector('media-status-announcer')!; + setState({ volume: 0.75 }); + await Promise.resolve(); + vi.advanceTimersByTime(200); + await (announcer as StatusAnnouncerElement).updateComplete; + + expect(announcer.textContent).toBe(''); + } finally { + vi.useRealTimers(); + } + }); + + it('announces volume changes when a slider outside the player is focused', async () => { + vi.useFakeTimers(); const slider = document.createElement('button'); slider.setAttribute('role', 'slider'); document.body.append(slider); @@ -222,9 +245,10 @@ describe('input indicators', () => { setState({ volume: 0.75 }); await Promise.resolve(); vi.advanceTimersByTime(200); + await Promise.resolve(); await (announcer as StatusAnnouncerElement).updateComplete; - expect(announcer.textContent).toBe(''); + expect(announcer.textContent).toBe('Volume 75%'); } finally { vi.useRealTimers(); } diff --git a/packages/html/src/ui/status-announcer/status-announcer-element.ts b/packages/html/src/ui/status-announcer/status-announcer-element.ts index 049790f08..9e1d3056e 100644 --- a/packages/html/src/ui/status-announcer/status-announcer-element.ts +++ b/packages/html/src/ui/status-announcer/status-announcer-element.ts @@ -3,7 +3,7 @@ import { applyVisuallyHiddenStyle, getMediaSnapshot, isSliderFocused } from '@vi import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextConsumer } from '@videojs/element/context'; -import { playerContext } from '../../player/context'; +import { containerContext, playerContext } from '../../player/context'; import { MediaElement } from '../media-element'; export class StatusAnnouncerElement extends MediaElement { @@ -21,6 +21,7 @@ export class StatusAnnouncerElement extends MediaElement { callback: () => this.#reconnect(), subscribe: true, }); + readonly #container = new ContextConsumer(this, { context: containerContext, subscribe: true }); #disconnect: AbortController | null = null; #storeUnsubscribe: (() => void) | null = null; @@ -56,8 +57,14 @@ export class StatusAnnouncerElement extends MediaElement { super.willUpdate(changed); this.#core.setProps({ closeDelay: this.closeDelay, - shouldAnnounceSeek: () => !isSliderFocused(this.ownerDocument), - shouldAnnounceVolume: () => !isSliderFocused(this.ownerDocument), + shouldAnnounceSeek: () => { + const container = this.#container.value?.container; + return !container || !isSliderFocused(container); + }, + shouldAnnounceVolume: () => { + const container = this.#container.value?.container; + return !container || !isSliderFocused(container); + }, }); } diff --git a/packages/react/src/player/context.tsx b/packages/react/src/player/context.tsx index 39a523d8d..19339de17 100644 --- a/packages/react/src/player/context.tsx +++ b/packages/react/src/player/context.tsx @@ -10,6 +10,7 @@ import { } from '@videojs/core/dom'; import type { UnknownState, UnknownStore } from '@videojs/store'; import { useStore } from '@videojs/store/react'; +import { containsComposed, getDeepActiveElement } from '@videojs/utils/dom'; import type { Dispatch, HTMLAttributes, ReactNode, PointerEvent as ReactPointerEvent, SetStateAction } from 'react'; import { createContext, forwardRef, useContext, useEffect, useRef } from 'react'; @@ -147,8 +148,10 @@ export const Container = forwardRef(function Con props.onPointerUp?.(event); const el = internalRef.current; if (!el) return; + const active = getDeepActiveElement(el.ownerDocument); + // If nothing inside has focus, grab it so keyboard events reach hotkey listeners. - if (!el.contains(document.activeElement) || document.activeElement === document.body) { + if (!active || active === el.ownerDocument.body || !containsComposed(el, active)) { el.focus({ preventScroll: true }); } }; diff --git a/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx b/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx index 6de64d8b9..5751f7b7a 100644 --- a/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx +++ b/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx @@ -83,14 +83,16 @@ describe('input indicators', () => { it('does not announce completed seeks while a time slider is focused', async () => { vi.useFakeTimers(); + const container = document.createElement('div'); const slider = document.createElement('button'); slider.setAttribute('role', 'slider'); - document.body.append(slider); + container.append(slider); + document.body.append(container); slider.focus(); try { const { store, setState } = createTestStore({ currentTime: 10, duration: 120, seeking: false }); - const { getByRole } = renderWithPlayer(, store); + const { getByRole } = renderWithPlayer(, store, container); await act(async () => {}); setState({ currentTime: 45, seeking: true }); @@ -101,21 +103,23 @@ describe('input indicators', () => { expect(getByRole('status').textContent).toBe(''); } finally { - slider.remove(); + container.remove(); vi.useRealTimers(); } }); it('does not announce volume changes while a volume slider is focused', async () => { vi.useFakeTimers(); + const container = document.createElement('div'); const slider = document.createElement('button'); slider.setAttribute('role', 'slider'); - document.body.append(slider); + container.append(slider); + document.body.append(container); slider.focus(); try { const { store, setState } = createTestStore({ volume: 0.5, muted: false }); - const { getByRole } = renderWithPlayer(, store); + const { getByRole } = renderWithPlayer(, store, container); await act(async () => {}); setState({ volume: 0.75 }); @@ -123,12 +127,53 @@ describe('input indicators', () => { act(() => vi.advanceTimersByTime(200)); expect(getByRole('status').textContent).toBe(''); + } finally { + container.remove(); + vi.useRealTimers(); + } + }); + + it('announces volume changes when a slider outside the player is focused', async () => { + vi.useFakeTimers(); + const slider = document.createElement('button'); + slider.setAttribute('role', 'slider'); + document.body.append(slider); + slider.focus(); + + try { + const { store, setState } = createTestStore({ volume: 0.5, muted: false }); + const { getByRole } = renderWithPlayer(, store); + await act(async () => {}); + + setState({ volume: 0.75 }); + await act(async () => {}); + await act(async () => vi.advanceTimersByTime(200)); + + expect(getByRole('status').textContent).toBe('Volume 75%'); } finally { slider.remove(); vi.useRealTimers(); } }); + it('uses StatusAnnouncer suppression props without passing them to the DOM', async () => { + vi.useFakeTimers(); + const { store, setState } = createTestStore({ volume: 0.5, muted: false }); + const shouldAnnounceVolume = vi.fn(() => false); + const { getByRole } = renderWithPlayer(, store); + await act(async () => {}); + + setState({ volume: 0.75 }); + await act(async () => {}); + act(() => vi.advanceTimersByTime(200)); + + expect(shouldAnnounceVolume).toHaveBeenCalled(); + expect(getByRole('status').textContent).toBe(''); + expect(getByRole('status').hasAttribute('shouldAnnounceVolume')).toBe(false); + + vi.useRealTimers(); + }); + it('scopes the volume CSS variable to VolumeIndicator.Fill', () => { const state: VolumeIndicatorCore.State = { open: true, @@ -228,8 +273,7 @@ function createTestStore(initialState: Record = {}) { return { store, setState }; } -function createPlayerContextValue(store: UnknownStore): PlayerContextValue { - const container = document.createElement('div'); +function createPlayerContextValue(store: UnknownStore, container = document.createElement('div')): PlayerContextValue { return { store, media: null, @@ -239,6 +283,6 @@ function createPlayerContextValue(store: UnknownStore): PlayerContextValue { } as unknown as PlayerContextValue; } -function renderWithPlayer(ui: ReactNode, store: UnknownStore = createTestStore().store) { - return render({ui}); +function renderWithPlayer(ui: ReactNode, store: UnknownStore = createTestStore().store, container?: HTMLElement) { + return render({ui}); } diff --git a/packages/react/src/ui/status-announcer/status-announcer.tsx b/packages/react/src/ui/status-announcer/status-announcer.tsx index e9c55a794..253b505b3 100644 --- a/packages/react/src/ui/status-announcer/status-announcer.tsx +++ b/packages/react/src/ui/status-announcer/status-announcer.tsx @@ -5,7 +5,7 @@ import { getMediaSnapshot, isSliderFocused, visuallyHiddenStyle } from '@videojs import type { ForwardedRef } from 'react'; import { forwardRef, useEffect, useState, useSyncExternalStore } from 'react'; -import { usePlayer } from '../../player/context'; +import { useContainer, usePlayer } from '../../player/context'; import type { UIComponentProps } from '../../utils/types'; import { useDestroy } from '../../utils/use-destroy'; import { renderElement } from '../../utils/use-render'; @@ -18,15 +18,19 @@ export const StatusAnnouncer = forwardRef(function StatusAnnouncer( componentProps: StatusAnnouncerProps, forwardedRef: ForwardedRef ) { - const { render, className, style, closeDelay, labels, ...elementProps } = componentProps; + const { render, className, style, closeDelay, labels, shouldAnnounceSeek, shouldAnnounceVolume, ...elementProps } = + componentProps; const [core] = useState(() => new StatusAnnouncerCore()); const store = usePlayer(); + const container = useContainer(); useDestroy(core); core.setProps({ closeDelay, labels, - shouldAnnounceSeek: () => !isSliderFocused(), - shouldAnnounceVolume: () => !isSliderFocused(), + shouldAnnounceSeek: (snapshot) => + shouldAnnounceSeek?.(snapshot) !== false && (!container || !isSliderFocused(container)), + shouldAnnounceVolume: (snapshot) => + shouldAnnounceVolume?.(snapshot) !== false && (!container || !isSliderFocused(container)), }); useEffect(() => { diff --git a/packages/utils/src/dom/focus.ts b/packages/utils/src/dom/focus.ts new file mode 100644 index 000000000..ab5af3231 --- /dev/null +++ b/packages/utils/src/dom/focus.ts @@ -0,0 +1,9 @@ +export function getDeepActiveElement(root: Document | ShadowRoot = document): Element | null { + let active = root.activeElement; + + while (active?.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + + return active; +} diff --git a/packages/utils/src/dom/index.ts b/packages/utils/src/dom/index.ts index 43bff5961..b87ec104e 100644 --- a/packages/utils/src/dom/index.ts +++ b/packages/utils/src/dom/index.ts @@ -2,6 +2,7 @@ export { animationFrame } from './animation-frame'; export { namedNodeMapToObject, serializeAttributes } from './attributes'; export { isRTL } from './direction'; export { type OnEventOptions, onEvent, resolveEventTarget } from './event'; +export { getDeepActiveElement } from './focus'; export { idleCallback } from './idle-callback'; export { EDITABLE_SELECTOR, @@ -14,7 +15,7 @@ export { export { listen } from './listen'; export { isMacOS } from './platform'; export { tryHidePopover, tryShowPopover } from './popover'; -export { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from './predicates'; +export { isDocument, isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement, isShadowRoot } from './predicates'; export { type RafThrottled, rafThrottle } from './raf-throttle'; export { applyShadowStyles, @@ -33,4 +34,5 @@ export { isCaptionOrSubtitleTrack, } from './text-track'; export { serializeTimeRanges } from './time-ranges'; +export { containsComposed } from './tree'; export type { CustomElement, CustomElementCallbacks } from './types'; diff --git a/packages/utils/src/dom/predicates.ts b/packages/utils/src/dom/predicates.ts index ddb003af8..5661871da 100644 --- a/packages/utils/src/dom/predicates.ts +++ b/packages/utils/src/dom/predicates.ts @@ -1,3 +1,11 @@ +export function isDocument(value: unknown): value is Document { + return value instanceof Node && value.nodeType === 9; +} + +export function isShadowRoot(value: unknown): value is ShadowRoot { + return value instanceof Node && value.nodeType === 11 && 'host' in value; +} + export function isHTMLVideoElement(value: unknown): value is HTMLVideoElement { return value instanceof HTMLVideoElement; } diff --git a/packages/utils/src/dom/tests/focus.test.ts b/packages/utils/src/dom/tests/focus.test.ts new file mode 100644 index 000000000..250fd5a3f --- /dev/null +++ b/packages/utils/src/dom/tests/focus.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { getDeepActiveElement } from '../focus'; + +describe('focus', () => { + it('gets the active element inside open shadow roots', () => { + const host = document.createElement('div'); + const shadow = host.attachShadow({ mode: 'open' }); + const button = document.createElement('button'); + shadow.append(button); + document.body.append(host); + + button.focus(); + + expect(document.activeElement).toBe(host); + expect(getDeepActiveElement()).toBe(button); + }); +}); diff --git a/packages/utils/src/dom/tests/predicates.test.ts b/packages/utils/src/dom/tests/predicates.test.ts index 33f73375a..1d9a8d257 100644 --- a/packages/utils/src/dom/tests/predicates.test.ts +++ b/packages/utils/src/dom/tests/predicates.test.ts @@ -1,8 +1,34 @@ import { describe, expect, it } from 'vitest'; -import { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from '../predicates'; +import { isDocument, isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement, isShadowRoot } from '../predicates'; describe('DOM predicates', () => { + describe('isDocument', () => { + it('returns true for documents', () => { + expect(isDocument(document)).toBe(true); + }); + + it('returns false for non-documents', () => { + expect(isDocument(document.body)).toBe(false); + expect(isDocument(null)).toBe(false); + }); + }); + + describe('isShadowRoot', () => { + it('returns true for shadow roots', () => { + const host = document.createElement('div'); + const shadow = host.attachShadow({ mode: 'open' }); + + expect(isShadowRoot(shadow)).toBe(true); + }); + + it('returns false for non-shadow roots', () => { + expect(isShadowRoot(document)).toBe(false); + expect(isShadowRoot(document.body)).toBe(false); + expect(isShadowRoot(null)).toBe(false); + }); + }); + describe('isHTMLVideoElement', () => { it('returns true for video elements', () => { const video = document.createElement('video'); diff --git a/packages/utils/src/dom/tests/tree.test.ts b/packages/utils/src/dom/tests/tree.test.ts new file mode 100644 index 000000000..f30c19e84 --- /dev/null +++ b/packages/utils/src/dom/tests/tree.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { containsComposed } from '../tree'; + +describe('tree', () => { + it('checks composed containment across shadow roots', () => { + const container = document.createElement('div'); + const host = document.createElement('div'); + const shadow = host.attachShadow({ mode: 'open' }); + const button = document.createElement('button'); + shadow.append(button); + container.append(host); + document.body.append(container); + + expect(container.contains(button)).toBe(false); + expect(containsComposed(container, button)).toBe(true); + }); + + it('returns false for elements outside the composed tree', () => { + const container = document.createElement('div'); + const button = document.createElement('button'); + document.body.append(container, button); + + expect(containsComposed(container, button)).toBe(false); + }); +}); diff --git a/packages/utils/src/dom/tree.ts b/packages/utils/src/dom/tree.ts new file mode 100644 index 000000000..baba1112a --- /dev/null +++ b/packages/utils/src/dom/tree.ts @@ -0,0 +1,14 @@ +import { isShadowRoot } from './predicates'; + +export function containsComposed(root: Element, element: Element): boolean { + let current: Node | null = element; + + while (current) { + if (current === root || root.contains(current)) return true; + + const nodeRoot = current.getRootNode(); + current = isShadowRoot(nodeRoot) ? nodeRoot.host : current.parentNode; + } + + return false; +} From 3bbb041612736c4142ab76984ec07a5e61f3165c Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Fri, 5 Jun 2026 12:31:06 +1000 Subject: [PATCH 08/11] test(react): widen status announcer container helper --- .../src/ui/input-indicators/tests/input-indicators.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx b/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx index 5751f7b7a..e05fe22ac 100644 --- a/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx +++ b/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx @@ -273,7 +273,10 @@ function createTestStore(initialState: Record = {}) { return { store, setState }; } -function createPlayerContextValue(store: UnknownStore, container = document.createElement('div')): PlayerContextValue { +function createPlayerContextValue( + store: UnknownStore, + container: HTMLElement = document.createElement('div') +): PlayerContextValue { return { store, media: null, From f80efacb7ff62b8c60cc6743666bcd82f8771c48 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Fri, 5 Jun 2026 12:42:37 +1000 Subject: [PATCH 09/11] fix(core): prefer seek announcement over volume --- .../ui/input-feedback/status-announcer-core.ts | 4 ++-- .../tests/status-indicator-core.test.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts index c66f8a903..ed2dbadff 100644 --- a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts +++ b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts @@ -103,11 +103,11 @@ export class StatusAnnouncerCore { handled = this.#announce(formatAnnouncementQueue(queue)); } - if (this.#processVolumeSnapshot(previous, snapshot, labels, handled)) { + if (this.#processSeekSnapshot(previous, snapshot, labels, handled)) { handled = true; } - if (this.#processSeekSnapshot(previous, snapshot, labels, handled)) { + if (this.#processVolumeSnapshot(previous, snapshot, labels, handled)) { handled = true; } diff --git a/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts b/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts index f3791b46f..4c88594a3 100644 --- a/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts +++ b/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts @@ -268,6 +268,24 @@ describe('StatusAnnouncerCore', () => { expect(core.state.current.label).toBe('Seeked to 45 seconds'); }); + it('announces a completed seek when volume changes in the same snapshot', () => { + const core = new StatusAnnouncerCore(); + let snapshot = { currentTime: 10, duration: 120, seeking: false, volume: 0.5, muted: false }; + const process = (partial: Partial) => { + snapshot = { ...snapshot, ...partial }; + return core.processSnapshot(snapshot); + }; + + core.processSnapshot(snapshot); + + process({ currentTime: 45, seeking: true }); + expect(process({ seeking: false, volume: 0.75 })).toBe(true); + + vi.advanceTimersByTime(200); + + expect(core.state.current.label).toBe('Seeked to 45 seconds'); + }); + it('allows seek announcements to be suppressed by callers', () => { const core = new StatusAnnouncerCore(); core.setProps({ shouldAnnounceSeek: () => false }); From 4528154075f131e68ebe094e7877c902d02867ac Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Fri, 5 Jun 2026 13:24:12 +1000 Subject: [PATCH 10/11] test(packages): simplify status announcer coverage --- .../input-feedback/status-announcer-core.ts | 6 +- .../tests/status-indicator-core.test.ts | 98 ++++++----------- .../tests/input-indicators.test.ts | 104 ++++++++---------- .../tests/input-indicators.test.tsx | 79 +++++++------ 4 files changed, 118 insertions(+), 169 deletions(-) diff --git a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts index ed2dbadff..89c22babb 100644 --- a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts +++ b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts @@ -100,7 +100,7 @@ export class StatusAnnouncerCore { } if (queue.length > 0) { - handled = this.#announce(formatAnnouncementQueue(queue)); + handled = this.#announce(queue.join('. ')); } if (this.#processSeekSnapshot(previous, snapshot, labels, handled)) { @@ -221,7 +221,3 @@ export namespace StatusAnnouncerCore { function hasChanged(previous: Value | undefined, next: Value | undefined): next is Value { return previous !== undefined && next !== undefined && !Object.is(previous, next); } - -function formatAnnouncementQueue(queue: string[]): string { - return queue.join('. '); -} diff --git a/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts b/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts index 4c88594a3..8068fafc5 100644 --- a/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts +++ b/packages/core/src/core/ui/input-feedback/tests/status-indicator-core.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MediaSnapshot } from '../status'; import { StatusAnnouncerCore } from '../status-announcer-core'; import { StatusIndicatorCore } from '../status-indicator-core'; @@ -168,35 +169,25 @@ describe('StatusAnnouncerCore', () => { it('announces confirmed playback, captions, fullscreen, pip, and playback-rate changes', () => { const core = new StatusAnnouncerCore(); - let snapshot = { + const process = createSnapshotProcessor(core, { paused: true, subtitlesShowing: false, subtitlesAvailable: true, fullscreen: false, pip: false, playbackRate: 1, - }; - const process = (partial: Partial) => { - snapshot = { ...snapshot, ...partial }; - return core.processSnapshot(snapshot); - }; - - core.processSnapshot(snapshot); - - expect(process({ paused: false })).toBe(true); - expect(core.state.current.label).toBe('Playing'); - - expect(process({ subtitlesShowing: true })).toBe(true); - expect(core.state.current.label).toBe('Captions on'); - - expect(process({ fullscreen: true })).toBe(true); - expect(core.state.current.label).toBe('Fullscreen'); - - expect(process({ pip: true })).toBe(true); - expect(core.state.current.label).toBe('Picture in picture'); + }); - expect(process({ playbackRate: 1.5 })).toBe(true); - expect(core.state.current.label).toBe('Playback rate 1.5x'); + for (const [partial, label] of [ + [{ paused: false }, 'Playing'], + [{ subtitlesShowing: true }, 'Captions on'], + [{ fullscreen: true }, 'Fullscreen'], + [{ pip: true }, 'Picture in picture'], + [{ playbackRate: 1.5 }, 'Playback rate 1.5x'], + ] as const) { + expect(process(partial)).toBe(true); + expect(core.state.current.label).toBe(label); + } }); it('does not announce captions when captions are unavailable', () => { @@ -210,13 +201,7 @@ describe('StatusAnnouncerCore', () => { it('debounces volume snapshot announcements to the final value', () => { const core = new StatusAnnouncerCore(); - let snapshot = { volume: 0.5, muted: false }; - const process = (partial: Partial) => { - snapshot = { ...snapshot, ...partial }; - return core.processSnapshot(snapshot); - }; - - core.processSnapshot(snapshot); + const process = createSnapshotProcessor(core, { volume: 0.5, muted: false }); expect(process({ volume: 0.55 })).toBe(true); expect(process({ volume: 0.6 })).toBe(true); @@ -231,13 +216,8 @@ describe('StatusAnnouncerCore', () => { it('announces muted volume snapshots', () => { const core = new StatusAnnouncerCore(); - let snapshot = { volume: 0.5, muted: false }; - const process = (partial: Partial) => { - snapshot = { ...snapshot, ...partial }; - return core.processSnapshot(snapshot); - }; + const process = createSnapshotProcessor(core, { volume: 0.5, muted: false }); - core.processSnapshot(snapshot); process({ muted: true }); vi.advanceTimersByTime(200); @@ -247,13 +227,7 @@ describe('StatusAnnouncerCore', () => { it('ignores regular currentTime updates and announces completed seeks once', () => { const core = new StatusAnnouncerCore(); - let snapshot = { currentTime: 10, duration: 120, seeking: false }; - const process = (partial: Partial) => { - snapshot = { ...snapshot, ...partial }; - return core.processSnapshot(snapshot); - }; - - core.processSnapshot(snapshot); + const process = createSnapshotProcessor(core, { currentTime: 10, duration: 120, seeking: false }); expect(process({ currentTime: 11 })).toBe(false); expect(core.state.current.label).toBeNull(); @@ -270,13 +244,13 @@ describe('StatusAnnouncerCore', () => { it('announces a completed seek when volume changes in the same snapshot', () => { const core = new StatusAnnouncerCore(); - let snapshot = { currentTime: 10, duration: 120, seeking: false, volume: 0.5, muted: false }; - const process = (partial: Partial) => { - snapshot = { ...snapshot, ...partial }; - return core.processSnapshot(snapshot); - }; - - core.processSnapshot(snapshot); + const process = createSnapshotProcessor(core, { + currentTime: 10, + duration: 120, + seeking: false, + volume: 0.5, + muted: false, + }); process({ currentTime: 45, seeking: true }); expect(process({ seeking: false, volume: 0.75 })).toBe(true); @@ -289,13 +263,7 @@ describe('StatusAnnouncerCore', () => { it('allows seek announcements to be suppressed by callers', () => { const core = new StatusAnnouncerCore(); core.setProps({ shouldAnnounceSeek: () => false }); - let snapshot = { currentTime: 10, duration: 120, seeking: false }; - const process = (partial: Partial) => { - snapshot = { ...snapshot, ...partial }; - return core.processSnapshot(snapshot); - }; - - core.processSnapshot(snapshot); + const process = createSnapshotProcessor(core, { currentTime: 10, duration: 120, seeking: false }); process({ currentTime: 45, seeking: true }); expect(process({ seeking: false })).toBe(false); @@ -308,13 +276,7 @@ describe('StatusAnnouncerCore', () => { it('allows volume announcements to be suppressed by callers', () => { const core = new StatusAnnouncerCore(); core.setProps({ shouldAnnounceVolume: () => false }); - let snapshot = { volume: 0.5, muted: false }; - const process = (partial: Partial) => { - snapshot = { ...snapshot, ...partial }; - return core.processSnapshot(snapshot); - }; - - core.processSnapshot(snapshot); + const process = createSnapshotProcessor(core, { volume: 0.5, muted: false }); expect(process({ volume: 0.75 })).toBe(false); @@ -323,3 +285,13 @@ describe('StatusAnnouncerCore', () => { expect(core.state.current.label).toBeNull(); }); }); + +function createSnapshotProcessor(core: StatusAnnouncerCore, initial: MediaSnapshot) { + let snapshot = initial; + core.processSnapshot(snapshot); + + return (partial: MediaSnapshot) => { + snapshot = { ...snapshot, ...partial }; + return core.processSnapshot(snapshot); + }; +} diff --git a/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts b/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts index 44988b976..8ba107e9f 100644 --- a/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts +++ b/packages/html/src/ui/input-indicators/tests/input-indicators.test.ts @@ -72,6 +72,22 @@ class TestStatusAnnouncerPlayerElement extends MediaElement { defineElement(StatusAnnouncerElement.tagName, StatusAnnouncerElement); defineElement('test-status-announcer-player', TestStatusAnnouncerPlayerElement); +async function renderStatusAnnouncerElement( + store: AnyPlayerStore, + markup = '' +) { + const provider = document.createElement('test-status-announcer-player') as TestStatusAnnouncerPlayerElement; + provider.store = store; + provider.innerHTML = markup; + document.body.append(provider); + await provider.updateComplete; + + return { + provider, + announcer: provider.querySelector('media-status-announcer')!, + }; +} + describe('input indicators', () => { it('exposes standalone indicator tag names', () => { expect(StatusIndicatorElement.tagName).toBe('media-status-indicator'); @@ -138,13 +154,7 @@ describe('input indicators', () => { it('updates StatusAnnouncerElement live text from store snapshots', async () => { const { store, setState } = createTestStore({ paused: true }); - const provider = document.createElement('test-status-announcer-player') as TestStatusAnnouncerPlayerElement; - provider.store = store; - provider.innerHTML = ''; - document.body.append(provider); - await provider.updateComplete; - - const announcer = provider.querySelector('media-status-announcer')!; + const { announcer } = await renderStatusAnnouncerElement(store); expect(announcer.textContent).toBe(''); setState({ paused: false }); @@ -157,13 +167,7 @@ describe('input indicators', () => { it('uses the next store snapshot as baseline when StatusAnnouncerElement store changes', async () => { const first = createTestStore({ paused: false }); const second = createTestStore({ paused: false }); - const provider = document.createElement('test-status-announcer-player') as TestStatusAnnouncerPlayerElement; - provider.store = first.store; - provider.innerHTML = ''; - document.body.append(provider); - await provider.updateComplete; - - const announcer = provider.querySelector('media-status-announcer')!; + const { announcer, provider } = await renderStatusAnnouncerElement(first.store); first.setState({ paused: true }); await Promise.resolve(); await (announcer as StatusAnnouncerElement).updateComplete; @@ -176,47 +180,35 @@ describe('input indicators', () => { expect(announcer.textContent).toBe(''); }); - it('does not announce completed seeks while a time slider is focused', async () => { - vi.useFakeTimers(); - - try { - const { store, setState } = createTestStore({ currentTime: 10, duration: 120, seeking: false }); - const provider = document.createElement('test-status-announcer-player') as TestStatusAnnouncerPlayerElement; - provider.store = store; - provider.innerHTML = ''; - document.body.append(provider); - await provider.updateComplete; - - provider.querySelector('button')?.focus(); - const announcer = provider.querySelector('media-status-announcer')!; - setState({ currentTime: 45, seeking: true }); - await Promise.resolve(); - setState({ seeking: false }); - await Promise.resolve(); - vi.advanceTimersByTime(200); - await (announcer as StatusAnnouncerElement).updateComplete; - - expect(announcer.textContent).toBe(''); - } finally { - vi.useRealTimers(); - } - }); - - it('does not announce volume changes while a volume slider is focused', async () => { + it.each([ + { + name: 'completed seeks', + initialState: { currentTime: 10, duration: 120, seeking: false }, + update: async (setState: (partial: Record) => void) => { + setState({ currentTime: 45, seeking: true }); + await Promise.resolve(); + setState({ seeking: false }); + }, + }, + { + name: 'volume changes', + initialState: { volume: 0.5, muted: false }, + update: async (setState: (partial: Record) => void) => { + setState({ volume: 0.75 }); + }, + }, + ])('does not announce $name while a slider inside the player is focused', async ({ initialState, update }) => { vi.useFakeTimers(); try { - const { store, setState } = createTestStore({ volume: 0.5, muted: false }); - const provider = document.createElement('test-status-announcer-player') as TestStatusAnnouncerPlayerElement; - provider.store = store; - provider.innerHTML = ''; - document.body.append(provider); - await provider.updateComplete; - - provider.querySelector('button')?.focus(); - const announcer = provider.querySelector('media-status-announcer')!; - setState({ volume: 0.75 }); - await Promise.resolve(); + const { store, setState } = createTestStore(initialState); + const { announcer, provider } = await renderStatusAnnouncerElement( + store, + '' + ); + + provider.querySelector('[role="slider"]')?.focus(); + await update(setState); vi.advanceTimersByTime(200); await (announcer as StatusAnnouncerElement).updateComplete; @@ -235,13 +227,7 @@ describe('input indicators', () => { try { const { store, setState } = createTestStore({ volume: 0.5, muted: false }); - const provider = document.createElement('test-status-announcer-player') as TestStatusAnnouncerPlayerElement; - provider.store = store; - provider.innerHTML = ''; - document.body.append(provider); - await provider.updateComplete; - - const announcer = provider.querySelector('media-status-announcer')!; + const { announcer } = await renderStatusAnnouncerElement(store); setState({ volume: 0.75 }); await Promise.resolve(); vi.advanceTimersByTime(200); diff --git a/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx b/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx index e05fe22ac..36be4d86a 100644 --- a/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx +++ b/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx @@ -81,64 +81,46 @@ describe('input indicators', () => { expect(getByRole('status').textContent).toBe(''); }); - it('does not announce completed seeks while a time slider is focused', async () => { - vi.useFakeTimers(); - const container = document.createElement('div'); - const slider = document.createElement('button'); - slider.setAttribute('role', 'slider'); - container.append(slider); - document.body.append(container); - slider.focus(); - - try { - const { store, setState } = createTestStore({ currentTime: 10, duration: 120, seeking: false }); - const { getByRole } = renderWithPlayer(, store, container); - await act(async () => {}); - - setState({ currentTime: 45, seeking: true }); - await act(async () => {}); - setState({ seeking: false }); - await act(async () => {}); - act(() => vi.advanceTimersByTime(200)); - - expect(getByRole('status').textContent).toBe(''); - } finally { - container.remove(); - vi.useRealTimers(); - } - }); - - it('does not announce volume changes while a volume slider is focused', async () => { + it.each([ + { + name: 'completed seeks', + initialState: { currentTime: 10, duration: 120, seeking: false }, + update: async (setState: (partial: Record) => void) => { + setState({ currentTime: 45, seeking: true }); + await act(async () => {}); + setState({ seeking: false }); + }, + }, + { + name: 'volume changes', + initialState: { volume: 0.5, muted: false }, + update: async (setState: (partial: Record) => void) => { + setState({ volume: 0.75 }); + }, + }, + ])('does not announce $name while a slider inside the player is focused', async ({ initialState, update }) => { vi.useFakeTimers(); - const container = document.createElement('div'); - const slider = document.createElement('button'); - slider.setAttribute('role', 'slider'); - container.append(slider); - document.body.append(container); - slider.focus(); + const { container, cleanup: cleanupSlider } = focusSliderInContainer(); try { - const { store, setState } = createTestStore({ volume: 0.5, muted: false }); + const { store, setState } = createTestStore(initialState); const { getByRole } = renderWithPlayer(, store, container); await act(async () => {}); - setState({ volume: 0.75 }); + await update(setState); await act(async () => {}); act(() => vi.advanceTimersByTime(200)); expect(getByRole('status').textContent).toBe(''); } finally { - container.remove(); + cleanupSlider(); vi.useRealTimers(); } }); it('announces volume changes when a slider outside the player is focused', async () => { vi.useFakeTimers(); - const slider = document.createElement('button'); - slider.setAttribute('role', 'slider'); - document.body.append(slider); - slider.focus(); + const { cleanup: cleanupSlider } = focusSliderInContainer(); try { const { store, setState } = createTestStore({ volume: 0.5, muted: false }); @@ -151,7 +133,7 @@ describe('input indicators', () => { expect(getByRole('status').textContent).toBe('Volume 75%'); } finally { - slider.remove(); + cleanupSlider(); vi.useRealTimers(); } }); @@ -289,3 +271,16 @@ function createPlayerContextValue( function renderWithPlayer(ui: ReactNode, store: UnknownStore = createTestStore().store, container?: HTMLElement) { return render({ui}); } + +function focusSliderInContainer(container = document.createElement('div')) { + const slider = document.createElement('button'); + slider.setAttribute('role', 'slider'); + container.append(slider); + document.body.append(container); + slider.focus(); + + return { + container, + cleanup: () => container.remove(), + }; +} From a50233788299a9934df1f92904b2a90a1296b856 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Fri, 5 Jun 2026 13:57:45 +1000 Subject: [PATCH 11/11] fix(react): hide status announcer suppression hooks --- .../tests/input-indicators.test.tsx | 18 ------------------ .../ui/status-announcer/status-announcer.tsx | 11 ++++------- 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx b/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx index 36be4d86a..145dc9d26 100644 --- a/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx +++ b/packages/react/src/ui/input-indicators/tests/input-indicators.test.tsx @@ -138,24 +138,6 @@ describe('input indicators', () => { } }); - it('uses StatusAnnouncer suppression props without passing them to the DOM', async () => { - vi.useFakeTimers(); - const { store, setState } = createTestStore({ volume: 0.5, muted: false }); - const shouldAnnounceVolume = vi.fn(() => false); - const { getByRole } = renderWithPlayer(, store); - await act(async () => {}); - - setState({ volume: 0.75 }); - await act(async () => {}); - act(() => vi.advanceTimersByTime(200)); - - expect(shouldAnnounceVolume).toHaveBeenCalled(); - expect(getByRole('status').textContent).toBe(''); - expect(getByRole('status').hasAttribute('shouldAnnounceVolume')).toBe(false); - - vi.useRealTimers(); - }); - it('scopes the volume CSS variable to VolumeIndicator.Fill', () => { const state: VolumeIndicatorCore.State = { open: true, diff --git a/packages/react/src/ui/status-announcer/status-announcer.tsx b/packages/react/src/ui/status-announcer/status-announcer.tsx index 253b505b3..1ab806e45 100644 --- a/packages/react/src/ui/status-announcer/status-announcer.tsx +++ b/packages/react/src/ui/status-announcer/status-announcer.tsx @@ -12,14 +12,13 @@ import { renderElement } from '../../utils/use-render'; export interface StatusAnnouncerProps extends UIComponentProps<'div', StatusAnnouncerCore.State>, - StatusAnnouncerCore.Props {} + Pick {} export const StatusAnnouncer = forwardRef(function StatusAnnouncer( componentProps: StatusAnnouncerProps, forwardedRef: ForwardedRef ) { - const { render, className, style, closeDelay, labels, shouldAnnounceSeek, shouldAnnounceVolume, ...elementProps } = - componentProps; + const { render, className, style, closeDelay, labels, ...elementProps } = componentProps; const [core] = useState(() => new StatusAnnouncerCore()); const store = usePlayer(); const container = useContainer(); @@ -27,10 +26,8 @@ export const StatusAnnouncer = forwardRef(function StatusAnnouncer( core.setProps({ closeDelay, labels, - shouldAnnounceSeek: (snapshot) => - shouldAnnounceSeek?.(snapshot) !== false && (!container || !isSliderFocused(container)), - shouldAnnounceVolume: (snapshot) => - shouldAnnounceVolume?.(snapshot) !== false && (!container || !isSliderFocused(container)), + shouldAnnounceSeek: () => !container || !isSliderFocused(container), + shouldAnnounceVolume: () => !container || !isSliderFocused(container), }); useEffect(() => {