feat(packages): add pauseOnDrag to time slider (#1596)

Co-authored-by: shiminshen <16914659+shiminshen@users.noreply.github.com>
This commit is contained in:
Renzo Delfino
2026-06-30 13:55:59 -07:00
committed by GitHub
co-authored by shiminshen
parent 058fb8cfdd
commit 131e176dde
6 changed files with 328 additions and 42 deletions
@@ -12,42 +12,59 @@ import { TimeSliderRoot } from '../time-slider-root';
// --- Hoisted mock data (available inside vi.mock factories) ---
const { mockSliderApi, mockTimeState, mockBufferState } = vi.hoisted(() => ({
mockSliderApi: () => ({
input: {
current: {
pointerPercent: 0,
dragPercent: 0,
dragging: false,
pointing: false,
focused: false,
},
subscribe: vi.fn(() => vi.fn()),
const { mockSliderApi, mockTimeState, mockBufferState, mockPlaybackState, capturedSliderOptions } = vi.hoisted(() => {
const capturedSliderOptions: { current: { onDragStart?: () => void; onDragEnd?: () => void } } = {
current: {},
};
return {
mockSliderApi: (options: { onDragStart?: () => void; onDragEnd?: () => void }) => {
capturedSliderOptions.current = options;
return {
input: {
current: {
pointerPercent: 0,
dragPercent: 0,
dragging: false,
pointing: false,
focused: false,
},
subscribe: vi.fn(() => vi.fn()),
},
rootProps: {
onPointerDown: vi.fn(),
onPointerMove: vi.fn(),
onPointerLeave: vi.fn(),
},
thumbProps: {
onKeyDown: vi.fn(),
onFocus: vi.fn(),
onBlur: vi.fn(),
},
adjustForAlignment: <S,>(state: S): S => state,
destroy: vi.fn(),
};
},
rootProps: {
onPointerDown: vi.fn(),
onPointerMove: vi.fn(),
onPointerLeave: vi.fn(),
mockTimeState: {
currentTime: 30,
duration: 120,
seeking: false,
seek: vi.fn(),
},
thumbProps: {
onKeyDown: vi.fn(),
onFocus: vi.fn(),
onBlur: vi.fn(),
mockBufferState: {
buffered: [[0, 60]] as [number, number][],
seekable: [[0, 120]] as [number, number][],
},
adjustForAlignment: <S,>(state: S): S => state,
destroy: vi.fn(),
}),
mockTimeState: {
currentTime: 30,
duration: 120,
seeking: false,
seek: vi.fn(),
},
mockBufferState: {
buffered: [[0, 60]] as [number, number][],
seekable: [[0, 120]] as [number, number][],
},
}));
mockPlaybackState: {
paused: false,
ended: false,
started: true,
waiting: false,
play: vi.fn(() => Promise.resolve()),
pause: vi.fn(),
},
capturedSliderOptions,
};
});
// --- Module mocks ---
@@ -61,13 +78,13 @@ vi.mock('@videojs/store/react', () => ({
useStore: vi.fn((_store: unknown, selector?: (state: object) => unknown) => {
if (!selector) return _store;
try {
const result = selector({ time: mockTimeState, buffer: mockBufferState });
const result = selector({ time: mockTimeState, buffer: mockBufferState, playback: mockPlaybackState });
if (result !== undefined) return result;
} catch {
// fall through
}
try {
return selector({ ...mockTimeState, ...mockBufferState });
return selector({ ...mockTimeState, ...mockBufferState, ...mockPlaybackState });
} catch {
return undefined;
}
@@ -195,3 +212,125 @@ describe('TimeSlider compound', () => {
expect(output?.textContent).toBeTruthy();
});
});
describe('TimeSliderRoot pauseOnDrag', () => {
it('does nothing when pauseOnDrag is false (default)', () => {
mockPlaybackState.paused = false;
mockPlaybackState.play.mockClear();
mockPlaybackState.pause.mockClear();
const { Wrapper } = createPlayerWrapper();
render(
<Wrapper>
<TimeSliderRoot />
</Wrapper>
);
capturedSliderOptions.current.onDragStart?.();
expect(mockPlaybackState.pause).not.toHaveBeenCalled();
capturedSliderOptions.current.onDragEnd?.();
expect(mockPlaybackState.play).not.toHaveBeenCalled();
});
it('pauses on drag-start and resumes on drag-end when playing', () => {
mockPlaybackState.paused = false;
mockPlaybackState.play.mockClear();
mockPlaybackState.pause.mockClear();
const { Wrapper } = createPlayerWrapper();
render(
<Wrapper>
<TimeSliderRoot pauseOnDrag />
</Wrapper>
);
capturedSliderOptions.current.onDragStart?.();
expect(mockPlaybackState.pause).toHaveBeenCalledTimes(1);
capturedSliderOptions.current.onDragEnd?.();
expect(mockPlaybackState.play).toHaveBeenCalledTimes(1);
});
it('does not resume on drag-end when player was already paused', () => {
mockPlaybackState.paused = true;
mockPlaybackState.play.mockClear();
mockPlaybackState.pause.mockClear();
const { Wrapper } = createPlayerWrapper();
render(
<Wrapper>
<TimeSliderRoot pauseOnDrag />
</Wrapper>
);
capturedSliderOptions.current.onDragStart?.();
expect(mockPlaybackState.pause).not.toHaveBeenCalled();
capturedSliderOptions.current.onDragEnd?.();
expect(mockPlaybackState.play).not.toHaveBeenCalled();
});
it('forwards user-provided onDragStart and onDragEnd', () => {
mockPlaybackState.paused = false;
const onDragStart = vi.fn();
const onDragEnd = vi.fn();
const { Wrapper } = createPlayerWrapper();
render(
<Wrapper>
<TimeSliderRoot pauseOnDrag onDragStart={onDragStart} onDragEnd={onDragEnd} />
</Wrapper>
);
capturedSliderOptions.current.onDragStart?.();
expect(onDragStart).toHaveBeenCalled();
capturedSliderOptions.current.onDragEnd?.();
expect(onDragEnd).toHaveBeenCalled();
});
it('resumes on drag-end even if pauseOnDrag is turned off mid-drag', () => {
mockPlaybackState.paused = false;
mockPlaybackState.play.mockClear();
mockPlaybackState.pause.mockClear();
const { Wrapper } = createPlayerWrapper();
const { rerender } = render(
<Wrapper>
<TimeSliderRoot pauseOnDrag />
</Wrapper>
);
capturedSliderOptions.current.onDragStart?.();
expect(mockPlaybackState.pause).toHaveBeenCalledTimes(1);
rerender(
<Wrapper>
<TimeSliderRoot pauseOnDrag={false} />
</Wrapper>
);
capturedSliderOptions.current.onDragEnd?.();
expect(mockPlaybackState.play).toHaveBeenCalledTimes(1);
});
it('resumes on unmount if a drag paused playback', () => {
mockPlaybackState.paused = false;
mockPlaybackState.play.mockClear();
mockPlaybackState.pause.mockClear();
const { Wrapper } = createPlayerWrapper();
const { unmount } = render(
<Wrapper>
<TimeSliderRoot pauseOnDrag />
</Wrapper>
);
capturedSliderOptions.current.onDragStart?.();
expect(mockPlaybackState.pause).toHaveBeenCalledTimes(1);
unmount();
expect(mockPlaybackState.play).toHaveBeenCalledTimes(1);
});
});
@@ -1,9 +1,9 @@
'use client';
import { TimeSliderCore, TimeSliderDataAttrs } from '@videojs/core';
import { getTimeSliderCSSVars, logMissingFeature, selectBuffer, selectTime } from '@videojs/core/dom';
import { getTimeSliderCSSVars, logMissingFeature, selectBuffer, selectPlayback, selectTime } from '@videojs/core/dom';
import { formatTime } from '@videojs/utils/time';
import { forwardRef, useState } from 'react';
import { forwardRef, useEffect, useState } from 'react';
import { usePlayer } from '../../player/context';
import type { UIComponentProps } from '../../utils/types';
@@ -34,17 +34,26 @@ export const TimeSliderRoot = forwardRef<HTMLDivElement, TimeSliderRootProps>(
thumbAlignment,
onDragStart,
onDragEnd,
pauseOnDrag,
...elementProps
} = componentProps;
const time = usePlayer(selectTime);
const buffer = usePlayer(selectBuffer);
const playback = usePlayer(selectPlayback);
const [core] = useState(() => new TimeSliderCore());
core.setProps({ label, step, largeStep, orientation, disabled, thumbAlignment });
core.setProps({ label, step, largeStep, orientation, disabled, thumbAlignment, pauseOnDrag });
// Keep a ref to the latest media state for callbacks that fire outside the render cycle.
const mediaRef = useLatestRef(time && buffer ? { ...time, ...buffer } : null);
const playbackRef = useLatestRef(playback);
// Resume playback if the slider unmounts mid-drag — createSlider's destroy()
// does not fire onDragEnd, so without this the player would stay paused.
useEffect(() => {
return () => core.endDrag(playbackRef.current);
}, [core]);
const duration = time?.duration ?? 0;
@@ -79,8 +88,14 @@ export const TimeSliderRoot = forwardRef<HTMLDivElement, TimeSliderRootProps>(
const media = mediaRef.current;
if (media) media.seek(core.rawValueFromPercent(percent));
},
onDragStart,
onDragEnd,
onDragStart: () => {
core.startDrag(playbackRef.current);
onDragStart?.();
},
onDragEnd: () => {
core.endDrag(playbackRef.current);
onDragEnd?.();
},
});
if (!time) {