refactor(packages): clean up UI component types and data flow (#479)

This commit is contained in:
rahim
2026-02-09 20:15:26 +11:00
committed by GitHub
parent fef26c8421
commit 5f5ffb9f0a
32 changed files with 681 additions and 732 deletions
+95 -44
View File
@@ -66,11 +66,11 @@ interface FullscreenButtonState extends Pick<FullscreenState, 'fullscreen'> {
class PlayButtonCore {
static readonly defaultProps: NonNullableObject<Props>;
setProps(props: Props): void; // Merge with defaults
getLabel(state: FeatureState): string; // Computed label
getAttrs(state: FeatureState): ElementProps; // ARIA only
getState(state: FeatureState): State; // Primitives only
toggle(state: FeatureState): Promise<void>; // Action
setProps(props: Props): void; // Merge with defaults
getState(media: MediaPlaybackState): State; // Project media → UI state
getLabel(state: PlayButtonState): string; // Computed label
getAttrs(state: PlayButtonState): { ... }; // ARIA only (inferred)
toggle(media: MediaPlaybackState): Promise<void>; // Action
}
namespace PlayButtonCore {
@@ -79,12 +79,22 @@ namespace PlayButtonCore {
}
```
**Method signatures — queries vs commands:**
| Method | Accepts | Why |
|--------|---------|-----|
| `getState(media)` | Raw media state | Projection boundary — only place that touches `Media*State` |
| `getLabel(state)` | Projected UI state | Pure query, needs only data fields |
| `getAttrs(state)` | Projected UI state | Pure query, return type inferred from object literal |
| `toggle(media)` | Raw media state | Command — needs action methods (`play`, `pause`, etc.) |
**Rules:**
- `static readonly defaultProps` with `NonNullableObject<Props>` type
- `getAttrs()` returns ARIA attributes only (no `data-*`)
- `getAttrs()` returns ARIA attributes only (no `data-*`), return type inferred (no explicit interface)
- `getState()` returns primitives only (no methods) — converted to `data-*` for CSS
- Action methods receive feature state from store
- `toggle()` accepts raw media state (commands need action methods)
- `getLabel()` and `getAttrs()` accept projected UI state (queries need only data)
- Namespace exports `Props` and `State` types
---
@@ -104,30 +114,37 @@ namespace PlayButtonCore {
---
## Data Attribute Enums
## Data Attribute Maps
Enums provide single source of truth + API reference tooling:
Each component has a `*DataAttrs` constant that maps state keys to `data-*` attribute names.
The `satisfies StateAttrMap<*State>` constraint validates at compile-time that only keys from the
component's state type are mapped — preventing accidental serialization of unmapped keys.
```ts
export enum PlayButtonDataAttrs {
import type { StateAttrMap } from '../types';
import type { PlayButtonState } from './play-button-core';
export const PlayButtonDataAttrs = {
/** Present when the media is paused. */
paused = 'data-paused',
paused: 'data-paused',
/** Present when the media has ended. */
ended = 'data-ended',
}
ended: 'data-ended',
/** Present when playback has started. */
started: 'data-started',
} as const satisfies StateAttrMap<PlayButtonState>;
```
JSDoc comments generate API documentation.
`StateAttrMap<State>` is defined in `core/ui/types.ts`:
---
```ts
export type StateAttrMap<State> = {
[Key in keyof State]?: string;
};
```
## ElementProps
Shared interface in `core/element.ts`. Extend as components need new attributes:
- Add `aria-*` attributes used by any component
- Use string literal types where ARIA spec defines allowed values
- `undefined` removes the attribute
- JSDoc comments generate API documentation
- Maps are partial — only mapped state keys become `data-*` attributes
- `getStateDataAttrs` and `applyStateDataAttrs` skip unmapped keys when a map is provided
---
@@ -148,17 +165,44 @@ class PlayButtonElement extends MediaElement {
**Lifecycle flow:**
1. `connectedCallback` — Create `AbortController`, apply button props via `applyElementProps()`
1. `connectedCallback` — Create `AbortController`, apply button props, `__DEV__` warning for missing feature
2. `disconnectedCallback` — Abort controller for cleanup
3. `willUpdate` — Sync component props to core via `setProps()`
4. `update`Apply `getAttrs()` and `getState()` to element
3. `willUpdate` — Sync component props to core via `setProps(this)`
4. `update`Silent null guard, then project state and apply attrs
**`connectedCallback` + `update()` pattern:**
```ts
override connectedCallback(): void {
super.connectedCallback();
this.#disconnect = new AbortController();
const buttonProps = createButton({ ... });
applyElementProps(this, buttonProps, this.#disconnect.signal);
if (__DEV__ && !this.#state.value) {
logMissingFeature(PlayButtonElement.tagName, 'playback');
}
}
protected override update(changed: PropertyValues): void {
super.update(changed);
const media = this.#state.value;
if (!media) return;
const state = this.#core.getState(media);
applyElementProps(this, this.#core.getAttrs(state));
applyStateDataAttrs(this, state, PlayButtonDataAttrs);
}
```
**Key utilities:**
- `PlayerController(host, context, selector)` — Store subscription
- `applyElementProps(el, props, signal)` — Apply attrs + events
- `applyStateDataAttrs(el, state)` — State → `data-*`
- `logMissingFeature(name, feature)` — Deduped warning
- `applyElementProps(el, props, signal?)` — Apply attrs + events to DOM
- `applyStateDataAttrs(el, state, map)` — State → `data-*` (map controls which keys are serialized)
- `logMissingFeature(name, feature)` — Deduped `__DEV__`-only warning (called in `connectedCallback`)
---
@@ -168,12 +212,18 @@ class PlayButtonElement extends MediaElement {
const PlayButton = forwardRef(function PlayButton(props, ref) {
const playback = usePlayer(selectPlayback);
const [core] = useState(() => new PlayButtonCore());
const { getButtonProps, buttonRef } = useButton({ onActivate, isDisabled });
const { getButtonProps, buttonRef } = useButton({
onActivate: () => core.toggle(playback!),
isDisabled,
});
const state = core.getState(playback);
return renderElement('button', { render, className, style }, {
state: core.getState(playback),
state,
stateAttrMap: PlayButtonDataAttrs,
ref: [ref, buttonRef],
props: [core.getAttrs(playback), elementProps, getButtonProps()],
props: [core.getAttrs(state), elementProps, getButtonProps()],
});
});
```
@@ -197,8 +247,8 @@ const PlayButton = forwardRef(function PlayButton(props, ref) {
|---------|---------|
| `createButton(options)` | Accessible button (Enter/Space, click, disabled) |
| `applyElementProps(el, props, signal?)` | Apply attrs and events to DOM |
| `applyStateDataAttrs(el, state)` | State object `data-*` attributes |
| `getStateDataAttrs(state)` | State → data-attrs object (React) |
| `applyStateDataAttrs(el, state, map)` | State → `data-*` (map controls serialized keys) |
| `getStateDataAttrs(state, map)` | State → data-attrs object (React) |
| `logMissingFeature(name, feature)` | Deduped console.warn |
| `selectPlayback` / `selectVolume` | Store selectors |
@@ -218,20 +268,21 @@ const PlayButton = forwardRef(function PlayButton(props, ref) {
packages/
├── core/src/
│ ├── core/
│ │ ├── element.ts # ElementProps interface
│ │ ├── ui/types.ts # StateAttrMap type
│ │ └── ui/{component}/
│ │ ├── {component}-core.ts # Core class
│ │ ├── {component}-core.test.ts # Core tests
│ │ └── {component}-data-attrs.ts # Data attr enum
└── dom/ui/ # createButton, utils
│ │ ├── {component}-core.ts # Core class
│ │ ├── {component}-data-attrs.ts # Data attr map (satisfies StateAttrMap)
│ │ └── tests/
│ └── {component}-core.test.ts # Core tests
│ └── dom/ui/ # createButton, utils
├── html/src/
│ ├── ui/{component}/ # Web Component
│ ├── define/ui/ # Side-effect registration
│ └── player/player-controller.ts # Store controller
│ ├── ui/{component}/ # Web Component
│ ├── define/ui/ # Side-effect registration
│ └── player/player-controller.ts # Store controller
└── react/src/
├── ui/{component}/ # React component
├── ui/hooks/ # Behavior hooks
└── utils/ # renderElement, mergeProps
├── ui/{component}/ # React component
├── ui/hooks/ # Behavior hooks
└── utils/ # renderElement, mergeProps
```
**No barrel exports for simple components** — Don't create `index.ts` files for simple UI components. Export directly from individual files. Reserve `index.ts` barrels for compound components with multiple related exports that form a cohesive API.
@@ -16,16 +16,17 @@ Video.js-specific patterns. See [videojs.md](../../references/videojs.md) for ar
## State vs Attrs Separation
- [ ] `getState()` returns primitives only (no methods)
- [ ] `getAttrs()` returns ARIA only (no `data-*`)
- [ ] Data attribute enum with JSDoc for API tooling
- [ ] `getAttrs()` returns ARIA only (no `data-*`), return type inferred
- [ ] `getLabel()`/`getAttrs()` accept projected UI state, `toggle()` accepts raw media state
- [ ] Data attribute map with `as const satisfies StateAttrMap<*State>` constraint
## Web Component (Lit)
- [ ] Extends `MediaElement`
- [ ] Uses `PlayerController` with selector for store subscription
- [ ] Uses `AbortController` for cleanup in `disconnectedCallback`
- [ ] `willUpdate`: syncs props to core via `setProps()`
- [ ] `update`: applies attrs and state data attrs
- [ ] `willUpdate`: syncs props to core via `setProps(this)`
- [ ] `update`: silent null guard, project state, apply attrs and data attrs with map
## React Component
@@ -37,6 +38,6 @@ Video.js-specific patterns. See [videojs.md](../../references/videojs.md) for ar
## Common
- [ ] Missing feature handled with `logMissingFeature()`
- [ ] Missing feature warned with `if (__DEV__) logMissingFeature()` in `connectedCallback` (HTML) or null guard (React)
- [ ] Web Component registered in `define/ui/` with `HTMLElementTagNameMap`
- [ ] `static readonly tagName = 'media-{name}'`
-8
View File
@@ -1,8 +0,0 @@
export interface ElementProps {
'aria-label'?: string | undefined;
'aria-disabled'?: string | undefined;
'data-paused'?: boolean | undefined;
'data-ended'?: boolean | undefined;
'data-waiting'?: boolean | undefined;
'data-unsupported'?: boolean | undefined;
}
+2 -1
View File
@@ -1,9 +1,10 @@
export * from './element';
export * from './media/state';
export * from './ui/fullscreen-button/fullscreen-button-core';
export * from './ui/fullscreen-button/fullscreen-button-data-attrs';
export * from './ui/mute-button/mute-button-core';
export * from './ui/mute-button/mute-button-data-attrs';
export * from './ui/play-button/play-button-core';
export * from './ui/play-button/play-button-data-attrs';
export * from './ui/time/time-core';
export * from './ui/time/time-data-attrs';
export * from './ui/types';
@@ -1,158 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { MediaFullscreenState } from '../../media/state';
import { FullscreenButtonCore } from './fullscreen-button-core';
function createMockFullscreen(overrides: Partial<MediaFullscreenState> = {}): MediaFullscreenState {
return {
fullscreen: false,
fullscreenAvailability: 'available',
requestFullscreen: vi.fn(),
exitFullscreen: vi.fn(),
...overrides,
};
}
describe('FullscreenButtonCore', () => {
describe('getLabel', () => {
it('returns custom label string when provided', () => {
const core = new FullscreenButtonCore({ label: 'Custom Label' });
const state = createMockFullscreen();
expect(core.getLabel(state)).toBe('Custom Label');
});
it('returns custom label from function when provided', () => {
const core = new FullscreenButtonCore({
label: (state) => (state.fullscreen ? 'Exit' : 'Enter'),
});
const state = createMockFullscreen({ fullscreen: true });
expect(core.getLabel(state)).toBe('Exit');
});
it('falls back to default label when function returns empty string', () => {
const core = new FullscreenButtonCore({ label: () => '' });
const state = createMockFullscreen({ fullscreen: false });
expect(core.getLabel(state)).toBe('Enter fullscreen');
});
it('returns "Exit fullscreen" when active', () => {
const core = new FullscreenButtonCore();
const state = createMockFullscreen({ fullscreen: true });
expect(core.getLabel(state)).toBe('Exit fullscreen');
});
it('returns "Enter fullscreen" when not active', () => {
const core = new FullscreenButtonCore();
const state = createMockFullscreen({ fullscreen: false });
expect(core.getLabel(state)).toBe('Enter fullscreen');
});
});
describe('getAttrs', () => {
it('returns aria-label based on fullscreen state', () => {
const core = new FullscreenButtonCore();
const state = createMockFullscreen({ fullscreen: true });
const attrs = core.getAttrs(state);
expect(attrs['aria-label']).toBe('Exit fullscreen');
});
it('returns aria-disabled when disabled', () => {
const core = new FullscreenButtonCore({ disabled: true });
const state = createMockFullscreen();
const attrs = core.getAttrs(state);
expect(attrs['aria-disabled']).toBe('true');
});
it('returns undefined aria-disabled when not disabled', () => {
const core = new FullscreenButtonCore({ disabled: false });
const state = createMockFullscreen();
const attrs = core.getAttrs(state);
expect(attrs['aria-disabled']).toBeUndefined();
});
});
describe('getState', () => {
it('returns primitive values only (no methods)', () => {
const core = new FullscreenButtonCore();
const state = createMockFullscreen({ fullscreen: true });
const buttonState = core.getState(state);
expect(buttonState).toEqual({
fullscreen: true,
availability: 'available',
});
const functionKeys = Object.entries(buttonState).filter(([, value]) => typeof value === 'function');
expect(functionKeys).toHaveLength(0);
});
it('picks fullscreen from state', () => {
const core = new FullscreenButtonCore();
expect(core.getState(createMockFullscreen({ fullscreen: true })).fullscreen).toBe(true);
expect(core.getState(createMockFullscreen({ fullscreen: false })).fullscreen).toBe(false);
});
it('picks availability from state', () => {
const core = new FullscreenButtonCore();
expect(core.getState(createMockFullscreen({ fullscreenAvailability: 'unsupported' })).availability).toBe(
'unsupported'
);
});
});
describe('toggle', () => {
it('calls requestFullscreen when not active', async () => {
const core = new FullscreenButtonCore();
const state = createMockFullscreen({ fullscreen: false });
await core.toggle(state);
expect(state.requestFullscreen).toHaveBeenCalledTimes(1);
expect(state.exitFullscreen).not.toHaveBeenCalled();
});
it('calls exitFullscreen when active', async () => {
const core = new FullscreenButtonCore();
const state = createMockFullscreen({ fullscreen: true });
await core.toggle(state);
expect(state.exitFullscreen).toHaveBeenCalledTimes(1);
expect(state.requestFullscreen).not.toHaveBeenCalled();
});
it('does nothing when disabled', async () => {
const core = new FullscreenButtonCore({ disabled: true });
const state = createMockFullscreen();
await core.toggle(state);
expect(state.requestFullscreen).not.toHaveBeenCalled();
expect(state.exitFullscreen).not.toHaveBeenCalled();
});
it('does nothing when availability is not available', async () => {
const core = new FullscreenButtonCore();
const state = createMockFullscreen({ fullscreenAvailability: 'unsupported' });
await core.toggle(state);
expect(state.requestFullscreen).not.toHaveBeenCalled();
expect(state.exitFullscreen).not.toHaveBeenCalled();
});
});
});
@@ -2,7 +2,6 @@ import { defaults } from '@videojs/utils/object';
import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { ElementProps } from '../../element';
import type { MediaFullscreenState } from '../../media/state';
export interface FullscreenButtonProps {
@@ -33,43 +32,42 @@ export class FullscreenButtonCore {
this.#props = defaults(props, FullscreenButtonCore.defaultProps);
}
getLabel(state: MediaFullscreenState): string {
const buttonState = this.getState(state);
getLabel(state: FullscreenButtonState): string {
const { label } = this.#props;
if (isFunction(label)) {
const customLabel = label(buttonState);
const customLabel = label(state);
if (customLabel) return customLabel;
} else if (label) {
return label;
}
return buttonState.fullscreen ? 'Exit fullscreen' : 'Enter fullscreen';
return state.fullscreen ? 'Exit fullscreen' : 'Enter fullscreen';
}
getAttrs(state: MediaFullscreenState): ElementProps {
getAttrs(state: FullscreenButtonState) {
return {
'aria-label': this.getLabel(state),
'aria-disabled': this.#props.disabled ? 'true' : undefined,
};
}
getState(state: MediaFullscreenState): FullscreenButtonState {
getState(media: MediaFullscreenState): FullscreenButtonState {
return {
fullscreen: state.fullscreen,
availability: state.fullscreenAvailability,
fullscreen: media.fullscreen,
availability: media.fullscreenAvailability,
};
}
async toggle(state: MediaFullscreenState): Promise<void> {
async toggle(media: MediaFullscreenState): Promise<void> {
if (this.#props.disabled) return;
if (state.fullscreenAvailability !== 'available') return;
if (media.fullscreenAvailability !== 'available') return;
try {
if (state.fullscreen) {
await state.exitFullscreen();
if (media.fullscreen) {
await media.exitFullscreen();
} else {
await state.requestFullscreen();
await media.requestFullscreen();
}
} catch {
// Fullscreen requests can fail (user gesture required, permissions, etc.)
@@ -1,6 +1,9 @@
export const FullscreenButtonDataAttributes = {
import type { StateAttrMap } from '../types';
import type { FullscreenButtonState } from './fullscreen-button-core';
export const FullscreenButtonDataAttrs = {
/** Present when fullscreen mode is active. */
fullscreen: 'data-fullscreen',
/** Indicates fullscreen availability (`available` or `unsupported`). */
availability: 'data-availability',
} as const;
} as const satisfies StateAttrMap<FullscreenButtonState>;
@@ -0,0 +1,121 @@
import { describe, expect, it, vi } from 'vitest';
import type { MediaFullscreenState } from '../../../media/state';
import type { FullscreenButtonState } from '../fullscreen-button-core';
import { FullscreenButtonCore } from '../fullscreen-button-core';
function createMediaState(overrides: Partial<MediaFullscreenState> = {}): MediaFullscreenState {
return {
fullscreen: false,
fullscreenAvailability: 'available',
requestFullscreen: vi.fn(async () => {}),
exitFullscreen: vi.fn(async () => {}),
...overrides,
};
}
function createState(overrides: Partial<FullscreenButtonState> = {}): FullscreenButtonState {
return {
fullscreen: false,
availability: 'available',
...overrides,
};
}
describe('FullscreenButtonCore', () => {
describe('getState', () => {
it('projects fullscreen and availability', () => {
const core = new FullscreenButtonCore();
const media = createMediaState({ fullscreen: true });
const state = core.getState(media);
expect(state.fullscreen).toBe(true);
expect(state.availability).toBe('available');
});
it('reflects unsupported availability', () => {
const core = new FullscreenButtonCore();
const state = core.getState(createMediaState({ fullscreenAvailability: 'unsupported' }));
expect(state.availability).toBe('unsupported');
});
});
describe('getLabel', () => {
it('returns Enter fullscreen when not fullscreen', () => {
const core = new FullscreenButtonCore();
expect(core.getLabel(createState({ fullscreen: false }))).toBe('Enter fullscreen');
});
it('returns Exit fullscreen when fullscreen', () => {
const core = new FullscreenButtonCore();
expect(core.getLabel(createState({ fullscreen: true }))).toBe('Exit fullscreen');
});
it('returns custom string label', () => {
const core = new FullscreenButtonCore({ label: 'Full screen' });
expect(core.getLabel(createState())).toBe('Full screen');
});
it('returns custom function label', () => {
const core = new FullscreenButtonCore({
label: (state) => (state.fullscreen ? 'Minimize' : 'Maximize'),
});
expect(core.getLabel(createState({ fullscreen: true }))).toBe('Minimize');
});
});
describe('getAttrs', () => {
it('returns aria-label', () => {
const core = new FullscreenButtonCore();
const attrs = core.getAttrs(createState());
expect(attrs['aria-label']).toBe('Enter fullscreen');
});
it('sets aria-disabled when disabled', () => {
const core = new FullscreenButtonCore({ disabled: true });
const attrs = core.getAttrs(createState());
expect(attrs['aria-disabled']).toBe('true');
});
});
describe('toggle', () => {
it('calls requestFullscreen when not fullscreen', async () => {
const core = new FullscreenButtonCore();
const media = createMediaState({ fullscreen: false });
await core.toggle(media);
expect(media.requestFullscreen).toHaveBeenCalled();
});
it('calls exitFullscreen when fullscreen', async () => {
const core = new FullscreenButtonCore();
const media = createMediaState({ fullscreen: true });
await core.toggle(media);
expect(media.exitFullscreen).toHaveBeenCalled();
});
it('does nothing when disabled', async () => {
const core = new FullscreenButtonCore({ disabled: true });
const media = createMediaState();
await core.toggle(media);
expect(media.requestFullscreen).not.toHaveBeenCalled();
});
it('does nothing when unsupported', async () => {
const core = new FullscreenButtonCore();
const media = createMediaState({ fullscreenAvailability: 'unsupported' });
await core.toggle(media);
expect(media.requestFullscreen).not.toHaveBeenCalled();
});
it('catches fullscreen errors silently', async () => {
const core = new FullscreenButtonCore();
const media = createMediaState({
requestFullscreen: vi.fn(async () => {
throw new Error('permission denied');
}),
});
await expect(core.toggle(media)).resolves.toBeUndefined();
});
});
});
@@ -1,164 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { MediaVolumeState } from '../../media/state';
import { MuteButtonCore } from './mute-button-core';
function createMockVolume(overrides: Partial<MediaVolumeState> = {}): MediaVolumeState {
return {
volume: 1,
muted: false,
volumeAvailability: 'available',
changeVolume: vi.fn(),
toggleMute: vi.fn(),
...overrides,
};
}
describe('MuteButtonCore', () => {
describe('getLabel', () => {
it('returns custom label string when provided', () => {
const core = new MuteButtonCore({ label: 'Custom Label' });
const volume = createMockVolume();
expect(core.getLabel(volume)).toBe('Custom Label');
});
it('returns custom label from function when provided', () => {
const core = new MuteButtonCore({
label: (state) => (state.muted ? 'Sound On' : 'Sound Off'),
});
const volume = createMockVolume({ muted: true });
expect(core.getLabel(volume)).toBe('Sound On');
});
it('falls back to default label when function returns empty string', () => {
const core = new MuteButtonCore({ label: () => '' });
const volume = createMockVolume({ muted: false });
expect(core.getLabel(volume)).toBe('Mute');
});
it('returns "Unmute" when muted', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: true });
expect(core.getLabel(volume)).toBe('Unmute');
});
it('returns "Mute" when unmuted', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: false });
expect(core.getLabel(volume)).toBe('Mute');
});
});
describe('getAttrs', () => {
it('returns aria-label based on mute state', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: true });
const attrs = core.getAttrs(volume);
expect(attrs['aria-label']).toBe('Unmute');
});
it('returns aria-disabled when disabled', () => {
const core = new MuteButtonCore({ disabled: true });
const volume = createMockVolume();
const attrs = core.getAttrs(volume);
expect(attrs['aria-disabled']).toBe('true');
});
it('returns undefined aria-disabled when not disabled', () => {
const core = new MuteButtonCore({ disabled: false });
const volume = createMockVolume();
const attrs = core.getAttrs(volume);
expect(attrs['aria-disabled']).toBeUndefined();
});
it('does NOT return data-* attributes', () => {
const core = new MuteButtonCore();
const volume = createMockVolume();
const attrs = core.getAttrs(volume);
const dataKeys = Object.keys(attrs).filter((key) => key.startsWith('data-'));
expect(dataKeys).toHaveLength(0);
});
});
describe('getState', () => {
it('returns primitive values only (no methods)', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: true, volume: 0.2 });
const state = core.getState(volume);
expect(state).toEqual({ muted: true, volumeLevel: 'off' });
const functionKeys = Object.entries(state).filter(([, value]) => typeof value === 'function');
expect(functionKeys).toHaveLength(0);
});
it('returns off when muted', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: true, volume: 1 });
expect(core.getState(volume).volumeLevel).toBe('off');
});
it('returns off when volume is zero', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: false, volume: 0 });
expect(core.getState(volume).volumeLevel).toBe('off');
});
it('returns low when volume is below 0.5', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: false, volume: 0.4 });
expect(core.getState(volume).volumeLevel).toBe('low');
});
it('returns medium when volume is below 0.75', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: false, volume: 0.6 });
expect(core.getState(volume).volumeLevel).toBe('medium');
});
it('returns high when volume is 0.75 or above', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: false, volume: 0.9 });
expect(core.getState(volume).volumeLevel).toBe('high');
});
});
describe('toggle', () => {
it('calls toggleMute when enabled', () => {
const core = new MuteButtonCore();
const volume = createMockVolume();
core.toggle(volume);
expect(volume.toggleMute).toHaveBeenCalledTimes(1);
});
it('does nothing when disabled', () => {
const core = new MuteButtonCore({ disabled: true });
const volume = createMockVolume();
core.toggle(volume);
expect(volume.toggleMute).not.toHaveBeenCalled();
});
});
});
@@ -2,7 +2,6 @@ import { defaults } from '@videojs/utils/object';
import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { ElementProps } from '../../element';
import type { MediaVolumeState } from '../../media/state';
export type VolumeLevel = 'off' | 'low' | 'medium' | 'high';
@@ -14,9 +13,7 @@ export interface MuteButtonProps {
disabled?: boolean | undefined;
}
export interface MuteButtonState {
/** Whether audio is muted. */
muted: boolean;
export interface MuteButtonState extends Pick<MediaVolumeState, 'muted'> {
/**
* Derived volume level:
* - `off`: muted or volume is 0
@@ -43,8 +40,7 @@ export class MuteButtonCore {
this.#props = defaults(props, MuteButtonCore.defaultProps);
}
getLabel(volume: MediaVolumeState): string {
const state = this.getState(volume);
getLabel(state: MuteButtonState): string {
const { label } = this.#props;
if (isFunction(label)) {
@@ -57,23 +53,23 @@ export class MuteButtonCore {
return state.muted ? 'Unmute' : 'Mute';
}
getAttrs(volume: MediaVolumeState): ElementProps {
getAttrs(state: MuteButtonState) {
return {
'aria-label': this.getLabel(volume),
'aria-label': this.getLabel(state),
'aria-disabled': this.#props.disabled ? 'true' : undefined,
};
}
getState(volume: MediaVolumeState): MuteButtonState {
getState(media: MediaVolumeState): MuteButtonState {
return {
muted: volume.muted,
volumeLevel: getVolumeLevel(volume),
muted: media.muted,
volumeLevel: getVolumeLevel(media),
};
}
toggle(volume: MediaVolumeState): void {
toggle(media: MediaVolumeState): void {
if (this.#props.disabled) return;
volume.toggleMute();
media.toggleMute();
}
}
@@ -82,9 +78,9 @@ export namespace MuteButtonCore {
export type State = MuteButtonState;
}
function getVolumeLevel(volume: MediaVolumeState): VolumeLevel {
if (volume.muted || volume.volume === 0) return 'off';
if (volume.volume < 0.5) return 'low';
if (volume.volume < 0.75) return 'medium';
function getVolumeLevel(media: MediaVolumeState): VolumeLevel {
if (media.muted || media.volume === 0) return 'off';
if (media.volume < 0.5) return 'low';
if (media.volume < 0.75) return 'medium';
return 'high';
}
@@ -1,6 +1,9 @@
export const MuteButtonDataAttributes = {
import type { StateAttrMap } from '../types';
import type { MuteButtonState } from './mute-button-core';
export const MuteButtonDataAttrs = {
/** Present when the media is muted. */
muted: 'data-muted',
/** Indicates the volume level. */
volumeLevel: 'data-volume-level',
} as const;
} as const satisfies StateAttrMap<MuteButtonState>;
@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from 'vitest';
import type { MediaVolumeState } from '../../../media/state';
import type { MuteButtonState } from '../mute-button-core';
import { MuteButtonCore } from '../mute-button-core';
function createMediaState(overrides: Partial<MediaVolumeState> = {}): MediaVolumeState {
return {
volume: 1,
muted: false,
volumeAvailability: 'available',
changeVolume: vi.fn((v: number) => v),
toggleMute: vi.fn(() => false),
...overrides,
};
}
function createState(overrides: Partial<MuteButtonState> = {}): MuteButtonState {
return {
muted: false,
volumeLevel: 'high',
...overrides,
};
}
describe('MuteButtonCore', () => {
describe('getState', () => {
it('projects muted and volumeLevel', () => {
const core = new MuteButtonCore();
const media = createMediaState({ muted: false, volume: 1 });
const state = core.getState(media);
expect(state.muted).toBe(false);
expect(state.volumeLevel).toBe('high');
});
it('returns off when muted', () => {
const core = new MuteButtonCore();
const state = core.getState(createMediaState({ muted: true, volume: 0.8 }));
expect(state.muted).toBe(true);
expect(state.volumeLevel).toBe('off');
});
it('returns off when volume is 0', () => {
const core = new MuteButtonCore();
const state = core.getState(createMediaState({ volume: 0 }));
expect(state.volumeLevel).toBe('off');
});
it('returns low when volume < 0.5', () => {
const core = new MuteButtonCore();
const state = core.getState(createMediaState({ volume: 0.3 }));
expect(state.volumeLevel).toBe('low');
});
it('returns medium when volume < 0.75', () => {
const core = new MuteButtonCore();
const state = core.getState(createMediaState({ volume: 0.6 }));
expect(state.volumeLevel).toBe('medium');
});
it('returns high when volume >= 0.75', () => {
const core = new MuteButtonCore();
const state = core.getState(createMediaState({ volume: 0.75 }));
expect(state.volumeLevel).toBe('high');
});
});
describe('getLabel', () => {
it('returns Mute when unmuted', () => {
const core = new MuteButtonCore();
expect(core.getLabel(createState({ muted: false }))).toBe('Mute');
});
it('returns Unmute when muted', () => {
const core = new MuteButtonCore();
expect(core.getLabel(createState({ muted: true }))).toBe('Unmute');
});
it('returns custom string label', () => {
const core = new MuteButtonCore({ label: 'Toggle sound' });
expect(core.getLabel(createState())).toBe('Toggle sound');
});
it('returns custom function label', () => {
const core = new MuteButtonCore({
label: (state) => (state.muted ? 'Sound on' : 'Sound off'),
});
expect(core.getLabel(createState({ muted: true }))).toBe('Sound on');
});
});
describe('getAttrs', () => {
it('returns aria-label', () => {
const core = new MuteButtonCore();
const attrs = core.getAttrs(createState({ muted: false }));
expect(attrs['aria-label']).toBe('Mute');
});
it('sets aria-disabled when disabled', () => {
const core = new MuteButtonCore({ disabled: true });
const attrs = core.getAttrs(createState());
expect(attrs['aria-disabled']).toBe('true');
});
});
describe('toggle', () => {
it('calls toggleMute', () => {
const core = new MuteButtonCore();
const media = createMediaState();
core.toggle(media);
expect(media.toggleMute).toHaveBeenCalled();
});
it('does nothing when disabled', () => {
const core = new MuteButtonCore({ disabled: true });
const media = createMediaState();
core.toggle(media);
expect(media.toggleMute).not.toHaveBeenCalled();
});
});
});
@@ -1,186 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { MediaPlaybackState } from '../../media/state';
import { PlayButtonCore } from './play-button-core';
function createMockPlayback(overrides: Partial<MediaPlaybackState> = {}): MediaPlaybackState {
return {
paused: true,
ended: false,
started: false,
waiting: false,
play: vi.fn().mockResolvedValue(undefined),
pause: vi.fn(),
...overrides,
};
}
describe('PlayButtonCore', () => {
describe('getLabel', () => {
it('returns custom label string when provided', () => {
const core = new PlayButtonCore({ label: 'Custom Label' });
const playback = createMockPlayback();
expect(core.getLabel(playback)).toBe('Custom Label');
});
it('returns custom label from function when provided', () => {
const core = new PlayButtonCore({
label: (state) => (state.paused ? 'Start' : 'Stop'),
});
const playback = createMockPlayback({ paused: true });
expect(core.getLabel(playback)).toBe('Start');
});
it('falls back to default label when function returns empty string', () => {
const core = new PlayButtonCore({ label: () => '' });
const playback = createMockPlayback({ paused: true });
expect(core.getLabel(playback)).toBe('Play');
});
it('returns "Replay" when ended', () => {
const core = new PlayButtonCore();
const playback = createMockPlayback({ ended: true });
expect(core.getLabel(playback)).toBe('Replay');
});
it('returns "Play" when paused', () => {
const core = new PlayButtonCore();
const playback = createMockPlayback({ paused: true });
expect(core.getLabel(playback)).toBe('Play');
});
it('returns "Pause" when playing', () => {
const core = new PlayButtonCore();
const playback = createMockPlayback({ paused: false });
expect(core.getLabel(playback)).toBe('Pause');
});
});
describe('getAttrs', () => {
it('returns aria-label based on playback state', () => {
const core = new PlayButtonCore();
const playback = createMockPlayback({ paused: true });
const attrs = core.getAttrs(playback);
expect(attrs['aria-label']).toBe('Play');
});
it('returns aria-disabled when disabled', () => {
const core = new PlayButtonCore({ disabled: true });
const playback = createMockPlayback();
const attrs = core.getAttrs(playback);
expect(attrs['aria-disabled']).toBe('true');
});
it('returns undefined aria-disabled when not disabled', () => {
const core = new PlayButtonCore({ disabled: false });
const playback = createMockPlayback();
const attrs = core.getAttrs(playback);
expect(attrs['aria-disabled']).toBeUndefined();
});
it('does NOT return data-* attributes', () => {
const core = new PlayButtonCore();
const playback = createMockPlayback({ paused: true, ended: false, waiting: true });
const attrs = core.getAttrs(playback);
// Verify no data-* keys exist
const dataKeys = Object.keys(attrs).filter((key) => key.startsWith('data-'));
expect(dataKeys).toHaveLength(0);
});
});
describe('getState', () => {
it('returns primitive values only (no methods)', () => {
const core = new PlayButtonCore();
const playback = createMockPlayback({ paused: true, ended: false, started: true });
const state = core.getState(playback);
expect(state).toEqual({
paused: true,
ended: false,
started: true,
});
// Verify no functions in state
const functionKeys = Object.entries(state).filter(([, value]) => typeof value === 'function');
expect(functionKeys).toHaveLength(0);
});
it('includes paused from playback', () => {
const core = new PlayButtonCore();
expect(core.getState(createMockPlayback({ paused: true })).paused).toBe(true);
expect(core.getState(createMockPlayback({ paused: false })).paused).toBe(false);
});
it('includes ended from playback', () => {
const core = new PlayButtonCore();
expect(core.getState(createMockPlayback({ ended: true })).ended).toBe(true);
expect(core.getState(createMockPlayback({ ended: false })).ended).toBe(false);
});
it('includes started from playback', () => {
const core = new PlayButtonCore();
expect(core.getState(createMockPlayback({ started: true })).started).toBe(true);
expect(core.getState(createMockPlayback({ started: false })).started).toBe(false);
});
});
describe('toggle', () => {
it('calls play() when paused', async () => {
const core = new PlayButtonCore();
const playback = createMockPlayback({ paused: true });
await core.toggle(playback);
expect(playback.play).toHaveBeenCalledTimes(1);
expect(playback.pause).not.toHaveBeenCalled();
});
it('calls play() when ended', async () => {
const core = new PlayButtonCore();
const playback = createMockPlayback({ paused: false, ended: true });
await core.toggle(playback);
expect(playback.play).toHaveBeenCalledTimes(1);
expect(playback.pause).not.toHaveBeenCalled();
});
it('calls pause() when playing', async () => {
const core = new PlayButtonCore();
const playback = createMockPlayback({ paused: false, ended: false });
await core.toggle(playback);
expect(playback.pause).toHaveBeenCalledTimes(1);
expect(playback.play).not.toHaveBeenCalled();
});
it('does nothing when disabled', async () => {
const core = new PlayButtonCore({ disabled: true });
const playback = createMockPlayback({ paused: true });
await core.toggle(playback);
expect(playback.play).not.toHaveBeenCalled();
expect(playback.pause).not.toHaveBeenCalled();
});
});
});
@@ -2,7 +2,6 @@ import { defaults } from '@videojs/utils/object';
import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { ElementProps } from '../../element';
import type { MediaPlaybackState } from '../../media/state';
export interface PlayButtonProps {
@@ -30,8 +29,7 @@ export class PlayButtonCore {
this.#props = defaults(props, PlayButtonCore.defaultProps);
}
getLabel(playback: MediaPlaybackState): string {
const state = this.getState(playback);
getLabel(state: PlayButtonState): string {
const { label } = this.#props;
if (isFunction(label)) {
@@ -45,29 +43,29 @@ export class PlayButtonCore {
return state.paused ? 'Play' : 'Pause';
}
getAttrs(playback: MediaPlaybackState): ElementProps {
getAttrs(state: PlayButtonState) {
return {
'aria-label': this.getLabel(playback),
'aria-label': this.getLabel(state),
'aria-disabled': this.#props.disabled ? 'true' : undefined,
};
}
getState(playback: MediaPlaybackState): PlayButtonState {
getState(media: MediaPlaybackState): PlayButtonState {
return {
paused: playback.paused,
ended: playback.ended,
started: playback.started,
paused: media.paused,
ended: media.ended,
started: media.started,
};
}
async toggle(playback: MediaPlaybackState): Promise<void> {
async toggle(media: MediaPlaybackState): Promise<void> {
if (this.#props.disabled) return;
if (playback.paused || playback.ended) {
return playback.play();
if (media.paused || media.ended) {
return media.play();
}
playback.pause();
media.pause();
}
}
@@ -1,3 +1,6 @@
import type { StateAttrMap } from '../types';
import type { PlayButtonState } from './play-button-core';
export const PlayButtonDataAttrs = {
/** Present when the media is paused. */
paused: 'data-paused',
@@ -5,4 +8,4 @@ export const PlayButtonDataAttrs = {
ended: 'data-ended',
/** Present when playback has started. */
started: 'data-started',
} as const;
} as const satisfies StateAttrMap<PlayButtonState>;
@@ -0,0 +1,146 @@
import { describe, expect, it, vi } from 'vitest';
import type { MediaPlaybackState } from '../../../media/state';
import type { PlayButtonState } from '../play-button-core';
import { PlayButtonCore } from '../play-button-core';
function createMediaState(overrides: Partial<MediaPlaybackState> = {}): MediaPlaybackState {
return {
paused: true,
ended: false,
started: false,
waiting: false,
play: vi.fn(async () => {}),
pause: vi.fn(),
...overrides,
};
}
function createState(overrides: Partial<PlayButtonState> = {}): PlayButtonState {
return {
paused: true,
ended: false,
started: false,
...overrides,
};
}
describe('PlayButtonCore', () => {
describe('setProps', () => {
it('uses default props', () => {
const core = new PlayButtonCore();
const attrs = core.getAttrs(createState());
expect(attrs['aria-disabled']).toBeUndefined();
});
it('accepts constructor props', () => {
const core = new PlayButtonCore({ disabled: true });
const attrs = core.getAttrs(createState());
expect(attrs['aria-disabled']).toBe('true');
});
});
describe('getState', () => {
it('projects data fields from media state', () => {
const core = new PlayButtonCore();
const media = createMediaState({ paused: true, ended: false, started: true });
const state = core.getState(media);
expect(state.paused).toBe(true);
expect(state.ended).toBe(false);
expect(state.started).toBe(true);
});
it('reflects playing state', () => {
const core = new PlayButtonCore();
const state = core.getState(createMediaState({ paused: false, started: true }));
expect(state.paused).toBe(false);
expect(state.started).toBe(true);
});
});
describe('getLabel', () => {
it('returns Play when paused', () => {
const core = new PlayButtonCore();
expect(core.getLabel(createState({ paused: true }))).toBe('Play');
});
it('returns Pause when playing', () => {
const core = new PlayButtonCore();
expect(core.getLabel(createState({ paused: false }))).toBe('Pause');
});
it('returns Replay when ended', () => {
const core = new PlayButtonCore();
expect(core.getLabel(createState({ ended: true }))).toBe('Replay');
});
it('returns custom string label', () => {
const core = new PlayButtonCore({ label: 'Start' });
expect(core.getLabel(createState())).toBe('Start');
});
it('returns custom function label', () => {
const core = new PlayButtonCore({
label: (state) => (state.paused ? 'Resume' : 'Stop'),
});
expect(core.getLabel(createState({ paused: true }))).toBe('Resume');
});
it('falls back to default when function returns empty', () => {
const core = new PlayButtonCore({ label: () => '' });
expect(core.getLabel(createState({ paused: true }))).toBe('Play');
});
});
describe('getAttrs', () => {
it('returns aria-label', () => {
const core = new PlayButtonCore();
const attrs = core.getAttrs(createState({ paused: true }));
expect(attrs['aria-label']).toBe('Play');
});
it('sets aria-disabled when disabled', () => {
const core = new PlayButtonCore({ disabled: true });
const attrs = core.getAttrs(createState());
expect(attrs['aria-disabled']).toBe('true');
});
it('omits aria-disabled when not disabled', () => {
const core = new PlayButtonCore();
const attrs = core.getAttrs(createState());
expect(attrs['aria-disabled']).toBeUndefined();
});
});
describe('toggle', () => {
it('calls play when paused', async () => {
const core = new PlayButtonCore();
const media = createMediaState({ paused: true });
await core.toggle(media);
expect(media.play).toHaveBeenCalled();
});
it('calls pause when playing', async () => {
const core = new PlayButtonCore();
const media = createMediaState({ paused: false });
await core.toggle(media);
expect(media.pause).toHaveBeenCalled();
});
it('calls play when ended', async () => {
const core = new PlayButtonCore();
const media = createMediaState({ ended: true });
await core.toggle(media);
expect(media.play).toHaveBeenCalled();
});
it('does nothing when disabled', async () => {
const core = new PlayButtonCore({ disabled: true });
const media = createMediaState({ paused: true });
await core.toggle(media);
expect(media.play).not.toHaveBeenCalled();
});
});
});
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import type { MediaTimeState } from '../../../media/state';
import { TimeCore } from '../time-core';
function createTimeState(overrides: Partial<MediaTimeState> = {}): MediaTimeState {
function createMediaState(overrides: Partial<MediaTimeState> = {}): MediaTimeState {
return {
currentTime: 90,
duration: 300,
@@ -17,13 +17,13 @@ describe('TimeCore', () => {
describe('setProps', () => {
it('uses default props', () => {
const core = new TimeCore();
const state = core.getState(createTimeState());
const state = core.getState(createMediaState());
expect(state.type).toBe('current');
});
it('accepts custom props', () => {
const core = new TimeCore({ type: 'duration' });
const state = core.getState(createTimeState());
const state = core.getState(createMediaState());
expect(state.type).toBe('duration');
});
});
@@ -31,10 +31,11 @@ describe('TimeCore', () => {
describe('getState', () => {
it('returns current time state', () => {
const core = new TimeCore({ type: 'current' });
const state = core.getState(createTimeState({ currentTime: 90 }));
const state = core.getState(createMediaState({ currentTime: 90 }));
expect(state.type).toBe('current');
expect(state.seconds).toBe(90);
expect(state.negative).toBe(false);
expect(state.text).toBe('1:30');
expect(state.phrase).toBe('1 minute, 30 seconds');
expect(state.datetime).toBe('PT1M30S');
@@ -42,10 +43,11 @@ describe('TimeCore', () => {
it('returns duration state', () => {
const core = new TimeCore({ type: 'duration' });
const state = core.getState(createTimeState({ duration: 300 }));
const state = core.getState(createMediaState({ duration: 300 }));
expect(state.type).toBe('duration');
expect(state.seconds).toBe(300);
expect(state.negative).toBe(false);
expect(state.text).toBe('5:00');
expect(state.phrase).toBe('5 minutes');
expect(state.datetime).toBe('PT5M');
@@ -53,25 +55,36 @@ describe('TimeCore', () => {
it('returns remaining time state', () => {
const core = new TimeCore({ type: 'remaining' });
const state = core.getState(createTimeState({ currentTime: 90, duration: 300 }));
const state = core.getState(createMediaState({ currentTime: 90, duration: 300 }));
expect(state.type).toBe('remaining');
expect(state.seconds).toBe(-210); // 90 - 300
expect(state.text).toBe('-3:30');
expect(state.negative).toBe(true);
expect(state.text).toBe('3:30');
expect(state.phrase).toBe('3 minutes, 30 seconds remaining');
expect(state.datetime).toBe('PT3M30S');
});
it('uses custom negative sign', () => {
it('returns unsigned text regardless of negativeSign prop', () => {
const core = new TimeCore({ type: 'remaining', negativeSign: '' });
const state = core.getState(createTimeState({ currentTime: 90, duration: 300 }));
const state = core.getState(createMediaState({ currentTime: 90, duration: 300 }));
expect(state.text).toBe('3:30');
expect(state.negative).toBe(true);
expect(state.text).toBe('3:30');
});
it('is not negative when remaining time is zero', () => {
const core = new TimeCore({ type: 'remaining' });
const state = core.getState(createMediaState({ currentTime: 300, duration: 300 }));
expect(state.seconds).toBe(0);
expect(state.negative).toBe(false);
expect(state.text).toBe('0:00');
});
it('shows hours when duration has hours', () => {
const core = new TimeCore({ type: 'current' });
const state = core.getState(createTimeState({ currentTime: 90, duration: 3700 }));
const state = core.getState(createMediaState({ currentTime: 90, duration: 3700 }));
expect(state.text).toBe('0:01:30');
});
@@ -80,22 +93,26 @@ describe('TimeCore', () => {
describe('getLabel', () => {
it('returns default label for current', () => {
const core = new TimeCore({ type: 'current' });
expect(core.getLabel(createTimeState())).toBe('Current time');
const state = core.getState(createMediaState());
expect(core.getLabel(state)).toBe('Current time');
});
it('returns default label for duration', () => {
const core = new TimeCore({ type: 'duration' });
expect(core.getLabel(createTimeState())).toBe('Duration');
const state = core.getState(createMediaState());
expect(core.getLabel(state)).toBe('Duration');
});
it('returns default label for remaining', () => {
const core = new TimeCore({ type: 'remaining' });
expect(core.getLabel(createTimeState())).toBe('Remaining');
const state = core.getState(createMediaState());
expect(core.getLabel(state)).toBe('Remaining');
});
it('returns custom string label', () => {
const core = new TimeCore({ type: 'current', label: 'Position' });
expect(core.getLabel(createTimeState())).toBe('Position');
const state = core.getState(createMediaState());
expect(core.getLabel(state)).toBe('Position');
});
it('returns custom function label', () => {
@@ -103,14 +120,16 @@ describe('TimeCore', () => {
type: 'current',
label: (state) => `Time: ${state.text}`,
});
expect(core.getLabel(createTimeState({ currentTime: 90 }))).toBe('Time: 1:30');
const state = core.getState(createMediaState({ currentTime: 90 }));
expect(core.getLabel(state)).toBe('Time: 1:30');
});
});
describe('getAttrs', () => {
it('returns aria attributes', () => {
const core = new TimeCore({ type: 'current' });
const attrs = core.getAttrs(createTimeState({ currentTime: 90 }));
const state = core.getState(createMediaState({ currentTime: 90 }));
const attrs = core.getAttrs(state);
expect(attrs['aria-label']).toBe('Current time');
expect(attrs['aria-valuetext']).toBe('1 minute, 30 seconds');
@@ -118,7 +137,8 @@ describe('TimeCore', () => {
it('includes remaining suffix in valuetext', () => {
const core = new TimeCore({ type: 'remaining' });
const attrs = core.getAttrs(createTimeState({ currentTime: 90, duration: 300 }));
const state = core.getState(createMediaState({ currentTime: 90, duration: 300 }));
const attrs = core.getAttrs(state);
expect(attrs['aria-label']).toBe('Remaining');
expect(attrs['aria-valuetext']).toBe('3 minutes, 30 seconds remaining');
+24 -29
View File
@@ -22,7 +22,9 @@ export interface TimeState {
type: TimeType;
/** Raw value in seconds. */
seconds: number;
/** Formatted display text (e.g., "1:30"). */
/** Whether the time value is negative (remaining time before end). */
negative: boolean;
/** Formatted display text without sign (e.g., "1:30"). */
text: string;
/** Human-readable phrase (e.g., "1 minute, 30 seconds"). */
phrase: string;
@@ -53,35 +55,28 @@ export class TimeCore {
this.#props = defaults(props, TimeCore.defaultProps);
}
#getSeconds(time: MediaTimeState): number {
#getSeconds(media: MediaTimeState): number {
const { type } = this.#props;
switch (type) {
case 'current':
return time.currentTime;
return media.currentTime;
case 'duration':
return time.duration;
return media.duration;
case 'remaining':
return time.currentTime - time.duration;
return media.currentTime - media.duration;
default:
return 0;
}
}
#getText(time: MediaTimeState): string {
const { type, negativeSign } = this.#props;
const seconds = this.#getSeconds(time);
if (type === 'remaining') {
const formatted = formatTime(Math.abs(seconds), time.duration);
return seconds < 0 ? `${negativeSign}${formatted}` : formatted;
}
return formatTime(seconds, time.duration);
#getText(media: MediaTimeState): string {
const seconds = this.#getSeconds(media);
return formatTime(Math.abs(seconds), media.duration);
}
#getPhrase(time: MediaTimeState): string {
#getPhrase(media: MediaTimeState): string {
const { type } = this.#props;
const seconds = this.#getSeconds(time);
const seconds = this.#getSeconds(media);
if (type === 'remaining') {
// Use negative to trigger "remaining" suffix
@@ -91,13 +86,12 @@ export class TimeCore {
return formatTimeAsPhrase(seconds);
}
#getDatetime(time: MediaTimeState): string {
const seconds = this.#getSeconds(time);
#getDatetime(media: MediaTimeState): string {
const seconds = this.#getSeconds(media);
return secondsToIsoDuration(Math.abs(seconds));
}
getLabel(time: MediaTimeState): string {
const state = this.getState(time);
getLabel(state: TimeState): string {
const { label } = this.#props;
if (isFunction(label)) {
@@ -110,21 +104,22 @@ export class TimeCore {
return DEFAULT_LABELS[this.#props.type];
}
getAttrs(time: MediaTimeState): Record<string, string | undefined> {
getAttrs(state: TimeState) {
return {
'aria-label': this.getLabel(time),
'aria-valuetext': this.#getPhrase(time),
'aria-label': this.getLabel(state),
'aria-valuetext': state.phrase,
};
}
getState(time: MediaTimeState): TimeState {
const seconds = this.#getSeconds(time);
getState(media: MediaTimeState): TimeState {
const seconds = this.#getSeconds(media);
return {
type: this.#props.type,
seconds,
text: this.#getText(time),
phrase: this.#getPhrase(time),
datetime: this.#getDatetime(time),
negative: this.#props.type === 'remaining' && seconds < 0,
text: this.#getText(media),
phrase: this.#getPhrase(media),
datetime: this.#getDatetime(media),
};
}
}
@@ -1,4 +1,7 @@
import type { StateAttrMap } from '../types';
import type { TimeState } from './time-core';
export const TimeDataAttrs = {
/** The type of time being displayed. */
type: 'data-type',
} as const;
} as const satisfies StateAttrMap<TimeState>;
+3
View File
@@ -0,0 +1,3 @@
export type StateAttrMap<State> = {
[Key in keyof State]?: string;
};
+1 -3
View File
@@ -1,8 +1,6 @@
import { listen } from '@videojs/utils/dom';
import { isFunction, isUndefined } from '@videojs/utils/predicate';
import type { ElementProps } from '../../core/element';
/**
* Apply props to a DOM element.
*
@@ -12,7 +10,7 @@ import type { ElementProps } from '../../core/element';
* - `undefined` removes the attribute
* - Other props are set as string attributes
*/
export function applyElementProps(element: HTMLElement, props: ElementProps | object, signal?: AbortSignal): void {
export function applyElementProps(element: HTMLElement, props: object, signal?: AbortSignal): void {
for (const [key, value] of Object.entries(props)) {
if (isFunction(value) && key.startsWith('on')) {
const event = key.slice(2).toLowerCase();
+1 -1
View File
@@ -1,4 +1,4 @@
export type { StateAttrMap } from '../../core/ui/types';
export { applyElementProps } from './element-props';
export { logMissingFeature } from './log';
export type { StateAttrMap } from './state-data-attrs';
export { applyStateDataAttrs, getStateDataAttrs } from './state-data-attrs';
@@ -1,6 +1,4 @@
export type StateAttrMap<State> = {
[Key in keyof State]?: string;
};
import type { StateAttrMap } from '../../core/ui/types';
/**
* Convert state object to data attributes.
@@ -25,6 +23,8 @@ export function getStateDataAttrs<State extends object>(
const attrs: Record<string, string> = {};
for (const key in state) {
if (map && !(key in map)) continue;
const name = map?.[key] ?? toDataAttrName(key),
value = state[key];
@@ -58,6 +58,8 @@ export function applyStateDataAttrs<State extends object>(
map?: StateAttrMap<State>
): void {
for (const key in state) {
if (map && !(key in map)) continue;
const name = map?.[key] ?? toDataAttrName(key),
value = state[key];
@@ -78,13 +78,12 @@ describe('getStateDataAttrs', () => {
});
});
it('allows unmapped keys when mapping is provided', () => {
it('skips unmapped keys when mapping is provided', () => {
const state = { muted: true, volumeLevel: 'low' };
const mapping = { muted: 'data-muted' };
expect(getStateDataAttrs(state, mapping)).toEqual({
'data-muted': '',
'data-volumelevel': 'low',
});
});
});
@@ -1,5 +1,5 @@
import type { PropertyValues } from '@lit/reactive-element';
import { FullscreenButtonCore } from '@videojs/core';
import { FullscreenButtonCore, FullscreenButtonDataAttrs } from '@videojs/core';
import {
applyElementProps,
applyStateDataAttrs,
@@ -40,7 +40,7 @@ export class FullscreenButtonElement extends MediaElement {
applyElementProps(this, buttonProps, this.#disconnect.signal);
if (!this.#state.value) {
if (__DEV__ && !this.#state.value) {
logMissingFeature(FullscreenButtonElement.tagName, 'fullscreen');
}
}
@@ -59,13 +59,12 @@ export class FullscreenButtonElement extends MediaElement {
protected override update(changed: PropertyValues): void {
super.update(changed);
const state = this.#state.value;
const media = this.#state.value;
if (!state) {
return;
}
if (!media) return;
const state = this.#core.getState(media);
applyElementProps(this, this.#core.getAttrs(state));
applyStateDataAttrs(this, this.#core.getState(state));
applyStateDataAttrs(this, state, FullscreenButtonDataAttrs);
}
}
@@ -1,5 +1,5 @@
import type { PropertyValues } from '@lit/reactive-element';
import { MuteButtonCore, MuteButtonDataAttributes } from '@videojs/core';
import { MuteButtonCore, MuteButtonDataAttrs } from '@videojs/core';
import {
applyElementProps,
applyStateDataAttrs,
@@ -40,7 +40,7 @@ export class MuteButtonElement extends MediaElement {
applyElementProps(this, buttonProps, this.#disconnect.signal);
if (!this.#state.value) {
if (__DEV__ && !this.#state.value) {
logMissingFeature(MuteButtonElement.tagName, 'volume');
}
}
@@ -59,13 +59,12 @@ export class MuteButtonElement extends MediaElement {
protected override update(changed: PropertyValues): void {
super.update(changed);
const state = this.#state.value;
const media = this.#state.value;
if (!state) {
return;
}
if (!media) return;
const state = this.#core.getState(media);
applyElementProps(this, this.#core.getAttrs(state));
applyStateDataAttrs(this, this.#core.getState(state), MuteButtonDataAttributes);
applyStateDataAttrs(this, state, MuteButtonDataAttrs);
}
}
@@ -1,5 +1,5 @@
import type { PropertyValues } from '@lit/reactive-element';
import { PlayButtonCore } from '@videojs/core';
import { PlayButtonCore, PlayButtonDataAttrs } from '@videojs/core';
import {
applyElementProps,
applyStateDataAttrs,
@@ -40,7 +40,7 @@ export class PlayButtonElement extends MediaElement {
applyElementProps(this, buttonProps, this.#disconnect.signal);
if (!this.#state.value) {
if (__DEV__ && !this.#state.value) {
logMissingFeature(PlayButtonElement.tagName, 'playback');
}
}
@@ -59,13 +59,12 @@ export class PlayButtonElement extends MediaElement {
protected override update(changed: PropertyValues): void {
super.update(changed);
const state = this.#state.value;
const media = this.#state.value;
if (!state) {
return;
}
if (!media) return;
const state = this.#core.getState(media);
applyElementProps(this, this.#core.getAttrs(state));
applyStateDataAttrs(this, this.#core.getState(state));
applyStateDataAttrs(this, state, PlayButtonDataAttrs);
}
}
+10 -13
View File
@@ -1,5 +1,5 @@
import type { PropertyValues } from '@lit/reactive-element';
import { TimeCore, type TimeType } from '@videojs/core';
import { TimeCore, TimeDataAttrs, type TimeType } from '@videojs/core';
import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectTime } from '@videojs/core/dom';
import { playerContext } from '../../player/context';
@@ -33,31 +33,28 @@ export class TimeElement extends MediaElement {
override connectedCallback(): void {
super.connectedCallback();
if (!this.#state.value) {
if (__DEV__ && !this.#state.value) {
logMissingFeature(TimeElement.tagName, 'time');
}
}
protected override willUpdate(changed: PropertyValues): void {
super.willUpdate(changed);
this.#core.setProps({ type: this.type, negativeSign: this.negativeSign, label: this.label });
this.#core.setProps(this);
}
protected override update(changed: PropertyValues): void {
super.update(changed);
const time = this.#state.value;
const media = this.#state.value;
if (!time) {
return;
}
if (!media) return;
const state = this.#core.getState(time);
const showSign = state.type === 'remaining' && state.seconds < 0;
const state = this.#core.getState(media);
if (showSign) {
if (state.negative) {
this.#signSpan.textContent = this.negativeSign;
this.#textNode.textContent = state.text.replace(/^-/, '');
this.#textNode.textContent = state.text;
// Append elements if not already in DOM
if (!this.#signSpan.parentNode) {
@@ -74,7 +71,7 @@ export class TimeElement extends MediaElement {
this.textContent = state.text;
}
applyElementProps(this, this.#core.getAttrs(time));
applyStateDataAttrs(this, state);
applyElementProps(this, this.#core.getAttrs(state));
applyStateDataAttrs(this, state, TimeDataAttrs);
}
}
@@ -1,6 +1,6 @@
'use client';
import { FullscreenButtonCore } from '@videojs/core';
import { FullscreenButtonCore, FullscreenButtonDataAttrs } from '@videojs/core';
import { logMissingFeature, selectFullscreen } from '@videojs/core/dom';
import type { ForwardedRef } from 'react';
import { forwardRef, useState } from 'react';
@@ -32,17 +32,20 @@ export const FullscreenButton = forwardRef(function FullscreenButton(
});
if (!fullscreen) {
logMissingFeature('FullscreenButton', 'fullscreen');
if (__DEV__) logMissingFeature('FullscreenButton', 'fullscreen');
return null;
}
const state = core.getState(fullscreen);
return renderElement(
'button',
{ render, className, style },
{
state: core.getState(fullscreen),
state,
stateAttrMap: FullscreenButtonDataAttrs,
ref: [forwardedRef, buttonRef],
props: [core.getAttrs(fullscreen), elementProps, getButtonProps()],
props: [core.getAttrs(state), elementProps, getButtonProps()],
}
);
});
@@ -1,6 +1,6 @@
'use client';
import { MuteButtonCore, MuteButtonDataAttributes } from '@videojs/core';
import { MuteButtonCore, MuteButtonDataAttrs } from '@videojs/core';
import { logMissingFeature, selectVolume } from '@videojs/core/dom';
import type { ForwardedRef } from 'react';
import { forwardRef, useState } from 'react';
@@ -33,18 +33,20 @@ export const MuteButton = forwardRef(function MuteButton(
});
if (!volume) {
logMissingFeature('MuteButton', 'volume');
if (__DEV__) logMissingFeature('MuteButton', 'volume');
return null;
}
const state = core.getState(volume);
return renderElement(
'button',
{ render, className, style },
{
state: core.getState(volume),
state,
stateAttrMap: MuteButtonDataAttrs,
ref: [forwardedRef, buttonRef],
props: [core.getAttrs(volume), elementProps, getButtonProps()],
stateAttrMap: MuteButtonDataAttributes,
props: [core.getAttrs(state), elementProps, getButtonProps()],
}
);
});
@@ -1,6 +1,6 @@
'use client';
import { PlayButtonCore } from '@videojs/core';
import { PlayButtonCore, PlayButtonDataAttrs } from '@videojs/core';
import { logMissingFeature, selectPlayback } from '@videojs/core/dom';
import type { ForwardedRef } from 'react';
import { forwardRef, useState } from 'react';
@@ -46,17 +46,20 @@ export const PlayButton = forwardRef(function PlayButton(
});
if (!playback) {
logMissingFeature('PlayButton', 'playback');
if (__DEV__) logMissingFeature('PlayButton', 'playback');
return null;
}
const state = core.getState(playback);
return renderElement(
'button',
{ render, className, style },
{
state: core.getState(playback),
state,
stateAttrMap: PlayButtonDataAttrs,
ref: [forwardedRef, buttonRef],
props: [core.getAttrs(playback), elementProps, getButtonProps()],
props: [core.getAttrs(state), elementProps, getButtonProps()],
}
);
});
+12 -13
View File
@@ -1,6 +1,6 @@
'use client';
import { TimeCore } from '@videojs/core';
import { TimeCore, TimeDataAttrs } from '@videojs/core';
import { logMissingFeature, selectTime } from '@videojs/core/dom';
import type { ForwardedRef } from 'react';
import { forwardRef, useState } from 'react';
@@ -33,34 +33,33 @@ export const Value = forwardRef(function Value(
core.setProps({ type, negativeSign, label });
if (!time) {
logMissingFeature('Time.Value', 'time');
if (__DEV__) logMissingFeature('Time.Value', 'time');
return null;
}
const state = core.getState(time);
// Render negative sign as aria-hidden span for remaining time
const content =
state.type === 'remaining' && state.seconds < 0 ? (
<>
<span aria-hidden="true">{negativeSign ?? '-'}</span>
{state.text.replace(/^-/, '')}
</>
) : (
state.text
);
const content = state.negative ? (
<>
<span aria-hidden="true">{negativeSign ?? '-'}</span>
{state.text}
</>
) : (
state.text
);
return renderElement(
'time',
{ render, className, style },
{
state,
stateAttrMap: TimeDataAttrs,
ref: [forwardedRef],
props: [
{
datetime: state.datetime,
children: content,
...core.getAttrs(time),
...core.getAttrs(state),
},
elementProps,
],