mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
refactor(packages): move slider throttle from commit to change (#1219)
This commit is contained in:
@@ -42,7 +42,7 @@ describe('TimeSliderCore', () => {
|
||||
value: 0,
|
||||
min: 0,
|
||||
max: 100,
|
||||
commitThrottle: 100,
|
||||
changeThrottle: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,8 +13,8 @@ export interface TimeSliderProps extends SliderProps {
|
||||
min?: number | undefined;
|
||||
/** @internal Derived from `duration` — not user-settable. */
|
||||
max?: number | undefined;
|
||||
/** Trailing-edge throttle (ms) for seek requests during drag. */
|
||||
commitThrottle?: number | undefined;
|
||||
/** Leading+trailing throttle (ms) for `onValueChange` during drag. */
|
||||
changeThrottle?: number | undefined;
|
||||
}
|
||||
|
||||
export interface TimeSliderState extends SliderState, Pick<MediaTimeState, 'currentTime' | 'duration' | 'seeking'> {
|
||||
@@ -27,7 +27,7 @@ export class TimeSliderCore extends SliderCore {
|
||||
static override readonly defaultProps: NonNullableObject<TimeSliderProps> = {
|
||||
...SliderCore.defaultProps,
|
||||
label: 'Seek',
|
||||
commitThrottle: 100,
|
||||
changeThrottle: 100,
|
||||
};
|
||||
|
||||
#props = { ...TimeSliderCore.defaultProps };
|
||||
|
||||
@@ -25,14 +25,17 @@ export interface SliderOptions {
|
||||
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.
|
||||
* Leading+trailing throttle (ms) for `onValueChange` during drag. When
|
||||
* `> 0`, `onValueChange` fires immediately on the first drag move (leading
|
||||
* edge), then at most once per window during subsequent moves. `0` (default)
|
||||
* disables throttling — `onValueChange` fires on every pointermove.
|
||||
*/
|
||||
commitThrottle?: number | undefined;
|
||||
changeThrottle?: number | undefined;
|
||||
/** Adjust a raw 0–100 percent for thumb alignment. Enables `adjustForAlignment()`. */
|
||||
adjustPercent?: ((rawPercent: number, thumbSize: number, trackSize: number) => number) | undefined;
|
||||
/** Fires continuously as the value changes (every pointermove during drag, keyboard steps). */
|
||||
onValueChange?: ((percent: number) => void) | undefined;
|
||||
/** Fires once when the user commits the value (pointer release, keyboard step). */
|
||||
onValueCommit?: ((percent: number) => void) | undefined;
|
||||
onDragStart?: (() => void) | undefined;
|
||||
onDragEnd?: (() => void) | undefined;
|
||||
@@ -86,16 +89,29 @@ export function createSlider(options: SliderOptions): SliderApi {
|
||||
});
|
||||
|
||||
const abort = new AbortController();
|
||||
const commitThrottleMs = options.commitThrottle ?? 0;
|
||||
const changeThrottleMs = options.changeThrottle ?? 0;
|
||||
|
||||
let isDragging = false,
|
||||
moveCount = 0,
|
||||
cachedRTL = false,
|
||||
cachedRect: DOMRect | null = null,
|
||||
capturedPointerId: number | null = null;
|
||||
capturedPointerId: number | null = null,
|
||||
lastDragPercent = 0,
|
||||
committedOnRelease = false;
|
||||
|
||||
const throttledCommit =
|
||||
commitThrottleMs > 0 ? throttle((percent: number) => options.onValueCommit?.(percent), commitThrottleMs) : null;
|
||||
const throttledChange =
|
||||
changeThrottleMs > 0
|
||||
? throttle((percent: number) => options.onValueChange?.(percent), changeThrottleMs, { leading: true })
|
||||
: null;
|
||||
|
||||
/** Fire `onValueChange` — throttled during drag when `changeThrottle > 0`. */
|
||||
function fireChange(percent: number, duringDrag: boolean): void {
|
||||
if (duringDrag && throttledChange) {
|
||||
throttledChange(percent);
|
||||
} else {
|
||||
options.onValueChange?.(percent);
|
||||
}
|
||||
}
|
||||
|
||||
function releaseCapture(): void {
|
||||
if (isNull(capturedPointerId)) return;
|
||||
@@ -114,16 +130,22 @@ export function createSlider(options: SliderOptions): SliderApi {
|
||||
if (!isDragging) {
|
||||
input.patch({ pointing: false });
|
||||
} else {
|
||||
// Fire a final commit if pointerup didn't already handle it.
|
||||
if (!committedOnRelease) {
|
||||
options.onValueCommit?.(lastDragPercent);
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
input.patch({ dragging: false, pointing: false });
|
||||
options.onDragEnd?.();
|
||||
}
|
||||
|
||||
committedOnRelease = false;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
throttledCommit?.cancel();
|
||||
throttledChange?.cancel();
|
||||
capturedPointerId = null;
|
||||
cachedRect = null;
|
||||
}
|
||||
@@ -144,6 +166,7 @@ export function createSlider(options: SliderOptions): SliderApi {
|
||||
cachedRect = el.getBoundingClientRect();
|
||||
cachedRTL = options.isRTL();
|
||||
moveCount = 0;
|
||||
committedOnRelease = false;
|
||||
|
||||
releaseCapture();
|
||||
capturedPointerId = event.pointerId;
|
||||
@@ -151,6 +174,7 @@ export function createSlider(options: SliderOptions): SliderApi {
|
||||
|
||||
const percent = getPercentFromPointerEvent(event, cachedRect, options.getOrientation(), cachedRTL);
|
||||
|
||||
lastDragPercent = percent;
|
||||
input.patch({ pointing: true, pointerPercent: percent, dragPercent: percent });
|
||||
options.onValueChange?.(percent);
|
||||
|
||||
@@ -178,14 +202,14 @@ export function createSlider(options: SliderOptions): SliderApi {
|
||||
|
||||
if (!isDragging && moveCount >= DRAG_THRESHOLD) {
|
||||
isDragging = true;
|
||||
lastDragPercent = percent;
|
||||
input.patch({ dragging: true, dragPercent: percent, pointerPercent: percent });
|
||||
options.onDragStart?.();
|
||||
options.onValueChange?.(percent);
|
||||
throttledCommit?.(percent);
|
||||
fireChange(percent, true);
|
||||
} else if (isDragging) {
|
||||
lastDragPercent = percent;
|
||||
input.patch({ dragPercent: percent, pointerPercent: percent });
|
||||
options.onValueChange?.(percent);
|
||||
throttledCommit?.(percent);
|
||||
fireChange(percent, true);
|
||||
} else {
|
||||
// Below drag threshold — update hover preview only.
|
||||
input.patch({ pointerPercent: percent });
|
||||
@@ -207,9 +231,11 @@ export function createSlider(options: SliderOptions): SliderApi {
|
||||
|
||||
const percent = getPercentFromPointerEvent(event, cachedRect!, options.getOrientation(), cachedRTL);
|
||||
|
||||
// Cancel pending throttled commit before the final unthrottled one.
|
||||
throttledCommit?.cancel();
|
||||
// Cancel any pending throttled change before the final unthrottled pair.
|
||||
throttledChange?.cancel();
|
||||
options.onValueChange?.(percent);
|
||||
options.onValueCommit?.(percent);
|
||||
committedOnRelease = true;
|
||||
},
|
||||
|
||||
onPointerLeave() {
|
||||
|
||||
@@ -873,31 +873,11 @@ describe('createSlider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('commitThrottle', () => {
|
||||
it('does not fire onValueCommit during drag when commitThrottle is 0', () => {
|
||||
describe('commit semantics', () => {
|
||||
it('does not fire onValueCommit during drag', () => {
|
||||
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
|
||||
firePointerMove(slider, { clientX: 60 });
|
||||
firePointerMove(slider, { clientX: 80 });
|
||||
firePointerMove(slider, { 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 }));
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit }));
|
||||
|
||||
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
|
||||
onValueCommit.mockClear();
|
||||
@@ -906,103 +886,211 @@ describe('createSlider', () => {
|
||||
firePointerMove(slider, { clientX: 60 });
|
||||
firePointerMove(slider, { clientX: 80 });
|
||||
firePointerMove(slider, { clientX: 100 });
|
||||
firePointerMove(slider, { clientX: 120 });
|
||||
|
||||
// Not yet — throttle hasn't fired
|
||||
// Commit must not fire during drag — only on release.
|
||||
expect(onValueCommit).not.toHaveBeenCalled();
|
||||
|
||||
// Advance timer past throttle
|
||||
vi.advanceTimersByTime(100);
|
||||
slider.destroy();
|
||||
});
|
||||
|
||||
it('fires onValueChange on pointerup before onValueCommit', () => {
|
||||
const order: string[] = [];
|
||||
const onValueChange = vi.fn(() => order.push('change'));
|
||||
const onValueCommit = vi.fn(() => order.push('commit'));
|
||||
const el = createMockElement({ left: 0, width: 200 });
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueChange, onValueCommit }));
|
||||
|
||||
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
|
||||
firePointerMove(slider, { clientX: 60 });
|
||||
firePointerMove(slider, { clientX: 80 });
|
||||
|
||||
onValueChange.mockClear();
|
||||
onValueCommit.mockClear();
|
||||
order.length = 0;
|
||||
|
||||
firePointerUp(slider, { clientX: 100 });
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith(50);
|
||||
expect(onValueCommit).toHaveBeenCalledWith(50);
|
||||
expect(order).toEqual(['change', 'commit']);
|
||||
|
||||
slider.destroy();
|
||||
});
|
||||
|
||||
it('fires onValueCommit on stale drag exit with last drag percent', () => {
|
||||
const onValueCommit = vi.fn();
|
||||
const el = createMockElement({ left: 0, width: 200 });
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit }));
|
||||
|
||||
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
|
||||
firePointerMove(slider, { clientX: 60 });
|
||||
firePointerMove(slider, { clientX: 80 });
|
||||
onValueCommit.mockClear();
|
||||
|
||||
// Stale drag: buttons = 0, mouse pointer
|
||||
firePointerMove(slider, { clientX: 100, buttons: 0, pointerType: 'mouse' });
|
||||
|
||||
expect(onValueCommit).toHaveBeenCalledOnce();
|
||||
// Should have the latest drag percent (120/200 = 60%)
|
||||
expect(onValueCommit).toHaveBeenCalledWith(60);
|
||||
// Last drag percent was 40% (80/200) — the stale move doesn't update it.
|
||||
expect(onValueCommit).toHaveBeenCalledWith(40);
|
||||
|
||||
slider.destroy();
|
||||
});
|
||||
|
||||
it('fires onValueCommit on lostpointercapture without pointerup', () => {
|
||||
const onValueCommit = vi.fn();
|
||||
const el = createMockElement({ left: 0, width: 200 });
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit }));
|
||||
|
||||
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
|
||||
firePointerMove(slider, { clientX: 60 });
|
||||
firePointerMove(slider, { clientX: 80 });
|
||||
onValueCommit.mockClear();
|
||||
|
||||
// Lost capture without pointerup (e.g., tab switch, pointercancel).
|
||||
fireLostPointerCapture(slider);
|
||||
|
||||
expect(onValueCommit).toHaveBeenCalledOnce();
|
||||
expect(onValueCommit).toHaveBeenCalledWith(40);
|
||||
|
||||
slider.destroy();
|
||||
});
|
||||
|
||||
it('does not double-commit on pointerup followed by lostpointercapture', () => {
|
||||
const onValueCommit = vi.fn();
|
||||
const el = createMockElement({ left: 0, width: 200 });
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit }));
|
||||
|
||||
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
|
||||
firePointerMove(slider, { clientX: 60 });
|
||||
firePointerMove(slider, { clientX: 80 });
|
||||
onValueCommit.mockClear();
|
||||
|
||||
// Normal flow: pointerup commits, then lostpointercapture cleans up.
|
||||
firePointerUp(slider, { clientX: 100 });
|
||||
fireLostPointerCapture(slider);
|
||||
|
||||
// Only one commit from pointerup.
|
||||
expect(onValueCommit).toHaveBeenCalledOnce();
|
||||
expect(onValueCommit).toHaveBeenCalledWith(50);
|
||||
|
||||
slider.destroy();
|
||||
});
|
||||
|
||||
it('does not fire onValueCommit on lostpointercapture 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: 50 }));
|
||||
onValueCommit.mockClear();
|
||||
|
||||
// Lost capture before drag threshold was reached.
|
||||
fireLostPointerCapture(slider);
|
||||
|
||||
expect(onValueCommit).not.toHaveBeenCalled();
|
||||
|
||||
slider.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeThrottle', () => {
|
||||
it('fires onValueChange immediately on first drag move (leading edge)', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const onValueChange = vi.fn();
|
||||
const el = createMockElement({ left: 0, width: 200 });
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueChange, changeThrottle: 100 }));
|
||||
|
||||
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
|
||||
onValueChange.mockClear();
|
||||
|
||||
// Pass drag threshold — first drag move fires immediately (leading edge).
|
||||
firePointerMove(slider, { clientX: 60 });
|
||||
firePointerMove(slider, { clientX: 80 });
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledOnce();
|
||||
expect(onValueChange).toHaveBeenCalledWith(40);
|
||||
|
||||
slider.destroy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses trailing-edge: batches rapid moves into one commit', () => {
|
||||
it('coalesces rapid moves during cooldown to trailing edge', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const onValueCommit = vi.fn();
|
||||
const onValueChange = vi.fn();
|
||||
const el = createMockElement({ left: 0, width: 200 });
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit, commitThrottle: 100 }));
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueChange, changeThrottle: 100 }));
|
||||
|
||||
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
|
||||
onValueCommit.mockClear();
|
||||
onValueChange.mockClear();
|
||||
|
||||
// Pass threshold
|
||||
// Pass threshold — leading fires.
|
||||
firePointerMove(slider, { clientX: 60 });
|
||||
firePointerMove(slider, { clientX: 80 });
|
||||
expect(onValueChange).toHaveBeenCalledOnce();
|
||||
|
||||
// Multiple rapid moves during drag
|
||||
// More moves during cooldown.
|
||||
firePointerMove(slider, { clientX: 100 });
|
||||
firePointerMove(slider, { clientX: 120 });
|
||||
firePointerMove(slider, { clientX: 140 });
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
// Still only the leading call.
|
||||
expect(onValueChange).toHaveBeenCalledOnce();
|
||||
|
||||
// Only one commit with the latest value (140/200 = 70%)
|
||||
expect(onValueCommit).toHaveBeenCalledOnce();
|
||||
expect(onValueCommit).toHaveBeenCalledWith(70);
|
||||
// Trailing fires on cooldown expiry with latest value (140/200 = 70%).
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(onValueChange).toHaveBeenCalledTimes(2);
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(70);
|
||||
|
||||
slider.destroy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('fires another throttled commit after the first one completes', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const onValueCommit = vi.fn();
|
||||
it('does not throttle onValueChange when changeThrottle is 0', () => {
|
||||
const onValueChange = vi.fn();
|
||||
const el = createMockElement({ left: 0, width: 200 });
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit, commitThrottle: 100 }));
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueChange, changeThrottle: 0 }));
|
||||
|
||||
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
|
||||
onValueCommit.mockClear();
|
||||
onValueChange.mockClear();
|
||||
|
||||
// Pass threshold and drag
|
||||
// Every drag move fires immediately.
|
||||
firePointerMove(slider, { clientX: 60 });
|
||||
firePointerMove(slider, { clientX: 80 });
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(onValueCommit).toHaveBeenCalledOnce();
|
||||
firePointerMove(slider, { clientX: 100 });
|
||||
|
||||
// Continue dragging — should schedule another throttle
|
||||
firePointerMove(slider, { clientX: 140 });
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(onValueCommit).toHaveBeenCalledTimes(2);
|
||||
expect(onValueCommit).toHaveBeenLastCalledWith(70);
|
||||
expect(onValueChange).toHaveBeenCalledTimes(2);
|
||||
|
||||
slider.destroy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('cancels throttle and fires final unthrottled commit on pointerup', () => {
|
||||
it('cancels throttled change and fires unthrottled on pointerup', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const onValueCommit = vi.fn();
|
||||
const onValueChange = vi.fn();
|
||||
const el = createMockElement({ left: 0, width: 200 });
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit, commitThrottle: 100 }));
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueChange, changeThrottle: 100 }));
|
||||
|
||||
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
|
||||
onValueCommit.mockClear();
|
||||
onValueChange.mockClear();
|
||||
|
||||
// Pass threshold and drag
|
||||
// Pass threshold (leading fires) and more moves.
|
||||
firePointerMove(slider, { clientX: 60 });
|
||||
firePointerMove(slider, { clientX: 80 });
|
||||
firePointerMove(slider, { clientX: 120 });
|
||||
|
||||
// Release before throttle fires
|
||||
// Release before trailing fires.
|
||||
firePointerUp(slider, { clientX: 150 });
|
||||
|
||||
// Final commit with release position (150/200 = 75%)
|
||||
expect(onValueCommit).toHaveBeenCalledOnce();
|
||||
expect(onValueCommit).toHaveBeenCalledWith(75);
|
||||
// Leading (threshold) + unthrottled pointerup. The coalesced moves were cancelled.
|
||||
expect(onValueChange).toHaveBeenCalledTimes(2);
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(75);
|
||||
|
||||
// Advancing timer should NOT fire a stale throttled commit
|
||||
// Advancing timer should NOT fire a stale trailing change.
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(onValueCommit).toHaveBeenCalledOnce();
|
||||
expect(onValueChange).toHaveBeenCalledTimes(2);
|
||||
|
||||
slider.destroy();
|
||||
vi.useRealTimers();
|
||||
@@ -1011,85 +1099,56 @@ describe('createSlider', () => {
|
||||
it('cancels throttle on destroy', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const onValueCommit = vi.fn();
|
||||
const onValueChange = vi.fn();
|
||||
const el = createMockElement({ left: 0, width: 200 });
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit, commitThrottle: 100 }));
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueChange, changeThrottle: 100 }));
|
||||
|
||||
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
|
||||
onValueCommit.mockClear();
|
||||
onValueChange.mockClear();
|
||||
|
||||
// Pass threshold
|
||||
// Pass threshold (leading fires).
|
||||
firePointerMove(slider, { clientX: 60 });
|
||||
firePointerMove(slider, { clientX: 80 });
|
||||
// More moves pending.
|
||||
firePointerMove(slider, { clientX: 120 });
|
||||
|
||||
slider.destroy();
|
||||
|
||||
// Advancing timer should NOT fire
|
||||
// Advancing timer should NOT fire the pending trailing change.
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(onValueCommit).not.toHaveBeenCalled();
|
||||
expect(onValueChange).toHaveBeenCalledOnce();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('cancels throttle on lostpointercapture', () => {
|
||||
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
|
||||
firePointerMove(slider, { clientX: 60 });
|
||||
firePointerMove(slider, { clientX: 80 });
|
||||
|
||||
fireLostPointerCapture(slider);
|
||||
|
||||
// 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 }));
|
||||
it('does not throttle keyboard changes', () => {
|
||||
const onValueChange = vi.fn();
|
||||
const slider = createSlider(createOptions({ getPercent: () => 50, onValueChange, changeThrottle: 100 }));
|
||||
|
||||
slider.thumbProps.onKeyDown(keyboardEvent('ArrowRight'));
|
||||
|
||||
// Keyboard commits fire immediately, not throttled
|
||||
expect(onValueCommit).toHaveBeenCalledOnce();
|
||||
expect(onValueCommit).toHaveBeenCalledWith(51);
|
||||
expect(onValueChange).toHaveBeenCalledOnce();
|
||||
expect(onValueChange).toHaveBeenCalledWith(51);
|
||||
|
||||
slider.destroy();
|
||||
});
|
||||
|
||||
it('defaults commitThrottle to 0 when not provided', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const onValueCommit = vi.fn();
|
||||
it('defaults changeThrottle to 0 when not provided', () => {
|
||||
const onValueChange = vi.fn();
|
||||
const el = createMockElement({ left: 0, width: 200 });
|
||||
// No commitThrottle option — should default to 0 (disabled)
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueCommit }));
|
||||
const slider = createSlider(createOptions({ getElement: () => el, onValueChange }));
|
||||
|
||||
slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 }));
|
||||
onValueCommit.mockClear();
|
||||
onValueChange.mockClear();
|
||||
|
||||
// Every drag move fires immediately (no throttle).
|
||||
firePointerMove(slider, { clientX: 60 });
|
||||
firePointerMove(slider, { clientX: 80 });
|
||||
firePointerMove(slider, { clientX: 100 });
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
// No throttled commits — default is disabled
|
||||
expect(onValueCommit).not.toHaveBeenCalled();
|
||||
expect(onValueChange).toHaveBeenCalledTimes(2);
|
||||
|
||||
slider.destroy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('TimeSliderElement', () => {
|
||||
it('initializes with default property values', () => {
|
||||
const slider = createElement(TimeSliderElement);
|
||||
expect(slider.label).toBe('Seek');
|
||||
expect(slider.commitThrottle).toBe(100);
|
||||
expect(slider.changeThrottle).toBe(100);
|
||||
expect(slider.step).toBe(1);
|
||||
expect(slider.largeStep).toBe(10);
|
||||
expect(slider.orientation).toBe('horizontal');
|
||||
|
||||
@@ -24,7 +24,7 @@ export class TimeSliderElement extends MediaElement {
|
||||
|
||||
static override properties = {
|
||||
label: { type: String },
|
||||
commitThrottle: { type: Number, attribute: 'commit-throttle' },
|
||||
changeThrottle: { type: Number, attribute: 'change-throttle' },
|
||||
step: { type: Number },
|
||||
largeStep: { type: Number, attribute: 'large-step' },
|
||||
orientation: { type: String },
|
||||
@@ -33,7 +33,7 @@ export class TimeSliderElement extends MediaElement {
|
||||
} satisfies PropertyDeclarationMap<Exclude<keyof TimeSliderCore.Props, 'value' | 'min' | 'max'>>;
|
||||
|
||||
label = TimeSliderCore.defaultProps.label;
|
||||
commitThrottle = TimeSliderCore.defaultProps.commitThrottle;
|
||||
changeThrottle = TimeSliderCore.defaultProps.changeThrottle;
|
||||
step = TimeSliderCore.defaultProps.step;
|
||||
largeStep = TimeSliderCore.defaultProps.largeStep;
|
||||
orientation = TimeSliderCore.defaultProps.orientation;
|
||||
@@ -68,11 +68,15 @@ export class TimeSliderElement extends MediaElement {
|
||||
},
|
||||
getStepPercent: () => this.#core.getStepPercent(),
|
||||
getLargeStepPercent: () => this.#core.getLargeStepPercent(),
|
||||
onValueChange: (percent) => {
|
||||
const media = this.#timeState.value;
|
||||
if (media) media.seek(this.#core.rawValueFromPercent(percent));
|
||||
},
|
||||
onValueCommit: (percent) => {
|
||||
const media = this.#timeState.value;
|
||||
if (media) media.seek(this.#core.rawValueFromPercent(percent));
|
||||
},
|
||||
commitThrottle: this.commitThrottle,
|
||||
changeThrottle: this.changeThrottle,
|
||||
onDragStart: () => {
|
||||
this.dispatchEvent(new CustomEvent('drag-start', { bubbles: true }));
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface UseSliderOptions<State extends SliderState = SliderState>
|
||||
| 'getPercent'
|
||||
| 'getStepPercent'
|
||||
| 'getLargeStepPercent'
|
||||
| 'commitThrottle'
|
||||
| 'changeThrottle'
|
||||
| 'onValueChange'
|
||||
| 'onValueCommit'
|
||||
| 'onDragStart'
|
||||
@@ -74,7 +74,7 @@ export function useSlider<State extends SliderState = SliderState>(
|
||||
getPercent: () => optionsRef.current.getPercent(),
|
||||
getStepPercent: () => optionsRef.current.getStepPercent(),
|
||||
getLargeStepPercent: () => optionsRef.current.getLargeStepPercent(),
|
||||
commitThrottle: optionsRef.current.commitThrottle,
|
||||
changeThrottle: optionsRef.current.changeThrottle,
|
||||
adjustPercent: optionsRef.current.adjustPercent,
|
||||
onValueChange: (percent) => optionsRef.current.onValueChange?.(percent),
|
||||
onValueCommit: (percent) => optionsRef.current.onValueCommit?.(percent),
|
||||
|
||||
@@ -26,7 +26,7 @@ export const TimeSliderRoot = forwardRef<HTMLDivElement, TimeSliderRootProps>(
|
||||
className,
|
||||
style,
|
||||
label,
|
||||
commitThrottle = TimeSliderCore.defaultProps.commitThrottle,
|
||||
changeThrottle = TimeSliderCore.defaultProps.changeThrottle,
|
||||
step = TimeSliderCore.defaultProps.step,
|
||||
largeStep = TimeSliderCore.defaultProps.largeStep,
|
||||
orientation,
|
||||
@@ -71,10 +71,14 @@ export const TimeSliderRoot = forwardRef<HTMLDivElement, TimeSliderRootProps>(
|
||||
getLargeStepPercent: () => core.getLargeStepPercent(),
|
||||
orientation,
|
||||
disabled,
|
||||
commitThrottle,
|
||||
changeThrottle,
|
||||
adjustPercent: (rawPercent, thumbSize, trackSize) =>
|
||||
core.adjustPercentForAlignment(rawPercent, thumbSize, trackSize),
|
||||
getCSSVars: getTimeSliderCSSVars,
|
||||
onValueChange: (percent) => {
|
||||
const media = mediaRef.current;
|
||||
if (media) media.seek(core.rawValueFromPercent(percent));
|
||||
},
|
||||
onValueCommit: (percent) => {
|
||||
const media = mediaRef.current;
|
||||
if (media) media.seek(core.rawValueFromPercent(percent));
|
||||
|
||||
@@ -99,3 +99,135 @@ describe('throttle', () => {
|
||||
expect(() => throttled.cancel()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('throttle with leading: true', () => {
|
||||
it('invokes immediately on the first call', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const callback = vi.fn();
|
||||
const throttled = throttle(callback, 100, { leading: true });
|
||||
|
||||
throttled('first');
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
expect(callback).toHaveBeenCalledWith('first');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('coalesces calls within the cooldown to a trailing invocation', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const callback = vi.fn();
|
||||
const throttled = throttle(callback, 100, { leading: true });
|
||||
|
||||
throttled('first');
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
|
||||
throttled('second');
|
||||
throttled('third');
|
||||
|
||||
// Still only the leading call so far.
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// Trailing fires with latest args.
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
expect(callback).toHaveBeenLastCalledWith('third');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('resets to leading after cooldown expires with no pending calls', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const callback = vi.fn();
|
||||
const throttled = throttle(callback, 100, { leading: true });
|
||||
|
||||
// First leading call.
|
||||
throttled('batch-1');
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
|
||||
// Let cooldown expire without any trailing calls.
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
|
||||
// Next call should be treated as a new leading invocation.
|
||||
throttled('batch-2');
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
expect(callback).toHaveBeenLastCalledWith('batch-2');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('chains trailing into a new cooldown window', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const callback = vi.fn();
|
||||
const throttled = throttle(callback, 100, { leading: true });
|
||||
|
||||
// Leading call.
|
||||
throttled('a');
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
|
||||
// Pending trailing.
|
||||
throttled('b');
|
||||
|
||||
// Trailing fires at 100ms, starting a new cooldown.
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
expect(callback).toHaveBeenLastCalledWith('b');
|
||||
|
||||
// Another pending during the new cooldown.
|
||||
throttled('c');
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
expect(callback).toHaveBeenLastCalledWith('c');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('cancel prevents trailing invocation', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const callback = vi.fn();
|
||||
const throttled = throttle(callback, 100, { leading: true });
|
||||
|
||||
throttled('leading');
|
||||
throttled('pending');
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
|
||||
throttled.cancel();
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
// Only the leading call, no trailing.
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('accepts new leading call after cancel', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const callback = vi.fn();
|
||||
const throttled = throttle(callback, 100, { leading: true });
|
||||
|
||||
throttled('first');
|
||||
throttled.cancel();
|
||||
|
||||
throttled('after-cancel');
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
expect(callback).toHaveBeenLastCalledWith('after-cancel');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('cancel is a no-op when no timer is pending', () => {
|
||||
const callback = vi.fn();
|
||||
const throttled = throttle(callback, 100, { leading: true });
|
||||
|
||||
expect(() => throttled.cancel()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,22 +5,69 @@ export interface Throttled<Args extends unknown[]> {
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export interface ThrottleOptions {
|
||||
/**
|
||||
* When `true`, the first call invokes `fn` immediately (leading edge) and
|
||||
* starts the cooldown window. Calls during cooldown are coalesced and fire
|
||||
* on the trailing edge. If no calls arrive during the window the next call
|
||||
* is treated as a fresh leading invocation.
|
||||
*/
|
||||
leading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trailing-edge throttle: the first call schedules a timer; subsequent calls
|
||||
* within the window update the arguments. The function fires once per `ms`
|
||||
* window with the latest arguments.
|
||||
* Throttle: limits `fn` to at most once per `ms` window.
|
||||
*
|
||||
* - Default (no options): trailing-edge only — the first call schedules a
|
||||
* timer; subsequent calls within the window update the arguments. The
|
||||
* function fires once per window with the latest arguments.
|
||||
* - `{ leading: true }`: leading + trailing — the first call invokes
|
||||
* immediately and opens a cooldown window. Subsequent calls within the
|
||||
* window are coalesced to a single trailing-edge invocation.
|
||||
*/
|
||||
export function throttle<Args extends unknown[]>(fn: (...args: Args) => void, ms: number): Throttled<Args> {
|
||||
export function throttle<Args extends unknown[]>(
|
||||
fn: (...args: Args) => void,
|
||||
ms: number,
|
||||
options?: ThrottleOptions
|
||||
): Throttled<Args> {
|
||||
const leading = options?.leading ?? false;
|
||||
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null;
|
||||
let latestArgs: Args;
|
||||
let hasPending = false;
|
||||
|
||||
function startCooldown(): void {
|
||||
timerId = setTimeout(() => {
|
||||
timerId = null;
|
||||
|
||||
if (hasPending) {
|
||||
hasPending = false;
|
||||
fn(...latestArgs);
|
||||
startCooldown();
|
||||
}
|
||||
}, ms);
|
||||
}
|
||||
|
||||
const throttled = (...args: Args): void => {
|
||||
latestArgs = args;
|
||||
if (timerId !== null) return;
|
||||
timerId = setTimeout(() => {
|
||||
timerId = null;
|
||||
fn(...latestArgs);
|
||||
}, ms);
|
||||
|
||||
if (leading) {
|
||||
if (timerId === null) {
|
||||
// No active window — fire immediately (leading edge).
|
||||
fn(...latestArgs);
|
||||
startCooldown();
|
||||
} else {
|
||||
// Inside cooldown — mark pending for trailing edge.
|
||||
hasPending = true;
|
||||
}
|
||||
} else {
|
||||
// Trailing-only (original behavior).
|
||||
if (timerId !== null) return;
|
||||
timerId = setTimeout(() => {
|
||||
timerId = null;
|
||||
fn(...latestArgs);
|
||||
}, ms);
|
||||
}
|
||||
};
|
||||
|
||||
throttled.cancel = (): void => {
|
||||
@@ -28,6 +75,7 @@ export function throttle<Args extends unknown[]>(fn: (...args: Args) => void, ms
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
hasPending = false;
|
||||
};
|
||||
|
||||
return throttled;
|
||||
|
||||
Reference in New Issue
Block a user