Files
v10/.claude/plans/slider.md
T

1748 lines
64 KiB
Markdown

# Slider Component Implementation
**Status:** PR 3 COMPLETE — PR 4 (HTML) ready to start
**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<T>` 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<T>` (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<T>` 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<T extends object>(state: State<T>): T;
export function useSnapshot<T extends object, R>(
state: State<T>,
selector: Selector<T, R>,
isEqual?: Comparator<R>
): R;
export function useSnapshot<T extends object, R>(
state: State<T>,
selector?: Selector<T, R>,
isEqual?: Comparator<R>
): 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 <changed-files>
```
---
## 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 extends unknown[]> {
(...args: Args): void;
cancel(): void;
}
export function rafThrottle<Args extends unknown[]>(
fn: (...args: Args) => void
): RafThrottled<Args> {
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<SliderProps>;
// 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<MediaTimeState, 'currentTime' | 'duration' | 'seeking'> {
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<TimeSliderProps>;
// 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<MediaVolumeState, 'volume' | 'muted'> {}
```
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<VolumeSliderProps>;
// 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<SliderState>;
```
**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<TimeSliderState>;
```
**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 <changed-files>
```
---
## PR 2: DOM Layer — COMPLETE
**Branch:** `slider-dom` — [PR #613](https://github.com/videojs/v10/pull/613)
**Base:** `main` (after PR 1 merges)
**Package:** `@videojs/core` (dom subpath)
### What was built
- `createSlider()` factory with pointer drag (threshold-based), keyboard stepping, focus management, and AbortController cleanup
- `getPercentFromPointerEvent()` extracted as a public DOM utility in `packages/core/src/dom/utils/pointer.ts`
- `getSliderCSSVars()` and `getTimeSliderCSSVars()` CSS variable formatters
- `UIPointerEvent` / `UIKeyboardEvent` event interfaces extended with required properties
- Shared test infrastructure: `createMockVideo()`, `createTimeRanges()`, `createSliderState()`, `createTimeSliderState()`
- jsdom `PointerEvent` polyfill in test setup
- Refactored 8 feature test files to use shared helpers
- 149 tests passing, lint clean, build clean
### Deviations from plan
1. **`getPercentFromPointerEvent` extracted as public utility** — Plan had it as module-private. Extracted to `packages/core/src/dom/utils/pointer.ts` and exported from the `@videojs/core/dom` barrel so the React `useSlider` hook can reuse it without duplication.
2. **Widened event param type** — `getPercentFromPointerEvent` accepts `{ clientX: number; clientY: number }` instead of `UIPointerEvent`. This allows both `UIPointerEvent` (from public props) and native `PointerEvent` (from document listeners) without type casts.
3. **Document listeners use native `PointerEvent` typing** — Internal handlers `onDocumentPointerMove` and `onDocumentPointerUp` accept native `PointerEvent` directly, eliminating `as unknown as EventListener` casts.
4. **`onValueChange` guarded behind drag threshold** — Plan had `onValueChange` firing on every `pointermove`. Changed so pre-threshold moves only update `pointerPercent` for hover preview — `onValueChange` fires only once drag threshold is reached. Prevents premature seeking.
5. **`getThumbElement` option added** — Not in original plan. Allows `createSlider` to focus the thumb on pointerdown for keyboard follow-up and screen reader tracking.
6. **`UIKeyboardEvent` extended** — Added `shiftKey` (for Shift+Arrow large step) and `metaKey` (for meta key guard on numeric 0-9).
7. **AbortController renamed** — `ac` → `abort` per user preference.
8. **Shared test helpers** — Plan didn't specify these. Created `packages/core/src/dom/tests/test-helpers.ts` with importable factories and `setup.ts` with global jsdom `PointerEvent` polyfill, wired into vitest config.
9. **Document listener cleanup simplified** — `onDocumentPointerCancel` inlined (just calls `endDrag`), `onDocumentTouchMove` inlined as arrow function.
### Files created
- `packages/core/src/dom/ui/slider.ts`
- `packages/core/src/dom/ui/slider-css-vars.ts`
- `packages/core/src/dom/utils/pointer.ts`
- `packages/core/src/dom/tests/setup.ts`
- `packages/core/src/dom/tests/test-helpers.ts`
- `packages/core/src/dom/ui/tests/slider.test.ts` (46 tests)
- `packages/core/src/dom/ui/tests/slider-css-vars.test.ts` (7 tests)
### Files modified
- `packages/core/src/dom/ui/event.ts` — added `UIPointerEvent`, `shiftKey`/`metaKey` to `UIKeyboardEvent`
- `packages/core/src/dom/index.ts` — barrel exports for event, slider, slider-css-vars
- `packages/core/src/dom/utils/index.ts` — added `getPercentFromPointerEvent` export
- `packages/core/vitest.config.ts` — added `setupFiles` for dom project
- 8 feature test files — refactored to use shared `createMockVideo`/`createTimeRanges`
---
## PR 3: React Components — COMPLETE
**Branch:** `feat/slider-react`
**Base:** `feat/slider-dom`
**Package:** `@videojs/react`
### What was built
- `useSlider` generic hook wrapping `createSlider()` + `useSnapshot` for React lifecycle
- `SliderProvider` / `useSliderContext` — first component-level React context in the codebase (children need interaction state from `createSlider()`, not player store state)
- 6 generic parts: `SliderRoot`, `SliderTrack`, `SliderFill`, `SliderBuffer`, `SliderThumb`, `SliderValue`
- `TimeSliderRoot` and `VolumeSliderRoot` domain roots connecting to player store via `usePlayer`
- Barrel files with compound namespace pattern (`Slider.*`, `TimeSlider.*`, `VolumeSlider.*`)
- `useLatestRef` utility for stable callback refs
- `SliderCore.props` getter added to core package
- Shared test helpers (`createMockStore`, `createPlayerWrapper`) in `packages/react/src/testing/mocks.tsx`
- Skin presets updated with real `TimeSlider` compound components
- 38 new tests (22 slider + 8 time-slider + 8 volume-slider), all 139 React tests passing
- Sandbox page with standalone slider, vertical, disabled, thumbAlignment demo, TimeSlider with video, VolumeSlider horizontal + vertical
### Deviations from plan
1. **`useSlider` is generic over state type** — Plan showed `UseSliderResult` returning `SliderState`. Implementation uses `<State extends SliderState>` generic so domain roots get proper `TimeSliderState` / `VolumeSliderState` typing.
2. **`thumbRef` threaded through context** — Not in plan. Required so `createSlider` can programmatically focus the thumb on pointerdown.
3. **Domain roots don't compose on `SliderRoot`** — Plan implied composition. Each domain root has different state sources, cores, CSS var functions, and hardwired callbacks, making composition impractical.
4. **Domain barrel re-exports simplified** — Plan showed explicit re-exports of each part. Implementation uses `export * from '../slider/index.parts'` then overrides `Root` only.
5. **Context uses `.Provider` pattern** — React 19's direct `<Context value={...}>` isn't supported by the rolldown bundler. Uses `<Context.Provider value={...}>`.
6. **`getAttrs` in context** — Not in plan. Justified for `SliderThumb` to get domain-specific ARIA attrs without knowing which core it's using.
7. **Shared test helpers extracted** — Plan didn't specify. Created `packages/react/src/testing/mocks.tsx` with `vi.hoisted()` pattern for mock data.
### Files created
- `packages/react/src/utils/use-latest-ref.ts`
- `packages/react/src/ui/slider/slider-context.tsx`
- `packages/react/src/ui/hooks/use-slider.ts`
- `packages/react/src/ui/slider/slider-root.tsx`
- `packages/react/src/ui/slider/slider-track.tsx`
- `packages/react/src/ui/slider/slider-fill.tsx`
- `packages/react/src/ui/slider/slider-buffer.tsx`
- `packages/react/src/ui/slider/slider-thumb.tsx`
- `packages/react/src/ui/slider/slider-value.tsx`
- `packages/react/src/ui/slider/index.ts` + `index.parts.ts`
- `packages/react/src/ui/time-slider/time-slider-root.tsx`
- `packages/react/src/ui/time-slider/index.ts` + `index.parts.ts`
- `packages/react/src/ui/volume-slider/volume-slider-root.tsx`
- `packages/react/src/ui/volume-slider/index.ts` + `index.parts.ts`
- `packages/react/src/testing/mocks.tsx`
- `packages/react/src/ui/slider/tests/slider.test.tsx` (22 tests)
- `packages/react/src/ui/time-slider/tests/time-slider.test.tsx` (8 tests)
- `packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx` (8 tests)
### Files modified
- `packages/core/src/core/ui/slider/slider-core.ts` — added `get props()` getter
- `packages/react/src/index.ts` — added Slider/TimeSlider/VolumeSlider/useSlider exports
- `packages/react/src/presets/video/skin.tsx` — replaced placeholder with TimeSlider compound
- `packages/react/src/presets/video/minimal-skin.tsx` — same replacement
- `packages/react/src/player/tests/context.test.tsx` — updated to use shared `createMockStore`
- `packages/sandbox/vite.config.ts` — added `react-slider` input
- `packages/sandbox/src/index.html` — added nav link
### Original plan (for reference)
### 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<SliderState>;
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<SliderInteraction>`.
```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<string, string>;
}
function useSlider(options: UseSliderOptions): UseSliderResult;
```
**Implementation:**
- `useState(() => createSlider(...))` for lazy init.
- `useSnapshot(slider.interaction)` to subscribe to `State<SliderInteraction>` (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 `<div>` 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<HTMLDivElement>
) {
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 `<div>` with data attributes from context state.
- No special behavior.
**`Slider.Fill`** — `packages/react/src/ui/slider/slider-fill.tsx`
- Renders `<div>` with data attributes.
- Sizing is pure CSS via `var(--media-slider-fill)`.
**`Slider.Buffer`** — `packages/react/src/ui/slider/slider-buffer.tsx`
- Renders `<div>` with data attributes.
- Sizing is pure CSS via `var(--media-slider-buffer)`.
**`Slider.Thumb`** — `packages/react/src/ui/slider/slider-thumb.tsx`
- Renders `<div>` 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 `<output>` 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.setVolume(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
<Provider>
<Container>
<Video src="..." />
<TimeSlider.Root>
<TimeSlider.Track>
<TimeSlider.Fill />
<TimeSlider.Buffer />
</TimeSlider.Track>
<TimeSlider.Thumb />
</TimeSlider.Root>
<VolumeSlider.Root>
<VolumeSlider.Track>
<VolumeSlider.Fill />
</VolumeSlider.Track>
<VolumeSlider.Thumb />
</VolumeSlider.Root>
</Container>
</Provider>
```
### 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.
- `setVolume` 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 <changed-files>
```
---
## 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<SliderValueEventDetail>;
'value-commit': CustomEvent<SliderValueEventDetail>;
'drag-start': CustomEvent<void>;
'drag-end': CustomEvent<void>;
}
export interface DomainSliderEventMap {
'drag-start': CustomEvent<void>;
'drag-end': CustomEvent<void>;
}
```
### 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<keyof SliderCore.Props>;
// 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<SliderState>;
formatValue?: (value: number, type: 'current' | 'pointer') => string;
}
const SLIDER_CONTEXT_KEY = Symbol('@videojs/slider');
export const sliderContext = createContext<SliderContextValue, typeof SLIDER_CONTEXT_KEY>(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<keyof TimeSliderCore.Props>;
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<keyof VolumeSliderCore.Props>;
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.setVolume(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
<media-time-slider>
<media-slider-track>
<media-slider-fill></media-slider-fill>
<media-slider-buffer></media-slider-buffer>
</media-slider-track>
<media-slider-thumb></media-slider-thumb>
</media-time-slider>
<media-volume-slider>
<media-slider-track>
<media-slider-fill></media-slider-fill>
</media-slider-track>
<media-slider-thumb></media-slider-thumb>
</media-volume-slider>
```
### 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 <changed-files>
```
---
## 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<HTMLDivElement>
) {
const context = useContext(SliderContext);
const previewRef = useRef<HTMLDivElement>(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 <div> 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 # <media-slider>
slider-track-element.ts # <media-slider-track>
slider-fill-element.ts # <media-slider-fill>
slider-buffer-element.ts # <media-slider-buffer>
slider-thumb-element.ts # <media-slider-thumb>
slider-value-element.ts # <media-slider-value>
slider-preview-element.ts # <media-slider-preview> (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 # <media-time-slider>
tests/
time-slider-element.test.ts
volume-slider/
volume-slider-element.ts # <media-volume-slider>
tests/
volume-slider-element.test.ts
packages/html/src/define/ui/
slider-track.ts # Shared: <media-slider-track>
slider-fill.ts # Shared: <media-slider-fill>
slider-buffer.ts # Shared: <media-slider-buffer>
slider-thumb.ts # Shared: <media-slider-thumb>
slider-value.ts # Shared: <media-slider-value>
time-slider.ts # Domain: <media-time-slider> + imports shared
volume-slider.ts # Domain: <media-volume-slider> + imports shared
slider-preview.ts # Registration: preview (PR 6)
```
## Resolved Questions
1. **Idempotent custom element registration** — Extract shared child elements (`<media-slider-track>`, `-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).