diff --git a/.claude/skills/component/references/videojs.md b/.claude/skills/component/references/videojs.md index 8cb17752..95364a57 100644 --- a/.claude/skills/component/references/videojs.md +++ b/.claude/skills/component/references/videojs.md @@ -66,11 +66,11 @@ interface FullscreenButtonState extends Pick { class PlayButtonCore { static readonly defaultProps: NonNullableObject; - 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; // 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; // 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` 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; ``` -JSDoc comments generate API documentation. +`StateAttrMap` is defined in `core/ui/types.ts`: ---- +```ts +export type StateAttrMap = { + [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. diff --git a/.claude/skills/component/review/checklists/videojs.md b/.claude/skills/component/review/checklists/videojs.md index 9723a0ee..61624224 100644 --- a/.claude/skills/component/review/checklists/videojs.md +++ b/.claude/skills/component/review/checklists/videojs.md @@ -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}'` diff --git a/packages/core/src/core/element.ts b/packages/core/src/core/element.ts deleted file mode 100644 index 41806da1..00000000 --- a/packages/core/src/core/element.ts +++ /dev/null @@ -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; -} diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index ad0035ef..3559d209 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -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'; diff --git a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.test.ts b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.test.ts deleted file mode 100644 index a32d03df..00000000 --- a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.test.ts +++ /dev/null @@ -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 { - 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(); - }); - }); -}); diff --git a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts index e3370e03..6e33ea23 100644 --- a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts +++ b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts @@ -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 { + async toggle(media: MediaFullscreenState): Promise { 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.) diff --git a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-data-attrs.ts b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-data-attrs.ts index d194d2f9..5ab4eb1e 100644 --- a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-data-attrs.ts +++ b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-data-attrs.ts @@ -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; diff --git a/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts b/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts new file mode 100644 index 00000000..6c961313 --- /dev/null +++ b/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts @@ -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 { + return { + fullscreen: false, + fullscreenAvailability: 'available', + requestFullscreen: vi.fn(async () => {}), + exitFullscreen: vi.fn(async () => {}), + ...overrides, + }; +} + +function createState(overrides: Partial = {}): 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(); + }); + }); +}); diff --git a/packages/core/src/core/ui/mute-button/mute-button-core.test.ts b/packages/core/src/core/ui/mute-button/mute-button-core.test.ts deleted file mode 100644 index c88fec0a..00000000 --- a/packages/core/src/core/ui/mute-button/mute-button-core.test.ts +++ /dev/null @@ -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 { - 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(); - }); - }); -}); diff --git a/packages/core/src/core/ui/mute-button/mute-button-core.ts b/packages/core/src/core/ui/mute-button/mute-button-core.ts index 0b6877ef..ac28c5a1 100644 --- a/packages/core/src/core/ui/mute-button/mute-button-core.ts +++ b/packages/core/src/core/ui/mute-button/mute-button-core.ts @@ -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 { /** * 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'; } diff --git a/packages/core/src/core/ui/mute-button/mute-button-data-attrs.ts b/packages/core/src/core/ui/mute-button/mute-button-data-attrs.ts index d036c117..8f17f089 100644 --- a/packages/core/src/core/ui/mute-button/mute-button-data-attrs.ts +++ b/packages/core/src/core/ui/mute-button/mute-button-data-attrs.ts @@ -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; diff --git a/packages/core/src/core/ui/mute-button/tests/mute-button-core.test.ts b/packages/core/src/core/ui/mute-button/tests/mute-button-core.test.ts new file mode 100644 index 00000000..a631e5af --- /dev/null +++ b/packages/core/src/core/ui/mute-button/tests/mute-button-core.test.ts @@ -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 { + return { + volume: 1, + muted: false, + volumeAvailability: 'available', + changeVolume: vi.fn((v: number) => v), + toggleMute: vi.fn(() => false), + ...overrides, + }; +} + +function createState(overrides: Partial = {}): 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(); + }); + }); +}); diff --git a/packages/core/src/core/ui/play-button/play-button-core.test.ts b/packages/core/src/core/ui/play-button/play-button-core.test.ts deleted file mode 100644 index 00063dc9..00000000 --- a/packages/core/src/core/ui/play-button/play-button-core.test.ts +++ /dev/null @@ -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 { - 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(); - }); - }); -}); diff --git a/packages/core/src/core/ui/play-button/play-button-core.ts b/packages/core/src/core/ui/play-button/play-button-core.ts index ce3939e4..73e016d9 100644 --- a/packages/core/src/core/ui/play-button/play-button-core.ts +++ b/packages/core/src/core/ui/play-button/play-button-core.ts @@ -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 { + async toggle(media: MediaPlaybackState): Promise { 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(); } } diff --git a/packages/core/src/core/ui/play-button/play-button-data-attrs.ts b/packages/core/src/core/ui/play-button/play-button-data-attrs.ts index 7eee41a8..3e459f02 100644 --- a/packages/core/src/core/ui/play-button/play-button-data-attrs.ts +++ b/packages/core/src/core/ui/play-button/play-button-data-attrs.ts @@ -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; diff --git a/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts b/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts new file mode 100644 index 00000000..b07b46e0 --- /dev/null +++ b/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts @@ -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 { + return { + paused: true, + ended: false, + started: false, + waiting: false, + play: vi.fn(async () => {}), + pause: vi.fn(), + ...overrides, + }; +} + +function createState(overrides: Partial = {}): 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(); + }); + }); +}); diff --git a/packages/core/src/core/ui/time/tests/time-core.test.ts b/packages/core/src/core/ui/time/tests/time-core.test.ts index a31317c2..889fd540 100644 --- a/packages/core/src/core/ui/time/tests/time-core.test.ts +++ b/packages/core/src/core/ui/time/tests/time-core.test.ts @@ -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 { +function createMediaState(overrides: Partial = {}): 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'); diff --git a/packages/core/src/core/ui/time/time-core.ts b/packages/core/src/core/ui/time/time-core.ts index 5ae7949f..57fd2d91 100644 --- a/packages/core/src/core/ui/time/time-core.ts +++ b/packages/core/src/core/ui/time/time-core.ts @@ -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 { + 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), }; } } diff --git a/packages/core/src/core/ui/time/time-data-attrs.ts b/packages/core/src/core/ui/time/time-data-attrs.ts index c5d5de3b..9659d48e 100644 --- a/packages/core/src/core/ui/time/time-data-attrs.ts +++ b/packages/core/src/core/ui/time/time-data-attrs.ts @@ -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; diff --git a/packages/core/src/core/ui/types.ts b/packages/core/src/core/ui/types.ts new file mode 100644 index 00000000..49fa8ea2 --- /dev/null +++ b/packages/core/src/core/ui/types.ts @@ -0,0 +1,3 @@ +export type StateAttrMap = { + [Key in keyof State]?: string; +}; diff --git a/packages/core/src/dom/utils/element-props.ts b/packages/core/src/dom/utils/element-props.ts index 222349d8..467bf150 100644 --- a/packages/core/src/dom/utils/element-props.ts +++ b/packages/core/src/dom/utils/element-props.ts @@ -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(); diff --git a/packages/core/src/dom/utils/index.ts b/packages/core/src/dom/utils/index.ts index f6a987b2..7b805d1a 100644 --- a/packages/core/src/dom/utils/index.ts +++ b/packages/core/src/dom/utils/index.ts @@ -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'; diff --git a/packages/core/src/dom/utils/state-data-attrs.ts b/packages/core/src/dom/utils/state-data-attrs.ts index 01f87bd4..ea019537 100644 --- a/packages/core/src/dom/utils/state-data-attrs.ts +++ b/packages/core/src/dom/utils/state-data-attrs.ts @@ -1,6 +1,4 @@ -export type StateAttrMap = { - [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( const attrs: Record = {}; 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( map?: StateAttrMap ): void { for (const key in state) { + if (map && !(key in map)) continue; + const name = map?.[key] ?? toDataAttrName(key), value = state[key]; diff --git a/packages/core/src/dom/utils/tests/state-data-attrs.test.ts b/packages/core/src/dom/utils/tests/state-data-attrs.test.ts index e5c509e3..55844331 100644 --- a/packages/core/src/dom/utils/tests/state-data-attrs.test.ts +++ b/packages/core/src/dom/utils/tests/state-data-attrs.test.ts @@ -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', }); }); }); diff --git a/packages/html/src/ui/fullscreen-button/fullscreen-button-element.ts b/packages/html/src/ui/fullscreen-button/fullscreen-button-element.ts index 654813ca..ba51dee4 100644 --- a/packages/html/src/ui/fullscreen-button/fullscreen-button-element.ts +++ b/packages/html/src/ui/fullscreen-button/fullscreen-button-element.ts @@ -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); } } diff --git a/packages/html/src/ui/mute-button/mute-button-element.ts b/packages/html/src/ui/mute-button/mute-button-element.ts index aa3a6fa5..2077f90a 100644 --- a/packages/html/src/ui/mute-button/mute-button-element.ts +++ b/packages/html/src/ui/mute-button/mute-button-element.ts @@ -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); } } diff --git a/packages/html/src/ui/play-button/play-button-element.ts b/packages/html/src/ui/play-button/play-button-element.ts index 841766a7..78407b26 100644 --- a/packages/html/src/ui/play-button/play-button-element.ts +++ b/packages/html/src/ui/play-button/play-button-element.ts @@ -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); } } diff --git a/packages/html/src/ui/time/time-element.ts b/packages/html/src/ui/time/time-element.ts index 851eca4c..9d956002 100644 --- a/packages/html/src/ui/time/time-element.ts +++ b/packages/html/src/ui/time/time-element.ts @@ -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); } } diff --git a/packages/react/src/ui/fullscreen-button/fullscreen-button.tsx b/packages/react/src/ui/fullscreen-button/fullscreen-button.tsx index 473f0aea..0e6acdff 100644 --- a/packages/react/src/ui/fullscreen-button/fullscreen-button.tsx +++ b/packages/react/src/ui/fullscreen-button/fullscreen-button.tsx @@ -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()], } ); }); diff --git a/packages/react/src/ui/mute-button/mute-button.tsx b/packages/react/src/ui/mute-button/mute-button.tsx index fb154132..681ac953 100644 --- a/packages/react/src/ui/mute-button/mute-button.tsx +++ b/packages/react/src/ui/mute-button/mute-button.tsx @@ -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()], } ); }); diff --git a/packages/react/src/ui/play-button/play-button.tsx b/packages/react/src/ui/play-button/play-button.tsx index bd886172..49b63f9b 100644 --- a/packages/react/src/ui/play-button/play-button.tsx +++ b/packages/react/src/ui/play-button/play-button.tsx @@ -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()], } ); }); diff --git a/packages/react/src/ui/time/time-value.tsx b/packages/react/src/ui/time/time-value.tsx index ba10bf15..2eb8bb98 100644 --- a/packages/react/src/ui/time/time-value.tsx +++ b/packages/react/src/ui/time/time-value.tsx @@ -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 ? ( - <> - - {state.text.replace(/^-/, '')} - - ) : ( - state.text - ); + const content = state.negative ? ( + <> + + {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, ],