feat(react): add time slider component (#647)

This commit is contained in:
rahim
2026-02-28 01:02:40 -08:00
committed by GitHub
parent 2f8ca094ad
commit 158378ac73
9 changed files with 678 additions and 0 deletions
+2
View File
@@ -51,6 +51,8 @@ export type { SliderTrackProps } from './ui/slider/slider-track';
export type { SliderValueProps } from './ui/slider/slider-value';
export { Thumbnail, type ThumbnailProps } from './ui/thumbnail/thumbnail';
export { Time } from './ui/time';
export { TimeSlider } from './ui/time-slider';
export { VolumeSlider } from './ui/volume-slider';
// Utilities
export { mergeProps } from './utils/merge-props';
@@ -0,0 +1,13 @@
export {
Buffer,
type BufferProps,
Fill,
type FillProps,
Thumb,
type ThumbProps,
Track,
type TrackProps,
Value,
type ValueProps,
} from '../slider/index.parts';
export { TimeSliderRoot as Root, type TimeSliderRootProps as RootProps } from './time-slider-root';
@@ -0,0 +1 @@
export * as TimeSlider from './index.parts';
@@ -0,0 +1,196 @@
import { cleanup, render } from '@testing-library/react';
import { createRef } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createPlayerWrapper } from '../../../testing/mocks';
import { SliderBuffer } from '../../slider/slider-buffer';
import { SliderFill } from '../../slider/slider-fill';
import { SliderThumb } from '../../slider/slider-thumb';
import { SliderTrack } from '../../slider/slider-track';
import { SliderValue } from '../../slider/slider-value';
import { TimeSliderRoot } from '../time-slider-root';
// --- Hoisted mock data (available inside vi.mock factories) ---
const { mockSliderHandle, mockTimeState, mockBufferState } = vi.hoisted(() => ({
mockSliderHandle: () => ({
interaction: {
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(),
},
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][],
},
}));
// --- Module mocks ---
vi.mock('@videojs/core/dom', async (importOriginal) => {
const orig: Record<string, unknown> = await importOriginal();
return { ...orig, createSlider: vi.fn(mockSliderHandle) };
});
vi.mock('@videojs/store/react', () => ({
useSnapshot: vi.fn((state: { current: unknown }) => state.current),
useStore: vi.fn((_store: unknown, selector?: (state: object) => unknown) => {
if (!selector) return _store;
try {
const result = selector({ time: mockTimeState, buffer: mockBufferState });
if (result !== undefined) return result;
} catch {
// fall through
}
try {
return selector({ ...mockTimeState, ...mockBufferState });
} catch {
return undefined;
}
}),
}));
afterEach(cleanup);
// --- Tests ---
describe('TimeSliderRoot', () => {
it('renders a div element', () => {
const { Wrapper } = createPlayerWrapper();
const { container } = render(
<Wrapper>
<TimeSliderRoot />
</Wrapper>
);
const el = container.querySelector('div > div');
expect(el).toBeTruthy();
expect(el?.tagName).toBe('DIV');
});
it('forwards ref to the root element', () => {
const { Wrapper } = createPlayerWrapper();
const ref = createRef<HTMLDivElement>();
render(
<Wrapper>
<TimeSliderRoot ref={ref} />
</Wrapper>
);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
});
it('spreads additional props', () => {
const { Wrapper } = createPlayerWrapper();
const { container } = render(
<Wrapper>
<TimeSliderRoot data-testid="time-slider" />
</Wrapper>
);
expect(container.querySelector('[data-testid="time-slider"]')).toBeTruthy();
});
it('sets data-orientation to horizontal', () => {
const { Wrapper } = createPlayerWrapper();
const { container } = render(
<Wrapper>
<TimeSliderRoot />
</Wrapper>
);
const el = container.querySelector('[data-orientation]');
expect(el?.getAttribute('data-orientation')).toBe('horizontal');
});
it('sets slider CSS custom properties', () => {
const { Wrapper } = createPlayerWrapper();
const { container } = render(
<Wrapper>
<TimeSliderRoot />
</Wrapper>
);
const el = container.querySelector('[data-orientation]') as HTMLElement;
expect(el?.style.getPropertyValue('--media-slider-fill')).toBeTruthy();
expect(el?.style.getPropertyValue('--media-slider-pointer')).toBeTruthy();
expect(el?.style.getPropertyValue('--media-slider-buffer')).toBeTruthy();
});
});
describe('TimeSlider compound', () => {
it('renders all parts together', () => {
const { Wrapper } = createPlayerWrapper();
const { container } = render(
<Wrapper>
<TimeSliderRoot data-testid="root">
<SliderTrack data-testid="track">
<SliderFill data-testid="fill" />
<SliderBuffer data-testid="buffer" />
<SliderThumb data-testid="thumb" />
</SliderTrack>
<SliderValue data-testid="value" />
</TimeSliderRoot>
</Wrapper>
);
expect(container.querySelector('[data-testid="root"]')).toBeTruthy();
expect(container.querySelector('[data-testid="track"]')).toBeTruthy();
expect(container.querySelector('[data-testid="fill"]')).toBeTruthy();
expect(container.querySelector('[data-testid="buffer"]')).toBeTruthy();
expect(container.querySelector('[data-testid="thumb"]')).toBeTruthy();
expect(container.querySelector('[data-testid="value"]')).toBeTruthy();
});
it('thumb receives ARIA attributes from TimeSliderCore', () => {
const { Wrapper } = createPlayerWrapper();
const { container } = render(
<Wrapper>
<TimeSliderRoot>
<SliderThumb data-testid="thumb" />
</TimeSliderRoot>
</Wrapper>
);
const thumb = container.querySelector('[data-testid="thumb"]');
expect(thumb?.getAttribute('role')).toBe('slider');
expect(thumb?.getAttribute('aria-label')).toBe('Seek');
});
it('SliderValue displays formatted time', () => {
const { Wrapper } = createPlayerWrapper();
const { container } = render(
<Wrapper>
<TimeSliderRoot>
<SliderValue data-testid="value" />
</TimeSliderRoot>
</Wrapper>
);
const output = container.querySelector('[data-testid="value"]');
expect(output?.textContent).toBeTruthy();
});
});
@@ -0,0 +1,151 @@
'use client';
import { TimeSliderCore, TimeSliderDataAttrs } from '@videojs/core';
import { getTimeSliderCSSVars, logMissingFeature, selectBuffer, selectTime } from '@videojs/core/dom';
import { formatTime } from '@videojs/utils/time';
import { forwardRef, useRef, useState } from 'react';
import { usePlayer } from '../../player/context';
import type { UIComponentProps } from '../../utils/types';
import { useLatestRef } from '../../utils/use-latest-ref';
import { renderElement } from '../../utils/use-render';
import { useSlider } from '../hooks/use-slider';
import { SliderProvider } from '../slider/slider-context';
const noopSeek = (): Promise<number> => Promise.resolve(0);
export interface TimeSliderRootProps
extends UIComponentProps<'div', TimeSliderCore.State>,
Pick<TimeSliderCore.Props, 'label' | 'step' | 'largeStep' | 'disabled' | 'thumbAlignment'> {
/** Trailing-edge throttle (ms) for seek requests during drag. Default `100`. `0` disables. */
commitThrottle?: number | undefined;
onDragStart?: (() => void) | undefined;
onDragEnd?: (() => void) | undefined;
}
export const TimeSliderRoot = forwardRef<HTMLDivElement, TimeSliderRootProps>(
function TimeSliderRoot(componentProps, forwardedRef) {
const {
render,
className,
style,
label,
step = 1,
largeStep = 10,
disabled,
thumbAlignment,
commitThrottle = 100,
onDragStart,
onDragEnd,
...elementProps
} = componentProps;
const time = usePlayer(selectTime);
const buffer = usePlayer(selectBuffer);
const [core] = useState(() => new TimeSliderCore());
core.setProps({ label, step, largeStep, disabled, thumbAlignment });
// Keep a ref to the latest media state for callbacks that fire outside the render cycle.
const mediaRef = useLatestRef(time && buffer ? { ...time, ...buffer } : null);
// Holds the target time (seconds) between commit and seek completion, preventing the slider
// from snapping back to the stale `currentTime` while the async seek settles.
const pendingSeekRef = useRef<number | null>(null);
const duration = time?.duration ?? 0;
const range = duration || 1;
const { state, cssVars, rootRef, thumbRef, rootProps, thumbProps } = useSlider<TimeSliderCore.State>({
computeState: (interaction) => {
if (!time || !buffer) {
return core.getTimeState(
{ currentTime: 0, duration: 0, seeking: false, seek: noopSeek, buffered: [], seekable: [] },
interaction
);
}
const baseState = core.getTimeState({ ...time, ...buffer }, interaction);
// After drag release, `dragging` resets before the async seek completes. Hold the
// slider at the committed position until `currentTime` catches up.
const pending = pendingSeekRef.current;
if (!interaction.dragging && pending !== null) {
if (Math.abs(time.currentTime - pending) < 0.5) {
// Seek landed — clear pending.
pendingSeekRef.current = null;
} else {
const dur = time.duration || 1;
const fillPercent = (pending / dur) * 100;
return { ...baseState, value: pending, fillPercent };
}
}
return baseState;
},
getPercent: () => (duration > 0 ? ((time?.currentTime ?? 0) / duration) * 100 : 0),
getStepPercent: () => (step / range) * 100,
getLargeStepPercent: () => (largeStep / range) * 100,
orientation: 'horizontal',
disabled,
commitThrottle,
adjustPercent: (rawPercent, thumbSize, trackSize) =>
core.adjustPercentForAlignment(rawPercent, thumbSize, trackSize),
getCSSVars: getTimeSliderCSSVars,
onValueChange: (percent) => {
// Track the target position for visual hold during pointer interaction.
const media = mediaRef.current;
if (media) {
pendingSeekRef.current = (percent / 100) * (media.duration || 0);
}
},
onValueCommit: (percent) => {
const media = mediaRef.current;
if (!media) return;
const seconds = (percent / 100) * (media.duration || 0);
pendingSeekRef.current = seconds;
// seek() is async — catch rejection if the media target isn't attached yet.
media.seek(seconds).catch(() => {
pendingSeekRef.current = null;
});
},
onDragStart,
onDragEnd,
});
if (!time) {
if (__DEV__) logMissingFeature('TimeSlider', 'time');
return null;
}
return (
<SliderProvider
value={{
state,
pointerValue: core.valueFromPercent(state.pointerPercent),
thumbRef,
thumbProps,
stateAttrMap: TimeSliderDataAttrs,
getAttrs: (sliderState) => core.getAttrs(sliderState as TimeSliderCore.State),
formatValue: (value) => formatTime(value, duration),
}}
>
{renderElement(
'div',
{ render, className, style },
{
state,
stateAttrMap: TimeSliderDataAttrs,
ref: [forwardedRef, rootRef],
props: [{ style: cssVars }, rootProps, elementProps],
}
)}
</SliderProvider>
);
}
);
export namespace TimeSliderRoot {
export type Props = TimeSliderRootProps;
export type State = TimeSliderCore.State;
}
@@ -0,0 +1,11 @@
export {
Fill,
type FillProps,
Thumb,
type ThumbProps,
Track,
type TrackProps,
Value,
type ValueProps,
} from '../slider/index.parts';
export { VolumeSliderRoot as Root, type VolumeSliderRootProps as RootProps } from './volume-slider-root';
@@ -0,0 +1 @@
export * as VolumeSlider from './index.parts';
@@ -0,0 +1,189 @@
import { cleanup, render } from '@testing-library/react';
import { createRef } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createPlayerWrapper } from '../../../testing/mocks';
import { SliderFill } from '../../slider/slider-fill';
import { SliderThumb } from '../../slider/slider-thumb';
import { SliderTrack } from '../../slider/slider-track';
import { SliderValue } from '../../slider/slider-value';
import { VolumeSliderRoot } from '../volume-slider-root';
// --- Hoisted mock data (available inside vi.mock factories) ---
const { mockSliderHandle, mockVolumeState } = vi.hoisted(() => ({
mockSliderHandle: () => ({
interaction: {
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(),
},
destroy: vi.fn(),
}),
mockVolumeState: {
volume: 0.8,
muted: false,
volumeAvailability: 'available' as const,
changeVolume: vi.fn(),
toggleMute: vi.fn(),
},
}));
// --- Module mocks ---
vi.mock('@videojs/core/dom', async (importOriginal) => {
const orig: Record<string, unknown> = await importOriginal();
return { ...orig, createSlider: vi.fn(mockSliderHandle) };
});
vi.mock('@videojs/store/react', () => ({
useSnapshot: vi.fn((state: { current: unknown }) => state.current),
useStore: vi.fn((_store: unknown, selector?: (state: object) => unknown) => {
if (!selector) return _store;
try {
const result = selector({ volume: mockVolumeState });
if (result !== undefined) return result;
} catch {
// fall through
}
try {
return selector(mockVolumeState);
} catch {
return undefined;
}
}),
}));
afterEach(cleanup);
// --- Tests ---
describe('VolumeSliderRoot', () => {
it('renders a div element', () => {
const { Wrapper } = createPlayerWrapper();
const { container } = render(
<Wrapper>
<VolumeSliderRoot />
</Wrapper>
);
const el = container.querySelector('[data-orientation]');
expect(el).toBeTruthy();
expect(el?.tagName).toBe('DIV');
});
it('forwards ref', () => {
const { Wrapper } = createPlayerWrapper();
const ref = createRef<HTMLDivElement>();
render(
<Wrapper>
<VolumeSliderRoot ref={ref} />
</Wrapper>
);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
});
it('spreads additional props', () => {
const { Wrapper } = createPlayerWrapper();
const { container } = render(
<Wrapper>
<VolumeSliderRoot data-testid="vol-slider" />
</Wrapper>
);
expect(container.querySelector('[data-testid="vol-slider"]')).toBeTruthy();
});
it('defaults orientation to horizontal', () => {
const { Wrapper } = createPlayerWrapper();
const { container } = render(
<Wrapper>
<VolumeSliderRoot />
</Wrapper>
);
const el = container.querySelector('[data-orientation]');
expect(el?.getAttribute('data-orientation')).toBe('horizontal');
});
it('sets slider CSS custom properties', () => {
const { Wrapper } = createPlayerWrapper();
const { container } = render(
<Wrapper>
<VolumeSliderRoot />
</Wrapper>
);
const el = container.querySelector('[data-orientation]') as HTMLElement;
expect(el?.style.getPropertyValue('--media-slider-fill')).toBeTruthy();
expect(el?.style.getPropertyValue('--media-slider-pointer')).toBeTruthy();
});
});
describe('VolumeSlider compound', () => {
it('renders all parts together', () => {
const { Wrapper } = createPlayerWrapper();
const { container } = render(
<Wrapper>
<VolumeSliderRoot data-testid="root">
<SliderTrack data-testid="track">
<SliderFill data-testid="fill" />
<SliderThumb data-testid="thumb" />
</SliderTrack>
<SliderValue data-testid="value" />
</VolumeSliderRoot>
</Wrapper>
);
expect(container.querySelector('[data-testid="root"]')).toBeTruthy();
expect(container.querySelector('[data-testid="track"]')).toBeTruthy();
expect(container.querySelector('[data-testid="fill"]')).toBeTruthy();
expect(container.querySelector('[data-testid="thumb"]')).toBeTruthy();
expect(container.querySelector('[data-testid="value"]')).toBeTruthy();
});
it('thumb receives ARIA attributes from VolumeSliderCore', () => {
const { Wrapper } = createPlayerWrapper();
const { container } = render(
<Wrapper>
<VolumeSliderRoot>
<SliderThumb data-testid="thumb" />
</VolumeSliderRoot>
</Wrapper>
);
const thumb = container.querySelector('[data-testid="thumb"]');
expect(thumb?.getAttribute('role')).toBe('slider');
expect(thumb?.getAttribute('aria-label')).toBe('Volume');
});
it('SliderValue formats as percentage', () => {
const { Wrapper } = createPlayerWrapper();
const { container } = render(
<Wrapper>
<VolumeSliderRoot>
<SliderValue data-testid="value" />
</VolumeSliderRoot>
</Wrapper>
);
const output = container.querySelector('[data-testid="value"]');
expect(output?.textContent).toContain('%');
});
});
@@ -0,0 +1,114 @@
'use client';
import { SliderDataAttrs, VolumeSliderCore } from '@videojs/core';
import { getSliderCSSVars, logMissingFeature, selectVolume } from '@videojs/core/dom';
import { forwardRef, useState } from 'react';
import { usePlayer } from '../../player/context';
import type { UIComponentProps } from '../../utils/types';
import { useLatestRef } from '../../utils/use-latest-ref';
import { renderElement } from '../../utils/use-render';
import { useSlider } from '../hooks/use-slider';
import { SliderProvider } from '../slider/slider-context';
const noopVolume = {
volume: 0,
muted: false,
volumeAvailability: 'unsupported' as const,
changeVolume: () => 0,
toggleMute: () => false,
};
export interface VolumeSliderRootProps
extends UIComponentProps<'div', VolumeSliderCore.State>,
Pick<VolumeSliderCore.Props, 'label' | 'orientation' | 'step' | 'largeStep' | 'disabled' | 'thumbAlignment'> {
onDragStart?: (() => void) | undefined;
onDragEnd?: (() => void) | undefined;
}
export const VolumeSliderRoot = forwardRef<HTMLDivElement, VolumeSliderRootProps>(
function VolumeSliderRoot(componentProps, forwardedRef) {
const {
render,
className,
style,
label,
orientation,
step = 1,
largeStep = 10,
disabled,
thumbAlignment,
onDragStart,
onDragEnd,
...elementProps
} = componentProps;
const volume = usePlayer(selectVolume);
const [core] = useState(() => new VolumeSliderCore());
core.setProps({ label, orientation, step, largeStep, disabled, thumbAlignment });
// Keep a ref to the latest volume state for callbacks.
const volumeRef = useLatestRef(volume);
const { state, cssVars, rootRef, thumbRef, rootProps, thumbProps } = useSlider<VolumeSliderCore.State>({
computeState: (interaction) => {
if (!volume) {
return core.getVolumeState(noopVolume, interaction);
}
return core.getVolumeState(volume, interaction);
},
getPercent: () => (volume ? volume.volume * 100 : 0),
getStepPercent: () => step,
getLargeStepPercent: () => largeStep,
orientation,
disabled,
adjustPercent: (rawPercent, thumbSize, trackSize) =>
core.adjustPercentForAlignment(rawPercent, thumbSize, trackSize),
getCSSVars: getSliderCSSVars,
onValueChange: (percent) => {
volumeRef.current?.changeVolume(percent / 100);
},
onValueCommit: (percent) => {
volumeRef.current?.changeVolume(percent / 100);
},
onDragStart,
onDragEnd,
});
if (!volume) {
if (__DEV__) logMissingFeature('VolumeSlider', 'volume');
return null;
}
return (
<SliderProvider
value={{
state,
pointerValue: core.valueFromPercent(state.pointerPercent),
thumbRef,
thumbProps,
stateAttrMap: SliderDataAttrs,
getAttrs: (sliderState) => core.getAttrs(sliderState as VolumeSliderCore.State),
formatValue: (value) => `${Math.round(value)}%`,
}}
>
{renderElement(
'div',
{ render, className, style },
{
state,
stateAttrMap: SliderDataAttrs,
ref: [forwardedRef, rootRef],
props: [{ style: cssVars }, rootProps, elementProps],
}
)}
</SliderProvider>
);
}
);
export namespace VolumeSliderRoot {
export type Props = VolumeSliderRootProps;
export type State = VolumeSliderCore.State;
}