mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
refactor(packages): clean up UI component types and data flow (#479)
This commit is contained in:
@@ -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}'`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export type StateAttrMap<State> = {
|
||||
[Key in keyof State]?: string;
|
||||
};
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user