feat(react): add slider component (#644)

This commit is contained in:
rahim
2026-02-28 00:48:51 -08:00
committed by GitHub
parent e63d591d6e
commit 2f8ca094ad
21 changed files with 1276 additions and 12 deletions
+18
View File
@@ -1,5 +1,6 @@
import { createState, type State } from '@videojs/store';
import { listen } from '@videojs/utils/dom';
import { throttle } from '@videojs/utils/function';
import { clamp, roundToStep } from '@videojs/utils/number';
import { isNull } from '@videojs/utils/predicate';
import type { SliderInteraction } from '../../core/ui/slider/slider-core';
@@ -24,6 +25,13 @@ export interface SliderOptions {
/** Large step size as 0100 percent. Page Up/Down, Shift+Arrow. */
getLargeStepPercent: () => number;
/**
* Trailing-edge throttle (ms) for `onValueCommit` during drag. When `> 0`,
* `onValueCommit` fires periodically while dragging, then a final unthrottled
* commit fires on pointer release. `0` (default) disables — commits only on release.
*/
commitThrottle?: number | undefined;
onValueChange?: ((percent: number) => void) | undefined;
onValueCommit?: ((percent: number) => void) | undefined;
onDragStart?: (() => void) | undefined;
@@ -62,6 +70,8 @@ export function createSlider(options: SliderOptions): SliderHandle {
});
const abort = new AbortController();
const commitThrottleMs = options.commitThrottle ?? 0;
let isDragging = false,
moveCount = 0,
cachedRTL = false,
@@ -69,6 +79,9 @@ export function createSlider(options: SliderOptions): SliderHandle {
documentCleanup: (() => void) | null = null,
capturedPointerId: number | null = null;
const throttledCommit =
commitThrottleMs > 0 ? throttle((percent: number) => options.onValueCommit?.(percent), commitThrottleMs) : null;
function releaseCapture(): void {
if (isNull(capturedPointerId)) return;
@@ -95,6 +108,7 @@ export function createSlider(options: SliderOptions): SliderHandle {
}
function cleanup() {
throttledCommit?.cancel();
releaseCapture();
documentCleanup?.();
documentCleanup = null;
@@ -117,9 +131,11 @@ export function createSlider(options: SliderOptions): SliderHandle {
state.patch({ dragging: true, dragPercent: percent, pointerPercent: percent });
options.onDragStart?.();
options.onValueChange?.(percent);
throttledCommit?.(percent);
} else if (isDragging) {
state.patch({ dragPercent: percent, pointerPercent: percent });
options.onValueChange?.(percent);
throttledCommit?.(percent);
} else {
// Below drag threshold — update hover preview only.
state.patch({ pointerPercent: percent });
@@ -129,6 +145,8 @@ export function createSlider(options: SliderOptions): SliderHandle {
function onDocumentPointerUp(event: PointerEvent): void {
const percent = getPercentFromPointerEvent(event, cachedRect!, options.getOrientation(), cachedRTL);
// Cancel pending throttled commit before the final unthrottled one.
throttledCommit?.cancel();
options.onValueCommit?.(percent);
endDrag();
}
@@ -873,4 +873,224 @@ describe('createSlider', () => {
expect(onDragEnd).not.toHaveBeenCalled();
});
});
describe('commitThrottle', () => {
it('does not fire onValueCommit during drag when commitThrottle is 0', () => {
const onValueCommit = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit, commitThrottle: 0 }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
onValueCommit.mockClear();
// Pass drag threshold
fireDocumentPointerMove({ clientX: 60 });
fireDocumentPointerMove({ clientX: 80 });
fireDocumentPointerMove({ clientX: 100 });
expect(onValueCommit).not.toHaveBeenCalled();
slider.destroy();
});
it('fires throttled onValueCommit during drag when commitThrottle > 0', () => {
vi.useFakeTimers();
const onValueCommit = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit, commitThrottle: 100 }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
onValueCommit.mockClear();
// Pass drag threshold and continue dragging
fireDocumentPointerMove({ clientX: 60 });
fireDocumentPointerMove({ clientX: 80 });
fireDocumentPointerMove({ clientX: 100 });
fireDocumentPointerMove({ clientX: 120 });
// Not yet — throttle hasn't fired
expect(onValueCommit).not.toHaveBeenCalled();
// Advance timer past throttle
vi.advanceTimersByTime(100);
expect(onValueCommit).toHaveBeenCalledOnce();
// Should have the latest drag percent (120/200 = 60%)
expect(onValueCommit).toHaveBeenCalledWith(60);
slider.destroy();
vi.useRealTimers();
});
it('uses trailing-edge: batches rapid moves into one commit', () => {
vi.useFakeTimers();
const onValueCommit = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit, commitThrottle: 100 }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
onValueCommit.mockClear();
// Pass threshold
fireDocumentPointerMove({ clientX: 60 });
fireDocumentPointerMove({ clientX: 80 });
// Multiple rapid moves during drag
fireDocumentPointerMove({ clientX: 100 });
fireDocumentPointerMove({ clientX: 120 });
fireDocumentPointerMove({ clientX: 140 });
vi.advanceTimersByTime(100);
// Only one commit with the latest value (140/200 = 70%)
expect(onValueCommit).toHaveBeenCalledOnce();
expect(onValueCommit).toHaveBeenCalledWith(70);
slider.destroy();
vi.useRealTimers();
});
it('fires another throttled commit after the first one completes', () => {
vi.useFakeTimers();
const onValueCommit = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit, commitThrottle: 100 }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
onValueCommit.mockClear();
// Pass threshold and drag
fireDocumentPointerMove({ clientX: 60 });
fireDocumentPointerMove({ clientX: 80 });
vi.advanceTimersByTime(100);
expect(onValueCommit).toHaveBeenCalledOnce();
// Continue dragging — should schedule another throttle
fireDocumentPointerMove({ clientX: 140 });
vi.advanceTimersByTime(100);
expect(onValueCommit).toHaveBeenCalledTimes(2);
expect(onValueCommit).toHaveBeenLastCalledWith(70);
slider.destroy();
vi.useRealTimers();
});
it('cancels throttle and fires final unthrottled commit on pointerup', () => {
vi.useFakeTimers();
const onValueCommit = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit, commitThrottle: 100 }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
onValueCommit.mockClear();
// Pass threshold and drag
fireDocumentPointerMove({ clientX: 60 });
fireDocumentPointerMove({ clientX: 80 });
fireDocumentPointerMove({ clientX: 120 });
// Release before throttle fires
fireDocumentPointerUp({ clientX: 150 });
// Final commit with release position (150/200 = 75%)
expect(onValueCommit).toHaveBeenCalledOnce();
expect(onValueCommit).toHaveBeenCalledWith(75);
// Advancing timer should NOT fire a stale throttled commit
vi.advanceTimersByTime(200);
expect(onValueCommit).toHaveBeenCalledOnce();
slider.destroy();
vi.useRealTimers();
});
it('cancels throttle on destroy', () => {
vi.useFakeTimers();
const onValueCommit = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit, commitThrottle: 100 }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
onValueCommit.mockClear();
// Pass threshold
fireDocumentPointerMove({ clientX: 60 });
fireDocumentPointerMove({ clientX: 80 });
slider.destroy();
// Advancing timer should NOT fire
vi.advanceTimersByTime(200);
expect(onValueCommit).not.toHaveBeenCalled();
vi.useRealTimers();
});
it('cancels throttle on pointercancel', () => {
vi.useFakeTimers();
const onValueCommit = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit, commitThrottle: 100 }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
onValueCommit.mockClear();
// Pass threshold
fireDocumentPointerMove({ clientX: 60 });
fireDocumentPointerMove({ clientX: 80 });
fireDocumentPointerCancel();
// Advancing timer should NOT fire
vi.advanceTimersByTime(200);
expect(onValueCommit).not.toHaveBeenCalled();
slider.destroy();
vi.useRealTimers();
});
it('does not throttle keyboard commits', () => {
const onValueCommit = vi.fn();
const slider = createSlider(createOptions({ getPercent: () => 50, onValueCommit, commitThrottle: 100 }));
slider.thumbProps.onKeyDown(keyboardEvent('ArrowRight'));
// Keyboard commits fire immediately, not throttled
expect(onValueCommit).toHaveBeenCalledOnce();
expect(onValueCommit).toHaveBeenCalledWith(51);
slider.destroy();
});
it('defaults commitThrottle to 0 when not provided', () => {
vi.useFakeTimers();
const onValueCommit = vi.fn();
const el = createMockElement({ left: 0, width: 200 });
// No commitThrottle option — should default to 0 (disabled)
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit }));
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
onValueCommit.mockClear();
fireDocumentPointerMove({ clientX: 60 });
fireDocumentPointerMove({ clientX: 80 });
fireDocumentPointerMove({ clientX: 100 });
vi.advanceTimersByTime(200);
// No throttled commits — default is disabled
expect(onValueCommit).not.toHaveBeenCalled();
slider.destroy();
vi.useRealTimers();
});
});
});