Skip to content

Commit 7efee3d

Browse files
authored
feat(packages): add slider core layer (#529)
1 parent 493fc2b commit 7efee3d

27 files changed

+2957
-76
lines changed

.claude/plans/slider.md

Lines changed: 1819 additions & 0 deletions
Large diffs are not rendered by default.

internal/design/ui/slider/architecture.md

Lines changed: 44 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ interface SliderInteraction {
8484
}
8585
```
8686

87-
`interactive` is derived by Core: `pointing || focused || dragging`.
87+
`interactive` is derived by Core: `dragging || pointing || focused`.
8888

8989
### Methods
9090

@@ -99,10 +99,11 @@ class SliderCore {
9999
// For generic slider, `value` is the controlled/uncontrolled value.
100100
// Domain cores override to accept media state and compute `value` internally.
101101

102-
getThumbAttrs(state: SliderState): SliderThumbAttrs;
103-
// Returns: role, tabIndex, aria-valuemin, aria-valuemax, aria-valuenow,
104-
// aria-orientation, aria-disabled
102+
getAttrs(state: SliderState);
103+
// Returns: role, tabindex, autocomplete, aria-valuemin, aria-valuemax,
104+
// aria-valuenow, aria-orientation, aria-disabled
105105
// These go on the Thumb element (the focusable role="slider" element).
106+
// Inlined return type (no explicit annotation — TS infers).
106107

107108
valueFromPercent(percent: number): number;
108109
// Clamps to [min, max], snaps to step.
@@ -116,12 +117,13 @@ class SliderCore {
116117

117118
### ARIA Output
118119

119-
`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/):
120+
`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/):
120121

121122
```ts
122123
{
123124
role: 'slider',
124-
tabIndex: 0,
125+
tabindex: 0,
126+
autocomplete: 'off',
125127
'aria-valuemin': 0,
126128
'aria-valuemax': 100,
127129
'aria-valuenow': 45,
@@ -130,50 +132,41 @@ class SliderCore {
130132
}
131133
```
132134

133-
`aria-label` and `aria-valuetext` are NOT set by `SliderCore` — they're domain-specific. `TimeSliderCore` and `VolumeSliderCore` add them.
135+
`aria-label` and `aria-valuetext` are NOT set by `SliderCore` — they're domain-specific. `TimeSliderCore` and `VolumeSliderCore` add them via `override getAttrs()`.
134136

135137
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.
136138

137139
## Value Snapping Precision
138140

139141
`valueFromPercent()` must handle floating-point precision carefully. Without rounding, values like `0.1 + 0.2` produce `0.30000000000000004`.
140142

141-
### `roundValueToStep`
143+
### `roundToStep` and `clamp`
142144

143-
Rounds a value to the nearest step, anchored at `min`:
145+
Located in `@videojs/utils/number` — shared utilities used by `SliderCore.valueFromPercent()`:
144146

145147
```ts
146-
function roundValueToStep(value: number, step: number, min: number): number {
147-
const nearest = Math.round((value - min) / step) * step + min;
148-
return Number(nearest.toFixed(getDecimalPrecision(step)));
149-
}
148+
function clamp(value: number, min: number, max: number): number;
149+
function roundToStep(value: number, step: number, min: number): number;
150150
```
151151

152-
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.
153-
154-
### `getDecimalPrecision`
152+
`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:
155153

156154
```ts
157-
function getDecimalPrecision(num: number): number {
158-
if (Math.abs(num) < 1) {
159-
// Handles scientific notation (e.g., 0.00000001 → 1e-8)
160-
const parts = num.toExponential().split('e-');
161-
const mantissaDecimals = parts[0].split('.')[1];
162-
return (mantissaDecimals ? mantissaDecimals.length : 0) + parseInt(parts[1], 10);
163-
}
164-
const decimalPart = num.toString().split('.')[1];
165-
return decimalPart ? decimalPart.length : 0;
155+
function roundToStep(value: number, step: number, min: number): number {
156+
const nearest = Math.round((value - min) / step) * step + min;
157+
const dot = `${step}`.indexOf('.');
158+
return dot === -1 ? nearest : Number(nearest.toFixed(`${step}`.length - dot - 1));
166159
}
167160
```
168161

162+
Integer steps skip `toFixed` entirely. Fractional steps (e.g., `step = 0.1`) get cleaned up using the step's own decimal count.
163+
169164
### Where It's Used
170165

171-
- `SliderCore.valueFromPercent()`snaps computed value to step precision after percent → value conversion.
166+
- `SliderCore.valueFromPercent()`clamps to `[min, max]` then snaps to step precision after percent → value conversion.
172167
- 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).
173168
- All percentage calculations for CSS vars use 3 decimal places (`45.123%`) for smooth visual animation, separate from value-level step precision.
174169

175-
Pattern taken from Base UI's `roundValueToStep` utility.
176-
177170
## Documentation Constants
178171

179172
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 {
245238
label?: string; // default: 'Seek'
246239
}
247240

248-
interface TimeSliderState extends SliderState {
249-
currentTime: number;
250-
duration: number;
251-
seeking: boolean;
241+
interface TimeSliderState extends SliderState, Pick<MediaTimeState, 'currentTime' | 'duration' | 'seeking'> {
252242
bufferPercent: number;
253243
}
254244

255245
class TimeSliderCore extends SliderCore {
256-
getTimeState(interaction: SliderInteraction, media: TimeMediaState): TimeSliderState;
246+
getTimeState(media: MediaTimeState & MediaBufferState, interaction: SliderInteraction): TimeSliderState;
247+
// Accepts canonical media state types directly — no wrapper interface.
257248
// Core owns the value swap: dragging ? valueFromPercent(dragPercent) : currentTime.
258-
// bufferPercent is computed from bufferedEnd / duration.
259-
// The DOM layer uses this to format --media-slider-buffer.
249+
// Computes bufferedEnd internally from media.buffered ranges.
250+
// bufferPercent = (bufferedEnd / duration) * 100.
251+
// Overrides min=0, max=duration on each call.
260252

261-
getTimeThumbAttrs(state: TimeSliderState): TimeSliderThumbAttrs;
262-
// Extends getThumbAttrs() with: aria-label (from props.label, default "Seek"),
253+
override getAttrs(state: TimeSliderState);
254+
// Extends super.getAttrs() with: aria-label (from props.label, default "Seek"),
263255
// aria-valuetext="2 minutes, 30 seconds of 10 minutes"
264-
265-
formatValue(value: number): string;
266-
// Returns formatted time string (e.g., "1:30")
256+
// Inlined return type.
267257
}
268258
```
269259

270-
```ts
271-
interface TimeMediaState {
272-
currentTime: number;
273-
duration: number;
274-
seeking: boolean;
275-
bufferedEnd: number; // end of last buffered range
276-
}
277-
```
260+
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.
278261

279-
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.
262+
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.
280263

281264
### Time Formatting
282265

283-
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).
266+
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).
284267

285268
## VolumeSliderCore
286269

@@ -291,32 +274,26 @@ interface VolumeSliderProps extends SliderProps {
291274
label?: string; // default: 'Volume'
292275
}
293276

294-
interface VolumeSliderState extends SliderState {
295-
volume: number;
296-
muted: boolean;
297-
}
277+
interface VolumeSliderState extends SliderState, Pick<MediaVolumeState, 'volume' | 'muted'> {}
298278

299279
class VolumeSliderCore extends SliderCore {
300-
getVolumeState(interaction: SliderInteraction, media: VolumeMediaState): VolumeSliderState;
280+
getVolumeState(media: MediaVolumeState, interaction: SliderInteraction): VolumeSliderState;
281+
// Accepts canonical MediaVolumeState directly — no wrapper interface.
301282
// Core owns the value swap: dragging ? valueFromPercent(dragPercent) : volume * 100.
302283
// When muted: `value` = actual volume * 100 (always reflects real level),
303-
// `fillPercent` = muted ? 0 : percentFromValue(value) (visual silence).
284+
// `fillPercent` = muted ? 0 : base.fillPercent (visual silence).
304285
// `aria-valuenow` uses `value` (actual volume), not `fillPercent`.
305286

306-
getVolumeThumbAttrs(state: VolumeSliderState): VolumeSliderThumbAttrs;
307-
// Extends getThumbAttrs() with: aria-label (from props.label, default "Volume"),
287+
override getAttrs(state: VolumeSliderState);
288+
// Extends super.getAttrs() with: aria-label (from props.label, default "Volume"),
308289
// aria-valuetext="75 percent" (or "75 percent, muted" when muted)
290+
// Inlined return type.
309291
}
310292
```
311293

312-
```ts
313-
interface VolumeMediaState {
314-
volume: number; // 0-1 from store
315-
muted: boolean;
316-
}
317-
```
294+
Uses `MediaVolumeState` from `@videojs/core` directly — no custom `VolumeMediaState` wrapper. `VolumeSliderState` uses `Pick<>` to select the specific fields it exposes (`volume`, `muted`).
318295

319-
Default props override: `min=0, max=100, step=5, largeStep=10, orientation='horizontal'`.
296+
Default props: `min=0, max=100, step=1, largeStep=10, orientation='horizontal'`.
320297

321298
Volume is stored as 0-1 in the store but displayed as 0-100 in the slider. `VolumeSliderCore` handles this conversion.
322299

@@ -442,7 +419,7 @@ All via `onKeyDown` on the **Thumb** element (the focusable `role="slider"` elem
442419

443420
**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.
444421

445-
**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).
422+
**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).
446423

447424
**Numeric keys** match YouTube behavior (0 = start, 5 = midpoint, 9 = 90%). Only active when `metaKey` is not held.
448425

@@ -478,7 +455,7 @@ interaction.current onValueChange(%) onValueCommit(%)
478455
│ const media = usePlayer(selectTimeAndBuffer); │
479456
│ const state = core.getTimeState(interaction, media); │
480457
│ const cssVars = getTimeSliderCSSVars(state); │
481-
│ const thumbAttrs = core.getTimeThumbAttrs(state); │
458+
│ const thumbAttrs = core.getAttrs(state);
482459
│ │
483460
│ → CSS vars on Root │
484461
│ → Data attrs on Root + children (via context) │

internal/design/ui/slider/decisions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ slider.interaction.subscribe(cb); // notified on change
345345

346346
### Core Accepts Split (Interaction, Media) Inputs
347347

348-
**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`).
348+
**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.
349349

350350
**Alternatives:**
351351

@@ -419,7 +419,7 @@ This follows the [WAI-ARIA Slider Pattern](https://www.w3.org/WAI/ARIA/apg/patte
419419

420420
### Domain Sliders Set aria-label and aria-valuetext
421421

422-
**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.
422+
**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.
423423

424424
**Alternatives:**
425425

internal/design/ui/slider/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,8 @@ Keyboard events are handled by the **Thumb** element (the focusable `role="slide
192192
| `0``9` | Jump to 0%–90% of duration | Jump to 0%–90% of range |
193193

194194
Default step values:
195-
- Time slider: `step = 5` (seconds), `largeStep = 10` (seconds)
196-
- Volume slider: `step = 5` (%), `largeStep = 10` (%)
195+
- Time slider: `step = 1` (second), `largeStep = 10` (seconds)
196+
- Volume slider: `step = 1` (%), `largeStep = 10` (%)
197197

198198
Customizable via `step` and `largeStep` props on the Root.
199199

internal/design/ui/slider/parts.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ Focusable slider handle. Carries `role="slider"`, `tabindex="0"`, all ARIA attri
237237

238238
#### ARIA (automatic)
239239

240-
Set by `SliderCore.getThumbAttrs()`. Domain cores extend with `aria-label` and `aria-valuetext`.
240+
Set by `SliderCore.getAttrs()`. Domain cores extend via `override getAttrs()` with `aria-label` and `aria-valuetext`.
241241

242242
| Attribute | Source |
243243
| --------- | ------ |
@@ -451,7 +451,7 @@ import { TimeSlider } from '@videojs/react';
451451
| Prop | Type | Default | Description |
452452
| ---- | ---- | ------- | ----------- |
453453
| `label` | `string` | `'Seek'` | Accessible label for the slider. Sets `aria-label` on Thumb. |
454-
| `step` | `number` | `5` | Arrow key step in seconds. Drag precision is handled internally (sub-second). |
454+
| `step` | `number` | `1` | Arrow key step in seconds. Drag uses raw precision (no step snapping) for smooth scrubbing. |
455455
| `largeStep` | `number` | `10` | Shift+Arrow / Page Up/Down step in seconds. |
456456
| `seekThrottle` | `number` | `100` | Trailing-edge throttle (ms) for seek requests during drag. `0` disables throttling. |
457457
| `disabled` | `boolean` | `false` | Disables interaction. |
@@ -492,7 +492,7 @@ Inherited by all children (including `data-seeking`).
492492
- **Value formatting:** Provides time formatter (`formatTime`) to `Slider.Value` children — `type="current"` shows formatted current time (`1:30`), `type="pointer"` shows formatted pointer time.
493493
- **Data attributes:** All slider data attributes + `data-seeking` are propagated to children.
494494
- **ARIA for Thumb:** Provides domain-specific ARIA attrs to `Slider.Thumb` via context.
495-
- **Keyboard step values:** `step` defaults to `5` (seconds), `largeStep` defaults to `10` (seconds).
495+
- **Keyboard step values:** `step` defaults to `1` (second), `largeStep` defaults to `10` (seconds).
496496

497497
#### ARIA (on Thumb)
498498

@@ -565,7 +565,7 @@ import { VolumeSlider } from '@videojs/react';
565565
| ---- | ---- | ------- | ----------- |
566566
| `label` | `string` | `'Volume'` | Accessible label for the slider. Sets `aria-label` on Thumb. |
567567
| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Layout direction. |
568-
| `step` | `number` | `5` | Arrow key step as percentage (0-100). |
568+
| `step` | `number` | `1` | Arrow key step as percentage (0-100). |
569569
| `largeStep` | `number` | `10` | Shift+Arrow / Page Up/Down step as percentage. |
570570
| `disabled` | `boolean` | `false` | Disables interaction. |
571571
| `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.
592592
- **Value formatting:** Provides percentage formatter to `Slider.Value` children — displays `75%`.
593593
- **Data attributes:** All slider data attributes are propagated to children.
594594
- **ARIA for Thumb:** Provides domain-specific ARIA attrs to `Slider.Thumb` via context.
595-
- **Keyboard step values:** `step` defaults to `5` (%), `largeStep` defaults to `10` (%).
595+
- **Keyboard step values:** `step` defaults to `1` (%), `largeStep` defaults to `10` (%).
596596

597597
#### ARIA (on Thumb)
598598

packages/core/src/core/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ export * from './ui/poster/poster-core';
1515
export * from './ui/poster/poster-data-attrs';
1616
export * from './ui/seek-button/seek-button-core';
1717
export * from './ui/seek-button/seek-button-data-attrs';
18+
export * from './ui/slider/slider-core';
19+
export * from './ui/slider/slider-css-vars';
20+
export * from './ui/slider/slider-data-attrs';
21+
export * from './ui/slider/time-slider-core';
22+
export * from './ui/slider/time-slider-data-attrs';
23+
export * from './ui/slider/volume-slider-core';
1824
export * from './ui/time/time-core';
1925
export * from './ui/time/time-data-attrs';
2026
export * from './ui/types';

0 commit comments

Comments
 (0)