diff --git a/.claude/plans/slider.md b/.claude/plans/slider.md new file mode 100644 index 00000000..56cb6baa --- /dev/null +++ b/.claude/plans/slider.md @@ -0,0 +1,1819 @@ +# Slider Component Implementation + +**Status:** READY +**Design Doc:** `internal/design/ui/slider/` +**Issues:** #275 (time slider), #267 (volume slider), #269 (seek slider) + +## Branch Strategy + +``` +main + ├─ feat/store-snapshot (PR 0 — merges to main first) + └─ feat/slider-core (PR 1, rebased after PR 0 merges) + └─ feat/slider-dom (PR 2) + ├─ feat/slider-react (PR 3) + │ └─ feat/slider-preview-react (PR 5) + └─ feat/slider-html (PR 4) + └─ feat/slider-preview-html (PR 6) +``` + +PR 0 merges to `main` independently — it's a general-purpose store primitive, not slider-specific. +PRs 3 and 4 are siblings off `feat/slider-dom`. React and HTML have no dependency on each other. +Preview PRs branch off their respective UI PRs because they need the slider context definitions. + +--- + +## PR 0: Store Snapshot Primitives + +**Branch:** `feat/store-snapshot` +**Base:** `main` +**Package:** `@videojs/store` + +Adds `useSnapshot` — a React hook for subscribing to `State` containers from `createState()`. +This bridges the store's raw reactive state to React rendering. Required because `useStore` +only accepts `AnyStore` (with `.state`, `.attach()`, `.destroy()`), not `State` (with +`.current` and `.subscribe()`). HTML elements don't need a controller equivalent — they +subscribe directly via `state.subscribe(() => this.requestUpdate(), { signal })`. + +### 0.1 `useSnapshot` — React Hook + +**File:** `packages/store/src/react/hooks/use-snapshot.ts` + +Subscribes to a `State` container. Without selector, returns the full snapshot. With +selector, returns derived value with `shallowEqual` comparison to avoid unnecessary re-renders. + +```ts +import type { State } from '../../core/state'; +import { type Comparator, type Selector, useSelector } from './use-selector'; + +/** Subscribe to a State container's current value. */ +export function useSnapshot(state: State): T; + +export function useSnapshot( + state: State, + selector: Selector, + isEqual?: Comparator +): R; + +export function useSnapshot( + state: State, + selector?: Selector, + isEqual?: Comparator +): T | R { + return useSelector( + (cb) => state.subscribe(cb), + () => state.current, + selector ?? ((s: T) => s as unknown as R), + isEqual + ); +} +``` + +**Overload semantics:** +- `useSnapshot(state)` — returns `T`, re-renders on any shallow change to the state object. +- `useSnapshot(state, selector)` — returns `R`, re-renders only when `selector(state.current)` changes (per `shallowEqual`). +- Optional third arg `isEqual` for custom comparator. + +### 0.2 Barrel Exports + +**`packages/store/src/react/hooks/index.ts`** — add: +```ts +export { useSnapshot } from './use-snapshot'; +``` + +**`packages/store/src/react/index.ts`** — add (this barrel imports directly from individual +hook files, not from `hooks/index.ts`): +```ts +export { useSnapshot } from './hooks/use-snapshot'; +``` + +### 0.3 Tests + +**File:** `packages/store/src/react/hooks/tests/use-snapshot.test.tsx` + +- Without selector: returns full state, re-renders on patch +- With selector: returns selected value, only re-renders when selected value changes +- Custom comparator +- Does not re-render when patched values are identical (Object.is) +- Works with microtask batching (multiple patches → one render) + +### 0.4 Verify + +```bash +pnpm -F @videojs/store test src/react/hooks/tests/use-snapshot +pnpm -F @videojs/store build +pnpm typecheck +pnpm lint:fix:file +``` + +--- + +## PR 1: Core Layer + +**Branch:** `feat/slider-core` +**Base:** `main` (after PR 0 merges) +**Packages:** `@videojs/utils`, `@videojs/core` + +### 1.1 Fix `formatTimeAsPhrase(0)` in `@videojs/utils` + +**File:** `packages/utils/src/time/format.ts` + +The function returns `""` when `seconds = 0` because all three units (h, m, s) fail the `value > 0` check, producing an empty `parts` array. When `seconds = 0`, `aria-valuetext` for a time slider at video start would be `"of 10 minutes"` instead of `"0 seconds of 10 minutes"`. + +**Fix:** When `positiveSeconds === 0`, return `"0 seconds"` directly. Or modify the seconds entry in the map to pass through when all parts would be empty. + +**Test:** Add `it('formats zero seconds', () => expect(formatTimeAsPhrase(0)).toBe('0 seconds'))` to `packages/utils/src/time/tests/format.test.ts`. + +### 1.2 `rafThrottle` — rAF-Based Throttle Utility + +**File:** `packages/utils/src/dom/raf-throttle.ts` + +Throttles a function to fire at most once per animation frame. Used by the slider to +throttle seek events during scrubbing — naturally adapts to device refresh rate and avoids +intermediate seeks that would never be visually rendered. + +```ts +export interface RafThrottled { + (...args: Args): void; + cancel(): void; +} + +export function rafThrottle( + fn: (...args: Args) => void +): RafThrottled { + let rafId: number | null = null; + let latestArgs: Args; + + const throttled = (...args: Args): void => { + latestArgs = args; + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + fn(...latestArgs); + }); + }; + + throttled.cancel = (): void => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + }; + + return throttled; +} +``` + +**Barrel:** Add to `packages/utils/src/dom/index.ts`: +```ts +export { rafThrottle, type RafThrottled } from './raf-throttle'; +``` + +**Test:** `packages/utils/src/dom/tests/raf-throttle.test.ts` +- Calls function on next animation frame +- Collapses multiple calls into one (latest args win) +- `.cancel()` prevents pending call +- New calls work after cancel + +### 1.3 `isRTL` — RTL Direction Detection + +**File:** `packages/utils/src/dom/direction.ts` + +Detects right-to-left text direction for an element. Checks `element.closest('[dir]')` first +for fast attribute lookup, then falls back to `getComputedStyle` which respects inherited +direction from stylesheets. + +```ts +export function isRTL(element: Element): boolean { + const dir = element.closest('[dir]')?.getAttribute('dir'); + if (dir) return dir.toLowerCase() === 'rtl'; + return getComputedStyle(element).direction === 'rtl'; +} +``` + +**Barrel:** Add to `packages/utils/src/dom/index.ts`: +```ts +export { isRTL } from './direction'; +``` + +**Test:** `packages/utils/src/dom/tests/direction.test.ts` +- Returns `false` for default LTR element +- Returns `true` when ancestor has `dir="rtl"` +- Returns `true` when element has `dir="rtl"` + +### 1.4 `SliderCore` — Generic Slider State + +**File:** `packages/core/src/core/ui/slider/slider-core.ts` + +Stateless class that computes derived slider state from interaction + value inputs. Follows existing core class patterns (`PlayButtonCore`, `TimeCore`). + +```ts +export interface SliderProps { + min?: number | undefined; + max?: number | undefined; + step?: number | undefined; + largeStep?: number | undefined; + orientation?: 'horizontal' | 'vertical' | undefined; + disabled?: boolean | undefined; + thumbAlignment?: 'center' | 'edge' | undefined; +} + +export interface SliderInteraction { + pointerPercent: number; + dragPercent: number; + dragging: boolean; + pointing: boolean; + focused: boolean; +} + +export interface SliderState { + value: number; + fillPercent: number; + pointerPercent: number; + dragging: boolean; + pointing: boolean; + interactive: boolean; // dragging || pointing || focused + orientation: 'horizontal' | 'vertical'; + disabled: boolean; + thumbAlignment: 'center' | 'edge'; +} + +``` + +**Class shape:** + +```ts +export class SliderCore { + static readonly defaultProps: NonNullableObject; + // defaultProps: min=0, max=100, step=1, largeStep=10, + // orientation='horizontal', disabled=false, thumbAlignment='center' + + #props = { ...SliderCore.defaultProps }; + + constructor(props?: SliderProps); + setProps(props: SliderProps): void; + + getState(interaction: SliderInteraction, value: number): SliderState; + // Computes fillPercent = percentFromValue(value), pointerPercent from interaction, + // interactive = dragging || pointing || focused. All percentages 0-100. + + getAttrs(state: SliderState); + // Returns inlined ARIA attrs object (no explicit return type — TS infers). + // Returns { role, tabIndex, autocomplete, aria-valuemin, aria-valuemax, + // aria-valuenow, aria-orientation, aria-disabled }. + // Generic — no label or valuetext. + + valueFromPercent(percent: number): number; + // Converts 0-100 percent to a value in [min, max], snapped to step. + // Uses roundValueToStep. + + percentFromValue(value: number): number; + // Converts a value to 0-100 percent relative to [min, max]. + + adjustPercentForAlignment(rawPercent: number, thumbSize: number, trackSize: number): number; + // For 'edge' thumb alignment: adjusts percent so thumb stays within track bounds. + // center mode: identity (return rawPercent). +} + +export namespace SliderCore { + export type Props = SliderProps; + export type State = SliderState; + export type Interaction = SliderInteraction; +} +``` + +**Utility functions** in `@videojs/utils/number` (new subpath): + +```ts +export function clamp(value: number, min: number, max: number): number; +// Clamp a value between min and max (inclusive). + +export function roundToStep(value: number, step: number, min: number): number; +// Snap a value to the nearest step, offset from min. +// Derives decimal precision from step's string representation to avoid floating-point drift. +// Integer steps skip toFixed entirely. +``` + +Requires adding `./number` export to `packages/utils/package.json` and `number` entry to `packages/utils/tsdown.config.ts`. + +### 1.5 `TimeSliderCore` — Time-Domain Slider + +**File:** `packages/core/src/core/ui/slider/time-slider-core.ts` + +Extends `SliderCore`. Accepts `MediaTimeState & MediaBufferState` (canonical types from `@videojs/core`) + `SliderInteraction`, returns time-specific state. + +```ts +export interface TimeSliderState extends SliderState, Pick { + bufferPercent: number; +} +``` + +Uses `Pick<>` to select specific fields from canonical media state. No custom `TimeMediaState` wrapper — accepts the full `MediaTimeState & MediaBufferState` and computes `bufferedEnd` internally from `media.buffered` ranges. + +**Class shape:** + +```ts +export interface TimeSliderProps extends SliderProps { + label?: string | undefined; +} + +export class TimeSliderCore extends SliderCore { + static override readonly defaultProps: NonNullableObject; + // Inherits slider defaults. label='Seek'. + + getTimeState(media: MediaTimeState & MediaBufferState, interaction: SliderInteraction): TimeSliderState; + // - min=0, max=duration (overrides generic min/max on each call). + // - Value swap: dragging ? valueFromPercent(dragPercent) : currentTime. + // - Computes bufferedEnd from media.buffered ranges internally. + // - bufferPercent = (bufferedEnd / duration) * 100 || 0. + // - Delegates to super.getState() for base slider state. + + override getAttrs(state: TimeSliderState); + // Returns inlined object (no explicit return type). + // Adds aria-label (default 'Seek') and aria-valuetext + // e.g., "5 minutes, 30 seconds of 10 minutes" + // Spreads super.getAttrs(state) for base ARIA. +} + +export namespace TimeSliderCore { + export type Props = TimeSliderProps; + export type State = TimeSliderState; +} +``` + +### 1.6 `VolumeSliderCore` — Volume-Domain Slider + +**File:** `packages/core/src/core/ui/slider/volume-slider-core.ts` + +Extends `SliderCore`. Accepts `MediaVolumeState` (canonical type from `@videojs/core`) + `SliderInteraction`, returns volume-specific state. + +```ts +export interface VolumeSliderState extends SliderState, Pick {} +``` + +Uses `Pick<>` to select specific fields from canonical media state. No custom `VolumeMediaState` wrapper. + +**Class shape:** + +```ts +export interface VolumeSliderProps extends SliderProps { + label?: string | undefined; +} + +export class VolumeSliderCore extends SliderCore { + static override readonly defaultProps: NonNullableObject; + // min=0, max=100, step=1, largeStep=10, label='Volume'. + + getVolumeState(media: MediaVolumeState, interaction: SliderInteraction): VolumeSliderState; + // - Value: always volume * 100 (actual volume as percent, regardless of muted). + // - Value swap: dragging ? valueFromPercent(dragPercent) : volume * 100. + // - fillPercent: muted ? 0 : base.fillPercent. Visual silence when muted. + // - Delegates to super.getState() for base slider state. + + override getAttrs(state: VolumeSliderState); + // Returns inlined object (no explicit return type). + // Adds aria-label (default 'Volume') and aria-valuetext + // e.g., "75 percent, muted" + // Spreads super.getAttrs(state) for base ARIA. +} + +export namespace VolumeSliderCore { + export type Props = VolumeSliderProps; + export type State = VolumeSliderState; +} +``` + +### 1.7 Constants + +**File:** `packages/core/src/core/ui/slider/slider-data-attrs.ts` + +```ts +import type { StateAttrMap } from '../types'; +import type { SliderState } from './slider-core'; + +export const SliderDataAttrs = { + dragging: 'data-dragging', + pointing: 'data-pointing', + interactive: 'data-interactive', + orientation: 'data-orientation', + disabled: 'data-disabled', +} as const satisfies StateAttrMap; +``` + +**File:** `packages/core/src/core/ui/slider/time-slider-data-attrs.ts` + +```ts +import type { StateAttrMap } from '../types'; +import type { TimeSliderState } from './time-slider-core'; + +export const TimeSliderDataAttrs = { + ...SliderDataAttrs, + seeking: 'data-seeking', +} as const satisfies StateAttrMap; +``` + +**File:** `packages/core/src/core/ui/slider/slider-css-vars.ts` + +```ts +export const SliderCSSVars = { + fill: '--media-slider-fill', + pointer: '--media-slider-pointer', + buffer: '--media-slider-buffer', +} as const; +``` + +### 1.8 Barrel Export + +No `index.ts` barrel — existing UI components don't use one. Add individual exports +to `packages/core/src/core/index.ts` (matching the pattern for play-button, time, etc.): + +```ts +export * from './ui/slider/slider-core'; +export * from './ui/slider/time-slider-core'; +export * from './ui/slider/volume-slider-core'; +export * from './ui/slider/slider-data-attrs'; +export * from './ui/slider/time-slider-data-attrs'; +export * from './ui/slider/slider-css-vars'; +``` + +### 1.9 Tests + +**File:** `packages/core/src/core/ui/slider/tests/slider-core.test.ts` + +- `clamp`: within range, clamp to min, clamp to max, min equals max, negative ranges +- `roundToStep`: nearest step, min offset, decimal steps, value equals min +- `SliderCore.getState`: basic value to percent, interaction passthrough, interactive derivation (including focused) +- `SliderCore.getAttrs`: ARIA output +- `SliderCore.valueFromPercent`: min/max bounds, step snapping +- `SliderCore.percentFromValue`: inverse of valueFromPercent +- `SliderCore.adjustPercentForAlignment`: center (identity) and edge modes +- Default props + +**File:** `packages/core/src/core/ui/slider/tests/time-slider-core.test.ts` + +- `getTimeState`: value swap on drag vs non-drag, buffer percent from buffered ranges, duration as max +- `getAttrs`: aria-label, aria-valuetext formatting +- Zero duration edge case +- Seeking state passthrough + +**File:** `packages/core/src/core/ui/slider/tests/volume-slider-core.test.ts` + +- `getVolumeState`: value always actual volume, fillPercent=0 when muted, value swap on drag +- `getAttrs`: aria-label, aria-valuetext with and without muted +- Volume 0-1 to 0-100 percent mapping + +### 1.10 Verify + +```bash +pnpm -F @videojs/utils test src/time/tests/format.test.ts +pnpm -F @videojs/utils test src/dom/tests/raf-throttle.test.ts +pnpm -F @videojs/utils test src/dom/tests/direction.test.ts +pnpm -F @videojs/utils build +pnpm -F @videojs/core test src/core/ui/slider +pnpm -F @videojs/core build +pnpm typecheck +pnpm lint:fix:file +``` + +--- + +## PR 2: DOM Layer + +**Branch:** `feat/slider-dom` +**Base:** `feat/slider-core` +**Package:** `@videojs/core` (dom subpath) + +### 2.1 `UIPointerEvent` Type + +**File:** `packages/core/src/dom/ui/event.ts` + +Extend existing event types with pointer-specific properties: + +```ts +export interface UIPointerEvent extends UIEvent { + clientX: number; + clientY: number; + pointerId: number; + pointerType: string; +} +``` + +### 2.2 `createSlider()` — Interaction Factory + +**File:** `packages/core/src/dom/ui/slider.ts` + +Factory function that manages pointer and keyboard interaction state. Returns a subscribable +`State` plus props objects for Root and Thumb elements. + +**New pattern:** Unlike `createButton()` (stateless, returns plain props), `createSlider()` is +stateful — it creates a `State` via `createState()` from `@videojs/store`, +has a `destroy()` lifecycle, and returns a subscription handle. This is justified because +slider interaction is inherently stateful (drag tracking, pointer capture, percent calculation) +unlike button activation. `@videojs/core` already depends on `@videojs/store` (`workspace:*`). + +```ts +export interface SliderOptions { + /** Element reference for getBoundingClientRect() and pointer capture. */ + getElement: () => HTMLElement; + + getOrientation: () => 'horizontal' | 'vertical'; + isRTL: () => boolean; + isDisabled: () => boolean; + + // For keyboard: what's the current value as a percent? + getPercent: () => number; + getStepPercent: () => number; + getLargeStepPercent: () => number; + + onValueChange?: ((percent: number) => void) | undefined; + onValueCommit?: ((percent: number) => void) | undefined; + onDragStart?: (() => void) | undefined; + onDragEnd?: (() => void) | undefined; +} + +export interface SliderRootProps { + onPointerDown: (event: UIPointerEvent) => void; + onPointerMove: (event: UIPointerEvent) => void; + onPointerLeave: (event: UIPointerEvent) => void; +} + +export interface SliderThumbProps { + onKeyDown: (event: UIKeyboardEvent) => void; + onFocus: (event: UIEvent) => void; + onBlur: (event: UIEvent) => void; +} + +export interface SliderHandle { + interaction: State; + rootProps: SliderRootProps; + thumbProps: SliderThumbProps; + destroy: () => void; +} + +export function createSlider(options: SliderOptions): SliderHandle; +``` + +**Behavior:** + +1. Creates `WritableState` via `createState()`. +2. **Pointer down on root:** Capture pointer, start drag. Compute percent from pointer position. Patch `{ dragging: true, dragPercent, pointerPercent }`. Call `onDragStart`. Call `onValueChange(dragPercent)`. +3. **Pointer move:** If dragging — update `dragPercent`, call `onValueChange`. If not dragging — update `pointerPercent` only (hover preview). Patch to state. +4. **Pointer up (window listener):** End drag. Call `onValueCommit(dragPercent)`. Call `onDragEnd`. Patch `{ dragging: false }`. Release pointer capture. +5. **Pointer leave:** If not dragging, reset `{ pointing: false, pointerPercent: 0 }`. +6. **Keyboard (on thumb):** + - `ArrowRight`/`ArrowUp`: increment by step percent + - `ArrowLeft`/`ArrowDown`: decrement by step percent + - `PageUp`: increment by large step percent + - `PageDown`: decrement by large step percent + - `Home`: go to 0% + - `End`: go to 100% + - RTL: invert horizontal arrows + - Vertical: Up = increase, Down = decrease + - Call `onValueChange` then `onValueCommit` on each key. +7. **Focus/blur on thumb:** Patch `{ focused }`. +8. **Disabled check:** All handlers early-return if `isDisabled()`. + +**Internal helper:** + +```ts +function getPercentFromPointerEvent( + event: UIPointerEvent, + rect: DOMRect, + orientation: 'horizontal' | 'vertical', + isRTL: boolean +): number; +// Horizontal: (clientX - rect.left) / rect.width * 100, flipped for RTL. +// Vertical: (rect.bottom - clientY) / rect.height * 100 (bottom = 0%). +// Clamped to [0, 100]. +``` + +**Notes:** +- The element reference (`getElement()`) is used for `getBoundingClientRect()` in percent calculations and `setPointerCapture()`/`releasePointerCapture()` during drag. `UIPointerEvent` does not carry `target`/`currentTarget` (consistent with the existing `UIEvent` pattern) — the element reference comes from options, not events. +- On pointerdown, the factory calls `getElement().setPointerCapture(event.pointerId)`. This means the Root element itself captures — all subsequent pointermove/pointerup events flow to it regardless of mouse position. +- The `destroy()` function aborts internal AbortController (cleans up any listeners). +- `onPointerMove` on the root also handles pointerup detection (via `event.buttons === 0` fallback for edge cases where pointerup fires on window but state is stale). +- RTL detection: `isRTL()` callback (from options) should use `isRTL(element)` from `@videojs/utils/dom` (added in PR 1). Cache the result on `pointerdown` — direction doesn't change mid-drag. +- The `onValueChange` callback can be wrapped with `rafThrottle` from `@videojs/utils/dom` by the consumer (e.g., `TimeSlider.Root` throttles seek events). The factory itself fires callbacks on every interaction. + +### 2.3 CSS Variable Formatting + +**File:** `packages/core/src/dom/ui/slider-css-vars.ts` + +```ts +import { SliderCSSVars } from '../../core/ui/slider/slider-css-vars'; +import type { SliderState } from '../../core/ui/slider/slider-core'; +import type { TimeSliderState } from '../../core/ui/slider/time-slider-core'; + +export function getSliderCSSVars(state: SliderState): Record { + return { + [SliderCSSVars.fill]: `${state.fillPercent.toFixed(3)}%`, + [SliderCSSVars.pointer]: `${state.pointerPercent.toFixed(3)}%`, + }; +} + +export function getTimeSliderCSSVars(state: TimeSliderState): Record { + return { + ...getSliderCSSVars(state), + [SliderCSSVars.buffer]: `${state.bufferPercent.toFixed(3)}%`, + }; +} +``` + +### 2.4 Barrel Export + +Add exports to `packages/core/src/dom/index.ts`: + +```ts +export * from './ui/event'; // Exposes UIEvent, UIKeyboardEvent, UIPointerEvent +export * from './ui/slider'; +export * from './ui/slider-css-vars'; +``` + +Note: `./ui/event` is not currently exported from the barrel. Adding it exposes `UIEvent`, +`UIKeyboardEvent`, and the new `UIPointerEvent` as public API from `@videojs/core/dom`. +This is intentional — these types are needed by consumers building custom interaction handlers. + +### 2.5 Tests + +**File:** `packages/core/src/dom/ui/tests/slider.test.ts` + +- `createSlider` returns correct shape +- Pointer down starts drag, `onDragStart` called, interaction state updates +- Pointer move during drag calls `onValueChange` with percent +- Pointer up calls `onValueCommit`, `onDragEnd`, drag ends +- Pointer move without drag updates `pointerPercent` (hover) +- Pointer leave resets pointing +- Keyboard: arrow keys increment/decrement, Page/Home/End, RTL flip, vertical inversion +- Disabled handlers no-op +- Destroy cleans up + +**File:** `packages/core/src/dom/ui/tests/slider-css-vars.test.ts` + +- `getSliderCSSVars`: correct keys and 3-decimal formatting +- `getTimeSliderCSSVars`: includes buffer var + +### 2.6 Verify + +```bash +pnpm -F @videojs/core test src/dom/ui/tests/slider +pnpm -F @videojs/core build +pnpm typecheck +pnpm lint:fix:file +``` + +--- + +## PR 3: React Components + +**Branch:** `feat/slider-react` +**Base:** `feat/slider-dom` +**Package:** `@videojs/react` + +### 3.1 Slider Context + +**File:** `packages/react/src/ui/slider/slider-context.ts` + +Internal context carrying shared slider state for child parts. The root element applies +ARIA attrs directly via `core.getAttrs(state)` and provides interaction data to children. +No `thumbAttrs` in context — `getAttrs()` returns an inferred type (no named interface). + +```ts +interface SliderContextValue { + state: SliderState; + thumbProps: SliderThumbProps; + stateAttrMap: StateAttrMap; + getAttrs: (state: SliderState) => object; // core.getAttrs bound to core instance + formatValue?: (value: number, type: 'current' | 'pointer') => string; +} +``` + +The `stateAttrMap` ensures generic parts only generate data attributes for the correct keys +(e.g., `data-dragging`, `data-disabled`) — not for every state property like `value` or +`fillPercent`. Domain roots provide their specific map: `SliderDataAttrs` for generic/volume, +`TimeSliderDataAttrs` for time (adds `data-seeking`). + +**Note:** This is the first UI component in the codebase to create its own React/Lit context. +Existing components (Time.Value, etc.) read from the player store directly. The slider needs +component-level context because child parts need shared interaction state from `createSlider()`, +not from the player store. This is a justified new pattern. + +### 3.2 `useSlider` Hook + +**File:** `packages/react/src/ui/hooks/use-slider.ts` + +Encapsulates `createSlider()` lifecycle for React. Uses `useSnapshot` from `@videojs/store/react` to subscribe to `State`. + +```ts +interface UseSliderOptions { + core: SliderCore; + computeState: (interaction: SliderInteraction) => SliderState; + onValueChange?: (percent: number) => void; + onValueCommit?: (percent: number) => void; + onDragStart?: () => void; + onDragEnd?: () => void; + disabled?: boolean; + orientation?: 'horizontal' | 'vertical'; + isRTL?: boolean; +} + +interface UseSliderResult { + state: SliderState; + rootProps: SliderRootProps; + thumbProps: SliderThumbProps; + cssVars: Record; +} + +function useSlider(options: UseSliderOptions): UseSliderResult; +``` + +**Implementation:** +- `useState(() => createSlider(...))` for lazy init. +- `useSnapshot(slider.interaction)` to subscribe to `State` (from PR 0). +- Compute `state = options.computeState(interaction)`. +- Compute `cssVars = getSliderCSSVars(state)`. +- On unmount: call `slider.destroy()`. +- Memoize `SliderOptions` callbacks with refs to avoid recreating the slider. + +### 3.3 Generic `Slider.Root` + +**File:** `packages/react/src/ui/slider/slider-root.tsx` + +The generic root creates its own `SliderCore`, calls `useSlider`, provides `SliderContext`, renders a `
` with CSS vars and pointer handlers. + +All React component files start with `'use client';` directive. Parameter is always named +`componentProps`. Destructure `render, className, style` first, then core props, then `...elementProps`. + +```tsx +'use client'; + +export interface SliderRootProps extends UIComponentProps<'div', SliderCore.State>, SliderCore.Props { + value?: number; + defaultValue?: number; + onValueChange?: (value: number) => void; + onValueCommit?: (value: number) => void; + onDragStart?: () => void; + onDragEnd?: () => void; +} + +export const Root = forwardRef(function SliderRoot( + componentProps: SliderRootProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, min, max, step, largeStep, + orientation, disabled, thumbAlignment, value, defaultValue, + onValueChange, onValueCommit, onDragStart, onDragEnd, + ...elementProps } = componentProps; + + // 1. Lazy-init SliderCore, call setProps. + const [core] = useState(() => new SliderCore()); + core.setProps({ min, max, step, largeStep, orientation, disabled, thumbAlignment }); + + // 2. Call useSlider with generic computeState. + // 3. For controlled: value comes from props. For uncontrolled: track internal state. + // 4. Provide SliderContext. + // 5. CSS vars passed as { style: cssVarsObj } in props array: + return renderElement('div', { render, className, style }, { + state, + stateAttrMap: SliderDataAttrs, + ref: [forwardedRef], + props: [core.getAttrs(state), { style: cssVars }, elementProps], + }); +}); + +export namespace Root { + export type Props = SliderRootProps; + export type State = SliderCore.State; +} +``` + +### 3.4 Generic Parts + +All generic parts consume `SliderContext` for data attributes. Each is a thin `forwardRef` wrapper around `renderElement`. + +**`Slider.Track`** — `packages/react/src/ui/slider/slider-track.tsx` +- Renders `
` with data attributes from context state. +- No special behavior. + +**`Slider.Fill`** — `packages/react/src/ui/slider/slider-fill.tsx` +- Renders `
` with data attributes. +- Sizing is pure CSS via `var(--media-slider-fill)`. + +**`Slider.Buffer`** — `packages/react/src/ui/slider/slider-buffer.tsx` +- Renders `
` with data attributes. +- Sizing is pure CSS via `var(--media-slider-buffer)`. + +**`Slider.Thumb`** — `packages/react/src/ui/slider/slider-thumb.tsx` +- Renders `
` with `role="slider"`, ARIA attrs from context `thumbAttrs`. +- Event handlers from context `thumbProps`. +- `tabIndex={0}`, `autocomplete="off"`. +- Data attributes from context state. + +**`Slider.Value`** — `packages/react/src/ui/slider/slider-value.tsx` +- Renders `` with `aria-live="off"`. +- Props: `type?: 'current' | 'pointer'`. +- Reads formatted value from context `formatValue(value, type)`. +- Falls back to raw number if no formatter. + +### 3.5 Domain Roots + +**`TimeSlider.Root`** — `packages/react/src/ui/time-slider/time-slider-root.tsx` + +- `'use client';` directive at line 1. +- Connects to media store: `usePlayer(selectTime)`, `usePlayer(selectBuffer)`. +- **Feature guard:** `if (!time) { if (__DEV__) logMissingFeature('TimeSlider', 'time'); return null; }` + Same for buffer (optional — buffer can be absent without breaking, just no buffer bar). +- Computes `bufferedEnd` from `MediaBufferState.buffered` (last range end, or 0). +- Lazy-inits `TimeSliderCore`, calls `setProps`. +- Calls `useSlider` with `computeState = (interaction) => core.getTimeState(interaction, mediaState)`. +- `onValueChange`: visual update (CSS vars refresh). +- `onValueCommit`: calls `mediaState.seek(core.valueFromPercent(percent))`, throttled via `seekThrottle` prop (default 100ms, trailing edge). +- `onDragStart`/`onDragEnd`: user callbacks. +- Provides `SliderContext` with time formatting: `formatValue = (value, type) => formatTime(value)`. +- Uses `TimeSliderDataAttrs` for state-to-data-attr mapping. +- Uses `getTimeSliderCSSVars` for CSS vars (includes buffer). +- Props: `label`, `seekThrottle`, `disabled`, `thumbAlignment`, `step` (default 0.1 in seconds), `largeStep` (default 10 seconds), `render`, `onDragStart`, `onDragEnd`. +- renderElement CSS vars: `props: [core.getAttrs(state), { style: cssVars }, elementProps]`. +- `export namespace Root { export type Props = TimeSliderRootProps; export type State = TimeSliderCore.State; }` + +**`VolumeSlider.Root`** — `packages/react/src/ui/volume-slider/volume-slider-root.tsx` + +- `'use client';` directive at line 1. +- Connects to media store: `usePlayer(selectVolume)`. +- **Feature guard:** `if (!volume) { if (__DEV__) logMissingFeature('VolumeSlider', 'volume'); return null; }` +- Lazy-inits `VolumeSliderCore`, calls `setProps`. +- Calls `useSlider` with `computeState = (interaction) => core.getVolumeState(interaction, mediaState)`. +- `onValueChange`: calls `mediaState.changeVolume(core.valueFromPercent(percent) / 100)` immediately. Volume changes are cheap and instant — no throttle. +- `onValueCommit`: same as `onValueChange` (no separate commit behavior for volume). +- Provides `SliderContext` with percentage formatting: `formatValue = (value) => Math.round(value) + '%'`. +- Uses `SliderDataAttrs` for state-to-data-attr mapping. +- Uses `getSliderCSSVars` for CSS vars (no buffer). +- Props: `label`, `orientation`, `disabled`, `thumbAlignment`, `step` (default 1%), `largeStep` (default 10%), `render`, `onDragStart`, `onDragEnd`. +- renderElement CSS vars: `props: [core.getAttrs(state), { style: cssVars }, elementProps]`. +- `export namespace Root { export type Props = VolumeSliderRootProps; export type State = VolumeSliderCore.State; }` + +### 3.6 Barrel Files + +**`packages/react/src/ui/slider/index.ts`:** + +```ts +export * as Slider from './index.parts'; +``` + +**`packages/react/src/ui/slider/index.parts.ts`:** + +```ts +export { Root, type SliderRootProps as RootProps } from './slider-root'; +export { Track, type SliderTrackProps as TrackProps } from './slider-track'; +export { Fill, type SliderFillProps as FillProps } from './slider-fill'; +export { Buffer, type SliderBufferProps as BufferProps } from './slider-buffer'; +export { Thumb, type SliderThumbProps as ThumbProps } from './slider-thumb'; +export { Value, type SliderValueProps as ValueProps } from './slider-value'; +``` + +**`packages/react/src/ui/time-slider/index.ts`:** + +```ts +export * as TimeSlider from './index.parts'; +``` + +**`packages/react/src/ui/time-slider/index.parts.ts`:** + +```ts +export { Root, type TimeSliderRootProps as RootProps } from './time-slider-root'; +export { Track, Fill, Buffer, Thumb, Value } from '../slider/index.parts'; +export type { SliderTrackProps as TrackProps, SliderFillProps as FillProps, + SliderBufferProps as BufferProps, SliderThumbProps as ThumbProps, + SliderValueProps as ValueProps } from '../slider/index.parts'; +``` + +**`packages/react/src/ui/volume-slider/index.ts`:** + +```ts +export * as VolumeSlider from './index.parts'; +``` + +**`packages/react/src/ui/volume-slider/index.parts.ts`:** + +```ts +export { Root, type VolumeSliderRootProps as RootProps } from './volume-slider-root'; +// Buffer is re-exported for API consistency but renders nothing useful +// for volume sliders — VolumeSlider.Root does not set --media-slider-buffer. +export { Track, Fill, Buffer, Thumb, Value } from '../slider/index.parts'; +export type { SliderTrackProps as TrackProps, SliderFillProps as FillProps, + SliderBufferProps as BufferProps, SliderThumbProps as ThumbProps, + SliderValueProps as ValueProps } from '../slider/index.parts'; +``` + +Add to `packages/react/src/index.ts`: + +```ts +export { Slider } from './ui/slider'; +export { TimeSlider } from './ui/time-slider'; +export { VolumeSlider } from './ui/volume-slider'; +``` + +### 3.7 Sandbox Demo (gitignored) + +Update `packages/sandbox/src/react/main.tsx` (or create a new route) with a working slider demo: + +```tsx + + + + +``` + +### 3.8 Tests + +**File:** `packages/react/src/ui/slider/tests/slider.test.tsx` + +- Generic `Slider.Root` renders with correct data attributes and CSS vars. +- `Slider.Thumb` has correct ARIA attributes. +- `Slider.Value` renders formatted output. +- Context propagation to children. +- `onValueChange` / `onValueCommit` callbacks fire. + +**File:** `packages/react/src/ui/time-slider/tests/time-slider.test.tsx` + +- Renders with mocked player store. +- CSS vars include buffer. +- `data-seeking` attribute. +- Seek is called on value commit. +- Time formatting in `Value`. + +**File:** `packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx` + +- Renders with mocked player store. +- Fill is 0 when muted. +- `changeVolume` called on value change. +- `aria-valuetext` includes muted state. + +### 3.9 Verify + +```bash +pnpm -F @videojs/react test src/ui/slider +pnpm -F @videojs/react test src/ui/time-slider +pnpm -F @videojs/react test src/ui/volume-slider +pnpm -F @videojs/react build +pnpm typecheck +pnpm lint:fix:file +``` + +--- + +## PR 4: HTML Custom Elements + +**Branch:** `feat/slider-html` +**Base:** `feat/slider-dom` +**Package:** `@videojs/html` + +### 4.1 Event Types + +**File:** `packages/html/src/ui/slider/slider-events.ts` + +```ts +export interface SliderValueEventDetail { + value: number; +} + +export interface SliderEventMap { + 'value-change': CustomEvent; + 'value-commit': CustomEvent; + 'drag-start': CustomEvent; + 'drag-end': CustomEvent; +} + +export interface DomainSliderEventMap { + 'drag-start': CustomEvent; + 'drag-end': CustomEvent; +} +``` + +### 4.2 `SliderElement` — Generic Root + +**File:** `packages/html/src/ui/slider/slider-element.ts` + +Extends `MediaElement`. The generic slider root handles pointer events, CSS variables, data attributes, and custom DOM events. Can be used standalone (without domain sliders) for custom slider use cases. + +```ts +import type { PropertyDeclarationMap } from '@videojs/element'; +import { ContextProvider } from '@videojs/element/context'; + +export class SliderElement extends MediaElement { + static readonly tagName = 'media-slider'; + + static override properties = { + value: { type: Number }, + min: { type: Number }, + max: { type: Number }, + step: { type: Number }, + largeStep: { type: Number, attribute: 'large-step' }, + orientation: { type: String }, + disabled: { type: Boolean }, + thumbAlignment: { type: String, attribute: 'thumb-alignment' }, + } satisfies PropertyDeclarationMap; + + // Property defaults from core + value = 0; + min = SliderCore.defaultProps.min; + max = SliderCore.defaultProps.max; + step = SliderCore.defaultProps.step; + largeStep = SliderCore.defaultProps.largeStep; + orientation = SliderCore.defaultProps.orientation; + disabled = SliderCore.defaultProps.disabled; + thumbAlignment = SliderCore.defaultProps.thumbAlignment; + + readonly #core = new SliderCore(); + #slider: SliderHandle | null = null; + #disconnect: AbortController | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + // 1. Create AbortController + // 2. Create slider via createSlider() with options: + // - getElement: () => this + // - getOrientation, isRTL (via isRTL from @videojs/utils/dom), isDisabled + // - getPercent, getStepPercent, getLargeStepPercent + // - onValueChange: dispatch 'value-change' event + // - onValueCommit: dispatch 'value-commit' event + // - onDragStart: dispatch 'drag-start' event + // - onDragEnd: dispatch 'drag-end' event + // 3. Apply rootProps to this element via applyElementProps(this, rootProps, signal) + // 4. Subscribe to interaction state for re-renders: + // slider.interaction.subscribe(() => this.requestUpdate(), + // { signal: this.#disconnect.signal }) + // 5. Set touch-action: none, user-select: none, contain: layout style + // 6. Provide sliderContext via ContextProvider + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + // 1. slider.destroy() + // 2. abort controller (also cleans up interaction subscription) + } + + protected override willUpdate(changed: PropertyValues): void { + super.willUpdate(changed); + this.#core.setProps(this); + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + // Recompute state from core + interaction + // Apply CSS vars via this.style.setProperty(key, value) — NOT applyElementProps + // Apply data attrs via applyStateDataAttrs(this, state, SliderDataAttrs) + // Apply ARIA via applyElementProps(this, this.#core.getAttrs(state)) + // Update sliderContext provider value + } +} +``` + +### 4.3 Structural Part Elements + +All child elements consume `sliderContext` via `ContextConsumer` and apply data attributes +from the context's `stateAttrMap` in their `update()` method. This ensures data attributes +like `data-dragging`, `data-pointing`, `data-interactive`, `data-orientation`, `data-disabled` +propagate to every child. + +**`SliderTrackElement`** — `packages/html/src/ui/slider/slider-track-element.ts` +```ts +export class SliderTrackElement extends MediaElement { + static readonly tagName = 'media-slider-track'; + + readonly #ctx = new ContextConsumer(this, { + context: sliderContext, + subscribe: true, + }); + + protected override update(): void { + super.update(); + const ctx = this.#ctx.value; + if (ctx) applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap); + } +} +``` + +**`SliderFillElement`** — `packages/html/src/ui/slider/slider-fill-element.ts` +```ts +export class SliderFillElement extends MediaElement { + static readonly tagName = 'media-slider-fill'; + // Same ContextConsumer + applyStateDataAttrs pattern as Track +} +``` + +**`SliderBufferElement`** — `packages/html/src/ui/slider/slider-buffer-element.ts` +```ts +export class SliderBufferElement extends MediaElement { + static readonly tagName = 'media-slider-buffer'; + // Same ContextConsumer + applyStateDataAttrs pattern as Track +} +``` + +### 4.4 `SliderThumbElement` + +**File:** `packages/html/src/ui/slider/slider-thumb-element.ts` + +```ts +export class SliderThumbElement extends MediaElement { + static readonly tagName = 'media-slider-thumb'; + + readonly #ctx = new ContextConsumer(this, { + context: sliderContext, + subscribe: true, + }); + + override connectedCallback(): void { + super.connectedCallback(); + // Set role="slider", tabindex="0", autocomplete="off" as initial attributes + } + + protected override update(): void { + super.update(); + const ctx = this.#ctx.value; + if (!ctx) return; + + // Apply ARIA from context thumbAttrs + applyElementProps(this, ctx.thumbAttrs); + // Apply keyboard/focus handlers from context thumbProps + // (needs signal management — apply once in connectedCallback or re-apply) + applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap); + } +} +``` + +**Note:** `thumbProps` (keyboard/focus handlers) should be applied once in `connectedCallback` +with a signal, not re-applied every update. The root element can apply them directly to the +thumb child, or the thumb can read them from context on first connect. + +### 4.5 `SliderValueElement` + +**File:** `packages/html/src/ui/slider/slider-value-element.ts` + +```ts +export class SliderValueElement extends MediaElement { + static readonly tagName = 'media-slider-value'; + + static override properties = { + type: { type: String }, // 'current' | 'pointer' + }; + + type: 'current' | 'pointer' = 'current'; + + readonly #ctx = new ContextConsumer(this, { + context: sliderContext, + subscribe: true, + }); + + override connectedCallback(): void { + super.connectedCallback(); + this.setAttribute('aria-live', 'off'); + } + + protected override update(): void { + super.update(); + const ctx = this.#ctx.value; + if (!ctx) return; + + // Get value based on type + const value = this.type === 'pointer' + ? ctx.state.pointerPercent + : ctx.state.value; + + // Format and set text content + this.textContent = ctx.formatValue + ? ctx.formatValue(value, this.type) + : String(Math.round(value)); + + applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap); + } +} +``` + +### 4.6 Context Communication (HTML) + +The HTML slider needs a way for the root element to communicate state to child elements. Options: + +- **Context** (`@videojs/element/context`): The `SliderElement` provides a context that children consume via `ContextConsumer`. This is consistent with how `PlayerController` uses `playerContext`. +- Create a `sliderContext` with `createContext()`. +- Root provides it, structural children consume it. + +```ts +// packages/html/src/ui/slider/slider-context.ts +import { createContext } from '@videojs/element/context'; +import type { StateAttrMap } from '@videojs/core'; + +export interface SliderContextValue { + state: SliderState; + thumbProps: SliderThumbProps; + stateAttrMap: StateAttrMap; + formatValue?: (value: number, type: 'current' | 'pointer') => string; +} + +const SLIDER_CONTEXT_KEY = Symbol('@videojs/slider'); +export const sliderContext = createContext(SLIDER_CONTEXT_KEY); +``` + +Root provides via `ContextProvider`. Children consume via `ContextConsumer` and apply +data-attrs/ARIA in their `update()` method. The `stateAttrMap` ensures only the correct +state keys become data attributes (not `value`, `fillPercent`, etc.). + +### 4.7 Domain Elements + +**`TimeSliderElement`** — `packages/html/src/ui/time-slider/time-slider-element.ts` + +```ts +import type { PropertyDeclarationMap } from '@videojs/element'; +import { ContextProvider } from '@videojs/element/context'; + +export class TimeSliderElement extends MediaElement { + static readonly tagName = 'media-time-slider'; + + static override properties = { + label: { type: String }, + seekThrottle: { type: Number, attribute: 'seek-throttle' }, + disabled: { type: Boolean }, + thumbAlignment: { type: String, attribute: 'thumb-alignment' }, + } satisfies PropertyDeclarationMap; + + label = TimeSliderCore.defaultProps.label; + seekThrottle = 100; + disabled = TimeSliderCore.defaultProps.disabled; + thumbAlignment = TimeSliderCore.defaultProps.thumbAlignment; + + readonly #core = new TimeSliderCore(); + readonly #timeState = new PlayerController(this, playerContext, selectTime); + readonly #bufferState = new PlayerController(this, playerContext, selectBuffer); + #slider: SliderHandle | null = null; + #disconnect: AbortController | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + // 1. Create AbortController + // 2. Create slider via createSlider({ getElement: () => this, ... }) + // 3. min=0, max=duration always + // 4. onValueChange: visual update only (triggers re-render) + // 5. onValueCommit: timeState.seek(core.valueFromPercent(percent)), throttled + // 6. Apply rootProps via applyElementProps(this, rootProps, signal) + // 7. Subscribe: slider.interaction.subscribe(() => this.requestUpdate(), { signal }) + // 8. Set contain, touch-action, user-select + // 9. Provide sliderContext via ContextProvider + // 10. DEV: logMissingFeature if !this.#timeState.value + } + + protected override willUpdate(changed: PropertyValues): void { + super.willUpdate(changed); + this.#core.setProps(this); + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + const media = this.#timeState.value; + if (!media) return; + // Compute TimeSliderState from core + interaction + media state + // Apply CSS vars via this.style.setProperty() using getTimeSliderCSSVars() + // Apply data attrs via applyStateDataAttrs(this, state, TimeSliderDataAttrs) + // Apply ARIA via applyElementProps(this, this.#core.getAttrs(state)) + // Update sliderContext provider value + } +} +``` + +**`VolumeSliderElement`** — `packages/html/src/ui/volume-slider/volume-slider-element.ts` + +```ts +import type { PropertyDeclarationMap } from '@videojs/element'; +import { ContextProvider } from '@videojs/element/context'; + +export class VolumeSliderElement extends MediaElement { + static readonly tagName = 'media-volume-slider'; + + static override properties = { + label: { type: String }, + orientation: { type: String }, + disabled: { type: Boolean }, + thumbAlignment: { type: String, attribute: 'thumb-alignment' }, + } satisfies PropertyDeclarationMap; + + label = VolumeSliderCore.defaultProps.label; + orientation = VolumeSliderCore.defaultProps.orientation; + disabled = VolumeSliderCore.defaultProps.disabled; + thumbAlignment = VolumeSliderCore.defaultProps.thumbAlignment; + + readonly #core = new VolumeSliderCore(); + readonly #volumeState = new PlayerController(this, playerContext, selectVolume); + #slider: SliderHandle | null = null; + #disconnect: AbortController | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + // 1. Create AbortController + // 2. Create slider via createSlider({ getElement: () => this, ... }) + // 3. onValueChange: volumeState.changeVolume(percent / 100) + // 4. Apply rootProps via applyElementProps(this, rootProps, signal) + // 5. Subscribe: slider.interaction.subscribe(() => this.requestUpdate(), { signal }) + // 6. Provide sliderContext via ContextProvider + // 7. DEV: logMissingFeature if !this.#volumeState.value + } + + protected override willUpdate(changed: PropertyValues): void { + super.willUpdate(changed); + this.#core.setProps(this); + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + const media = this.#volumeState.value; + if (!media) return; + // Compute VolumeSliderState from core + interaction + volume state + // Apply CSS vars via this.style.setProperty() using getSliderCSSVars() + // Apply data attrs via applyStateDataAttrs(this, state, SliderDataAttrs) + // Apply ARIA via applyElementProps(this, this.#core.getAttrs(state)) + // Update sliderContext provider value + } +} +``` + +### 4.8 Registration Files + +Shared child elements are extracted into their own define modules. Both `time-slider` and +`volume-slider` import these as side-effect imports. The ES module singleton guarantee ensures +each executes only once — no `defineCustomElement` guard needed. + +**Shared define modules:** + +**`packages/html/src/define/ui/slider-track.ts`:** +```ts +import { SliderTrackElement } from '../../ui/slider/slider-track-element'; + +customElements.define(SliderTrackElement.tagName, SliderTrackElement); + +declare global { + interface HTMLElementTagNameMap { + [SliderTrackElement.tagName]: SliderTrackElement; + } +} +``` + +**`packages/html/src/define/ui/slider-fill.ts`:** +```ts +import { SliderFillElement } from '../../ui/slider/slider-fill-element'; + +customElements.define(SliderFillElement.tagName, SliderFillElement); + +declare global { + interface HTMLElementTagNameMap { + [SliderFillElement.tagName]: SliderFillElement; + } +} +``` + +**`packages/html/src/define/ui/slider-buffer.ts`:** +```ts +import { SliderBufferElement } from '../../ui/slider/slider-buffer-element'; + +customElements.define(SliderBufferElement.tagName, SliderBufferElement); + +declare global { + interface HTMLElementTagNameMap { + [SliderBufferElement.tagName]: SliderBufferElement; + } +} +``` + +**`packages/html/src/define/ui/slider-thumb.ts`:** +```ts +import { SliderThumbElement } from '../../ui/slider/slider-thumb-element'; + +customElements.define(SliderThumbElement.tagName, SliderThumbElement); + +declare global { + interface HTMLElementTagNameMap { + [SliderThumbElement.tagName]: SliderThumbElement; + } +} +``` + +**`packages/html/src/define/ui/slider-value.ts`:** +```ts +import { SliderValueElement } from '../../ui/slider/slider-value-element'; + +customElements.define(SliderValueElement.tagName, SliderValueElement); + +declare global { + interface HTMLElementTagNameMap { + [SliderValueElement.tagName]: SliderValueElement; + } +} +``` + +**Domain define modules (import shared as side effects):** + +**`packages/html/src/define/ui/time-slider.ts`:** +```ts +import { TimeSliderElement } from '../../ui/time-slider/time-slider-element'; + +// Side-effect imports — each module executes once (ES module singleton) +import './slider-track'; +import './slider-fill'; +import './slider-buffer'; +import './slider-thumb'; +import './slider-value'; + +customElements.define(TimeSliderElement.tagName, TimeSliderElement); + +declare global { + interface HTMLElementTagNameMap { + [TimeSliderElement.tagName]: TimeSliderElement; + } +} +``` + +**`packages/html/src/define/ui/volume-slider.ts`:** +```ts +import { VolumeSliderElement } from '../../ui/volume-slider/volume-slider-element'; + +// Side-effect imports — shared modules already executed if time-slider was imported first +import './slider-track'; +import './slider-fill'; +import './slider-thumb'; +import './slider-value'; + +customElements.define(VolumeSliderElement.tagName, VolumeSliderElement); + +declare global { + interface HTMLElementTagNameMap { + [VolumeSliderElement.tagName]: VolumeSliderElement; + } +} +``` + +**Package.json exports** — No changes needed. The existing wildcard glob in `packages/html/package.json` +already covers any new files added to `src/define/ui/`: +```json +"./ui/*": { "types": "...", "development": "...", "default": "..." } +``` +New define files (`time-slider.ts`, `volume-slider.ts`, `slider-track.ts`, etc.) are automatically +exposed as `@videojs/html/ui/time-slider`, `@videojs/html/ui/slider-track`, etc. + +### 4.9 Barrel Export + +Add to `packages/html/src/index.ts`: +```ts +export { SliderElement } from './ui/slider/slider-element'; +export { SliderTrackElement } from './ui/slider/slider-track-element'; +export { SliderFillElement } from './ui/slider/slider-fill-element'; +export { SliderBufferElement } from './ui/slider/slider-buffer-element'; +export { SliderThumbElement } from './ui/slider/slider-thumb-element'; +export { SliderValueElement } from './ui/slider/slider-value-element'; +export { TimeSliderElement } from './ui/time-slider/time-slider-element'; +export { VolumeSliderElement } from './ui/volume-slider/volume-slider-element'; +``` + +### 4.10 Sandbox Demo (gitignored) + +Update `packages/sandbox/src/html/main.ts` with: + +```html + + + + + + + + + + + + + + +``` + +### 4.11 Tests + +**File:** `packages/html/src/ui/slider/tests/slider-element.test.ts` +- Element registers with correct tag name +- Reactive properties reflect to attributes +- CSS vars set on host +- Data attributes propagate +- Custom events fire: `value-change`, `value-commit`, `drag-start`, `drag-end` +- Thumb child gets ARIA + +**File:** `packages/html/src/ui/time-slider/tests/time-slider-element.test.ts` +- Connects to store, renders time state +- Buffer CSS var present +- `data-seeking` attribute +- Seek called on commit + +**File:** `packages/html/src/ui/volume-slider/tests/volume-slider-element.test.ts` +- Volume changes on value change +- Muted fill is 0 +- Vertical orientation support + +### 4.12 Verify + +```bash +pnpm -F @videojs/html test src/ui/slider +pnpm -F @videojs/html test src/ui/time-slider +pnpm -F @videojs/html test src/ui/volume-slider +pnpm -F @videojs/html build +pnpm typecheck +pnpm lint:fix:file +``` + +--- + +## PR 5: React Preview Component + +**Branch:** `feat/slider-preview-react` +**Base:** `feat/slider-react` +**Package:** `@videojs/react` + +### Positioning Strategy (CSS-Only) + +No Floating UI dependency. Slider preview uses pure CSS positioning with JS-computed clamping, +matching Vidstack's proven approach: + +- Absolute positioning within the slider root +- Horizontal position: `left: min(max(0px, calc(var(--media-slider-pointer) - halfWidth)), calc(100% - fullWidth))` +- Vertical offset: `bottom: calc(100% + var(--media-slider-preview-offset, 8px))` +- `ResizeObserver` on the preview element recalculates clamping when content changes size +- Supports `noClamp` prop for previews that intentionally extend beyond slider bounds +- The `--media-slider-pointer` CSS variable is already set by the slider root (from PR 2) + +### 5.1 `Slider.Preview` + +**File:** `packages/react/src/ui/slider/slider-preview.tsx` + +```tsx +export interface SliderPreviewProps extends UIComponentProps<'div', SliderState> { + noClamp?: boolean; +} + +export const Preview = forwardRef(function SliderPreview( + props: SliderPreviewProps, + ref: ForwardedRef +) { + const context = useContext(SliderContext); + const previewRef = useRef(null); + const [width, setWidth] = useState(0); + + // ResizeObserver tracks preview width for clamping + useEffect(() => { + const el = previewRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Compute clamped left position + const halfWidth = width / 2; + const style = props.noClamp + ? { left: `calc(var(--media-slider-pointer) - ${halfWidth}px)` } + : { left: `min(max(0px, calc(var(--media-slider-pointer) - ${halfWidth}px)), calc(100% - ${width}px))` }; + + // Render
with position: absolute, clamped style, data attributes from context. +}); +``` + +Re-export from `Slider`, `TimeSlider`, and `VolumeSlider` namespaces. + +### 5.2 Tests + +- Preview renders within slider context +- Data attributes propagate +- Clamping style applied by default +- `noClamp` disables clamping + +### 5.3 Verify + +```bash +pnpm -F @videojs/react test src/ui/slider/tests/slider-preview +pnpm -F @videojs/react build +pnpm typecheck +``` + +--- + +## PR 6: HTML Preview Element + +**Branch:** `feat/slider-preview-html` +**Base:** `feat/slider-html` +**Package:** `@videojs/html` + +### 6.1 `SliderPreviewElement` + +**File:** `packages/html/src/ui/slider/slider-preview-element.ts` + +```ts +export class SliderPreviewElement extends MediaElement { + static readonly tagName = 'media-slider-preview'; + + static override properties = { + noClamp: { type: Boolean, attribute: 'no-clamp' }, + }; + + noClamp = false; + + readonly #ctx = new ContextConsumer(this, { + context: sliderContext, + subscribe: true, + }); + + #resizeObserver: ResizeObserver | null = null; + #width = 0; + + override connectedCallback(): void { + super.connectedCallback(); + // Set position: absolute, width: max-content + this.#resizeObserver = new ResizeObserver(([entry]) => { + this.#width = entry.contentRect.width; + this.#updatePosition(); + }); + this.#resizeObserver.observe(this); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#resizeObserver?.disconnect(); + this.#resizeObserver = null; + } + + #updatePosition(): void { + const halfWidth = this.#width / 2; + if (this.noClamp) { + this.style.left = `calc(var(--media-slider-pointer) - ${halfWidth}px)`; + } else { + this.style.left = `min(max(0px, calc(var(--media-slider-pointer) - ${halfWidth}px)), calc(100% - ${this.#width}px))`; + } + } + + protected override update(): void { + super.update(); + const ctx = this.#ctx.value; + if (ctx) applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap); + this.#updatePosition(); + } +} +``` + +### 6.2 Registration + +**`packages/html/src/define/ui/slider-preview.ts`:** + +```ts +import { SliderPreviewElement } from '../../ui/slider/slider-preview-element'; +customElements.define(SliderPreviewElement.tagName, SliderPreviewElement); + +declare global { + interface HTMLElementTagNameMap { + [SliderPreviewElement.tagName]: SliderPreviewElement; + } +} +``` + +No changes needed to `packages/html/package.json` — the existing `"./ui/*"` wildcard +covers `@videojs/html/ui/slider-preview` automatically. + +### 6.3 Tests & Verify + +```bash +pnpm -F @videojs/html test src/ui/slider/tests/slider-preview +pnpm -F @videojs/html build +pnpm typecheck +``` + +--- + +## File Structure Summary + +``` +packages/store/src/react/hooks/ + use-snapshot.ts # useSnapshot hook (PR 0) + tests/use-snapshot.test.ts + +packages/utils/src/time/ + format.ts # Fix formatTimeAsPhrase(0) + tests/format.test.ts # Add zero-seconds test + +packages/utils/src/dom/ + raf-throttle.ts # rafThrottle utility (PR 1) + direction.ts # isRTL utility (PR 1) + tests/ + raf-throttle.test.ts + direction.test.ts + +packages/core/src/core/ui/slider/ + slider-core.ts # SliderCore class + types + utilities + time-slider-core.ts # TimeSliderCore class + types + volume-slider-core.ts # VolumeSliderCore class + types + slider-data-attrs.ts # SliderDataAttrs, TimeSliderDataAttrs + slider-css-vars.ts # SliderCSSVars constant + tests/ + slider-core.test.ts + time-slider-core.test.ts + volume-slider-core.test.ts + +packages/core/src/dom/ui/ + event.ts # Add UIPointerEvent + slider.ts # createSlider() factory + slider-css-vars.ts # getSliderCSSVars, getTimeSliderCSSVars + tests/ + slider.test.ts + slider-css-vars.test.ts + +packages/react/src/ui/ + hooks/use-slider.ts # useSlider hook + slider/ + index.ts # export * as Slider + index.parts.ts # Root, Track, Fill, Buffer, Thumb, Value + slider-context.ts # React context + slider-root.tsx + slider-track.tsx + slider-fill.tsx + slider-buffer.tsx + slider-thumb.tsx + slider-preview.tsx # PR 5 + slider-value.tsx + tests/ + slider.test.tsx + time-slider/ + index.ts # export * as TimeSlider + index.parts.ts # Root + re-exports + time-slider-root.tsx + tests/ + time-slider.test.tsx + volume-slider/ + index.ts # export * as VolumeSlider + index.parts.ts # Root + re-exports + volume-slider-root.tsx + tests/ + volume-slider.test.tsx + +packages/html/src/ui/ + slider/ + slider-element.ts # + slider-track-element.ts # + slider-fill-element.ts # + slider-buffer-element.ts # + slider-thumb-element.ts # + slider-value-element.ts # + slider-preview-element.ts # (PR 6) + slider-events.ts # Event type interfaces + slider-context.ts # Lit context for child communication + tests/ + slider-element.test.ts + time-slider/ + time-slider-element.ts # + tests/ + time-slider-element.test.ts + volume-slider/ + volume-slider-element.ts # + tests/ + volume-slider-element.test.ts + +packages/html/src/define/ui/ + slider-track.ts # Shared: + slider-fill.ts # Shared: + slider-buffer.ts # Shared: + slider-thumb.ts # Shared: + slider-value.ts # Shared: + time-slider.ts # Domain: + imports shared + volume-slider.ts # Domain: + imports shared + slider-preview.ts # Registration: preview (PR 6) +``` + +## Resolved Questions + +1. **Idempotent custom element registration** — Extract shared child elements (``, `-fill`, `-buffer`, `-thumb`, `-value`) into their own `define/ui/slider-track.ts`, `define/ui/slider-fill.ts`, etc. Both `time-slider` and `volume-slider` define files import the shared ones as side-effect imports. ES module singleton guarantee ensures each executes only once. No `defineCustomElement` guard utility needed — the shared module extraction is sufficient. + +2. **Seek throttle implementation** — No throttle/debounce exists in `@videojs/utils`. Add `rafThrottle` to `@videojs/utils/dom` — a reusable rAF-based throttle that automatically adapts to device refresh rate. Added to PR 1 as a new utility. API: `const throttled = rafThrottle(fn)` returns a throttled function with a `.cancel()` method. + +3. **RTL detection** — No RTL handling exists anywhere in v10. Add `isRTL(element: Element): boolean` to `@videojs/utils/dom` using `getComputedStyle(element).direction === 'rtl'`. In `createSlider`, the `isRTL()` callback reads this once on `pointerdown` and caches for the drag session. Keyboard arrows flip in RTL per WAI-ARIA slider spec. Added to PR 1 as a new utility. + +4. **Slider Preview positioning** — CSS-only. No Floating UI needed. Vidstack confirms: they use absolute positioning + `min(max(...), ...)` CSS clamping for slider preview, reserving `@floating-ui/dom` only for tooltips/menus. A `ResizeObserver` recalculates clamping when preview content changes size. PRs 5/6 use this approach. + +## Resolved During Audit + +- **`ElementProps` extension** — No. `SliderThumbAttrs` is a standalone type with slider-specific ARIA attrs. It bypasses `ElementProps` and is applied directly via `applyElementProps` to the thumb element. +- **CSS custom properties** — Set via `element.style.setProperty()` in the element's `update()` method, not through `applyElementProps` (which only handles attributes and event listeners). +- **HTML interaction subscription** — Direct `state.subscribe(() => this.requestUpdate(), { signal })` instead of `SnapshotController`. Simpler, uses existing `AbortController` cleanup. +- **Preview branch bases** — Preview PRs branch off their respective UI PRs (React/HTML), not off DOM, because they need the slider context definitions. +- **`StateAttrMap` import** — From `'../types'` (in `packages/core/src/core/ui/types.ts`), not from a nonexistent `element.ts`. +- **Core barrel** — Individual file exports in `packages/core/src/core/index.ts`, no `index.ts` barrel in the slider directory (matching existing convention). diff --git a/internal/design/ui/slider/architecture.md b/internal/design/ui/slider/architecture.md index 7277fd24..b3f43eb8 100644 --- a/internal/design/ui/slider/architecture.md +++ b/internal/design/ui/slider/architecture.md @@ -84,7 +84,7 @@ interface SliderInteraction { } ``` -`interactive` is derived by Core: `pointing || focused || dragging`. +`interactive` is derived by Core: `dragging || pointing || focused`. ### Methods @@ -99,10 +99,11 @@ class SliderCore { // For generic slider, `value` is the controlled/uncontrolled value. // Domain cores override to accept media state and compute `value` internally. - getThumbAttrs(state: SliderState): SliderThumbAttrs; - // Returns: role, tabIndex, aria-valuemin, aria-valuemax, aria-valuenow, - // aria-orientation, aria-disabled + getAttrs(state: SliderState); + // Returns: role, tabindex, autocomplete, aria-valuemin, aria-valuemax, + // aria-valuenow, aria-orientation, aria-disabled // These go on the Thumb element (the focusable role="slider" element). + // Inlined return type (no explicit annotation — TS infers). valueFromPercent(percent: number): number; // Clamps to [min, max], snaps to step. @@ -116,12 +117,13 @@ class SliderCore { ### ARIA Output -`getThumbAttrs()` returns attributes for the **Thumb** element — the focusable `role="slider"` element per the [WAI-ARIA Slider Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) and the [Media Seek Slider Example](https://www.w3.org/WAI/ARIA/apg/patterns/slider/examples/slider-seek/): +`getAttrs()` returns attributes for the **Thumb** element — the focusable `role="slider"` element per the [WAI-ARIA Slider Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) and the [Media Seek Slider Example](https://www.w3.org/WAI/ARIA/apg/patterns/slider/examples/slider-seek/): ```ts { role: 'slider', - tabIndex: 0, + tabindex: 0, + autocomplete: 'off', 'aria-valuemin': 0, 'aria-valuemax': 100, 'aria-valuenow': 45, @@ -130,7 +132,7 @@ class SliderCore { } ``` -`aria-label` and `aria-valuetext` are NOT set by `SliderCore` — they're domain-specific. `TimeSliderCore` and `VolumeSliderCore` add them. +`aria-label` and `aria-valuetext` are NOT set by `SliderCore` — they're domain-specific. `TimeSliderCore` and `VolumeSliderCore` add them via `override getAttrs()`. Root handles pointer events and provides context (data attrs, raw state for CSS vars) to children. Thumb carries `role="slider"`, receives keyboard focus, and owns all ARIA attributes. There is no separate Control element. Children other than Thumb (Track, Fill, Buffer, Preview) are purely visual. @@ -138,42 +140,33 @@ Root handles pointer events and provides context (data attrs, raw state for CSS `valueFromPercent()` must handle floating-point precision carefully. Without rounding, values like `0.1 + 0.2` produce `0.30000000000000004`. -### `roundValueToStep` +### `roundToStep` and `clamp` -Rounds a value to the nearest step, anchored at `min`: +Located in `@videojs/utils/number` — shared utilities used by `SliderCore.valueFromPercent()`: ```ts -function roundValueToStep(value: number, step: number, min: number): number { +function clamp(value: number, min: number, max: number): number; +function roundToStep(value: number, step: number, min: number): number; +``` + +`roundToStep` rounds a value to the nearest step, anchored at `min`. For fractional steps, it derives decimal precision from the step's string representation to avoid floating-point drift: + +```ts +function roundToStep(value: number, step: number, min: number): number { const nearest = Math.round((value - min) / step) * step + min; - return Number(nearest.toFixed(getDecimalPrecision(step))); + const dot = `${step}`.indexOf('.'); + return dot === -1 ? nearest : Number(nearest.toFixed(`${step}`.length - dot - 1)); } ``` -The `toFixed(getDecimalPrecision(step))` call derives precision from the step value itself. If `step = 0.01`, the result is fixed to 2 decimal places. If `step = 5`, no decimal places. This prevents accumulating floating-point drift. - -### `getDecimalPrecision` - -```ts -function getDecimalPrecision(num: number): number { - if (Math.abs(num) < 1) { - // Handles scientific notation (e.g., 0.00000001 → 1e-8) - const parts = num.toExponential().split('e-'); - const mantissaDecimals = parts[0].split('.')[1]; - return (mantissaDecimals ? mantissaDecimals.length : 0) + parseInt(parts[1], 10); - } - const decimalPart = num.toString().split('.')[1]; - return decimalPart ? decimalPart.length : 0; -} -``` +Integer steps skip `toFixed` entirely. Fractional steps (e.g., `step = 0.1`) get cleaned up using the step's own decimal count. ### Where It's Used -- `SliderCore.valueFromPercent()` — snaps computed value to step precision after percent → value conversion. +- `SliderCore.valueFromPercent()` — clamps to `[min, max]` then snaps to step precision after percent → value conversion. - Keyboard handler — rounds current value to nearest step before computing next value. Without this, a pointer drag that landed at 47.3 on a step-5 slider would produce unexpected keyboard steps (47.3 → 52.3 instead of 45 → 50). - All percentage calculations for CSS vars use 3 decimal places (`45.123%`) for smooth visual animation, separate from value-level step precision. -Pattern taken from Base UI's `roundValueToStep` utility. - ## Documentation Constants Data attributes and CSS custom properties each get a `as const` object with JSDoc descriptions. The site docs builder extracts these to generate API reference tables. @@ -245,42 +238,32 @@ interface TimeSliderProps extends SliderProps { label?: string; // default: 'Seek' } -interface TimeSliderState extends SliderState { - currentTime: number; - duration: number; - seeking: boolean; +interface TimeSliderState extends SliderState, Pick { bufferPercent: number; } class TimeSliderCore extends SliderCore { - getTimeState(interaction: SliderInteraction, media: TimeMediaState): TimeSliderState; + getTimeState(media: MediaTimeState & MediaBufferState, interaction: SliderInteraction): TimeSliderState; + // Accepts canonical media state types directly — no wrapper interface. // Core owns the value swap: dragging ? valueFromPercent(dragPercent) : currentTime. - // bufferPercent is computed from bufferedEnd / duration. - // The DOM layer uses this to format --media-slider-buffer. + // Computes bufferedEnd internally from media.buffered ranges. + // bufferPercent = (bufferedEnd / duration) * 100. + // Overrides min=0, max=duration on each call. - getTimeThumbAttrs(state: TimeSliderState): TimeSliderThumbAttrs; - // Extends getThumbAttrs() with: aria-label (from props.label, default "Seek"), + override getAttrs(state: TimeSliderState); + // Extends super.getAttrs() with: aria-label (from props.label, default "Seek"), // aria-valuetext="2 minutes, 30 seconds of 10 minutes" - - formatValue(value: number): string; - // Returns formatted time string (e.g., "1:30") + // Inlined return type. } ``` -```ts -interface TimeMediaState { - currentTime: number; - duration: number; - seeking: boolean; - bufferedEnd: number; // end of last buffered range -} -``` +Uses `MediaTimeState & MediaBufferState` from `@videojs/core` directly — no custom `TimeMediaState` wrapper. `TimeSliderState` uses `Pick<>` to select the specific fields it exposes (`currentTime`, `duration`, `seeking`), keeping the state interface focused. -Core owns the value swap: when not dragging, `value` = `currentTime`. When dragging, `value` = `valueFromPercent(interaction.dragPercent)`. This domain logic lives in Core so both frameworks get it for free. +Core owns the value swap: when not dragging, `value` = `currentTime`. When dragging, `value` = `valueFromPercent(interaction.dragPercent)`. `bufferedEnd` is computed internally from `media.buffered` (the end of the last buffered range). This domain logic lives in Core so both frameworks get it for free. ### Time Formatting -Uses `TimeCore` (already exists in `@videojs/core`) for `aria-valuetext`. On initialization and focus, the value text follows the pattern `"{current} of {duration}"`. During value changes, duration is omitted (`"{current}"` only) to reduce screen reader verbosity. Each uses a human-readable phrase from `formatTimeAsPhrase()`. See [decisions.md](decisions.md#time-slider-aria-valuetext-format). +Uses `formatTimeAsPhrase()` from `@videojs/utils/time` for `aria-valuetext`. The value text follows the pattern `"{current} of {duration}"` using human-readable phrases. See [decisions.md](decisions.md#time-slider-aria-valuetext-format). ## VolumeSliderCore @@ -291,32 +274,26 @@ interface VolumeSliderProps extends SliderProps { label?: string; // default: 'Volume' } -interface VolumeSliderState extends SliderState { - volume: number; - muted: boolean; -} +interface VolumeSliderState extends SliderState, Pick {} class VolumeSliderCore extends SliderCore { - getVolumeState(interaction: SliderInteraction, media: VolumeMediaState): VolumeSliderState; + getVolumeState(media: MediaVolumeState, interaction: SliderInteraction): VolumeSliderState; + // Accepts canonical MediaVolumeState directly — no wrapper interface. // Core owns the value swap: dragging ? valueFromPercent(dragPercent) : volume * 100. // When muted: `value` = actual volume * 100 (always reflects real level), - // `fillPercent` = muted ? 0 : percentFromValue(value) (visual silence). + // `fillPercent` = muted ? 0 : base.fillPercent (visual silence). // `aria-valuenow` uses `value` (actual volume), not `fillPercent`. - getVolumeThumbAttrs(state: VolumeSliderState): VolumeSliderThumbAttrs; - // Extends getThumbAttrs() with: aria-label (from props.label, default "Volume"), + override getAttrs(state: VolumeSliderState); + // Extends super.getAttrs() with: aria-label (from props.label, default "Volume"), // aria-valuetext="75 percent" (or "75 percent, muted" when muted) + // Inlined return type. } ``` -```ts -interface VolumeMediaState { - volume: number; // 0-1 from store - muted: boolean; -} -``` +Uses `MediaVolumeState` from `@videojs/core` directly — no custom `VolumeMediaState` wrapper. `VolumeSliderState` uses `Pick<>` to select the specific fields it exposes (`volume`, `muted`). -Default props override: `min=0, max=100, step=5, largeStep=10, orientation='horizontal'`. +Default props: `min=0, max=100, step=1, largeStep=10, orientation='horizontal'`. Volume is stored as 0-1 in the store but displayed as 0-100 in the slider. `VolumeSliderCore` handles this conversion. @@ -442,7 +419,7 @@ All via `onKeyDown` on the **Thumb** element (the focusable `role="slider"` elem **RTL direction:** `createSlider` accepts an `isRTL` callback. When RTL, `ArrowRight` subtracts `stepPercent` (decreases) and `ArrowLeft` adds `stepPercent` (increases). `ArrowUp`/`ArrowDown` are unaffected. Both Base UI and Vidstack implement this. -**Round before stepping:** Before computing the next value from a keyboard step, the current value is rounded to the nearest step via `roundValueToStep()`. This prevents drift when the current value isn't aligned to a step boundary (e.g., after a pointer drag landed between steps). See [Value Snapping Precision](#value-snapping-precision). +**Round before stepping:** Before computing the next value from a keyboard step, the current value is rounded to the nearest step via `roundToStep()` (from `@videojs/utils/number`). This prevents drift when the current value isn't aligned to a step boundary (e.g., after a pointer drag landed between steps). See [Value Snapping Precision](#value-snapping-precision). **Numeric keys** match YouTube behavior (0 = start, 5 = midpoint, 9 = 90%). Only active when `metaKey` is not held. @@ -478,7 +455,7 @@ interaction.current onValueChange(%) onValueCommit(%) │ const media = usePlayer(selectTimeAndBuffer); │ │ const state = core.getTimeState(interaction, media); │ │ const cssVars = getTimeSliderCSSVars(state); │ -│ const thumbAttrs = core.getTimeThumbAttrs(state); │ +│ const thumbAttrs = core.getAttrs(state); │ │ │ │ → CSS vars on Root │ │ → Data attrs on Root + children (via context) │ diff --git a/internal/design/ui/slider/decisions.md b/internal/design/ui/slider/decisions.md index ad72585b..d0e4d268 100644 --- a/internal/design/ui/slider/decisions.md +++ b/internal/design/ui/slider/decisions.md @@ -345,7 +345,7 @@ slider.interaction.subscribe(cb); // notified on change ### Core Accepts Split (Interaction, Media) Inputs -**Decision:** `SliderCore.getState()` accepts `SliderInteraction` and a value separately. Domain cores (`TimeSliderCore`, `VolumeSliderCore`) accept `(interaction, media)` where media is a domain-specific type (`TimeMediaState`, `VolumeMediaState`). Core owns the merge logic — e.g., the value swap (`dragging ? valueFromPercent(dragPercent) : currentTime`). +**Decision:** `SliderCore.getState()` accepts `SliderInteraction` and a value separately. Domain cores (`TimeSliderCore`, `VolumeSliderCore`) accept `(media, interaction)` where media is the canonical type from `@videojs/core` (`MediaTimeState & MediaBufferState`, `MediaVolumeState`). Core owns the merge logic — e.g., the value swap (`dragging ? valueFromPercent(dragPercent) : currentTime`). Media is the first parameter since it's the primary input; interaction is secondary context. **Alternatives:** @@ -419,7 +419,7 @@ This follows the [WAI-ARIA Slider Pattern](https://www.w3.org/WAI/ARIA/apg/patte ### Domain Sliders Set aria-label and aria-valuetext -**Decision:** Generic `SliderCore.getThumbAttrs()` sets `role`, `tabIndex`, `aria-valuemin/max/now`, `aria-orientation`, `aria-disabled` on the Thumb element. Domain cores (`TimeSliderCore`, `VolumeSliderCore`) extend with `aria-label` and `aria-valuetext`. Domain Roots accept a `label` prop (default `"Seek"` / `"Volume"`) that feeds into `aria-label`, and provide the complete ARIA attrs to Thumb via context. +**Decision:** Generic `SliderCore.getAttrs()` sets `role`, `tabindex`, `autocomplete`, `aria-valuemin/max/now`, `aria-orientation`, `aria-disabled` on the Thumb element. Domain cores (`TimeSliderCore`, `VolumeSliderCore`) extend via `override getAttrs()` with `aria-label` and `aria-valuetext`. Domain Roots accept a `label` prop (default `"Seek"` / `"Volume"`) that feeds into `aria-label`, and provide the complete ARIA attrs to Thumb via context. **Alternatives:** diff --git a/internal/design/ui/slider/index.md b/internal/design/ui/slider/index.md index 4889f857..dc951b9c 100644 --- a/internal/design/ui/slider/index.md +++ b/internal/design/ui/slider/index.md @@ -192,8 +192,8 @@ Keyboard events are handled by the **Thumb** element (the focusable `role="slide | `0`–`9` | Jump to 0%–90% of duration | Jump to 0%–90% of range | Default step values: -- Time slider: `step = 5` (seconds), `largeStep = 10` (seconds) -- Volume slider: `step = 5` (%), `largeStep = 10` (%) +- Time slider: `step = 1` (second), `largeStep = 10` (seconds) +- Volume slider: `step = 1` (%), `largeStep = 10` (%) Customizable via `step` and `largeStep` props on the Root. diff --git a/internal/design/ui/slider/parts.md b/internal/design/ui/slider/parts.md index 6fcb435e..98b12710 100644 --- a/internal/design/ui/slider/parts.md +++ b/internal/design/ui/slider/parts.md @@ -237,7 +237,7 @@ Focusable slider handle. Carries `role="slider"`, `tabindex="0"`, all ARIA attri #### ARIA (automatic) -Set by `SliderCore.getThumbAttrs()`. Domain cores extend with `aria-label` and `aria-valuetext`. +Set by `SliderCore.getAttrs()`. Domain cores extend via `override getAttrs()` with `aria-label` and `aria-valuetext`. | Attribute | Source | | --------- | ------ | @@ -451,7 +451,7 @@ import { TimeSlider } from '@videojs/react'; | Prop | Type | Default | Description | | ---- | ---- | ------- | ----------- | | `label` | `string` | `'Seek'` | Accessible label for the slider. Sets `aria-label` on Thumb. | -| `step` | `number` | `5` | Arrow key step in seconds. Drag precision is handled internally (sub-second). | +| `step` | `number` | `1` | Arrow key step in seconds. Drag uses raw precision (no step snapping) for smooth scrubbing. | | `largeStep` | `number` | `10` | Shift+Arrow / Page Up/Down step in seconds. | | `seekThrottle` | `number` | `100` | Trailing-edge throttle (ms) for seek requests during drag. `0` disables throttling. | | `disabled` | `boolean` | `false` | Disables interaction. | @@ -492,7 +492,7 @@ Inherited by all children (including `data-seeking`). - **Value formatting:** Provides time formatter (`formatTime`) to `Slider.Value` children — `type="current"` shows formatted current time (`1:30`), `type="pointer"` shows formatted pointer time. - **Data attributes:** All slider data attributes + `data-seeking` are propagated to children. - **ARIA for Thumb:** Provides domain-specific ARIA attrs to `Slider.Thumb` via context. -- **Keyboard step values:** `step` defaults to `5` (seconds), `largeStep` defaults to `10` (seconds). +- **Keyboard step values:** `step` defaults to `1` (second), `largeStep` defaults to `10` (seconds). #### ARIA (on Thumb) @@ -565,7 +565,7 @@ import { VolumeSlider } from '@videojs/react'; | ---- | ---- | ------- | ----------- | | `label` | `string` | `'Volume'` | Accessible label for the slider. Sets `aria-label` on Thumb. | | `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Layout direction. | -| `step` | `number` | `5` | Arrow key step as percentage (0-100). | +| `step` | `number` | `1` | Arrow key step as percentage (0-100). | | `largeStep` | `number` | `10` | Shift+Arrow / Page Up/Down step as percentage. | | `disabled` | `boolean` | `false` | Disables interaction. | | `thumbAlignment` | `'center' \| 'edge'` | `'center'` | How the thumb aligns at min/max. See [Slider.Root `thumbAlignment`](#props). | @@ -592,7 +592,7 @@ No `value` / `onValueChange` — managed from store. - **Value formatting:** Provides percentage formatter to `Slider.Value` children — displays `75%`. - **Data attributes:** All slider data attributes are propagated to children. - **ARIA for Thumb:** Provides domain-specific ARIA attrs to `Slider.Thumb` via context. -- **Keyboard step values:** `step` defaults to `5` (%), `largeStep` defaults to `10` (%). +- **Keyboard step values:** `step` defaults to `1` (%), `largeStep` defaults to `10` (%). #### ARIA (on Thumb) diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index f051a080..24598f98 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -15,6 +15,12 @@ export * from './ui/poster/poster-core'; export * from './ui/poster/poster-data-attrs'; export * from './ui/seek-button/seek-button-core'; export * from './ui/seek-button/seek-button-data-attrs'; +export * from './ui/slider/slider-core'; +export * from './ui/slider/slider-css-vars'; +export * from './ui/slider/slider-data-attrs'; +export * from './ui/slider/time-slider-core'; +export * from './ui/slider/time-slider-data-attrs'; +export * from './ui/slider/volume-slider-core'; 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/slider/slider-core.ts b/packages/core/src/core/ui/slider/slider-core.ts new file mode 100644 index 00000000..00bcc44f --- /dev/null +++ b/packages/core/src/core/ui/slider/slider-core.ts @@ -0,0 +1,135 @@ +import { clamp, roundToStep } from '@videojs/utils/number'; +import { defaults } from '@videojs/utils/object'; +import type { NonNullableObject } from '@videojs/utils/types'; + +export interface SliderProps { + /** Minimum value of the slider range. */ + min?: number | undefined; + /** Maximum value of the slider range. */ + max?: number | undefined; + /** Step increment for value changes (arrow keys). */ + step?: number | undefined; + /** Large step increment (Page Up/Down keys). */ + largeStep?: number | undefined; + /** Axis of slider movement. */ + orientation?: 'horizontal' | 'vertical' | undefined; + /** Whether the slider is non-interactive. */ + disabled?: boolean | undefined; + /** How the thumb aligns at the track edges. `edge` constrains the thumb within track bounds. */ + thumbAlignment?: 'center' | 'edge' | undefined; +} + +/** Current pointer/drag interaction state, typically provided by a DOM controller. */ +export interface SliderInteraction { + /** Pointer position as a percentage of the track (0–100). */ + pointerPercent: number; + /** Drag position as a percentage of the track (0–100). */ + dragPercent: number; + /** Whether the user is actively dragging. */ + dragging: boolean; + /** Whether the pointer is over the slider. */ + pointing: boolean; + /** Whether the slider has keyboard focus. */ + focused: boolean; +} + +export interface SliderState { + /** Current slider value in the min–max range. */ + value: number; + /** Fill level as a percentage (0–100), derived from value. */ + fillPercent: number; + /** Pointer position as a percentage of the track (0–100). */ + pointerPercent: number; + /** Whether the user is actively dragging. */ + dragging: boolean; + /** Whether the pointer is over the slider. */ + pointing: boolean; + /** Whether dragging or pointing is active. */ + interactive: boolean; + /** Axis of slider movement. */ + orientation: 'horizontal' | 'vertical'; + /** Whether the slider is non-interactive. */ + disabled: boolean; + /** How the thumb aligns at the track edges. */ + thumbAlignment: 'center' | 'edge'; +} + +export class SliderCore { + static readonly defaultProps: NonNullableObject = { + min: 0, + max: 100, + step: 1, + largeStep: 10, + orientation: 'horizontal', + disabled: false, + thumbAlignment: 'center', + }; + + #props = { ...SliderCore.defaultProps }; + + constructor(props?: SliderProps) { + if (props) this.setProps(props); + } + + setProps(props: SliderProps): void { + this.#props = defaults(props, SliderCore.defaultProps); + } + + getState(interaction: SliderInteraction, value: number): SliderState { + const { orientation, disabled, thumbAlignment } = this.#props; + + return { + value, + fillPercent: this.percentFromValue(value), + pointerPercent: interaction.pointerPercent, + dragging: interaction.dragging, + pointing: interaction.pointing, + interactive: interaction.dragging || interaction.pointing || interaction.focused, + orientation, + disabled, + thumbAlignment, + }; + } + + getAttrs(state: SliderState) { + return { + role: 'slider', + tabindex: state.disabled ? -1 : 0, + autocomplete: 'off', + 'aria-valuemin': this.#props.min, + 'aria-valuemax': this.#props.max, + 'aria-valuenow': state.value, + 'aria-orientation': state.orientation, + 'aria-disabled': state.disabled ? 'true' : undefined, + }; + } + + valueFromPercent(percent: number): number { + const { min, max, step } = this.#props; + const raw = min + (percent / 100) * (max - min); + return roundToStep(clamp(raw, min, max), step, min); + } + + percentFromValue(value: number): number { + const { min, max } = this.#props; + if (max === min) return 0; + return ((value - min) / (max - min)) * 100; + } + + adjustPercentForAlignment(rawPercent: number, thumbSize: number, trackSize: number): number { + if (this.#props.thumbAlignment === 'center' || trackSize === 0) { + return rawPercent; + } + + const thumbHalf = ((thumbSize / trackSize) * 100) / 2; + const minPercent = thumbHalf; + const maxPercent = 100 - thumbHalf; + return minPercent + (rawPercent / 100) * (maxPercent - minPercent); + } +} + +export namespace SliderCore { + export type Props = SliderProps; + export type State = SliderState; + export type Interaction = SliderInteraction; +} diff --git a/packages/core/src/core/ui/slider/slider-css-vars.ts b/packages/core/src/core/ui/slider/slider-css-vars.ts new file mode 100644 index 00000000..6b89b908 --- /dev/null +++ b/packages/core/src/core/ui/slider/slider-css-vars.ts @@ -0,0 +1,9 @@ +/** CSS custom property names for slider visual state. */ +export const SliderCSSVars = { + /** Fill level percentage (0–100). */ + fill: '--media-slider-fill', + /** Pointer position percentage (0–100). */ + pointer: '--media-slider-pointer', + /** Buffer level percentage (0–100). */ + buffer: '--media-slider-buffer', +} as const; diff --git a/packages/core/src/core/ui/slider/slider-data-attrs.ts b/packages/core/src/core/ui/slider/slider-data-attrs.ts new file mode 100644 index 00000000..73a16d16 --- /dev/null +++ b/packages/core/src/core/ui/slider/slider-data-attrs.ts @@ -0,0 +1,15 @@ +import type { StateAttrMap } from '../types'; +import type { SliderState } from './slider-core'; + +export const SliderDataAttrs = { + /** Present when the user is actively dragging. */ + dragging: 'data-dragging', + /** Present when the pointer is over the slider. */ + pointing: 'data-pointing', + /** Present when dragging or pointing is active. */ + interactive: 'data-interactive', + /** Current axis of slider movement (`horizontal` or `vertical`). */ + orientation: 'data-orientation', + /** Present when the slider is non-interactive. */ + disabled: 'data-disabled', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/slider/tests/slider-core.test.ts b/packages/core/src/core/ui/slider/tests/slider-core.test.ts new file mode 100644 index 00000000..9ede30b0 --- /dev/null +++ b/packages/core/src/core/ui/slider/tests/slider-core.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest'; + +import { SliderCore, type SliderInteraction } from '../slider-core'; + +function createInteraction(overrides: Partial = {}): SliderInteraction { + return { + pointerPercent: 0, + dragPercent: 0, + dragging: false, + pointing: false, + focused: false, + ...overrides, + }; +} + +describe('SliderCore', () => { + describe('defaultProps', () => { + it('has expected defaults', () => { + expect(SliderCore.defaultProps).toEqual({ + min: 0, + max: 100, + step: 1, + largeStep: 10, + orientation: 'horizontal', + disabled: false, + thumbAlignment: 'center', + }); + }); + }); + + describe('getState', () => { + it('returns state with defaults', () => { + const core = new SliderCore(); + const state = core.getState(createInteraction(), 50); + + expect(state.value).toBe(50); + expect(state.fillPercent).toBe(50); + expect(state.pointerPercent).toBe(0); + expect(state.dragging).toBe(false); + expect(state.pointing).toBe(false); + expect(state.interactive).toBe(false); + expect(state.orientation).toBe('horizontal'); + expect(state.disabled).toBe(false); + expect(state.thumbAlignment).toBe('center'); + }); + + it('computes fillPercent from value', () => { + const core = new SliderCore({ min: 0, max: 200 }); + const state = core.getState(createInteraction(), 100); + expect(state.fillPercent).toBe(50); + }); + + it('passes through interaction state', () => { + const core = new SliderCore(); + const state = core.getState(createInteraction({ dragging: true, pointing: true, pointerPercent: 75 }), 30); + + expect(state.dragging).toBe(true); + expect(state.pointing).toBe(true); + expect(state.pointerPercent).toBe(75); + expect(state.interactive).toBe(true); + }); + + it('interactive is true when only dragging', () => { + const core = new SliderCore(); + const state = core.getState(createInteraction({ dragging: true }), 0); + expect(state.interactive).toBe(true); + }); + + it('interactive is true when only pointing', () => { + const core = new SliderCore(); + const state = core.getState(createInteraction({ pointing: true }), 0); + expect(state.interactive).toBe(true); + }); + + it('interactive is true when only focused', () => { + const core = new SliderCore(); + const state = core.getState(createInteraction({ focused: true }), 0); + expect(state.interactive).toBe(true); + }); + + it('uses custom orientation and disabled', () => { + const core = new SliderCore({ orientation: 'vertical', disabled: true }); + const state = core.getState(createInteraction(), 0); + + expect(state.orientation).toBe('vertical'); + expect(state.disabled).toBe(true); + }); + }); + + describe('getAttrs', () => { + it('returns aria attributes', () => { + const core = new SliderCore(); + const state = core.getState(createInteraction(), 50); + const attrs = core.getAttrs(state); + + expect(attrs.role).toBe('slider'); + expect(attrs.tabindex).toBe(0); + expect(attrs.autocomplete).toBe('off'); + expect(attrs['aria-valuemin']).toBe(0); + expect(attrs['aria-valuemax']).toBe(100); + expect(attrs['aria-valuenow']).toBe(50); + expect(attrs['aria-orientation']).toBe('horizontal'); + expect(attrs['aria-disabled']).toBeUndefined(); + }); + + it('sets tabindex -1 and aria-disabled when disabled', () => { + const core = new SliderCore({ disabled: true }); + const state = core.getState(createInteraction(), 0); + const attrs = core.getAttrs(state); + + expect(attrs.tabindex).toBe(-1); + expect(attrs['aria-disabled']).toBe('true'); + }); + + it('uses custom min and max', () => { + const core = new SliderCore({ min: 10, max: 50 }); + const state = core.getState(createInteraction(), 30); + const attrs = core.getAttrs(state); + + expect(attrs['aria-valuemin']).toBe(10); + expect(attrs['aria-valuemax']).toBe(50); + expect(attrs['aria-valuenow']).toBe(30); + }); + }); + + describe('valueFromPercent', () => { + it('converts 0% to min', () => { + const core = new SliderCore(); + expect(core.valueFromPercent(0)).toBe(0); + }); + + it('converts 100% to max', () => { + const core = new SliderCore(); + expect(core.valueFromPercent(100)).toBe(100); + }); + + it('converts 50% to midpoint', () => { + const core = new SliderCore(); + expect(core.valueFromPercent(50)).toBe(50); + }); + + it('respects custom min/max', () => { + const core = new SliderCore({ min: 10, max: 20 }); + expect(core.valueFromPercent(50)).toBe(15); + }); + + it('rounds to step', () => { + const core = new SliderCore({ step: 5 }); + expect(core.valueFromPercent(53)).toBe(55); + expect(core.valueFromPercent(47)).toBe(45); + }); + + it('clamps to range', () => { + const core = new SliderCore(); + expect(core.valueFromPercent(-10)).toBe(0); + expect(core.valueFromPercent(110)).toBe(100); + }); + }); + + describe('percentFromValue', () => { + it('returns 0 at min', () => { + const core = new SliderCore(); + expect(core.percentFromValue(0)).toBe(0); + }); + + it('returns 100 at max', () => { + const core = new SliderCore(); + expect(core.percentFromValue(100)).toBe(100); + }); + + it('returns 50 at midpoint', () => { + const core = new SliderCore(); + expect(core.percentFromValue(50)).toBe(50); + }); + + it('handles custom min/max', () => { + const core = new SliderCore({ min: 50, max: 150 }); + expect(core.percentFromValue(100)).toBe(50); + }); + + it('returns 0 when min equals max', () => { + const core = new SliderCore({ min: 50, max: 50 }); + expect(core.percentFromValue(50)).toBe(0); + }); + }); + + describe('adjustPercentForAlignment', () => { + it('returns raw percent for center alignment', () => { + const core = new SliderCore({ thumbAlignment: 'center' }); + expect(core.adjustPercentForAlignment(50, 20, 200)).toBe(50); + }); + + it('returns raw percent when track size is 0', () => { + const core = new SliderCore({ thumbAlignment: 'edge' }); + expect(core.adjustPercentForAlignment(50, 20, 0)).toBe(50); + }); + + it('adjusts percent for edge alignment', () => { + const core = new SliderCore({ thumbAlignment: 'edge' }); + // thumbSize=20, trackSize=200 → thumbHalf = (20/200)*100/2 = 5% + // minPercent=5, maxPercent=95 + // result = 5 + (50/100) * (95-5) = 5 + 45 = 50 + expect(core.adjustPercentForAlignment(50, 20, 200)).toBe(50); + + // 0% → minPercent = 5 + expect(core.adjustPercentForAlignment(0, 20, 200)).toBe(5); + + // 100% → maxPercent = 95 + expect(core.adjustPercentForAlignment(100, 20, 200)).toBe(95); + }); + }); + + describe('setProps', () => { + it('updates props after construction', () => { + const core = new SliderCore(); + core.setProps({ min: 10, max: 50 }); + + const state = core.getState(createInteraction(), 30); + const attrs = core.getAttrs(state); + + expect(attrs['aria-valuemin']).toBe(10); + expect(attrs['aria-valuemax']).toBe(50); + expect(state.fillPercent).toBe(50); // (30-10)/(50-10) * 100 + }); + }); +}); diff --git a/packages/core/src/core/ui/slider/tests/time-slider-core.test.ts b/packages/core/src/core/ui/slider/tests/time-slider-core.test.ts new file mode 100644 index 00000000..e1d8fa6b --- /dev/null +++ b/packages/core/src/core/ui/slider/tests/time-slider-core.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { MediaBufferState, MediaTimeState } from '../../../media/state'; +import type { SliderInteraction } from '../slider-core'; +import { TimeSliderCore } from '../time-slider-core'; + +type TimeSliderMedia = MediaTimeState & MediaBufferState; + +function createInteraction(overrides: Partial = {}): SliderInteraction { + return { + pointerPercent: 0, + dragPercent: 0, + dragging: false, + pointing: false, + focused: false, + ...overrides, + }; +} + +function createMediaState(overrides: Partial = {}): TimeSliderMedia { + return { + currentTime: 0, + duration: 300, + seeking: false, + seek: vi.fn(async (t: number) => t), + buffered: [], + seekable: [], + ...overrides, + }; +} + +describe('TimeSliderCore', () => { + describe('defaultProps', () => { + it('extends SliderCore defaults with label', () => { + expect(TimeSliderCore.defaultProps.label).toBe('Seek'); + expect(TimeSliderCore.defaultProps.min).toBe(0); + expect(TimeSliderCore.defaultProps.max).toBe(100); + }); + }); + + describe('getTimeState', () => { + it('uses currentTime as value when not dragging', () => { + const core = new TimeSliderCore(); + const state = core.getTimeState(createMediaState({ currentTime: 90, duration: 300 }), createInteraction()); + + expect(state.value).toBe(90); + expect(state.currentTime).toBe(90); + expect(state.duration).toBe(300); + expect(state.fillPercent).toBe(30); // 90/300 * 100 + }); + + it('uses drag percent for value when dragging', () => { + const core = new TimeSliderCore(); + const state = core.getTimeState( + createMediaState({ currentTime: 90, duration: 300 }), + createInteraction({ dragging: true, dragPercent: 50 }) + ); + + expect(state.value).toBe(150); // 50% of 300 + expect(state.dragging).toBe(true); + expect(state.currentTime).toBe(90); // unchanged + }); + + it('uses raw precision during drag for smooth scrubbing', () => { + const core = new TimeSliderCore(); + const state = core.getTimeState( + createMediaState({ currentTime: 0, duration: 10 }), + createInteraction({ dragging: true, dragPercent: 33.333 }) + ); + + // 33.333% of 10 = 3.3333, NOT snapped to step (which would be 3) + expect(state.value).toBeCloseTo(3.3333, 3); + }); + + it('computes buffer percent from buffered ranges', () => { + const core = new TimeSliderCore(); + const state = core.getTimeState(createMediaState({ duration: 200, buffered: [[0, 100]] }), createInteraction()); + + expect(state.bufferPercent).toBe(50); // 100/200 * 100 + }); + + it('uses end of the furthest buffered range', () => { + const core = new TimeSliderCore(); + const state = core.getTimeState( + createMediaState({ + duration: 200, + buffered: [ + [0, 50], + [60, 150], + ], + }), + createInteraction() + ); + + expect(state.bufferPercent).toBe(75); // 150/200 * 100 + }); + + it('returns 0 buffer percent when no buffered ranges', () => { + const core = new TimeSliderCore(); + const state = core.getTimeState(createMediaState({ duration: 200, buffered: [] }), createInteraction()); + + expect(state.bufferPercent).toBe(0); + }); + + it('returns 0 buffer percent when duration is 0', () => { + const core = new TimeSliderCore(); + const state = core.getTimeState(createMediaState({ duration: 0, buffered: [] }), createInteraction()); + + expect(state.bufferPercent).toBe(0); + }); + + it('passes through seeking state', () => { + const core = new TimeSliderCore(); + const state = core.getTimeState(createMediaState({ seeking: true }), createInteraction()); + + expect(state.seeking).toBe(true); + }); + + it('sets min to 0 and max to duration', () => { + const core = new TimeSliderCore(); + const state = core.getTimeState(createMediaState({ duration: 600 }), createInteraction()); + + const attrs = core.getAttrs(state); + expect(attrs['aria-valuemin']).toBe(0); + expect(attrs['aria-valuemax']).toBe(600); + }); + + it('handles zero duration', () => { + const core = new TimeSliderCore(); + const state = core.getTimeState(createMediaState({ currentTime: 0, duration: 0 }), createInteraction()); + + expect(state.fillPercent).toBe(0); + expect(state.value).toBe(0); + }); + }); + + describe('getAttrs', () => { + it('returns aria-label and aria-valuetext', () => { + const core = new TimeSliderCore(); + const state = core.getTimeState(createMediaState({ currentTime: 90, duration: 300 }), createInteraction()); + const attrs = core.getAttrs(state); + + expect(attrs['aria-label']).toBe('Seek'); + expect(attrs['aria-valuetext']).toBe('1 minute, 30 seconds of 5 minutes'); + expect(attrs.role).toBe('slider'); + }); + + it('uses custom label', () => { + const core = new TimeSliderCore({ label: 'Scrub' }); + const state = core.getTimeState(createMediaState({ currentTime: 0, duration: 300 }), createInteraction()); + const attrs = core.getAttrs(state); + + expect(attrs['aria-label']).toBe('Scrub'); + }); + + it('shows both times when duration is 0', () => { + const core = new TimeSliderCore(); + const state = core.getTimeState(createMediaState({ currentTime: 0, duration: 0 }), createInteraction()); + const attrs = core.getAttrs(state); + + expect(attrs['aria-valuetext']).toBe('0 seconds of 0 seconds'); + }); + + it('includes dragged value in valuetext', () => { + const core = new TimeSliderCore(); + const state = core.getTimeState( + createMediaState({ currentTime: 0, duration: 300 }), + createInteraction({ dragging: true, dragPercent: 50 }) + ); + const attrs = core.getAttrs(state); + + // value is 150 (50% of 300) → "2 minutes, 30 seconds of 5 minutes" + expect(attrs['aria-valuetext']).toBe('2 minutes, 30 seconds of 5 minutes'); + }); + }); + + describe('setProps', () => { + it('updates label', () => { + const core = new TimeSliderCore(); + core.setProps({ label: 'Progress' }); + + const state = core.getTimeState(createMediaState({ currentTime: 0, duration: 100 }), createInteraction()); + const attrs = core.getAttrs(state); + + expect(attrs['aria-label']).toBe('Progress'); + }); + }); +}); diff --git a/packages/core/src/core/ui/slider/tests/volume-slider-core.test.ts b/packages/core/src/core/ui/slider/tests/volume-slider-core.test.ts new file mode 100644 index 00000000..76b359bf --- /dev/null +++ b/packages/core/src/core/ui/slider/tests/volume-slider-core.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { MediaVolumeState } from '../../../media/state'; +import type { SliderInteraction } from '../slider-core'; +import { VolumeSliderCore } from '../volume-slider-core'; + +function createInteraction(overrides: Partial = {}): SliderInteraction { + return { + pointerPercent: 0, + dragPercent: 0, + dragging: false, + pointing: false, + focused: false, + ...overrides, + }; +} + +function createMediaState(overrides: Partial = {}): MediaVolumeState { + return { + volume: 1, + muted: false, + volumeAvailability: 'available', + changeVolume: vi.fn((v: number) => v), + toggleMute: vi.fn(() => false), + ...overrides, + }; +} + +describe('VolumeSliderCore', () => { + describe('defaultProps', () => { + it('extends SliderCore defaults with label', () => { + expect(VolumeSliderCore.defaultProps.label).toBe('Volume'); + expect(VolumeSliderCore.defaultProps.min).toBe(0); + expect(VolumeSliderCore.defaultProps.max).toBe(100); + }); + }); + + describe('getVolumeState', () => { + it('maps volume 0-1 to 0-100 percent', () => { + const core = new VolumeSliderCore(); + const state = core.getVolumeState(createMediaState({ volume: 0.75 }), createInteraction()); + + expect(state.value).toBe(75); + expect(state.fillPercent).toBe(75); + expect(state.volume).toBe(0.75); + }); + + it('returns 0 when volume is 0', () => { + const core = new VolumeSliderCore(); + const state = core.getVolumeState(createMediaState({ volume: 0 }), createInteraction()); + + expect(state.value).toBe(0); + expect(state.fillPercent).toBe(0); + }); + + it('returns 100 when volume is 1', () => { + const core = new VolumeSliderCore(); + const state = core.getVolumeState(createMediaState({ volume: 1 }), createInteraction()); + + expect(state.value).toBe(100); + expect(state.fillPercent).toBe(100); + }); + + it('sets fillPercent to 0 when muted', () => { + const core = new VolumeSliderCore(); + const state = core.getVolumeState(createMediaState({ volume: 0.75, muted: true }), createInteraction()); + + expect(state.value).toBe(75); + expect(state.fillPercent).toBe(0); + expect(state.muted).toBe(true); + }); + + it('uses drag percent for value when dragging', () => { + const core = new VolumeSliderCore(); + const state = core.getVolumeState( + createMediaState({ volume: 0.75 }), + createInteraction({ dragging: true, dragPercent: 40 }) + ); + + expect(state.value).toBe(40); + expect(state.dragging).toBe(true); + expect(state.volume).toBe(0.75); // unchanged + }); + + it('preserves muted state', () => { + const core = new VolumeSliderCore(); + const state = core.getVolumeState(createMediaState({ volume: 0.5, muted: false }), createInteraction()); + + expect(state.muted).toBe(false); + }); + }); + + describe('getAttrs', () => { + it('returns aria-label and aria-valuetext', () => { + const core = new VolumeSliderCore(); + const state = core.getVolumeState(createMediaState({ volume: 0.75 }), createInteraction()); + const attrs = core.getAttrs(state); + + expect(attrs['aria-label']).toBe('Volume'); + expect(attrs['aria-valuetext']).toBe('75 percent'); + expect(attrs.role).toBe('slider'); + }); + + it('includes muted in valuetext when muted', () => { + const core = new VolumeSliderCore(); + const state = core.getVolumeState(createMediaState({ volume: 0.5, muted: true }), createInteraction()); + const attrs = core.getAttrs(state); + + expect(attrs['aria-valuetext']).toBe('50 percent, muted'); + }); + + it('rounds value in valuetext', () => { + const core = new VolumeSliderCore(); + const state = core.getVolumeState(createMediaState({ volume: 0.333 }), createInteraction()); + const attrs = core.getAttrs(state); + + expect(attrs['aria-valuetext']).toBe('33 percent'); + }); + + it('uses custom label', () => { + const core = new VolumeSliderCore({ label: 'Audio' }); + const state = core.getVolumeState(createMediaState({ volume: 1 }), createInteraction()); + const attrs = core.getAttrs(state); + + expect(attrs['aria-label']).toBe('Audio'); + }); + + it('shows 0 percent when volume is 0', () => { + const core = new VolumeSliderCore(); + const state = core.getVolumeState(createMediaState({ volume: 0 }), createInteraction()); + const attrs = core.getAttrs(state); + + expect(attrs['aria-valuetext']).toBe('0 percent'); + }); + }); + + describe('setProps', () => { + it('updates label', () => { + const core = new VolumeSliderCore(); + core.setProps({ label: 'Sound' }); + + const state = core.getVolumeState(createMediaState({ volume: 0.5 }), createInteraction()); + const attrs = core.getAttrs(state); + + expect(attrs['aria-label']).toBe('Sound'); + }); + }); +}); diff --git a/packages/core/src/core/ui/slider/time-slider-core.ts b/packages/core/src/core/ui/slider/time-slider-core.ts new file mode 100644 index 00000000..40565191 --- /dev/null +++ b/packages/core/src/core/ui/slider/time-slider-core.ts @@ -0,0 +1,77 @@ +import { clamp } from '@videojs/utils/number'; +import { defaults } from '@videojs/utils/object'; +import { formatTimeAsPhrase } from '@videojs/utils/time'; +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { MediaBufferState, MediaTimeState } from '../../media/state'; +import { SliderCore, type SliderInteraction, type SliderProps, type SliderState } from './slider-core'; + +export interface TimeSliderProps extends SliderProps { + /** Accessible label for the slider. */ + label?: string | undefined; +} + +export interface TimeSliderState extends SliderState, Pick { + /** Buffered amount as a percentage of duration (0–100). */ + bufferPercent: number; +} + +export class TimeSliderCore extends SliderCore { + static override readonly defaultProps: NonNullableObject = { + ...SliderCore.defaultProps, + label: 'Seek', + }; + + #props = { ...TimeSliderCore.defaultProps }; + + constructor(props?: TimeSliderProps) { + super(); + if (props) this.setProps(props); + } + + override setProps(props: TimeSliderProps): void { + this.#props = defaults(props, TimeSliderCore.defaultProps); + super.setProps({ ...props, min: 0 }); + } + + getTimeState(media: MediaTimeState & MediaBufferState, interaction: SliderInteraction): TimeSliderState { + const { duration, currentTime, seeking, buffered } = media; + + // Override min/max for time domain + super.setProps({ min: 0, max: duration, step: this.#props.step, largeStep: this.#props.largeStep }); + + // Raw precision during drag for smooth scrubbing — step snapping only applies to keyboard. + const value = interaction.dragging ? clamp((interaction.dragPercent / 100) * duration, 0, duration) : currentTime; + const base = super.getState(interaction, value); + + // Use end of the furthest buffered range + const bufferedEnd = buffered.length > 0 ? buffered[buffered.length - 1]![1] : 0; + const bufferPercent = duration > 0 ? (bufferedEnd / duration) * 100 : 0; + + return { + ...base, + currentTime, + duration, + seeking, + bufferPercent, + }; + } + + override getAttrs(state: TimeSliderState) { + const base = super.getAttrs(state); + const currentPhrase = formatTimeAsPhrase(state.value); + const durationPhrase = formatTimeAsPhrase(state.duration); + const valuetext = durationPhrase ? `${currentPhrase} of ${durationPhrase}` : currentPhrase; + + return { + ...base, + 'aria-label': this.#props.label, + 'aria-valuetext': valuetext, + }; + } +} + +export namespace TimeSliderCore { + export type Props = TimeSliderProps; + export type State = TimeSliderState; +} diff --git a/packages/core/src/core/ui/slider/time-slider-data-attrs.ts b/packages/core/src/core/ui/slider/time-slider-data-attrs.ts new file mode 100644 index 00000000..06f817ec --- /dev/null +++ b/packages/core/src/core/ui/slider/time-slider-data-attrs.ts @@ -0,0 +1,9 @@ +import type { StateAttrMap } from '../types'; +import { SliderDataAttrs } from './slider-data-attrs'; +import type { TimeSliderState } from './time-slider-core'; + +export const TimeSliderDataAttrs = { + ...SliderDataAttrs, + /** Present when a seek operation is in progress. */ + seeking: 'data-seeking', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/slider/volume-slider-core.ts b/packages/core/src/core/ui/slider/volume-slider-core.ts new file mode 100644 index 00000000..e7d31819 --- /dev/null +++ b/packages/core/src/core/ui/slider/volume-slider-core.ts @@ -0,0 +1,61 @@ +import { defaults } from '@videojs/utils/object'; +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { MediaVolumeState } from '../../media/state'; +import { SliderCore, type SliderInteraction, type SliderProps, type SliderState } from './slider-core'; + +export interface VolumeSliderProps extends SliderProps { + /** Accessible label for the slider. */ + label?: string | undefined; +} + +export interface VolumeSliderState extends SliderState, Pick {} + +export class VolumeSliderCore extends SliderCore { + static override readonly defaultProps: NonNullableObject = { + ...SliderCore.defaultProps, + label: 'Volume', + }; + + #props = { ...VolumeSliderCore.defaultProps }; + + constructor(props?: VolumeSliderProps) { + super(); + if (props) this.setProps(props); + } + + override setProps(props: VolumeSliderProps): void { + this.#props = defaults(props, VolumeSliderCore.defaultProps); + super.setProps(props); + } + + getVolumeState(media: MediaVolumeState, interaction: SliderInteraction): VolumeSliderState { + const { volume, muted } = media; + const volumePercent = volume * 100; + const value = interaction.dragging ? this.valueFromPercent(interaction.dragPercent) : volumePercent; + const base = super.getState(interaction, value); + + return { + ...base, + fillPercent: muted ? 0 : base.fillPercent, + volume, + muted, + }; + } + + override getAttrs(state: VolumeSliderState) { + const base = super.getAttrs(state); + const valuetext = `${Math.round(state.value)} percent${state.muted ? ', muted' : ''}`; + + return { + ...base, + 'aria-label': this.#props.label, + 'aria-valuetext': valuetext, + }; + } +} + +export namespace VolumeSliderCore { + export type Props = VolumeSliderProps; + export type State = VolumeSliderState; +} diff --git a/packages/utils/package.json b/packages/utils/package.json index 5e25dbaf..56f90157 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -34,6 +34,10 @@ "types": "./dist/time.d.ts", "default": "./dist/time.js" }, + "./number": { + "types": "./dist/number.d.ts", + "default": "./dist/number.js" + }, "./types": { "types": "./dist/types.d.ts", "default": "./dist/types.js" diff --git a/packages/utils/src/dom/direction.ts b/packages/utils/src/dom/direction.ts new file mode 100644 index 00000000..8a85cea5 --- /dev/null +++ b/packages/utils/src/dom/direction.ts @@ -0,0 +1,6 @@ +/** Check whether an element's text direction is right-to-left. */ +export function isRTL(element: Element): boolean { + const dir = element.closest('[dir]')?.getAttribute('dir'); + if (dir) return dir.toLowerCase() === 'rtl'; + return getComputedStyle(element).direction === 'rtl'; +} diff --git a/packages/utils/src/dom/index.ts b/packages/utils/src/dom/index.ts index 3921b1fe..02c6082d 100644 --- a/packages/utils/src/dom/index.ts +++ b/packages/utils/src/dom/index.ts @@ -1,8 +1,10 @@ export { animationFrame } from './animation-frame'; +export { isRTL } from './direction'; export { type OnEventOptions, onEvent } from './event'; export { idleCallback } from './idle-callback'; export { listen } from './listen'; export { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from './predicates'; +export { type RafThrottled, rafThrottle } from './raf-throttle'; export { getSlottedElement, querySlot } from './slotted'; export { supportsAnimationFrame, supportsIdleCallback } from './supports'; export { serializeTimeRanges } from './time-ranges'; diff --git a/packages/utils/src/dom/raf-throttle.ts b/packages/utils/src/dom/raf-throttle.ts new file mode 100644 index 00000000..20cde99e --- /dev/null +++ b/packages/utils/src/dom/raf-throttle.ts @@ -0,0 +1,30 @@ +/** A throttled function that can be cancelled. */ +export interface RafThrottled { + (...args: Args): void; + /** Cancel any pending animation frame. */ + cancel(): void; +} + +/** Throttle a function to fire at most once per animation frame. */ +export function rafThrottle(fn: (...args: Args) => void): RafThrottled { + let rafId: number | null = null; + let latestArgs: Args; + + const throttled = (...args: Args): void => { + latestArgs = args; + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + fn(...latestArgs); + }); + }; + + throttled.cancel = (): void => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + }; + + return throttled; +} diff --git a/packages/utils/src/dom/tests/direction.test.ts b/packages/utils/src/dom/tests/direction.test.ts new file mode 100644 index 00000000..54b4a4e2 --- /dev/null +++ b/packages/utils/src/dom/tests/direction.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { isRTL } from '../direction'; + +describe('isRTL', () => { + afterEach(() => { + document.body.innerHTML = ''; + document.documentElement.removeAttribute('dir'); + }); + + it('returns false for default LTR element', () => { + const el = document.createElement('div'); + document.body.appendChild(el); + + expect(isRTL(el)).toBe(false); + }); + + it('returns true when ancestor has dir="rtl"', () => { + const parent = document.createElement('div'); + parent.setAttribute('dir', 'rtl'); + const child = document.createElement('div'); + parent.appendChild(child); + document.body.appendChild(parent); + + expect(isRTL(child)).toBe(true); + }); + + it('returns true when element has dir="rtl"', () => { + const el = document.createElement('div'); + el.setAttribute('dir', 'rtl'); + document.body.appendChild(el); + + expect(isRTL(el)).toBe(true); + }); +}); diff --git a/packages/utils/src/dom/tests/raf-throttle.test.ts b/packages/utils/src/dom/tests/raf-throttle.test.ts new file mode 100644 index 00000000..e5d22b68 --- /dev/null +++ b/packages/utils/src/dom/tests/raf-throttle.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { rafThrottle } from '../raf-throttle'; + +describe('rafThrottle', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('calls function on next animation frame', () => { + const fn = vi.fn(); + const throttled = rafThrottle(fn); + + throttled(1); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(16); + expect(fn).toHaveBeenCalledWith(1); + }); + + it('collapses multiple calls into one with latest args', () => { + const fn = vi.fn(); + const throttled = rafThrottle(fn); + + throttled(1); + throttled(2); + throttled(3); + + vi.advanceTimersByTime(16); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith(3); + }); + + it('cancel prevents pending call', () => { + const fn = vi.fn(); + const throttled = rafThrottle(fn); + + throttled(1); + throttled.cancel(); + + vi.advanceTimersByTime(16); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('works after cancel', () => { + const fn = vi.fn(); + const throttled = rafThrottle(fn); + + throttled(1); + throttled.cancel(); + + throttled(2); + vi.advanceTimersByTime(16); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith(2); + }); +}); diff --git a/packages/utils/src/number/index.ts b/packages/utils/src/number/index.ts new file mode 100644 index 00000000..6655a18d --- /dev/null +++ b/packages/utils/src/number/index.ts @@ -0,0 +1 @@ +export { clamp, roundToStep } from './number'; diff --git a/packages/utils/src/number/number.ts b/packages/utils/src/number/number.ts new file mode 100644 index 00000000..3caadb08 --- /dev/null +++ b/packages/utils/src/number/number.ts @@ -0,0 +1,11 @@ +/** Clamp a value between min and max (inclusive). */ +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** Snap a value to the nearest step, offset from min. */ +export function roundToStep(value: number, step: number, min: number): number { + const nearest = Math.round((value - min) / step) * step + min; + const dot = `${step}`.indexOf('.'); + return dot === -1 ? nearest : Number(nearest.toFixed(`${step}`.length - dot - 1)); +} diff --git a/packages/utils/src/number/tests/number.test.ts b/packages/utils/src/number/tests/number.test.ts new file mode 100644 index 00000000..cca25957 --- /dev/null +++ b/packages/utils/src/number/tests/number.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { clamp, roundToStep } from '../number'; + +describe('clamp', () => { + it('returns value when within range', () => { + expect(clamp(5, 0, 10)).toBe(5); + }); + + it('clamps to min', () => { + expect(clamp(-5, 0, 10)).toBe(0); + }); + + it('clamps to max', () => { + expect(clamp(15, 0, 10)).toBe(10); + }); + + it('returns min when min equals max', () => { + expect(clamp(5, 3, 3)).toBe(3); + }); + + it('handles negative ranges', () => { + expect(clamp(0, -10, -5)).toBe(-5); + expect(clamp(-20, -10, -5)).toBe(-10); + }); +}); + +describe('roundToStep', () => { + it('rounds to nearest step from min', () => { + expect(roundToStep(7, 5, 0)).toBe(5); + expect(roundToStep(8, 5, 0)).toBe(10); + }); + + it('respects min offset', () => { + expect(roundToStep(4, 5, 2)).toBe(2); + expect(roundToStep(6, 5, 2)).toBe(7); + }); + + it('handles decimal steps', () => { + expect(roundToStep(0.35, 0.1, 0)).toBe(0.3); + expect(roundToStep(0.36, 0.1, 0)).toBe(0.4); + expect(roundToStep(0.14, 0.1, 0)).toBe(0.1); + }); + + it('returns min when value equals min', () => { + expect(roundToStep(0, 1, 0)).toBe(0); + expect(roundToStep(5, 10, 5)).toBe(5); + }); +}); diff --git a/packages/utils/src/time/format.ts b/packages/utils/src/time/format.ts index 678be960..849abc7d 100644 --- a/packages/utils/src/time/format.ts +++ b/packages/utils/src/time/format.ts @@ -78,6 +78,10 @@ export function formatTimeAsPhrase(seconds: number): string { const m = Math.floor((positiveSeconds / 60) % 60); const s = Math.floor(positiveSeconds % 60); + if (positiveSeconds === 0) { + return `${toTimeUnitPhrase(0, 2)}${negative ? ' remaining' : ''}`; + } + const parts = [h, m, s].map((value, index) => (value > 0 ? toTimeUnitPhrase(value, index) : null)).filter(Boolean); const phrase = parts.join(', '); diff --git a/packages/utils/src/time/tests/format.test.ts b/packages/utils/src/time/tests/format.test.ts index bcd82a22..a05077f9 100644 --- a/packages/utils/src/time/tests/format.test.ts +++ b/packages/utils/src/time/tests/format.test.ts @@ -57,6 +57,10 @@ describe('formatTime', () => { }); describe('formatTimeAsPhrase', () => { + it('formats zero seconds', () => { + expect(formatTimeAsPhrase(0)).toBe('0 seconds'); + }); + it('formats seconds only', () => { expect(formatTimeAsPhrase(1)).toBe('1 second'); expect(formatTimeAsPhrase(30)).toBe('30 seconds'); diff --git a/packages/utils/tsdown.config.ts b/packages/utils/tsdown.config.ts index cdbc7adc..c3f25ee6 100644 --- a/packages/utils/tsdown.config.ts +++ b/packages/utils/tsdown.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ dom: './src/dom/index.ts', events: './src/events/index.ts', function: './src/function/index.ts', + number: './src/number/index.ts', object: './src/object/index.ts', predicate: './src/predicate/index.ts', time: './src/time/index.ts',