Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/e2e/tests/video-controls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ for (const { name, path, media, skipBrowsers } of ALL_VIDEO_PAGES as readonly Pa
await expect(player.seekBackward).toHaveAttribute(DATA_ATTRS.direction, 'backward');
await expect(player.muteButton).toHaveAttribute(DATA_ATTRS.volumeLevel);
await expect(player.fullscreenButton).toHaveAttribute(DATA_ATTRS.availability);
await expect(player.pipButton).toHaveAttribute(DATA_ATTRS.availability);
// PiP is unsupported on WebKit — the button is hidden and removed from the DOM.
if (await player.pipButton.isVisible()) {
await expect(player.pipButton).toHaveAttribute(DATA_ATTRS.availability);
}
await expect(player.captionsButton).toHaveAttribute(DATA_ATTRS.availability);
await expect(player.duration).not.toHaveText('');
await player.showControls();
Expand Down
76 changes: 76 additions & 0 deletions internal/design/ui/disabled-hidden.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
status: decided
date: 2026-04-18
---

# Disabled & Hidden States for Controls

## Decision

Use `aria-disabled` (never HTML `disabled`) for all toolbar buttons. Three visual states driven by data attributes:

| State | ARIA | HTML (custom element) | React | Styling |
|-------|------|-----------------------|-------|---------|
| **Unsupported** | `aria-disabled="true"` | `hidden` + `data-hidden` + `data-disabled` | returns `null` | Browser hides natively |
| **Unavailable** (Cast only) | `aria-disabled="true"` | `data-disabled` | `data-disabled` on `<button>` | Reduced opacity via `[data-disabled]` |
| **Disabled** (prop) | `aria-disabled="true"` | `data-disabled` | `data-disabled` on `<button>` | Reduced opacity via `[data-disabled]` |
| **Available + enabled** | _(none)_ | _(none)_ | _(none)_ | Fully interactive |

`data-availability` remains as a string enum (`available`, `unavailable`, `unsupported`) for consumers that need the raw value.

## Context

Feature buttons (Fullscreen, PiP, Cast) need to communicate three distinct states to users and assistive technology:

1. **Unsupported** — the browser lacks the capability entirely (e.g., PiP on older Safari). Applies to Fullscreen, PiP, and Cast.
2. **Unavailable** — the API exists but no target is available (e.g., no cast device found). Only applies to Cast.
3. **Disabled** — the developer explicitly disabled the control via a prop

Unsupported features are hidden entirely. Unavailable features (Cast only — no device found) and explicitly disabled buttons remain visible but non-interactive. `disabled` in state covers both the prop and feature unavailability. We evaluated `disabled` vs `aria-disabled`, `hidden` vs `aria-hidden`, and how Radix, Base UI, and WAI-ARIA APG handle these patterns.

## Alternatives Considered

- **HTML `disabled` attribute** — Removes elements from the tab order entirely. This breaks the APG toolbar pattern, which requires all toolbar buttons to remain focusable via arrow keys. It also prevents tooltips and hover states from working on disabled buttons.

- **Hybrid approach (like Base UI's `focusableWhenDisabled`)** — Adds a prop to toggle between `disabled` and `aria-disabled`. Unnecessary complexity for our use case since we always want buttons to remain focusable.

- **CSS-only hiding (`display: none` via data attributes)** — Our prior approach used `[data-availability]:not([data-available])` to hide buttons. This works but lacks native semantics. The HTML `hidden` attribute provides the same effect with proper semantics and works without any CSS.

## Rationale

### Why `aria-disabled` over `disabled`

The WAI-ARIA APG toolbar pattern explicitly recommends `aria-disabled` for toolbar buttons:

- **Keeps buttons in tab order** — keyboard users can discover disabled controls and understand what's available
- **Allows tooltips** — hover events still fire on `aria-disabled` elements, so tooltips can explain why a control is disabled
- **Consistent across custom elements** — HTML `disabled` only has native behavior on form controls (`<button>`, `<input>`), not custom elements

This aligns with both Radix (uses `aria-disabled` for custom interactive elements, `[data-disabled]` for styling) and Base UI (uses `aria-disabled` when `focusableWhenDisabled` is true, exposes `[data-disabled]`).

### Why HTML `hidden` for unavailable features

When a feature is unsupported or unavailable, the button should not be visible at all. The HTML `hidden` attribute:

- Works without CSS — no `display: none` rule needed
- Has native browser semantics
- Is set via `getAttrs()` alongside `aria-disabled`, keeping all attribute logic in one place

On the React side, the component returns `null` instead — the idiomatic React approach for conditional rendering.

### Why separate `data-disabled` and `data-hidden`

These serve different styling purposes:

- `data-disabled` — reduced opacity, `cursor: not-allowed` (button is visible but non-interactive)
- `data-hidden` — a styling hook for consumers; the HTML `hidden` attribute handles actual hiding

Both are driven by state fields (`disabled`, `hidden`) through the standard `applyStateDataAttrs` data attribute system.

## References

- [WAI-ARIA APG Toolbar Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/) — recommends `aria-disabled` for toolbar buttons
- [WAI-ARIA APG Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) — "when the action associated with a button is unavailable, the button has `aria-disabled` set to `true`"
- [Radix Primitives Accessibility](https://www.radix-ui.com/primitives/docs/overview/accessibility) — `aria-disabled` + `[data-disabled]` for custom elements
- [Base UI Accessibility](https://base-ui.com/react/handbook/styling) — `focusableWhenDisabled` prop, `[data-disabled]` attr
- [MDN: aria-disabled](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-disabled)
66 changes: 66 additions & 0 deletions packages/core/src/core/media/predicate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { isFunction, isObject } from '@videojs/utils/predicate';

import type {
MediaBufferCapability,
MediaErrorCapability,
MediaFullscreenCapability,
MediaPauseCapability,
MediaPictureInPictureCapability,
MediaPlaybackRateCapability,
MediaRemotePlaybackCapability,
MediaSeekCapability,
MediaSourceCapability,
MediaTextTrackCapability,
MediaVolumeCapability,
} from './types';

export function isMediaPauseCapable(value: unknown): value is MediaPauseCapability {
return isObject(value) && 'paused' in value && 'ended' in value && 'pause' in value && isFunction(value.pause);
}

export function isMediaSeekCapable(value: unknown): value is MediaSeekCapability {
return isObject(value) && 'currentTime' in value && 'duration' in value && 'seeking' in value;
}

export function isMediaSourceCapable(value: unknown): value is MediaSourceCapability {
return (
isObject(value) &&
'src' in value &&
'currentSrc' in value &&
'readyState' in value &&
'load' in value &&
isFunction(value.load)
);
}

export function isMediaVolumeCapable(value: unknown): value is MediaVolumeCapability {
return isObject(value) && 'volume' in value && 'muted' in value;
}

export function isMediaPlaybackRateCapable(value: unknown): value is MediaPlaybackRateCapability {
return isObject(value) && 'playbackRate' in value;
}

export function isMediaBufferCapable(value: unknown): value is MediaBufferCapability {
return isObject(value) && 'buffered' in value && 'seekable' in value;
}

export function isMediaErrorCapable(value: unknown): value is MediaErrorCapability {
return isObject(value) && 'error' in value;
}

export function isMediaTextTrackCapable(value: unknown): value is MediaTextTrackCapability {
return isObject(value) && 'textTracks' in value;
}

export function isMediaRemotePlaybackCapable(value: unknown): value is MediaRemotePlaybackCapability {
return isObject(value) && 'remote' in value && isObject(value.remote);
}

export function isMediaFullscreenCapable(value: unknown): value is MediaFullscreenCapability {
return isObject(value) && 'requestFullscreen' in value && isFunction(value.requestFullscreen);
}

export function isMediaPictureInPictureCapable(value: unknown): value is MediaPictureInPictureCapability {
return isObject(value) && 'requestPictureInPicture' in value && isFunction(value.requestPictureInPicture);
}
Comment thread
mihar-22 marked this conversation as resolved.
4 changes: 2 additions & 2 deletions packages/core/src/core/media/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,13 +311,13 @@ export interface MediaPictureInPictureState {
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/requestPictureInPicture
*/
requestPictureInPicture(): Promise<void>;
requestPictureInPicture(): Promise<unknown>;
/**
* Exit picture-in-picture mode.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/exitPictureInPicture
*/
exitPictureInPicture(): Promise<void>;
/** Toggle picture-in-picture mode. */
togglePictureInPicture(): Promise<void>;
togglePictureInPicture(): Promise<unknown>;
}
11 changes: 11 additions & 0 deletions packages/core/src/core/media/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,17 @@ export interface MediaPictureInPictureCapability {
requestPictureInPicture(): Promise<unknown>;
}

export interface RemotePlaybackLike extends EventTarget {
readonly state: string;
prompt(): Promise<void>;
watchAvailability(callback: (available: boolean) => void): Promise<number>;
cancelWatchAvailability(id?: number): Promise<void>;
}

export interface MediaRemotePlaybackCapability {
readonly remote: RemotePlaybackLike;
}

interface MediaEvents extends MediaPlaybackEvents {}

export interface Media extends MediaPlaybackCapability, EventTargetLike<MediaEvents> {
Expand Down
24 changes: 16 additions & 8 deletions packages/core/src/core/ui/cast-button/cast-button-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,14 @@ export interface CastButtonProps {
}

export interface CastButtonState extends ButtonState {
/** Current cast connection state. */
castState: CastState;
/** Whether casting can be requested on this platform. */
availability: MediaFeatureAvailability;
/** Whether the button is non-interactive (explicitly disabled or feature not available). */
disabled: boolean;
/** Whether the button is hidden because the feature is unsupported. */
hidden: boolean;
}

export class CastButtonCore {
Expand All @@ -28,6 +34,8 @@ export class CastButtonCore {
readonly state = createState<CastButtonState>({
castState: 'disconnected',
availability: 'unavailable',
disabled: true,
hidden: false,
label: '',
});

Expand Down Expand Up @@ -60,7 +68,8 @@ export class CastButtonCore {
getAttrs(state: CastButtonState) {
return {
'aria-label': this.getLabel(state),
'aria-disabled': this.#props.disabled ? 'true' : undefined,
'aria-disabled': state.disabled || state.hidden ? 'true' : undefined,
hidden: state.hidden || undefined,
};
}

Expand All @@ -70,21 +79,20 @@ export class CastButtonCore {

getState(): CastButtonState {
const media = this.#media!;
this.state.patch({ castState: media.castState, availability: media.castAvailability });
const availability = media.castAvailability;
const disabled = this.#props.disabled || availability !== 'available';
const hidden = availability === 'unsupported';
this.state.patch({ castState: media.castState, availability, disabled, hidden });
this.state.patch({ label: this.getLabel(this.state.current) });

return this.state.current;
}

async toggle(media: MediaCastState): Promise<void> {
toggle(media: MediaCastState): void | Promise<void> {
if (this.#props.disabled) return;
if (media.castAvailability !== 'available') return;

try {
await media.toggleCast();
} catch {
// Cast requests can fail (user cancelled, permissions, etc.)
}
return media.toggleCast();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import type { StateAttrMap } from '../types';
import type { CastButtonState } from './cast-button-core';

export const CastButtonDataAttrs = {
/** Indicates cast connection state (`disconnected`, `connecting`, or `connected`). */
castState: 'data-cast-state',
/** Indicates cast availability (`available`, `unavailable`, or `unsupported`). */
availability: 'data-availability',
/** Present when the button is non-interactive (explicitly disabled or feature not available). */
disabled: 'data-disabled',
/** Present when the feature is unsupported. */
hidden: 'data-hidden',
} as const satisfies StateAttrMap<CastButtonState>;
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ function createState(overrides: Partial<CastButtonState> = {}): CastButtonState
return {
castState: 'disconnected',
availability: 'available',
disabled: false,
hidden: false,
label: '',
...overrides,
};
Expand All @@ -42,6 +44,33 @@ describe('CastButtonCore', () => {
expect(state.availability).toBe('unsupported');
});

it('sets disabled when unavailable', () => {
const core = new CastButtonCore();
core.setMedia(createMediaState({ castAvailability: 'unavailable' }));
expect(core.getState().disabled).toBe(true);
expect(core.getState().hidden).toBe(false);
});

it('sets disabled and hidden when unsupported', () => {
const core = new CastButtonCore();
core.setMedia(createMediaState({ castAvailability: 'unsupported' }));
expect(core.getState().disabled).toBe(true);
expect(core.getState().hidden).toBe(true);
});

it('clears disabled and hidden when available', () => {
const core = new CastButtonCore();
core.setMedia(createMediaState({ castAvailability: 'available' }));
expect(core.getState().disabled).toBe(false);
expect(core.getState().hidden).toBe(false);
});

it('sets disabled from prop', () => {
const core = new CastButtonCore({ disabled: true });
core.setMedia(createMediaState());
expect(core.getState().disabled).toBe(true);
});

it('reflects connecting state', () => {
const core = new CastButtonCore();
core.setMedia(createMediaState({ castState: 'connecting' }));
Expand Down Expand Up @@ -88,10 +117,34 @@ describe('CastButtonCore', () => {
});

it('sets aria-disabled when disabled', () => {
const core = new CastButtonCore({ disabled: true });
const attrs = core.getAttrs(createState());
const core = new CastButtonCore();
const attrs = core.getAttrs(createState({ disabled: true }));
expect(attrs['aria-disabled']).toBe('true');
});

it('sets aria-disabled when hidden', () => {
const core = new CastButtonCore();
const attrs = core.getAttrs(createState({ hidden: true }));
expect(attrs['aria-disabled']).toBe('true');
});

it('omits aria-disabled when available and not disabled', () => {
const core = new CastButtonCore();
const attrs = core.getAttrs(createState());
expect(attrs['aria-disabled']).toBeUndefined();
});

it('sets hidden attr when hidden', () => {
const core = new CastButtonCore();
const attrs = core.getAttrs(createState({ hidden: true }));
expect(attrs.hidden).toBe(true);
});

it('omits hidden attr when not hidden', () => {
const core = new CastButtonCore();
const attrs = core.getAttrs(createState());
expect(attrs.hidden).toBeUndefined();
});
});

describe('toggle', () => {
Expand Down Expand Up @@ -123,14 +176,14 @@ describe('CastButtonCore', () => {
expect(media.toggleCast).not.toHaveBeenCalled();
});

it('catches cast errors silently', async () => {
it('propagates cast errors to caller', async () => {
const core = new CastButtonCore();
const media = createMediaState({
toggleCast: vi.fn(async () => {
throw new Error('user cancelled');
}),
});
await expect(core.toggle(media)).resolves.toBeUndefined();
await expect(core.toggle(media)).rejects.toThrow('user cancelled');
});
});
});
Loading
Loading