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..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 @@ -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 ANNOUNCEMENT_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) @@ -31,24 +43,181 @@ 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(); 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 handled = false; + const queue: string[] = []; + + if (hasChanged(previous.paused, snapshot.paused)) { + queue.push(snapshot.paused ? labels.paused : labels.playing); + } + + if (hasChanged(previous.subtitlesShowing, snapshot.subtitlesShowing) && snapshot.subtitlesAvailable !== false) { + queue.push(snapshot.subtitlesShowing ? labels.captionsOn : labels.captionsOff); + } + + if (hasChanged(previous.fullscreen, snapshot.fullscreen)) { + queue.push(snapshot.fullscreen ? labels.fullscreen : labels.exitFullscreen); + } + + if (hasChanged(previous.pip, snapshot.pip)) { + queue.push(snapshot.pip ? labels.pictureInPicture : labels.exitPictureInPicture); + } + + if (hasChanged(previous.playbackRate, snapshot.playbackRate)) { + queue.push(formatPlaybackRateAnnouncerLabel(snapshot.playbackRate, labels)); + } + + if (queue.length > 0) { + handled = this.#announce(queue.join('. ')); + } + + if (this.#processSeekSnapshot(previous, snapshot, labels, handled)) { + handled = true; + } + + if (this.#processVolumeSnapshot(previous, snapshot, labels, handled)) { + handled = true; + } + + return handled; + } + + #getLabels(): StatusAnnouncerLabels { + return { + ...DEFAULT_STATUS_ANNOUNCER_LABELS, + ...this.#props.labels, + }; + } + + #announce(label: string): boolean { + this.#clearSeekTimer(); + this.#clearVolumeTimer(); this.state.patch({ label }); this.#close.arm(); return true; } + + #processVolumeSnapshot( + previous: MediaSnapshot, + snapshot: MediaSnapshot, + labels: StatusAnnouncerLabels, + 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 (alreadyHandled) 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, snapshot); + return true; + } + + #processSeekSnapshot( + previous: MediaSnapshot, + snapshot: MediaSnapshot, + labels: StatusAnnouncerLabels, + alreadyHandled: boolean + ): 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; + if (alreadyHandled) return false; + + this.#scheduleSeekAnnouncement(formatSeekAnnouncerLabel(targetTime, labels), snapshot); + return true; + } + + #scheduleVolumeAnnouncement(label: string, snapshot: MediaSnapshot): void { + this.#clearVolumeTimer(); + this.#volumeTimer = setTimeout(() => { + this.#volumeTimer = null; + if (this.#props.shouldAnnounceVolume?.(snapshot) === false) return; + this.#announce(label); + }, ANNOUNCEMENT_DEBOUNCE); + } + + #scheduleSeekAnnouncement(label: string, snapshot: MediaSnapshot): void { + this.#clearSeekTimer(); + this.#seekTimer = setTimeout(() => { + this.#seekTimer = null; + if (this.#props.shouldAnnounceSeek?.(snapshot) === false) return; + this.#announce(label); + }, ANNOUNCEMENT_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..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'; @@ -51,4 +52,246 @@ 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('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('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('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(); + const process = createSnapshotProcessor(core, { + paused: true, + subtitlesShowing: false, + subtitlesAvailable: true, + fullscreen: false, + pip: false, + playbackRate: 1, + }); + + 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', () => { + 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(); + const process = createSnapshotProcessor(core, { volume: 0.5, muted: false }); + + 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(); + const process = createSnapshotProcessor(core, { volume: 0.5, muted: false }); + + 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(); + const process = createSnapshotProcessor(core, { currentTime: 10, duration: 120, seeking: false }); + + 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('announces a completed seek when volume changes in the same snapshot', () => { + const core = new StatusAnnouncerCore(); + 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); + + 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 }); + const process = createSnapshotProcessor(core, { currentTime: 10, duration: 120, seeking: false }); + + 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 }); + const process = createSnapshotProcessor(core, { volume: 0.5, muted: false }); + + expect(process({ volume: 0.75 })).toBe(false); + + vi.advanceTimersByTime(200); + + 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/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..f8ddabedd --- /dev/null +++ b/packages/core/src/dom/ui/slider-focus.ts @@ -0,0 +1,9 @@ +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 afd912286..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 }; @@ -36,24 +37,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, }); }); @@ -71,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/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..c430953d8 100644 --- a/packages/html/src/media/container-element.ts +++ b/packages/html/src/media/container-element.ts @@ -1,4 +1,5 @@ -import { listen } from '@videojs/utils/dom'; +import { applyContainerAttrs } from '@videojs/core/dom'; +import { containsComposed, getDeepActiveElement, listen } from '@videojs/utils/dom'; import { containerContext, playerContext } from '../player/context'; import { createContainerMixin } from '../store/container-mixin'; @@ -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 }); @@ -30,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/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..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 @@ -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 { 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'; import { StatusAnnouncerElement } from '../../status-announcer/status-announcer-element'; @@ -20,6 +23,71 @@ 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 { + readonly #provider = new ContextProvider(this, { context: playerContext }); + readonly #containerProvider = new ContextProvider(this, { context: containerContext }); + #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.#containerProvider.setValue({ container: this, setContainer: vi.fn() }); + super.connectedCallback(); + } +} + +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'); @@ -84,6 +152,94 @@ 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 { announcer } = await renderStatusAnnouncerElement(store); + expect(announcer.textContent).toBe(''); + + setState({ paused: false }); + await Promise.resolve(); + await (announcer as StatusAnnouncerElement).updateComplete; + + 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 { announcer, provider } = await renderStatusAnnouncerElement(first.store); + 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.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(initialState); + const { announcer, provider } = await renderStatusAnnouncerElement( + store, + '' + ); + + provider.querySelector('[role="slider"]')?.focus(); + await update(setState); + 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); + slider.focus(); + + try { + const { store, setState } = createTestStore({ volume: 0.5, muted: false }); + const { announcer } = await renderStatusAnnouncerElement(store); + setState({ volume: 0.75 }); + await Promise.resolve(); + vi.advanceTimersByTime(200); + await Promise.resolve(); + await (announcer as StatusAnnouncerElement).updateComplete; + + expect(announcer.textContent).toBe('Volume 75%'); + } 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..9e1d3056e 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 { MediaElement } from '../media-element'; export class StatusAnnouncerElement extends MediaElement { @@ -17,21 +16,23 @@ 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, }); + readonly #container = new ContextConsumer(this, { context: containerContext, 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 +41,62 @@ 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: () => { + const container = this.#container.value?.container; + return !container || !isSliderFocused(container); + }, + shouldAnnounceVolume: () => { + const container = this.#container.value?.container; + return !container || !isSliderFocused(container); + }, + }); } 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; + this.#core.resetSnapshot(); - 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..19339de17 100644 --- a/packages/react/src/player/context.tsx +++ b/packages/react/src/player/context.tsx @@ -1,9 +1,16 @@ '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 { 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'; @@ -118,7 +125,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(); @@ -134,14 +148,28 @@ 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 }); } }; + 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..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 @@ -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,100 @@ 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('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.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, cleanup: cleanupSlider } = focusSliderInContainer(); + + try { + const { store, setState } = createTestStore(initialState); + const { getByRole } = renderWithPlayer(, store, container); + await act(async () => {}); + + await update(setState); + await act(async () => {}); + act(() => vi.advanceTimersByTime(200)); + + expect(getByRole('status').textContent).toBe(''); + } finally { + cleanupSlider(); + vi.useRealTimers(); + } + }); + + it('announces volume changes when a slider outside the player is focused', async () => { + vi.useFakeTimers(); + const { cleanup: cleanupSlider } = focusSliderInContainer(); + + 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 { + cleanupSlider(); + vi.useRealTimers(); + } + }); + it('scopes the volume CSS variable to VolumeIndicator.Fill', () => { const state: VolumeIndicatorCore.State = { open: true, @@ -119,18 +214,55 @@ function VisibilityProbe({ close, id }: { close: () => void; id: string }) { ); } -function renderWithPlayer(ui: ReactNode) { - const container = document.createElement('div'); - const playerContextValue = { - store: { - state: {}, - subscribe: () => () => {}, +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 createPlayerContextValue( + store: UnknownStore, + container: HTMLElement = document.createElement('div') +): 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, 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(), + }; } diff --git a/packages/react/src/ui/status-announcer/status-announcer.tsx b/packages/react/src/ui/status-announcer/status-announcer.tsx index 4f8f6983c..1ab806e45 100644 --- a/packages/react/src/ui/status-announcer/status-announcer.tsx +++ b/packages/react/src/ui/status-announcer/status-announcer.tsx @@ -1,17 +1,18 @@ '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 { useContainer, 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>, - StatusAnnouncerCore.Props {} + Pick {} export const StatusAnnouncer = forwardRef(function StatusAnnouncer( componentProps: StatusAnnouncerProps, @@ -19,13 +20,22 @@ export const StatusAnnouncer = forwardRef(function StatusAnnouncer( ) { const { render, className, style, closeDelay, labels, ...elementProps } = componentProps; const [core] = useState(() => new StatusAnnouncerCore()); + const store = usePlayer(); + const container = useContainer(); useDestroy(core); - core.setProps({ closeDelay, labels }); - - useInputActionSubscription((event, snapshot) => { - core.processEvent(event, snapshot); + core.setProps({ + closeDelay, + labels, + shouldAnnounceSeek: () => !container || !isSliderFocused(container), + shouldAnnounceVolume: () => !container || !isSliderFocused(container), }); + useEffect(() => { + core.resetSnapshot(); + 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 +49,11 @@ export const StatusAnnouncer = forwardRef(function StatusAnnouncer( state, ref: forwardedRef, props: [ + elementProps, { role: 'status', - 'aria-label': state.label ?? undefined, + children: {state.label ?? ''}, }, - elementProps, ], } ); 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; +}