From 4c7d287d357e1f1b820aca62b0a26eda225c441f Mon Sep 17 00:00:00 2001 From: rahim Date: Wed, 25 Feb 2026 16:11:58 -0800 Subject: [PATCH] feat(core): add slider dom (#613) --- .claude/plans/slider.md | 203 +---- packages/core/src/dom/index.ts | 3 + .../dom/store/features/tests/buffer.test.ts | 40 +- .../dom/store/features/tests/controls.test.ts | 29 +- .../store/features/tests/fullscreen.test.ts | 6 +- .../src/dom/store/features/tests/pip.test.ts | 6 +- .../dom/store/features/tests/playback.test.ts | 28 +- .../dom/store/features/tests/source.test.ts | 24 +- .../src/dom/store/features/tests/time.test.ts | 33 +- .../dom/store/features/tests/volume.test.ts | 20 +- packages/core/src/dom/tests/setup.ts | 20 + packages/core/src/dom/tests/test-helpers.ts | 109 +++ packages/core/src/dom/ui/event.ts | 10 + packages/core/src/dom/ui/slider-css-vars.ts | 17 + packages/core/src/dom/ui/slider.ts | 260 ++++++ .../src/dom/ui/tests/slider-css-vars.test.ts | 57 ++ packages/core/src/dom/ui/tests/slider.test.ts | 784 ++++++++++++++++++ packages/core/src/dom/utils/index.ts | 1 + packages/core/src/dom/utils/pointer.ts | 21 + packages/core/vitest.config.ts | 1 + 20 files changed, 1331 insertions(+), 341 deletions(-) create mode 100644 packages/core/src/dom/tests/setup.ts create mode 100644 packages/core/src/dom/tests/test-helpers.ts create mode 100644 packages/core/src/dom/ui/slider-css-vars.ts create mode 100644 packages/core/src/dom/ui/slider.ts create mode 100644 packages/core/src/dom/ui/tests/slider-css-vars.test.ts create mode 100644 packages/core/src/dom/ui/tests/slider.test.ts create mode 100644 packages/core/src/dom/utils/pointer.ts diff --git a/.claude/plans/slider.md b/.claude/plans/slider.md index 56cb6baa..4319858e 100644 --- a/.claude/plans/slider.md +++ b/.claude/plans/slider.md @@ -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 --- -## 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` 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` 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; - rootProps: SliderRootProps; - thumbProps: SliderThumbProps; - destroy: () => void; -} +### Files modified -export function createSlider(options: SliderOptions): SliderHandle; -``` - -**Behavior:** - -1. Creates `WritableState` via `createState()`. -2. **Pointer down on root:** Capture pointer, start drag. Compute percent from pointer position. Patch `{ dragging: true, dragPercent, pointerPercent }`. Call `onDragStart`. Call `onValueChange(dragPercent)`. -3. **Pointer move:** If dragging — update `dragPercent`, call `onValueChange`. If not dragging — update `pointerPercent` only (hover preview). Patch to state. -4. **Pointer up (window listener):** End drag. Call `onValueCommit(dragPercent)`. Call `onDragEnd`. Patch `{ dragging: false }`. Release pointer capture. -5. **Pointer leave:** If not dragging, reset `{ pointing: false, pointerPercent: 0 }`. -6. **Keyboard (on thumb):** - - `ArrowRight`/`ArrowUp`: increment by step percent - - `ArrowLeft`/`ArrowDown`: decrement by step percent - - `PageUp`: increment by large step percent - - `PageDown`: decrement by large step percent - - `Home`: go to 0% - - `End`: go to 100% - - RTL: invert horizontal arrows - - Vertical: Up = increase, Down = decrease - - Call `onValueChange` then `onValueCommit` on each key. -7. **Focus/blur on thumb:** Patch `{ focused }`. -8. **Disabled check:** All handlers early-return if `isDisabled()`. - -**Internal helper:** - -```ts -function getPercentFromPointerEvent( - event: UIPointerEvent, - rect: DOMRect, - orientation: 'horizontal' | 'vertical', - isRTL: boolean -): number; -// Horizontal: (clientX - rect.left) / rect.width * 100, flipped for RTL. -// Vertical: (rect.bottom - clientY) / rect.height * 100 (bottom = 0%). -// Clamped to [0, 100]. -``` - -**Notes:** -- The element reference (`getElement()`) is used for `getBoundingClientRect()` in percent calculations and `setPointerCapture()`/`releasePointerCapture()` during drag. `UIPointerEvent` does not carry `target`/`currentTarget` (consistent with the existing `UIEvent` pattern) — the element reference comes from options, not events. -- On pointerdown, the factory calls `getElement().setPointerCapture(event.pointerId)`. This means the Root element itself captures — all subsequent pointermove/pointerup events flow to it regardless of mouse position. -- The `destroy()` function aborts internal AbortController (cleans up any listeners). -- `onPointerMove` on the root also handles pointerup detection (via `event.buttons === 0` fallback for edge cases where pointerup fires on window but state is stale). -- RTL detection: `isRTL()` callback (from options) should use `isRTL(element)` from `@videojs/utils/dom` (added in PR 1). Cache the result on `pointerdown` — direction doesn't change mid-drag. -- The `onValueChange` callback can be wrapped with `rafThrottle` from `@videojs/utils/dom` by the consumer (e.g., `TimeSlider.Root` throttles seek events). The factory itself fires callbacks on every interaction. - -### 2.3 CSS Variable Formatting - -**File:** `packages/core/src/dom/ui/slider-css-vars.ts` - -```ts -import { SliderCSSVars } from '../../core/ui/slider/slider-css-vars'; -import type { SliderState } from '../../core/ui/slider/slider-core'; -import type { TimeSliderState } from '../../core/ui/slider/time-slider-core'; - -export function getSliderCSSVars(state: SliderState): Record { - return { - [SliderCSSVars.fill]: `${state.fillPercent.toFixed(3)}%`, - [SliderCSSVars.pointer]: `${state.pointerPercent.toFixed(3)}%`, - }; -} - -export function getTimeSliderCSSVars(state: TimeSliderState): Record { - return { - ...getSliderCSSVars(state), - [SliderCSSVars.buffer]: `${state.bufferPercent.toFixed(3)}%`, - }; -} -``` - -### 2.4 Barrel Export - -Add exports to `packages/core/src/dom/index.ts`: - -```ts -export * from './ui/event'; // Exposes UIEvent, UIKeyboardEvent, UIPointerEvent -export * from './ui/slider'; -export * from './ui/slider-css-vars'; -``` - -Note: `./ui/event` is not currently exported from the barrel. Adding it exposes `UIEvent`, -`UIKeyboardEvent`, and the new `UIPointerEvent` as public API from `@videojs/core/dom`. -This is intentional — these types are needed by consumers building custom interaction handlers. - -### 2.5 Tests - -**File:** `packages/core/src/dom/ui/tests/slider.test.ts` - -- `createSlider` returns correct shape -- Pointer down starts drag, `onDragStart` called, interaction state updates -- Pointer move during drag calls `onValueChange` with percent -- Pointer up calls `onValueCommit`, `onDragEnd`, drag ends -- Pointer move without drag updates `pointerPercent` (hover) -- Pointer leave resets pointing -- Keyboard: arrow keys increment/decrement, Page/Home/End, RTL flip, vertical inversion -- Disabled handlers no-op -- Destroy cleans up - -**File:** `packages/core/src/dom/ui/tests/slider-css-vars.test.ts` - -- `getSliderCSSVars`: correct keys and 3-decimal formatting -- `getTimeSliderCSSVars`: includes buffer var - -### 2.6 Verify - -```bash -pnpm -F @videojs/core test src/dom/ui/tests/slider -pnpm -F @videojs/core build -pnpm typecheck -pnpm lint:fix:file -``` +- `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` --- diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index c6c1f792..47f4a54f 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -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'; diff --git a/packages/core/src/dom/store/features/tests/buffer.test.ts b/packages/core/src/dom/store/features/tests/buffer.test.ts index 157d7659..bcef6d48 100644 --- a/packages/core/src/dom/store/features/tests/buffer.test.ts +++ b/packages/core/src/dom/store/features/tests/buffer.test.ts @@ -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]; - }, - }; -} diff --git a/packages/core/src/dom/store/features/tests/controls.test.ts b/packages/core/src/dom/store/features/tests/controls.test.ts index 256f9c58..d23452f0 100644 --- a/packages/core/src/dom/store/features/tests/controls.test.ts +++ b/packages/core/src/dom/store/features/tests/controls.test.ts @@ -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'); } diff --git a/packages/core/src/dom/store/features/tests/fullscreen.test.ts b/packages/core/src/dom/store/features/tests/fullscreen.test.ts index b98f1a6f..b0492cdb 100644 --- a/packages/core/src/dom/store/features/tests/fullscreen.test.ts +++ b/packages/core/src/dom/store/features/tests/fullscreen.test.ts @@ -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'); -} diff --git a/packages/core/src/dom/store/features/tests/pip.test.ts b/packages/core/src/dom/store/features/tests/pip.test.ts index 4e9c7a32..38a1758f 100644 --- a/packages/core/src/dom/store/features/tests/pip.test.ts +++ b/packages/core/src/dom/store/features/tests/pip.test.ts @@ -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'); -} diff --git a/packages/core/src/dom/store/features/tests/playback.test.ts b/packages/core/src/dom/store/features/tests/playback.test.ts index 287f2a57..c181bff9 100644 --- a/packages/core/src/dom/store/features/tests/playback.test.ts +++ b/packages/core/src/dom/store/features/tests/playback.test.ts @@ -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; -} diff --git a/packages/core/src/dom/store/features/tests/source.test.ts b/packages/core/src/dom/store/features/tests/source.test.ts index cd409635..c2ca97bb 100644 --- a/packages/core/src/dom/store/features/tests/source.test.ts +++ b/packages/core/src/dom/store/features/tests/source.test.ts @@ -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; -} diff --git a/packages/core/src/dom/store/features/tests/time.test.ts b/packages/core/src/dom/store/features/tests/time.test.ts index 606ba265..b0eaab45 100644 --- a/packages/core/src/dom/store/features/tests/time.test.ts +++ b/packages/core/src/dom/store/features/tests/time.test.ts @@ -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()(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()(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()(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; -} diff --git a/packages/core/src/dom/store/features/tests/volume.test.ts b/packages/core/src/dom/store/features/tests/volume.test.ts index bbf1ad01..601a883b 100644 --- a/packages/core/src/dom/store/features/tests/volume.test.ts +++ b/packages/core/src/dom/store/features/tests/volume.test.ts @@ -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; -} diff --git a/packages/core/src/dom/tests/setup.ts b/packages/core/src/dom/tests/setup.ts new file mode 100644 index 00000000..8bd56430 --- /dev/null +++ b/packages/core/src/dom/tests/setup.ts @@ -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 ?? ''; + } + }; +} diff --git a/packages/core/src/dom/tests/test-helpers.ts b/packages/core/src/dom/tests/test-helpers.ts new file mode 100644 index 00000000..f5c497a8 --- /dev/null +++ b/packages/core/src/dom/tests/test-helpers.ts @@ -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 = { + 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 { + 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 { + return { + ...createSliderState(), + currentTime: 30, + duration: 60, + seeking: false, + bufferPercent: 75, + ...overrides, + }; +} diff --git a/packages/core/src/dom/ui/event.ts b/packages/core/src/dom/ui/event.ts index 96bc4e36..df6b57ed 100644 --- a/packages/core/src/dom/ui/event.ts +++ b/packages/core/src/dom/ui/event.ts @@ -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; +} diff --git a/packages/core/src/dom/ui/slider-css-vars.ts b/packages/core/src/dom/ui/slider-css-vars.ts new file mode 100644 index 00000000..d483e5de --- /dev/null +++ b/packages/core/src/dom/ui/slider-css-vars.ts @@ -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 { + return { + [SliderCSSVars.fill]: `${state.fillPercent.toFixed(3)}%`, + [SliderCSSVars.pointer]: `${state.pointerPercent.toFixed(3)}%`, + }; +} + +export function getTimeSliderCSSVars(state: TimeSliderState): Record { + return { + ...getSliderCSSVars(state), + [SliderCSSVars.buffer]: `${state.bufferPercent.toFixed(3)}%`, + }; +} diff --git a/packages/core/src/dom/ui/slider.ts b/packages/core/src/dom/ui/slider.ts new file mode 100644 index 00000000..7f491c97 --- /dev/null +++ b/packages/core/src/dom/ui/slider.ts @@ -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 0–100 percent. Used by keyboard stepping. */ + getPercent: () => number; + /** Step size as 0–100 percent. Arrow keys. */ + getStepPercent: () => number; + /** Large step size as 0–100 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; + 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({ + 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(); + }, + }; +} diff --git a/packages/core/src/dom/ui/tests/slider-css-vars.test.ts b/packages/core/src/dom/ui/tests/slider-css-vars.test.ts new file mode 100644 index 00000000..93966dd0 --- /dev/null +++ b/packages/core/src/dom/ui/tests/slider-css-vars.test.ts @@ -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%'); + }); +}); diff --git a/packages/core/src/dom/ui/tests/slider.test.ts b/packages/core/src/dom/ui/tests/slider.test.ts new file mode 100644 index 00000000..fefad589 --- /dev/null +++ b/packages/core/src/dom/ui/tests/slider.test.ts @@ -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 = {}): 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 { + 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 { + return { + clientX: 0, + clientY: 0, + pointerId: 1, + pointerType: 'mouse', + buttons: 1, + preventDefault: vi.fn(), + ...overrides, + }; +} + +function keyboardEvent(key: string, overrides: Partial = {}): UIKeyboardEvent { + const node = document.createElement('div'); + + return { + key, + shiftKey: false, + metaKey: false, + target: node, + currentTarget: node, + preventDefault: vi.fn(), + ...overrides, + }; +} + +function fireDocumentPointerMove(overrides: Partial = {}): void { + const init = pointerEvent(overrides); + document.dispatchEvent(new PointerEvent('pointermove', init)); +} + +function fireDocumentPointerUp(overrides: Partial = {}): 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(); + }); + }); +}); diff --git a/packages/core/src/dom/utils/index.ts b/packages/core/src/dom/utils/index.ts index 7b805d1a..592bd446 100644 --- a/packages/core/src/dom/utils/index.ts +++ b/packages/core/src/dom/utils/index.ts @@ -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'; diff --git a/packages/core/src/dom/utils/pointer.ts b/packages/core/src/dom/utils/pointer.ts new file mode 100644 index 00000000..d2dd991b --- /dev/null +++ b/packages/core/src/dom/utils/pointer.ts @@ -0,0 +1,21 @@ +import { clamp } from '@videojs/utils/number'; + +/** Convert a pointer event position to a 0–100 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); +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 3c8f3f6a..b87d2035 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ name: 'core/dom', include: ['src/dom/**/*.test.ts'], environment: 'jsdom', + setupFiles: ['src/dom/tests/setup.ts'], }, }, ],