mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(react): add slider component (#644)
This commit is contained in:
@@ -67,6 +67,10 @@ export class SliderCore {
|
||||
|
||||
#props = { ...SliderCore.defaultProps };
|
||||
|
||||
get props(): Readonly<NonNullableObject<SliderProps>> {
|
||||
return this.#props;
|
||||
}
|
||||
|
||||
constructor(props?: SliderProps) {
|
||||
if (props) this.setProps(props);
|
||||
}
|
||||
|
||||
@@ -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 0–100 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user