mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add slider dom (#613)
This commit is contained in:
+37
-166
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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 ?? '';
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)}%`,
|
||||
};
|
||||
}
|
||||
@@ -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<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,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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export default defineConfig({
|
||||
name: 'core/dom',
|
||||
include: ['src/dom/**/*.test.ts'],
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['src/dom/tests/setup.ts'],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user