From 158378ac731db302b3d1f309de9d93f0f8fc92f2 Mon Sep 17 00:00:00 2001 From: rahim Date: Sat, 28 Feb 2026 01:02:40 -0800 Subject: [PATCH] feat(react): add time slider component (#647) --- packages/react/src/index.ts | 2 + .../react/src/ui/time-slider/index.parts.ts | 13 ++ packages/react/src/ui/time-slider/index.ts | 1 + .../ui/time-slider/tests/time-slider.test.tsx | 196 ++++++++++++++++++ .../src/ui/time-slider/time-slider-root.tsx | 151 ++++++++++++++ .../react/src/ui/volume-slider/index.parts.ts | 11 + packages/react/src/ui/volume-slider/index.ts | 1 + .../tests/volume-slider.test.tsx | 189 +++++++++++++++++ .../ui/volume-slider/volume-slider-root.tsx | 114 ++++++++++ 9 files changed, 678 insertions(+) create mode 100644 packages/react/src/ui/time-slider/index.parts.ts create mode 100644 packages/react/src/ui/time-slider/index.ts create mode 100644 packages/react/src/ui/time-slider/tests/time-slider.test.tsx create mode 100644 packages/react/src/ui/time-slider/time-slider-root.tsx create mode 100644 packages/react/src/ui/volume-slider/index.parts.ts create mode 100644 packages/react/src/ui/volume-slider/index.ts create mode 100644 packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx create mode 100644 packages/react/src/ui/volume-slider/volume-slider-root.tsx diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a40caa3d..6d753048 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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'; diff --git a/packages/react/src/ui/time-slider/index.parts.ts b/packages/react/src/ui/time-slider/index.parts.ts new file mode 100644 index 00000000..5ea7bda3 --- /dev/null +++ b/packages/react/src/ui/time-slider/index.parts.ts @@ -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'; diff --git a/packages/react/src/ui/time-slider/index.ts b/packages/react/src/ui/time-slider/index.ts new file mode 100644 index 00000000..6106dfc2 --- /dev/null +++ b/packages/react/src/ui/time-slider/index.ts @@ -0,0 +1 @@ +export * as TimeSlider from './index.parts'; diff --git a/packages/react/src/ui/time-slider/tests/time-slider.test.tsx b/packages/react/src/ui/time-slider/tests/time-slider.test.tsx new file mode 100644 index 00000000..0c5f1292 --- /dev/null +++ b/packages/react/src/ui/time-slider/tests/time-slider.test.tsx @@ -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 = 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( + + + + ); + 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(); + render( + + + + ); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it('spreads additional props', () => { + const { Wrapper } = createPlayerWrapper(); + const { container } = render( + + + + ); + + expect(container.querySelector('[data-testid="time-slider"]')).toBeTruthy(); + }); + + it('sets data-orientation to horizontal', () => { + const { Wrapper } = createPlayerWrapper(); + const { container } = render( + + + + ); + + 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( + + + + ); + + 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( + + + + + + + + + + + ); + + 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( + + + + + + ); + + 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( + + + + + + ); + + const output = container.querySelector('[data-testid="value"]'); + expect(output?.textContent).toBeTruthy(); + }); +}); diff --git a/packages/react/src/ui/time-slider/time-slider-root.tsx b/packages/react/src/ui/time-slider/time-slider-root.tsx new file mode 100644 index 00000000..b9b396e3 --- /dev/null +++ b/packages/react/src/ui/time-slider/time-slider-root.tsx @@ -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 => Promise.resolve(0); + +export interface TimeSliderRootProps + extends UIComponentProps<'div', TimeSliderCore.State>, + Pick { + /** 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( + 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(null); + + const duration = time?.duration ?? 0; + const range = duration || 1; + + const { state, cssVars, rootRef, thumbRef, rootProps, thumbProps } = useSlider({ + 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 ( + 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], + } + )} + + ); + } +); + +export namespace TimeSliderRoot { + export type Props = TimeSliderRootProps; + export type State = TimeSliderCore.State; +} diff --git a/packages/react/src/ui/volume-slider/index.parts.ts b/packages/react/src/ui/volume-slider/index.parts.ts new file mode 100644 index 00000000..62dc9cd9 --- /dev/null +++ b/packages/react/src/ui/volume-slider/index.parts.ts @@ -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'; diff --git a/packages/react/src/ui/volume-slider/index.ts b/packages/react/src/ui/volume-slider/index.ts new file mode 100644 index 00000000..45c5ec96 --- /dev/null +++ b/packages/react/src/ui/volume-slider/index.ts @@ -0,0 +1 @@ +export * as VolumeSlider from './index.parts'; diff --git a/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx b/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx new file mode 100644 index 00000000..c9087344 --- /dev/null +++ b/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx @@ -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 = 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( + + + + ); + + const el = container.querySelector('[data-orientation]'); + expect(el).toBeTruthy(); + expect(el?.tagName).toBe('DIV'); + }); + + it('forwards ref', () => { + const { Wrapper } = createPlayerWrapper(); + const ref = createRef(); + render( + + + + ); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it('spreads additional props', () => { + const { Wrapper } = createPlayerWrapper(); + const { container } = render( + + + + ); + + expect(container.querySelector('[data-testid="vol-slider"]')).toBeTruthy(); + }); + + it('defaults orientation to horizontal', () => { + const { Wrapper } = createPlayerWrapper(); + const { container } = render( + + + + ); + + 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( + + + + ); + + 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( + + + + + + + + + + ); + + 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( + + + + + + ); + + 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( + + + + + + ); + + const output = container.querySelector('[data-testid="value"]'); + expect(output?.textContent).toContain('%'); + }); +}); diff --git a/packages/react/src/ui/volume-slider/volume-slider-root.tsx b/packages/react/src/ui/volume-slider/volume-slider-root.tsx new file mode 100644 index 00000000..925e5f24 --- /dev/null +++ b/packages/react/src/ui/volume-slider/volume-slider-root.tsx @@ -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 { + onDragStart?: (() => void) | undefined; + onDragEnd?: (() => void) | undefined; +} + +export const VolumeSliderRoot = forwardRef( + 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({ + 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 ( + 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], + } + )} + + ); + } +); + +export namespace VolumeSliderRoot { + export type Props = VolumeSliderRootProps; + export type State = VolumeSliderCore.State; +}