feat(core): add slider dom (#613)

This commit is contained in:
rahim
2026-02-26 11:11:58 +11:00
committed by GitHub
parent fbf888fda3
commit 4c7d287d35
20 changed files with 1331 additions and 341 deletions
+37 -166
View File
@@ -1,6 +1,6 @@
# Slider Component Implementation
**Status:** READY
**Status:** PR 2 COMPLETE — PR 3 (React) and PR 4 (HTML) ready to start
**Design Doc:** `internal/design/ui/slider/`
**Issues:** #275 (time slider), #267 (volume slider), #269 (seek slider)
@@ -477,189 +477,60 @@ pnpm lint:fix:file <changed-files>
---
## PR 2: DOM Layer
## PR 2: DOM Layer — COMPLETE
**Branch:** `feat/slider-dom`
**Base:** `feat/slider-core`
**Branch:** `slider-dom` — [PR #613](https://github.com/videojs/v10/pull/613)
**Base:** `main` (after PR 1 merges)
**Package:** `@videojs/core` (dom subpath)
### 2.1 `UIPointerEvent` Type
### What was built
**File:** `packages/core/src/dom/ui/event.ts`
- `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
Extend existing event types with pointer-specific properties:
### Deviations from plan
```ts
export interface UIPointerEvent extends UIEvent {
clientX: number;
clientY: number;
pointerId: number;
pointerType: string;
}
```
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.2 `createSlider()` — Interaction Factory
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.
**File:** `packages/core/src/dom/ui/slider.ts`
3. **Document listeners use native `PointerEvent` typing** — Internal handlers `onDocumentPointerMove` and `onDocumentPointerUp` accept native `PointerEvent` directly, eliminating `as unknown as EventListener` casts.
Factory function that manages pointer and keyboard interaction state. Returns a subscribable
`State<SliderInteraction>` plus props objects for Root and Thumb elements.
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.
**New pattern:** Unlike `createButton()` (stateless, returns plain props), `createSlider()` is
stateful — it creates a `State<SliderInteraction>` 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:*`).
5. **`getThumbElement` option added** — Not in original plan. Allows `createSlider` to focus the thumb on pointerdown for keyboard follow-up and screen reader tracking.
```ts
export interface SliderOptions {
/** Element reference for getBoundingClientRect() and pointer capture. */
getElement: () => HTMLElement;
6. **`UIKeyboardEvent` extended** — Added `shiftKey` (for Shift+Arrow large step) and `metaKey` (for meta key guard on numeric 0-9).
getOrientation: () => 'horizontal' | 'vertical';
isRTL: () => boolean;
isDisabled: () => boolean;
7. **AbortController renamed**`ac``abort` per user preference.
// For keyboard: what's the current value as a percent?
getPercent: () => number;
getStepPercent: () => number;
getLargeStepPercent: () => number;
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.
onValueChange?: ((percent: number) => void) | undefined;
onValueCommit?: ((percent: number) => void) | undefined;
onDragStart?: (() => void) | undefined;
onDragEnd?: (() => void) | undefined;
}
9. **Document listener cleanup simplified**`onDocumentPointerCancel` inlined (just calls `endDrag`), `onDocumentTouchMove` inlined as arrow function.
export interface SliderRootProps {
onPointerDown: (event: UIPointerEvent) => void;
onPointerMove: (event: UIPointerEvent) => void;
onPointerLeave: (event: UIPointerEvent) => void;
}
### Files created
export interface SliderThumbProps {
onKeyDown: (event: UIKeyboardEvent) => void;
onFocus: (event: UIEvent) => void;
onBlur: (event: UIEvent) => void;
}
- `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)
export interface SliderHandle {
interaction: State<SliderInteraction>;
rootProps: SliderRootProps;
thumbProps: SliderThumbProps;
destroy: () => void;
}
### Files modified
export function createSlider(options: SliderOptions): SliderHandle;
```
**Behavior:**
1. Creates `WritableState<SliderInteraction>` 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<string, string> {
return {
[SliderCSSVars.fill]: `${state.fillPercent.toFixed(3)}%`,
[SliderCSSVars.pointer]: `${state.pointerPercent.toFixed(3)}%`,
};
}
export function getTimeSliderCSSVars(state: TimeSliderState): Record<string, string> {
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 <changed-files>
```
- `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`
---
+3
View File
@@ -3,4 +3,7 @@ export * from './media/types';
export * from './store/features';
export * from './store/selectors';
export * from './ui/button';
export * from './ui/event';
export * from './ui/slider';
export * from './ui/slider-css-vars';
export * from './utils';
@@ -1,7 +1,7 @@
import { createStore } from '@videojs/store';
import { describe, expect, it } from 'vitest';
import type { PlayerTarget } from '../../../media/types';
import { createMockVideo, createTimeRanges } from '../../../tests/test-helpers';
import { bufferFeature } from '../buffer';
describe('bufferFeature', () => {
@@ -86,41 +86,3 @@ describe('bufferFeature', () => {
});
});
});
function createMockVideo(
overrides: Partial<{
buffered: TimeRanges;
seekable: TimeRanges;
}>
): HTMLVideoElement {
const video = document.createElement('video');
if (overrides.buffered !== undefined) {
Object.defineProperty(video, 'buffered', { value: overrides.buffered, writable: false, configurable: true });
}
if (overrides.seekable !== undefined) {
Object.defineProperty(video, 'seekable', { value: overrides.seekable, writable: false, configurable: true });
}
return video;
}
function createTimeRanges(ranges: Array<[number, number]>): TimeRanges {
return {
length: ranges.length,
start(index: number): number {
const range = ranges[index];
if (index < 0 || index >= ranges.length || !range) {
throw new DOMException('Index out of range', 'IndexSizeError');
}
return range[0];
},
end(index: number): number {
const range = ranges[index];
if (index < 0 || index >= ranges.length || !range) {
throw new DOMException('Index out of range', 'IndexSizeError');
}
return range[1];
},
};
}
@@ -1,7 +1,7 @@
import { createStore, flush } from '@videojs/store';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PlayerTarget } from '../../../media/types';
import { createMockVideo } from '../../../tests/test-helpers';
import { controlsFeature } from '../controls';
const IDLE_DELAY = 2000;
@@ -363,33 +363,6 @@ describe('controlsFeature', () => {
// Helpers
// ---------------------------------------------------------------------------
function createMockVideo(
overrides: Partial<{
paused: boolean;
ended: boolean;
currentTime: number;
readyState: number;
tagName: string;
}> = {}
): HTMLVideoElement {
const video = document.createElement(overrides.tagName === 'AUDIO' ? 'audio' : 'video') as HTMLVideoElement;
if (overrides.paused !== undefined) {
Object.defineProperty(video, 'paused', { value: overrides.paused, configurable: true });
}
if (overrides.ended !== undefined) {
Object.defineProperty(video, 'ended', { value: overrides.ended, configurable: true });
}
if (overrides.currentTime !== undefined) {
video.currentTime = overrides.currentTime;
}
if (overrides.readyState !== undefined) {
Object.defineProperty(video, 'readyState', { value: overrides.readyState, configurable: true });
}
return video;
}
function createContainer(): HTMLElement {
return document.createElement('div');
}
@@ -1,7 +1,7 @@
import { createStore } from '@videojs/store';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PlayerTarget } from '../../../media/types';
import { createMockVideo } from '../../../tests/test-helpers';
import { fullscreenFeature } from '../fullscreen';
describe('fullscreenFeature', () => {
@@ -217,7 +217,3 @@ describe('fullscreenFeature', () => {
});
});
});
function createMockVideo(): HTMLVideoElement {
return document.createElement('video');
}
@@ -1,7 +1,7 @@
import { createStore } from '@videojs/store';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PlayerTarget } from '../../../media/types';
import { createMockVideo } from '../../../tests/test-helpers';
import { pipFeature } from '../pip';
describe('pipFeature', () => {
@@ -167,7 +167,3 @@ describe('pipFeature', () => {
});
});
});
function createMockVideo(): HTMLVideoElement {
return document.createElement('video');
}
@@ -1,7 +1,7 @@
import { createStore } from '@videojs/store';
import { describe, expect, it, vi } from 'vitest';
import type { PlayerTarget } from '../../../media/types';
import { createMockVideo } from '../../../tests/test-helpers';
import { playbackFeature } from '../playback';
describe('playbackFeature', () => {
@@ -147,29 +147,3 @@ describe('playbackFeature', () => {
});
});
});
function createMockVideo(
overrides: Partial<{
paused: boolean;
ended: boolean;
currentTime: number;
readyState: number;
}>
): HTMLVideoElement {
const video = document.createElement('video');
if (overrides.paused !== undefined) {
Object.defineProperty(video, 'paused', { value: overrides.paused, writable: false, configurable: true });
}
if (overrides.ended !== undefined) {
Object.defineProperty(video, 'ended', { value: overrides.ended, writable: false, configurable: true });
}
if (overrides.currentTime !== undefined) {
video.currentTime = overrides.currentTime;
}
if (overrides.readyState !== undefined) {
Object.defineProperty(video, 'readyState', { value: overrides.readyState, writable: false, configurable: true });
}
return video;
}
@@ -1,7 +1,7 @@
import { combine, createStore } from '@videojs/store';
import { describe, expect, it, vi } from 'vitest';
import type { PlayerTarget } from '../../../media/types';
import { createMockVideo } from '../../../tests/test-helpers';
import { sourceFeature } from '../source';
import { timeFeature } from '../time';
@@ -142,25 +142,3 @@ describe('sourceFeature', () => {
});
});
});
function createMockVideo(
overrides: Partial<{
currentSrc: string;
src: string;
readyState: number;
}>
): HTMLVideoElement {
const video = document.createElement('video');
if (overrides.currentSrc !== undefined) {
Object.defineProperty(video, 'currentSrc', { value: overrides.currentSrc, writable: false, configurable: true });
}
if (overrides.src !== undefined) {
video.src = overrides.src;
}
if (overrides.readyState !== undefined) {
Object.defineProperty(video, 'readyState', { value: overrides.readyState, writable: false, configurable: true });
}
return video;
}
@@ -1,7 +1,7 @@
import { createStore } from '@videojs/store';
import { describe, expect, it } from 'vitest';
import type { PlayerTarget } from '../../../media/types';
import { createMockVideo } from '../../../tests/test-helpers';
import { timeFeature } from '../time';
describe('timeFeature', () => {
@@ -96,7 +96,7 @@ describe('timeFeature', () => {
describe('actions', () => {
describe('seek', () => {
it('sets currentTime on target and waits for seeked event', async () => {
const video = createMockVideo({});
const video = createMockVideo({ readyState: HTMLMediaElement.HAVE_METADATA });
const store = createStore<PlayerTarget>()(timeFeature);
store.attach({ media: video, container: null });
@@ -112,7 +112,7 @@ describe('timeFeature', () => {
});
it('aborts pending seek on detach', async () => {
const video = createMockVideo({});
const video = createMockVideo({ readyState: HTMLMediaElement.HAVE_METADATA });
const store = createStore<PlayerTarget>()(timeFeature);
const detach = store.attach({ media: video, container: null });
@@ -129,7 +129,7 @@ describe('timeFeature', () => {
});
it('supersedes previous seek when new seek starts', async () => {
const video = createMockVideo({});
const video = createMockVideo({ readyState: HTMLMediaElement.HAVE_METADATA });
const store = createStore<PlayerTarget>()(timeFeature);
store.attach({ media: video, container: null });
@@ -154,28 +154,3 @@ describe('timeFeature', () => {
});
});
});
function createMockVideo(
overrides: Partial<{
currentTime: number;
duration: number;
readyState: number;
}> = {}
): HTMLVideoElement {
const video = document.createElement('video');
if (overrides.currentTime !== undefined) {
video.currentTime = overrides.currentTime;
}
if (overrides.duration !== undefined) {
Object.defineProperty(video, 'duration', { value: overrides.duration, writable: false, configurable: true });
}
// Default to HAVE_METADATA so seek tests work without waiting for loadedmetadata
Object.defineProperty(video, 'readyState', {
value: overrides.readyState ?? HTMLMediaElement.HAVE_METADATA,
writable: false,
configurable: true,
});
return video;
}
@@ -1,7 +1,7 @@
import { createStore } from '@videojs/store';
import { describe, expect, it } from 'vitest';
import type { PlayerTarget } from '../../../media/types';
import { createMockVideo } from '../../../tests/test-helpers';
import { volumeFeature } from '../volume';
describe('volumeFeature', () => {
@@ -105,21 +105,3 @@ describe('volumeFeature', () => {
});
});
});
function createMockVideo(
overrides: Partial<{
volume: number;
muted: boolean;
}>
): HTMLVideoElement {
const video = document.createElement('video');
if (overrides.volume !== undefined) {
video.volume = overrides.volume;
}
if (overrides.muted !== undefined) {
video.muted = overrides.muted;
}
return video;
}
+20
View File
@@ -0,0 +1,20 @@
/**
* jsdom test setup for `core/dom`.
*
* Patches missing APIs that jsdom doesn't implement but our DOM code depends on.
*/
// jsdom lacks PointerEvent — polyfill via MouseEvent with pointer-specific properties.
if (typeof globalThis.PointerEvent === 'undefined') {
// @ts-expect-error -- intentional incomplete polyfill for test environment.
globalThis.PointerEvent = class PointerEvent extends MouseEvent {
readonly pointerId: number;
readonly pointerType: string;
constructor(type: string, init: PointerEventInit & MouseEventInit = {}) {
super(type, { bubbles: true, ...init });
this.pointerId = init.pointerId ?? 0;
this.pointerType = init.pointerType ?? '';
}
};
}
+109
View File
@@ -0,0 +1,109 @@
import type { SliderState } from '../../core/ui/slider/slider-core';
import type { TimeSliderState } from '../../core/ui/slider/time-slider-core';
// ---------------------------------------------------------------------------
// Mock Video
// ---------------------------------------------------------------------------
interface MockVideoOverrides {
paused?: boolean;
ended?: boolean;
currentTime?: number;
duration?: number;
readyState?: number;
volume?: number;
muted?: boolean;
currentSrc?: string;
src?: string;
buffered?: TimeRanges;
seekable?: TimeRanges;
}
/**
* Create an `HTMLVideoElement` with overridable properties.
*
* Readonly properties (`paused`, `ended`, `readyState`, `duration`, `currentSrc`,
* `buffered`, `seekable`) are set via `Object.defineProperty`.
* Writable properties (`currentTime`, `volume`, `muted`, `src`) are assigned directly.
*/
export function createMockVideo(overrides: MockVideoOverrides = {}): HTMLVideoElement {
const video = document.createElement('video');
const readonly: Record<string, unknown> = {
paused: overrides.paused,
ended: overrides.ended,
readyState: overrides.readyState,
duration: overrides.duration,
currentSrc: overrides.currentSrc,
buffered: overrides.buffered,
seekable: overrides.seekable,
};
for (const [key, value] of Object.entries(readonly)) {
if (value !== undefined) {
Object.defineProperty(video, key, { value, configurable: true });
}
}
if (overrides.currentTime !== undefined) video.currentTime = overrides.currentTime;
if (overrides.volume !== undefined) video.volume = overrides.volume;
if (overrides.muted !== undefined) video.muted = overrides.muted;
if (overrides.src !== undefined) video.src = overrides.src;
return video;
}
// ---------------------------------------------------------------------------
// Mock TimeRanges
// ---------------------------------------------------------------------------
/** Create a mock `TimeRanges` from an array of `[start, end]` tuples. */
export function createTimeRanges(ranges: Array<[number, number]>): TimeRanges {
return {
length: ranges.length,
start(index: number): number {
const range = ranges[index];
if (index < 0 || index >= ranges.length || !range) {
throw new DOMException('Index out of range', 'IndexSizeError');
}
return range[0];
},
end(index: number): number {
const range = ranges[index];
if (index < 0 || index >= ranges.length || !range) {
throw new DOMException('Index out of range', 'IndexSizeError');
}
return range[1];
},
};
}
// ---------------------------------------------------------------------------
// Mock Slider State
// ---------------------------------------------------------------------------
export function createSliderState(overrides: Partial<SliderState> = {}): SliderState {
return {
value: 50,
fillPercent: 50,
pointerPercent: 30,
dragging: false,
pointing: false,
interactive: false,
orientation: 'horizontal',
disabled: false,
thumbAlignment: 'center',
...overrides,
};
}
export function createTimeSliderState(overrides: Partial<TimeSliderState> = {}): TimeSliderState {
return {
...createSliderState(),
currentTime: 30,
duration: 60,
seeking: false,
bufferPercent: 75,
...overrides,
};
}
+10
View File
@@ -4,6 +4,16 @@ export interface UIEvent {
export interface UIKeyboardEvent extends UIEvent {
key: string;
shiftKey: boolean;
metaKey: boolean;
target: Node;
currentTarget: Node;
}
export interface UIPointerEvent extends UIEvent {
clientX: number;
clientY: number;
pointerId: number;
pointerType: string;
buttons: number;
}
@@ -0,0 +1,17 @@
import type { SliderState } from '../../core/ui/slider/slider-core';
import { SliderCSSVars } from '../../core/ui/slider/slider-css-vars';
import type { TimeSliderState } from '../../core/ui/slider/time-slider-core';
export function getSliderCSSVars(state: SliderState): Record<string, string> {
return {
[SliderCSSVars.fill]: `${state.fillPercent.toFixed(3)}%`,
[SliderCSSVars.pointer]: `${state.pointerPercent.toFixed(3)}%`,
};
}
export function getTimeSliderCSSVars(state: TimeSliderState): Record<string, string> {
return {
...getSliderCSSVars(state),
[SliderCSSVars.buffer]: `${state.bufferPercent.toFixed(3)}%`,
};
}
+260
View File
@@ -0,0 +1,260 @@
import { createState, type State } from '@videojs/store';
import { clamp, roundToStep } from '@videojs/utils/number';
import type { SliderInteraction } from '../../core/ui/slider/slider-core';
import { getPercentFromPointerEvent } from '../utils/pointer';
import type { UIKeyboardEvent, UIPointerEvent } from './event';
export interface SliderOptions {
/** Element reference for getBoundingClientRect() and pointer capture. */
getElement: () => HTMLElement;
/** Optional thumb element reference for programmatic focus on pointerdown. */
getThumbElement?: (() => HTMLElement | null) | undefined;
getOrientation: () => 'horizontal' | 'vertical';
isRTL: () => boolean;
isDisabled: () => boolean;
/** Current value as 0100 percent. Used by keyboard stepping. */
getPercent: () => number;
/** Step size as 0100 percent. Arrow keys. */
getStepPercent: () => number;
/** Large step size as 0100 percent. Page Up/Down, Shift+Arrow. */
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: () => void;
onBlur: () => void;
}
export interface SliderHandle {
interaction: State<SliderInteraction>;
rootProps: SliderRootProps;
thumbProps: SliderThumbProps;
destroy: () => void;
}
/** Intentional drag threshold — number of pointermove events before drag starts. */
const DRAG_THRESHOLD = 2;
export function createSlider(options: SliderOptions): SliderHandle {
const state = createState<SliderInteraction>({
pointerPercent: 0,
dragPercent: 0,
dragging: false,
pointing: false,
focused: false,
});
const abort = new AbortController();
let isDragging = false;
let moveCount = 0;
let cachedRTL = false;
let cachedRect: DOMRect | null = null;
let documentCleanup: (() => void) | null = null;
function endDrag(): void {
if (!isDragging) {
state.patch({ pointing: false, pointerPercent: 0 });
} else {
isDragging = false;
state.patch({ dragging: false, pointing: false, pointerPercent: 0 });
options.onDragEnd?.();
}
documentCleanup?.();
documentCleanup = null;
cachedRect = null;
}
function onDocumentPointerMove(event: PointerEvent): void {
// Stale drag safety: if buttons === 0 for non-touch, browser lost the pointerup.
if (event.pointerType !== 'touch' && event.buttons === 0) {
endDrag();
return;
}
moveCount++;
const percent = getPercentFromPointerEvent(event, cachedRect!, options.getOrientation(), cachedRTL);
if (!isDragging && moveCount >= DRAG_THRESHOLD) {
isDragging = true;
state.patch({ dragging: true, dragPercent: percent, pointerPercent: percent });
options.onDragStart?.();
options.onValueChange?.(percent);
} else if (isDragging) {
state.patch({ dragPercent: percent, pointerPercent: percent });
options.onValueChange?.(percent);
} else {
// Below drag threshold — update hover preview only.
state.patch({ pointerPercent: percent });
}
}
function onDocumentPointerUp(event: PointerEvent): void {
const percent = getPercentFromPointerEvent(event, cachedRect!, options.getOrientation(), cachedRTL);
options.onValueCommit?.(percent);
endDrag();
}
function addDocumentListeners(): void {
const docAc = new AbortController();
const signal = docAc.signal;
document.addEventListener('pointermove', onDocumentPointerMove, { passive: true, signal });
document.addEventListener('pointerup', onDocumentPointerUp, { signal });
document.addEventListener('pointercancel', endDrag, { signal });
document.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false, signal });
documentCleanup = () => docAc.abort();
}
// --- Root props ---
const rootProps: SliderRootProps = {
onPointerDown(event) {
if (options.isDisabled()) return;
const el = options.getElement();
cachedRect = el.getBoundingClientRect();
cachedRTL = options.isRTL();
moveCount = 0;
el.setPointerCapture(event.pointerId);
const percent = getPercentFromPointerEvent(event, cachedRect, options.getOrientation(), cachedRTL);
state.patch({ pointing: true, pointerPercent: percent, dragPercent: percent });
options.onValueChange?.(percent);
// Focus the thumb for keyboard follow-up and screen reader tracking.
options.getThumbElement?.()?.focus();
// Clean up any stale document listeners before adding new ones.
documentCleanup?.();
addDocumentListeners();
},
onPointerMove(event) {
if (options.isDisabled() || isDragging) return;
const el = options.getElement();
const rect = el.getBoundingClientRect();
const percent = getPercentFromPointerEvent(event, rect, options.getOrientation(), options.isRTL());
state.patch({ pointing: true, pointerPercent: percent });
},
onPointerLeave() {
if (isDragging) return;
state.patch({ pointing: false, pointerPercent: 0 });
},
};
// --- Thumb props ---
const thumbProps: SliderThumbProps = {
onKeyDown(event) {
if (options.isDisabled()) {
if (event.key !== 'Tab') event.preventDefault();
return;
}
const stepPercent = options.getStepPercent();
const largeStepPercent = options.getLargeStepPercent();
const currentPercent = options.getPercent();
// Round to nearest step before stepping to prevent drift from pointer drags.
const rounded = roundToStep(currentPercent, stepPercent, 0);
const rtl = options.isRTL();
// Horizontal arrows flip for RTL. Vertical arrows are unaffected.
const horizontalSign = rtl ? -1 : 1;
// Shift upgrades arrow keys to large step.
const step = event.shiftKey ? largeStepPercent : stepPercent;
let newPercent: number | null = null;
switch (event.key) {
case 'ArrowRight':
newPercent = rounded + step * horizontalSign;
break;
case 'ArrowLeft':
newPercent = rounded - step * horizontalSign;
break;
case 'ArrowUp':
newPercent = rounded + step;
break;
case 'ArrowDown':
newPercent = rounded - step;
break;
case 'PageUp':
newPercent = rounded + largeStepPercent;
break;
case 'PageDown':
newPercent = rounded - largeStepPercent;
break;
case 'Home':
newPercent = 0;
break;
case 'End':
newPercent = 100;
break;
default:
// Numeric keys 0-9: jump to N * 10%.
if (!event.metaKey && event.key >= '0' && event.key <= '9') {
newPercent = Number(event.key) * 10;
}
break;
}
if (newPercent !== null) {
event.preventDefault();
newPercent = clamp(newPercent, 0, 100);
state.patch({ pointerPercent: newPercent, dragPercent: newPercent });
options.onValueChange?.(newPercent);
options.onValueCommit?.(newPercent);
}
},
onFocus() {
state.patch({ focused: true });
},
onBlur() {
state.patch({ focused: false });
},
};
// Abort all document listeners on destroy.
abort.signal.addEventListener('abort', () => {
documentCleanup?.();
documentCleanup = null;
});
return {
interaction: state,
rootProps,
thumbProps,
destroy() {
abort.abort();
},
};
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { createSliderState, createTimeSliderState } from '../../tests/test-helpers';
import { getSliderCSSVars, getTimeSliderCSSVars } from '../slider-css-vars';
describe('getSliderCSSVars', () => {
it('returns fill and pointer CSS vars with 3-decimal precision', () => {
const vars = getSliderCSSVars(createSliderState({ fillPercent: 45.1234, pointerPercent: 67.8 }));
expect(vars['--media-slider-fill']).toBe('45.123%');
expect(vars['--media-slider-pointer']).toBe('67.800%');
});
it('formats zero values correctly', () => {
const vars = getSliderCSSVars(createSliderState({ fillPercent: 0, pointerPercent: 0 }));
expect(vars['--media-slider-fill']).toBe('0.000%');
expect(vars['--media-slider-pointer']).toBe('0.000%');
});
it('formats 100% values correctly', () => {
const vars = getSliderCSSVars(createSliderState({ fillPercent: 100, pointerPercent: 100 }));
expect(vars['--media-slider-fill']).toBe('100.000%');
expect(vars['--media-slider-pointer']).toBe('100.000%');
});
it('does not include buffer', () => {
const vars = getSliderCSSVars(createSliderState());
expect(vars['--media-slider-buffer']).toBeUndefined();
});
});
describe('getTimeSliderCSSVars', () => {
it('includes fill, pointer, and buffer CSS vars', () => {
const vars = getTimeSliderCSSVars(
createTimeSliderState({ fillPercent: 50, pointerPercent: 30, bufferPercent: 75 })
);
expect(vars['--media-slider-fill']).toBe('50.000%');
expect(vars['--media-slider-pointer']).toBe('30.000%');
expect(vars['--media-slider-buffer']).toBe('75.000%');
});
it('formats buffer zero correctly', () => {
const vars = getTimeSliderCSSVars(createTimeSliderState({ bufferPercent: 0 }));
expect(vars['--media-slider-buffer']).toBe('0.000%');
});
it('formats high-precision buffer correctly', () => {
const vars = getTimeSliderCSSVars(createTimeSliderState({ bufferPercent: 33.33333 }));
expect(vars['--media-slider-buffer']).toBe('33.333%');
});
});
@@ -0,0 +1,784 @@
import { flush } from '@videojs/store';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { UIKeyboardEvent, UIPointerEvent } from '../event';
import { createSlider, type SliderOptions } from '../slider';
// --- Helpers ---
function createMockElement(rect: Partial<DOMRect> = {}): HTMLElement {
const el = document.createElement('div');
el.getBoundingClientRect = () => ({
x: 0,
y: 0,
width: 200,
height: 20,
top: 0,
right: 200,
bottom: 20,
left: 0,
toJSON() {},
...rect,
});
el.setPointerCapture = vi.fn();
el.releasePointerCapture = vi.fn();
return el;
}
function createOptions(overrides: Partial<SliderOptions> = {}): SliderOptions {
return {
getElement: () => createMockElement(),
getOrientation: () => 'horizontal',
isRTL: () => false,
isDisabled: () => false,
getPercent: () => 50,
getStepPercent: () => 1,
getLargeStepPercent: () => 10,
onValueChange: vi.fn(),
onValueCommit: vi.fn(),
onDragStart: vi.fn(),
onDragEnd: vi.fn(),
...overrides,
};
}
function pointerEvent(overrides: Partial<UIPointerEvent> = {}): UIPointerEvent {
return {
clientX: 0,
clientY: 0,
pointerId: 1,
pointerType: 'mouse',
buttons: 1,
preventDefault: vi.fn(),
...overrides,
};
}
function keyboardEvent(key: string, overrides: Partial<UIKeyboardEvent> = {}): UIKeyboardEvent {
const node = document.createElement('div');
return {
key,
shiftKey: false,
metaKey: false,
target: node,
currentTarget: node,
preventDefault: vi.fn(),
...overrides,
};
}
function fireDocumentPointerMove(overrides: Partial<UIPointerEvent> = {}): void {
const init = pointerEvent(overrides);
document.dispatchEvent(new PointerEvent('pointermove', init));
}
function fireDocumentPointerUp(overrides: Partial<UIPointerEvent> = {}): void {
const init = pointerEvent({ buttons: 0, ...overrides });
document.dispatchEvent(new PointerEvent('pointerup', init));
}
function fireDocumentPointerCancel(): void {
document.dispatchEvent(new PointerEvent('pointercancel'));
}
// --- Tests ---
describe('createSlider', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
describe('shape', () => {
it('returns interaction, rootProps, thumbProps, and destroy', () => {
const slider = createSlider(createOptions());
expect(slider.interaction).toBeDefined();
expect(slider.interaction.current).toBeDefined();
expect(slider.interaction.subscribe).toBeTypeOf('function');
expect(slider.rootProps.onPointerDown).toBeTypeOf('function');
expect(slider.rootProps.onPointerMove).toBeTypeOf('function');
expect(slider.rootProps.onPointerLeave).toBeTypeOf('function');
expect(slider.thumbProps.onKeyDown).toBeTypeOf('function');
expect(slider.thumbProps.onFocus).toBeTypeOf('function');
expect(slider.thumbProps.onBlur).toBeTypeOf('function');
expect(slider.destroy).toBeTypeOf('function');
slider.destroy();
});
it('has correct initial interaction state', () => {
const slider = createSlider(createOptions());
expect(slider.interaction.current).toEqual({
pointerPercent: 0,
dragPercent: 0,
dragging: false,
pointing: false,
focused: false,
});
slider.destroy();
});
});
describe('pointer: pointerdown', () => {
it('sets pointing to true and computes percent from position', () => {
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 100 }));
flush();
expect(slider.interaction.current.pointing).toBe(true);
expect(slider.interaction.current.pointerPercent).toBe(50);
slider.destroy();
});
it('calls onValueChange with computed percent', () => {
const onValueChange = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, onValueChange }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 100 }));
expect(onValueChange).toHaveBeenCalledWith(50);
slider.destroy();
});
it('sets pointer capture on element', () => {
const el = createMockElement();
const slider = createSlider(createOptions({ getElement: () => el }));
slider.rootProps.onPointerDown(pointerEvent({ pointerId: 42 }));
expect(el.setPointerCapture).toHaveBeenCalledWith(42);
slider.destroy();
});
it('focuses thumb element when getThumbElement is provided', () => {
const thumb = document.createElement('div');
thumb.focus = vi.fn();
const slider = createSlider(createOptions({ getThumbElement: () => thumb }));
slider.rootProps.onPointerDown(pointerEvent());
expect(thumb.focus).toHaveBeenCalled();
slider.destroy();
});
it('does nothing when disabled', () => {
const onValueChange = vi.fn();
const slider = createSlider(createOptions({ isDisabled: () => true, onValueChange }));
slider.rootProps.onPointerDown(pointerEvent());
flush();
expect(onValueChange).not.toHaveBeenCalled();
expect(slider.interaction.current.pointing).toBe(false);
slider.destroy();
});
});
describe('pointer: drag', () => {
it('starts drag after threshold pointermove events', () => {
const onDragStart = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, onDragStart }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
// First move — below threshold
fireDocumentPointerMove({ clientX: 60 });
flush();
expect(slider.interaction.current.dragging).toBe(false);
expect(onDragStart).not.toHaveBeenCalled();
// Second move — meets threshold
fireDocumentPointerMove({ clientX: 80 });
flush();
expect(slider.interaction.current.dragging).toBe(true);
expect(onDragStart).toHaveBeenCalledOnce();
slider.destroy();
});
it('calls onValueChange only after drag threshold is reached', () => {
const onValueChange = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, onValueChange }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
onValueChange.mockClear();
// Move 1: below threshold — no onValueChange
fireDocumentPointerMove({ clientX: 60 });
expect(onValueChange).not.toHaveBeenCalled();
// Move 2: meets threshold — onValueChange fires
fireDocumentPointerMove({ clientX: 80 });
expect(onValueChange).toHaveBeenCalledTimes(1);
// Move 3: during drag — onValueChange fires
fireDocumentPointerMove({ clientX: 100 });
expect(onValueChange).toHaveBeenCalledTimes(2);
slider.destroy();
});
it('updates dragPercent during drag', () => {
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
fireDocumentPointerMove({ clientX: 60 });
fireDocumentPointerMove({ clientX: 100 });
flush();
expect(slider.interaction.current.dragPercent).toBe(50);
slider.destroy();
});
});
describe('pointer: pointerup', () => {
it('calls onValueCommit and onDragEnd on pointerup', () => {
const onValueCommit = vi.fn();
const onDragEnd = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit, onDragEnd }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
fireDocumentPointerMove({ clientX: 60 });
fireDocumentPointerMove({ clientX: 80 });
fireDocumentPointerUp({ clientX: 100 });
flush();
expect(onValueCommit).toHaveBeenCalledWith(50);
expect(onDragEnd).toHaveBeenCalled();
expect(slider.interaction.current.dragging).toBe(false);
expect(slider.interaction.current.pointing).toBe(false);
slider.destroy();
});
it('calls onValueCommit on pointerup even without drag', () => {
const onValueCommit = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 100 }));
fireDocumentPointerUp({ clientX: 100 });
expect(onValueCommit).toHaveBeenCalledWith(50);
slider.destroy();
});
});
describe('pointer: pointercancel', () => {
it('ends drag on pointercancel', () => {
const onDragEnd = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, onDragEnd }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
fireDocumentPointerMove({ clientX: 60 });
fireDocumentPointerMove({ clientX: 80 });
fireDocumentPointerCancel();
flush();
expect(onDragEnd).toHaveBeenCalled();
expect(slider.interaction.current.dragging).toBe(false);
slider.destroy();
});
});
describe('pointer: stale drag safety', () => {
it('ends drag when buttons is 0 for non-touch pointer', () => {
const onDragEnd = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, onDragEnd }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
fireDocumentPointerMove({ clientX: 60 });
fireDocumentPointerMove({ clientX: 80 });
flush();
expect(slider.interaction.current.dragging).toBe(true);
// Stale: buttons = 0, mouse pointer
fireDocumentPointerMove({ clientX: 100, buttons: 0, pointerType: 'mouse' });
flush();
expect(slider.interaction.current.dragging).toBe(false);
expect(onDragEnd).toHaveBeenCalled();
slider.destroy();
});
});
describe('pointer: hover (no drag)', () => {
it('updates pointerPercent on hover', () => {
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el }));
slider.rootProps.onPointerMove(pointerEvent({ clientX: 60 }));
flush();
expect(slider.interaction.current.pointing).toBe(true);
expect(slider.interaction.current.pointerPercent).toBe(30);
slider.destroy();
});
it('resets on pointerleave', () => {
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el }));
slider.rootProps.onPointerMove(pointerEvent({ clientX: 60 }));
slider.rootProps.onPointerLeave(pointerEvent());
flush();
expect(slider.interaction.current.pointing).toBe(false);
expect(slider.interaction.current.pointerPercent).toBe(0);
slider.destroy();
});
it('does not reset on pointerleave during drag', () => {
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
fireDocumentPointerMove({ clientX: 60 });
fireDocumentPointerMove({ clientX: 80 });
flush();
expect(slider.interaction.current.dragging).toBe(true);
slider.rootProps.onPointerLeave(pointerEvent());
flush();
expect(slider.interaction.current.pointing).toBe(true);
slider.destroy();
});
});
describe('keyboard', () => {
it('ArrowRight increments by step', () => {
const onValueChange = vi.fn();
const onValueCommit = vi.fn();
const slider = createSlider(
createOptions({
getPercent: () => 50,
getStepPercent: () => 1,
onValueChange,
onValueCommit,
})
);
const event = keyboardEvent('ArrowRight');
slider.thumbProps.onKeyDown(event);
expect(onValueChange).toHaveBeenCalledWith(51);
expect(onValueCommit).toHaveBeenCalledWith(51);
expect(event.preventDefault).toHaveBeenCalled();
slider.destroy();
});
it('ArrowLeft decrements by step', () => {
const onValueChange = vi.fn();
const slider = createSlider(createOptions({ getPercent: () => 50, getStepPercent: () => 1, onValueChange }));
slider.thumbProps.onKeyDown(keyboardEvent('ArrowLeft'));
expect(onValueChange).toHaveBeenCalledWith(49);
slider.destroy();
});
it('ArrowUp increments by step', () => {
const onValueChange = vi.fn();
const slider = createSlider(createOptions({ getPercent: () => 50, getStepPercent: () => 5, onValueChange }));
slider.thumbProps.onKeyDown(keyboardEvent('ArrowUp'));
expect(onValueChange).toHaveBeenCalledWith(55);
slider.destroy();
});
it('ArrowDown decrements by step', () => {
const onValueChange = vi.fn();
const slider = createSlider(createOptions({ getPercent: () => 50, getStepPercent: () => 5, onValueChange }));
slider.thumbProps.onKeyDown(keyboardEvent('ArrowDown'));
expect(onValueChange).toHaveBeenCalledWith(45);
slider.destroy();
});
it('Shift+Arrow uses large step', () => {
const onValueChange = vi.fn();
const slider = createSlider(
createOptions({
getPercent: () => 50,
getStepPercent: () => 1,
getLargeStepPercent: () => 10,
onValueChange,
})
);
slider.thumbProps.onKeyDown(keyboardEvent('ArrowRight', { shiftKey: true }));
expect(onValueChange).toHaveBeenCalledWith(60);
slider.destroy();
});
it('PageUp increments by large step', () => {
const onValueChange = vi.fn();
const slider = createSlider(
createOptions({ getPercent: () => 50, getLargeStepPercent: () => 10, onValueChange })
);
slider.thumbProps.onKeyDown(keyboardEvent('PageUp'));
expect(onValueChange).toHaveBeenCalledWith(60);
slider.destroy();
});
it('PageDown decrements by large step', () => {
const onValueChange = vi.fn();
const slider = createSlider(
createOptions({ getPercent: () => 50, getLargeStepPercent: () => 10, onValueChange })
);
slider.thumbProps.onKeyDown(keyboardEvent('PageDown'));
expect(onValueChange).toHaveBeenCalledWith(40);
slider.destroy();
});
it('Home goes to 0%', () => {
const onValueChange = vi.fn();
const slider = createSlider(createOptions({ onValueChange }));
slider.thumbProps.onKeyDown(keyboardEvent('Home'));
expect(onValueChange).toHaveBeenCalledWith(0);
slider.destroy();
});
it('End goes to 100%', () => {
const onValueChange = vi.fn();
const slider = createSlider(createOptions({ onValueChange }));
slider.thumbProps.onKeyDown(keyboardEvent('End'));
expect(onValueChange).toHaveBeenCalledWith(100);
slider.destroy();
});
it('numeric keys jump to N * 10%', () => {
const onValueChange = vi.fn();
const slider = createSlider(createOptions({ onValueChange }));
slider.thumbProps.onKeyDown(keyboardEvent('5'));
expect(onValueChange).toHaveBeenCalledWith(50);
onValueChange.mockClear();
slider.thumbProps.onKeyDown(keyboardEvent('0'));
expect(onValueChange).toHaveBeenCalledWith(0);
onValueChange.mockClear();
slider.thumbProps.onKeyDown(keyboardEvent('9'));
expect(onValueChange).toHaveBeenCalledWith(90);
slider.destroy();
});
it('numeric keys do not fire when metaKey is held', () => {
const onValueChange = vi.fn();
const slider = createSlider(createOptions({ onValueChange }));
slider.thumbProps.onKeyDown(keyboardEvent('5', { metaKey: true }));
expect(onValueChange).not.toHaveBeenCalled();
slider.destroy();
});
it('clamps to 0-100 range', () => {
const onValueChange = vi.fn();
const slider = createSlider(createOptions({ getPercent: () => 99, getStepPercent: () => 5, onValueChange }));
slider.thumbProps.onKeyDown(keyboardEvent('ArrowRight'));
expect(onValueChange).toHaveBeenCalledWith(100);
slider.destroy();
});
it('calls both onValueChange and onValueCommit for each step', () => {
const onValueChange = vi.fn();
const onValueCommit = vi.fn();
const slider = createSlider(createOptions({ getPercent: () => 50, onValueChange, onValueCommit }));
slider.thumbProps.onKeyDown(keyboardEvent('ArrowRight'));
expect(onValueChange).toHaveBeenCalledOnce();
expect(onValueCommit).toHaveBeenCalledOnce();
slider.destroy();
});
it('preventDefault is called for handled keys', () => {
const slider = createSlider(createOptions());
const event = keyboardEvent('ArrowRight');
slider.thumbProps.onKeyDown(event);
expect(event.preventDefault).toHaveBeenCalled();
slider.destroy();
});
it('does not preventDefault for unhandled keys', () => {
const onValueChange = vi.fn();
const slider = createSlider(createOptions({ onValueChange }));
const event = keyboardEvent('Tab');
slider.thumbProps.onKeyDown(event);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(onValueChange).not.toHaveBeenCalled();
slider.destroy();
});
it('rounds before stepping to prevent drift', () => {
const onValueChange = vi.fn();
// Simulate a value between steps (e.g., from a drag that landed at 47.3)
const slider = createSlider(createOptions({ getPercent: () => 47.3, getStepPercent: () => 5, onValueChange }));
slider.thumbProps.onKeyDown(keyboardEvent('ArrowRight'));
// 47.3 rounds to 45 (nearest step of 5 from 0), then +5 = 50
expect(onValueChange).toHaveBeenCalledWith(50);
slider.destroy();
});
});
describe('keyboard: RTL', () => {
it('flips ArrowRight to decrement in RTL', () => {
const onValueChange = vi.fn();
const slider = createSlider(
createOptions({
isRTL: () => true,
getPercent: () => 50,
getStepPercent: () => 1,
onValueChange,
})
);
slider.thumbProps.onKeyDown(keyboardEvent('ArrowRight'));
expect(onValueChange).toHaveBeenCalledWith(49);
slider.destroy();
});
it('flips ArrowLeft to increment in RTL', () => {
const onValueChange = vi.fn();
const slider = createSlider(
createOptions({
isRTL: () => true,
getPercent: () => 50,
getStepPercent: () => 1,
onValueChange,
})
);
slider.thumbProps.onKeyDown(keyboardEvent('ArrowLeft'));
expect(onValueChange).toHaveBeenCalledWith(51);
slider.destroy();
});
it('does not flip ArrowUp/ArrowDown in RTL', () => {
const onValueChange = vi.fn();
const slider = createSlider(
createOptions({
isRTL: () => true,
getPercent: () => 50,
getStepPercent: () => 1,
onValueChange,
})
);
slider.thumbProps.onKeyDown(keyboardEvent('ArrowUp'));
expect(onValueChange).toHaveBeenCalledWith(51);
onValueChange.mockClear();
slider.thumbProps.onKeyDown(keyboardEvent('ArrowDown'));
expect(onValueChange).toHaveBeenCalledWith(49);
slider.destroy();
});
});
describe('keyboard: disabled', () => {
it('no-ops when disabled (except Tab)', () => {
const onValueChange = vi.fn();
const slider = createSlider(createOptions({ isDisabled: () => true, onValueChange }));
const arrowEvent = keyboardEvent('ArrowRight');
slider.thumbProps.onKeyDown(arrowEvent);
expect(onValueChange).not.toHaveBeenCalled();
expect(arrowEvent.preventDefault).toHaveBeenCalled();
slider.destroy();
});
it('does not preventDefault Tab when disabled', () => {
const slider = createSlider(createOptions({ isDisabled: () => true }));
const tabEvent = keyboardEvent('Tab');
slider.thumbProps.onKeyDown(tabEvent);
expect(tabEvent.preventDefault).not.toHaveBeenCalled();
slider.destroy();
});
});
describe('focus', () => {
it('sets focused true on focus', () => {
const slider = createSlider(createOptions());
slider.thumbProps.onFocus();
flush();
expect(slider.interaction.current.focused).toBe(true);
slider.destroy();
});
it('sets focused false on blur', () => {
const slider = createSlider(createOptions());
slider.thumbProps.onFocus();
slider.thumbProps.onBlur();
flush();
expect(slider.interaction.current.focused).toBe(false);
slider.destroy();
});
});
describe('orientation', () => {
it('computes percent from Y axis for vertical orientation', () => {
const el = createMockElement({ top: 0, height: 100 });
const slider = createSlider(createOptions({ getElement: () => el, getOrientation: () => 'vertical' }));
// vertical: 0% at bottom (y=100), 100% at top (y=0)
slider.rootProps.onPointerDown(pointerEvent({ clientY: 25 }));
flush();
expect(slider.interaction.current.pointerPercent).toBe(75);
slider.destroy();
});
it('computes percent from X axis for horizontal orientation', () => {
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, getOrientation: () => 'horizontal' }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
flush();
expect(slider.interaction.current.pointerPercent).toBe(25);
slider.destroy();
});
});
describe('orientation: vertical + RTL', () => {
it('ignores RTL for vertical orientation', () => {
const el = createMockElement({ top: 0, height: 100 });
const slider = createSlider(
createOptions({ getElement: () => el, getOrientation: () => 'vertical', isRTL: () => true })
);
slider.rootProps.onPointerDown(pointerEvent({ clientY: 25 }));
flush();
// Same result as vertical + LTR — RTL has no effect.
expect(slider.interaction.current.pointerPercent).toBe(75);
slider.destroy();
});
});
describe('RTL pointer', () => {
it('flips horizontal percent for RTL', () => {
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, isRTL: () => true }));
// RTL: right = 0%, left = 100%
// clientX=50, rect.right=200 → (200-50)/200 = 75%
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
flush();
expect(slider.interaction.current.pointerPercent).toBe(75);
slider.destroy();
});
});
describe('lifecycle', () => {
it('destroy cleans up without errors', () => {
const slider = createSlider(createOptions());
slider.destroy();
// Should not throw on repeated destroy
expect(() => slider.destroy()).not.toThrow();
});
it('destroy cleans up active document listeners', () => {
const onDragEnd = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, onDragEnd }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
slider.destroy();
// Document pointermove after destroy should not trigger callbacks
fireDocumentPointerMove({ clientX: 100 });
expect(onDragEnd).not.toHaveBeenCalled();
});
});
});
+1
View File
@@ -1,4 +1,5 @@
export type { StateAttrMap } from '../../core/ui/types';
export { applyElementProps } from './element-props';
export { logMissingFeature } from './log';
export { getPercentFromPointerEvent } from './pointer';
export { applyStateDataAttrs, getStateDataAttrs } from './state-data-attrs';
+21
View File
@@ -0,0 +1,21 @@
import { clamp } from '@videojs/utils/number';
/** Convert a pointer event position to a 0100 percent along an element's rect. */
export function getPercentFromPointerEvent(
event: { clientX: number; clientY: number },
rect: DOMRect,
orientation: 'horizontal' | 'vertical',
isRTL: boolean
): number {
let ratio: number;
if (orientation === 'vertical') {
ratio = 1 - (event.clientY - rect.top) / rect.height;
} else if (isRTL) {
ratio = (rect.right - event.clientX) / rect.width;
} else {
ratio = (event.clientX - rect.left) / rect.width;
}
return clamp(ratio * 100, 0, 100);
}
+1
View File
@@ -19,6 +19,7 @@ export default defineConfig({
name: 'core/dom',
include: ['src/dom/**/*.test.ts'],
environment: 'jsdom',
setupFiles: ['src/dom/tests/setup.ts'],
},
},
],