From 2f8ca094ad5bc5bafc5435c2e97bca58c6d29b5d Mon Sep 17 00:00:00 2001 From: rahim Date: Sat, 28 Feb 2026 00:48:51 -0800 Subject: [PATCH] feat(react): add slider component (#644) --- .claude/plans/slider.md | 61 +++- .../core/src/core/ui/slider/slider-core.ts | 4 + packages/core/src/dom/ui/slider.ts | 18 + packages/core/src/dom/ui/tests/slider.test.ts | 220 ++++++++++++ packages/react/src/index.ts | 8 + .../react/src/player/tests/context.test.tsx | 12 +- packages/react/src/testing/mocks.tsx | 46 +++ packages/react/src/ui/hooks/use-slider.ts | 131 +++++++ packages/react/src/ui/slider/index.parts.ts | 6 + packages/react/src/ui/slider/index.ts | 1 + .../react/src/ui/slider/slider-buffer.tsx | 36 ++ .../react/src/ui/slider/slider-context.tsx | 27 ++ packages/react/src/ui/slider/slider-fill.tsx | 36 ++ packages/react/src/ui/slider/slider-root.tsx | 102 ++++++ packages/react/src/ui/slider/slider-thumb.tsx | 39 +++ packages/react/src/ui/slider/slider-track.tsx | 36 ++ packages/react/src/ui/slider/slider-value.tsx | 45 +++ .../react/src/ui/slider/tests/slider.test.tsx | 324 ++++++++++++++++++ packages/utils/src/function/index.ts | 1 + .../utils/src/function/tests/throttle.test.ts | 101 ++++++ packages/utils/src/function/throttle.ts | 34 ++ 21 files changed, 1276 insertions(+), 12 deletions(-) create mode 100644 packages/react/src/testing/mocks.tsx create mode 100644 packages/react/src/ui/hooks/use-slider.ts create mode 100644 packages/react/src/ui/slider/index.parts.ts create mode 100644 packages/react/src/ui/slider/index.ts create mode 100644 packages/react/src/ui/slider/slider-buffer.tsx create mode 100644 packages/react/src/ui/slider/slider-context.tsx create mode 100644 packages/react/src/ui/slider/slider-fill.tsx create mode 100644 packages/react/src/ui/slider/slider-root.tsx create mode 100644 packages/react/src/ui/slider/slider-thumb.tsx create mode 100644 packages/react/src/ui/slider/slider-track.tsx create mode 100644 packages/react/src/ui/slider/slider-value.tsx create mode 100644 packages/react/src/ui/slider/tests/slider.test.tsx create mode 100644 packages/utils/src/function/tests/throttle.test.ts create mode 100644 packages/utils/src/function/throttle.ts diff --git a/.claude/plans/slider.md b/.claude/plans/slider.md index 4319858e..1bd10ec3 100644 --- a/.claude/plans/slider.md +++ b/.claude/plans/slider.md @@ -1,6 +1,6 @@ # Slider Component Implementation -**Status:** PR 2 COMPLETE — PR 3 (React) and PR 4 (HTML) ready to start +**Status:** PR 3 COMPLETE — PR 4 (HTML) ready to start **Design Doc:** `internal/design/ui/slider/` **Issues:** #275 (time slider), #267 (volume slider), #269 (seek slider) @@ -534,12 +534,69 @@ pnpm lint:fix:file --- -## PR 3: React Components +## PR 3: React Components — COMPLETE **Branch:** `feat/slider-react` **Base:** `feat/slider-dom` **Package:** `@videojs/react` +### What was built + +- `useSlider` generic hook wrapping `createSlider()` + `useSnapshot` for React lifecycle +- `SliderProvider` / `useSliderContext` — first component-level React context in the codebase (children need interaction state from `createSlider()`, not player store state) +- 6 generic parts: `SliderRoot`, `SliderTrack`, `SliderFill`, `SliderBuffer`, `SliderThumb`, `SliderValue` +- `TimeSliderRoot` and `VolumeSliderRoot` domain roots connecting to player store via `usePlayer` +- Barrel files with compound namespace pattern (`Slider.*`, `TimeSlider.*`, `VolumeSlider.*`) +- `useLatestRef` utility for stable callback refs +- `SliderCore.props` getter added to core package +- Shared test helpers (`createMockStore`, `createPlayerWrapper`) in `packages/react/src/testing/mocks.tsx` +- Skin presets updated with real `TimeSlider` compound components +- 38 new tests (22 slider + 8 time-slider + 8 volume-slider), all 139 React tests passing +- Sandbox page with standalone slider, vertical, disabled, thumbAlignment demo, TimeSlider with video, VolumeSlider horizontal + vertical + +### Deviations from plan + +1. **`useSlider` is generic over state type** — Plan showed `UseSliderResult` returning `SliderState`. Implementation uses `` generic so domain roots get proper `TimeSliderState` / `VolumeSliderState` typing. +2. **`thumbRef` threaded through context** — Not in plan. Required so `createSlider` can programmatically focus the thumb on pointerdown. +3. **Domain roots don't compose on `SliderRoot`** — Plan implied composition. Each domain root has different state sources, cores, CSS var functions, and hardwired callbacks, making composition impractical. +4. **Domain barrel re-exports simplified** — Plan showed explicit re-exports of each part. Implementation uses `export * from '../slider/index.parts'` then overrides `Root` only. +5. **Context uses `.Provider` pattern** — React 19's direct `` isn't supported by the rolldown bundler. Uses ``. +6. **`getAttrs` in context** — Not in plan. Justified for `SliderThumb` to get domain-specific ARIA attrs without knowing which core it's using. +7. **Shared test helpers extracted** — Plan didn't specify. Created `packages/react/src/testing/mocks.tsx` with `vi.hoisted()` pattern for mock data. + +### Files created + +- `packages/react/src/utils/use-latest-ref.ts` +- `packages/react/src/ui/slider/slider-context.tsx` +- `packages/react/src/ui/hooks/use-slider.ts` +- `packages/react/src/ui/slider/slider-root.tsx` +- `packages/react/src/ui/slider/slider-track.tsx` +- `packages/react/src/ui/slider/slider-fill.tsx` +- `packages/react/src/ui/slider/slider-buffer.tsx` +- `packages/react/src/ui/slider/slider-thumb.tsx` +- `packages/react/src/ui/slider/slider-value.tsx` +- `packages/react/src/ui/slider/index.ts` + `index.parts.ts` +- `packages/react/src/ui/time-slider/time-slider-root.tsx` +- `packages/react/src/ui/time-slider/index.ts` + `index.parts.ts` +- `packages/react/src/ui/volume-slider/volume-slider-root.tsx` +- `packages/react/src/ui/volume-slider/index.ts` + `index.parts.ts` +- `packages/react/src/testing/mocks.tsx` +- `packages/react/src/ui/slider/tests/slider.test.tsx` (22 tests) +- `packages/react/src/ui/time-slider/tests/time-slider.test.tsx` (8 tests) +- `packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx` (8 tests) + +### Files modified + +- `packages/core/src/core/ui/slider/slider-core.ts` — added `get props()` getter +- `packages/react/src/index.ts` — added Slider/TimeSlider/VolumeSlider/useSlider exports +- `packages/react/src/presets/video/skin.tsx` — replaced placeholder with TimeSlider compound +- `packages/react/src/presets/video/minimal-skin.tsx` — same replacement +- `packages/react/src/player/tests/context.test.tsx` — updated to use shared `createMockStore` +- `packages/sandbox/vite.config.ts` — added `react-slider` input +- `packages/sandbox/src/index.html` — added nav link + +### Original plan (for reference) + ### 3.1 Slider Context **File:** `packages/react/src/ui/slider/slider-context.ts` diff --git a/packages/core/src/core/ui/slider/slider-core.ts b/packages/core/src/core/ui/slider/slider-core.ts index 00bcc44f..3528ebd2 100644 --- a/packages/core/src/core/ui/slider/slider-core.ts +++ b/packages/core/src/core/ui/slider/slider-core.ts @@ -67,6 +67,10 @@ export class SliderCore { #props = { ...SliderCore.defaultProps }; + get props(): Readonly> { + return this.#props; + } + constructor(props?: SliderProps) { if (props) this.setProps(props); } diff --git a/packages/core/src/dom/ui/slider.ts b/packages/core/src/dom/ui/slider.ts index a0a4636f..fc144c72 100644 --- a/packages/core/src/dom/ui/slider.ts +++ b/packages/core/src/dom/ui/slider.ts @@ -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(); } diff --git a/packages/core/src/dom/ui/tests/slider.test.ts b/packages/core/src/dom/ui/tests/slider.test.ts index b3f38e0d..b76d7d3a 100644 --- a/packages/core/src/dom/ui/tests/slider.test.ts +++ b/packages/core/src/dom/ui/tests/slider.test.ts @@ -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(); + }); + }); }); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index f71b2aed..a40caa3d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -34,6 +34,7 @@ export type { ControlsGroupProps } from './ui/controls/controls-group'; export type { ControlsRootProps } from './ui/controls/controls-root'; export { FullscreenButton, type FullscreenButtonProps } from './ui/fullscreen-button/fullscreen-button'; export { useButton } from './ui/hooks/use-button'; +export { useSlider } from './ui/hooks/use-slider'; export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button'; export { PiPButton, type PiPButtonProps } from './ui/pip-button/pip-button'; export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button'; @@ -41,6 +42,13 @@ export { PlaybackRateButton, type PlaybackRateButtonProps } from './ui/playback- export { Popover } from './ui/popover'; export { Poster, type PosterProps } from './ui/poster/poster'; export { SeekButton, type SeekButtonProps } from './ui/seek-button/seek-button'; +export { Slider } from './ui/slider'; +export type { SliderBufferProps } from './ui/slider/slider-buffer'; +export type { SliderFillProps } from './ui/slider/slider-fill'; +export type { SliderRootProps } from './ui/slider/slider-root'; +export type { SliderThumbProps } from './ui/slider/slider-thumb'; +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'; diff --git a/packages/react/src/player/tests/context.test.tsx b/packages/react/src/player/tests/context.test.tsx index 5de03b04..1fdba2a4 100644 --- a/packages/react/src/player/tests/context.test.tsx +++ b/packages/react/src/player/tests/context.test.tsx @@ -2,6 +2,7 @@ import { render, renderHook } from '@testing-library/react'; import type { ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { createMockStore } from '../../testing/mocks'; import { Container, PlayerContextProvider, @@ -13,15 +14,6 @@ import { usePlayerContext, } from '../context'; -function createMockStore() { - return { - state: { paused: true, volume: 1 }, - attach: vi.fn(() => vi.fn()), - subscribe: vi.fn(() => vi.fn()), - destroy: vi.fn(), - }; -} - function createWrapper(value: PlayerContextValue) { return function Wrapper({ children }: { children: ReactNode }) { return {children}; @@ -114,7 +106,7 @@ describe('useOptionalPlayer', () => { }); it('returns selected state inside Provider', () => { - const store = createMockStore(); + const store = createMockStore({ paused: true }); const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() }; const { result } = renderHook(() => useOptionalPlayer((state: any) => state.paused), { diff --git a/packages/react/src/testing/mocks.tsx b/packages/react/src/testing/mocks.tsx new file mode 100644 index 00000000..e7dcd0cc --- /dev/null +++ b/packages/react/src/testing/mocks.tsx @@ -0,0 +1,46 @@ +/** + * Shared test helpers for `@videojs/react`. + * + * Provides factory functions for common mock objects used across component + * tests. `vi.mock()` blocks must still live in each test file (Vitest hoists + * them before imports), but non-mock helpers like store and wrapper factories + * can be shared here. + */ + +import type { ReactNode } from 'react'; +import { vi } from 'vitest'; + +import { PlayerContextProvider, type PlayerContextValue } from '../player/context'; + +/** + * Create a minimal mock store compatible with `PlayerContextValue`. + * + * Pass optional `state` to seed the store's state snapshot. + */ +export function createMockStore(state: Record = {}) { + return { + state, + attach: vi.fn(() => vi.fn()), + subscribe: vi.fn(() => vi.fn()), + destroy: vi.fn(), + }; +} + +/** + * Create a React wrapper that provides `PlayerContextProvider`. + * + * Accepts an optional store state seed. Returns the wrapper component, + * the mock store, and the context value for assertions. + */ +export function createPlayerWrapper(storeState: Record = {}) { + const store = createMockStore(storeState); + const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() }; + + return { + store, + value, + Wrapper({ children }: { children: ReactNode }) { + return {children}; + }, + }; +} diff --git a/packages/react/src/ui/hooks/use-slider.ts b/packages/react/src/ui/hooks/use-slider.ts new file mode 100644 index 00000000..3057992a --- /dev/null +++ b/packages/react/src/ui/hooks/use-slider.ts @@ -0,0 +1,131 @@ +'use client'; + +import type { SliderInteraction, SliderState } from '@videojs/core'; +import { + createSlider, + type SliderHandle, + type SliderOptions, + type SliderRootProps, + type SliderThumbProps, +} from '@videojs/core/dom'; +import { useSnapshot } from '@videojs/store/react'; +import { isRTL } from '@videojs/utils/dom'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useLatestRef } from '../../utils/use-latest-ref'; + +export interface UseSliderOptions + extends Pick< + SliderOptions, + | 'getPercent' + | 'getStepPercent' + | 'getLargeStepPercent' + | 'commitThrottle' + | 'onValueChange' + | 'onValueCommit' + | 'onDragStart' + | 'onDragEnd' + > { + computeState: (interaction: SliderInteraction) => State; + orientation?: 'horizontal' | 'vertical' | undefined; + disabled?: boolean | undefined; + /** Adjust a raw 0–100 percent for thumb alignment. Called for fill and pointer percents. */ + adjustPercent?: ((rawPercent: number, thumbSize: number, trackSize: number) => number) | undefined; + /** Compute CSS variable map from the (possibly alignment-adjusted) state. */ + getCSSVars: (state: State) => Record; +} + +export interface UseSliderReturnValue { + state: State; + cssVars: Record; + rootRef: React.RefCallback; + thumbRef: React.RefCallback; + rootProps: SliderRootProps; + thumbProps: SliderThumbProps; +} + +/** + * Manages slider interaction lifecycle for React. + * + * Wraps `createSlider()` from `@videojs/core/dom` and subscribes to its + * interaction state via `useSnapshot`. Returns split props for the root + * (pointer events) and thumb (keyboard/focus) elements. + */ +export function useSlider( + options: UseSliderOptions +): UseSliderReturnValue { + const optionsRef = useLatestRef(options); + + const rootElementRef = useRef(null); + const thumbElementRef = useRef(null); + + // Lazy-init the slider handle. Stable across re-renders. + const [slider] = useState(() => { + const stableOptions: SliderOptions = { + getElement: () => rootElementRef.current!, + getThumbElement: () => thumbElementRef.current, + getOrientation: () => optionsRef.current.orientation ?? 'horizontal', + isRTL: () => (rootElementRef.current ? isRTL(rootElementRef.current) : false), + isDisabled: () => optionsRef.current.disabled ?? false, + getPercent: () => optionsRef.current.getPercent(), + getStepPercent: () => optionsRef.current.getStepPercent(), + getLargeStepPercent: () => optionsRef.current.getLargeStepPercent(), + commitThrottle: optionsRef.current.commitThrottle, + onValueChange: (percent) => optionsRef.current.onValueChange?.(percent), + onValueCommit: (percent) => optionsRef.current.onValueCommit?.(percent), + onDragStart: () => optionsRef.current.onDragStart?.(), + onDragEnd: () => optionsRef.current.onDragEnd?.(), + }; + + return createSlider(stableOptions); + }); + + // Cleanup on unmount. + useEffect(() => () => slider.destroy(), [slider]); + + // Subscribe to interaction state. + const interaction = useSnapshot(slider.interaction); + + // Compute derived state from interaction + caller-provided projection. + const state = options.computeState(interaction); + + // Adjust CSS var percents for edge thumb alignment when DOM elements are available. + const rootEl = rootElementRef.current; + const thumbEl = thumbElementRef.current; + let cssState = state; + + if (state.thumbAlignment === 'edge' && rootEl && thumbEl && options.adjustPercent) { + const isHorizontal = state.orientation === 'horizontal'; + const thumbSize = isHorizontal ? thumbEl.offsetWidth : thumbEl.offsetHeight; + const trackSize = isHorizontal ? rootEl.offsetWidth : rootEl.offsetHeight; + cssState = { + ...state, + fillPercent: options.adjustPercent(state.fillPercent, thumbSize, trackSize), + pointerPercent: options.adjustPercent(state.pointerPercent, thumbSize, trackSize), + }; + } + + const cssVars = options.getCSSVars(cssState); + + // Ref callbacks for root and thumb elements. + const rootRef = useCallback((element: HTMLElement | null) => { + rootElementRef.current = element; + }, []); + + const thumbRef = useCallback((element: HTMLElement | null) => { + thumbElementRef.current = element; + }, []); + + return { + state, + cssVars, + rootRef, + thumbRef, + rootProps: slider.rootProps, + thumbProps: slider.thumbProps, + }; +} + +export namespace useSlider { + export type Options = UseSliderOptions; + export type ReturnValue = UseSliderReturnValue; +} diff --git a/packages/react/src/ui/slider/index.parts.ts b/packages/react/src/ui/slider/index.parts.ts new file mode 100644 index 00000000..2a3a8458 --- /dev/null +++ b/packages/react/src/ui/slider/index.parts.ts @@ -0,0 +1,6 @@ +export { SliderBuffer as Buffer, type SliderBufferProps as BufferProps } from './slider-buffer'; +export { SliderFill as Fill, type SliderFillProps as FillProps } from './slider-fill'; +export { SliderRoot as Root, type SliderRootProps as RootProps } from './slider-root'; +export { SliderThumb as Thumb, type SliderThumbProps as ThumbProps } from './slider-thumb'; +export { SliderTrack as Track, type SliderTrackProps as TrackProps } from './slider-track'; +export { SliderValue as Value, type SliderValueProps as ValueProps } from './slider-value'; diff --git a/packages/react/src/ui/slider/index.ts b/packages/react/src/ui/slider/index.ts new file mode 100644 index 00000000..953696ab --- /dev/null +++ b/packages/react/src/ui/slider/index.ts @@ -0,0 +1 @@ +export * as Slider from './index.parts'; diff --git a/packages/react/src/ui/slider/slider-buffer.tsx b/packages/react/src/ui/slider/slider-buffer.tsx new file mode 100644 index 00000000..25788451 --- /dev/null +++ b/packages/react/src/ui/slider/slider-buffer.tsx @@ -0,0 +1,36 @@ +'use client'; + +import type { SliderState } from '@videojs/core'; +import type { ForwardedRef } from 'react'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useSliderContext } from './slider-context'; + +export interface SliderBufferProps extends UIComponentProps<'div', SliderState> {} + +export const SliderBuffer = forwardRef(function SliderBuffer( + componentProps: SliderBufferProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, ...elementProps } = componentProps; + + const context = useSliderContext(); + if (!context) return null; + + return renderElement( + 'div', + { render, className, style }, + { + state: context.state, + stateAttrMap: context.stateAttrMap, + ref: forwardedRef, + props: [elementProps], + } + ); +}); + +export namespace SliderBuffer { + export type Props = SliderBufferProps; +} diff --git a/packages/react/src/ui/slider/slider-context.tsx b/packages/react/src/ui/slider/slider-context.tsx new file mode 100644 index 00000000..5fb3bdea --- /dev/null +++ b/packages/react/src/ui/slider/slider-context.tsx @@ -0,0 +1,27 @@ +'use client'; + +import type { SliderState, StateAttrMap } from '@videojs/core'; +import type { SliderThumbProps } from '@videojs/core/dom'; +import type { RefCallback } from 'react'; +import { createContext, useContext } from 'react'; + +export interface SliderContextValue { + state: SliderState; + /** Pointer position converted to the value domain (not 0–100 percent). */ + pointerValue: number; + thumbRef: RefCallback; + thumbProps: SliderThumbProps; + stateAttrMap: StateAttrMap; + getAttrs: (state: SliderState) => object; + formatValue?: ((value: number, type: 'current' | 'pointer') => string) | undefined; +} + +const SliderContext = createContext(undefined); + +export function SliderProvider({ value, children }: { value: SliderContextValue; children: React.ReactNode }) { + return {children}; +} + +export function useSliderContext(): SliderContextValue | undefined { + return useContext(SliderContext); +} diff --git a/packages/react/src/ui/slider/slider-fill.tsx b/packages/react/src/ui/slider/slider-fill.tsx new file mode 100644 index 00000000..873003ba --- /dev/null +++ b/packages/react/src/ui/slider/slider-fill.tsx @@ -0,0 +1,36 @@ +'use client'; + +import type { SliderState } from '@videojs/core'; +import type { ForwardedRef } from 'react'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useSliderContext } from './slider-context'; + +export interface SliderFillProps extends UIComponentProps<'div', SliderState> {} + +export const SliderFill = forwardRef(function SliderFill( + componentProps: SliderFillProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, ...elementProps } = componentProps; + + const context = useSliderContext(); + if (!context) return null; + + return renderElement( + 'div', + { render, className, style }, + { + state: context.state, + stateAttrMap: context.stateAttrMap, + ref: forwardedRef, + props: [elementProps], + } + ); +}); + +export namespace SliderFill { + export type Props = SliderFillProps; +} diff --git a/packages/react/src/ui/slider/slider-root.tsx b/packages/react/src/ui/slider/slider-root.tsx new file mode 100644 index 00000000..40b82316 --- /dev/null +++ b/packages/react/src/ui/slider/slider-root.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { SliderCore, SliderDataAttrs } from '@videojs/core'; +import { getSliderCSSVars } from '@videojs/core/dom'; +import type { ForwardedRef } from 'react'; +import { forwardRef, useState } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useSlider } from '../hooks/use-slider'; +import { SliderProvider } from './slider-context'; + +export interface SliderRootProps extends UIComponentProps<'div', SliderCore.State>, SliderCore.Props { + value?: number | undefined; + onValueChange?: ((value: number) => void) | undefined; + onValueCommit?: ((value: number) => void) | undefined; + onDragStart?: (() => void) | undefined; + onDragEnd?: (() => void) | undefined; +} + +export const SliderRoot = forwardRef(function SliderRoot( + componentProps: SliderRootProps, + forwardedRef: ForwardedRef +) { + const { + render, + className, + style, + min, + max, + step, + largeStep, + orientation, + disabled, + thumbAlignment, + value = 0, + onValueChange, + onValueCommit, + onDragStart, + onDragEnd, + ...elementProps + } = componentProps; + + const [core] = useState(() => new SliderCore()); + core.setProps({ min, max, step, largeStep, orientation, disabled, thumbAlignment }); + + const { min: resolvedMin, max: resolvedMax, step: resolvedStep, largeStep: resolvedLargeStep } = core.props; + const range = resolvedMax - resolvedMin || 1; + + const { + state, + cssVars, + rootRef, + thumbRef: sliderThumbRef, + rootProps, + thumbProps, + } = useSlider({ + computeState: (interaction) => core.getState(interaction, value), + getPercent: () => core.percentFromValue(value), + getStepPercent: () => (resolvedStep / range) * 100, + getLargeStepPercent: () => (resolvedLargeStep / range) * 100, + orientation, + disabled, + adjustPercent: (rawPercent, thumbSize, trackSize) => + core.adjustPercentForAlignment(rawPercent, thumbSize, trackSize), + getCSSVars: getSliderCSSVars, + onValueChange: (percent) => onValueChange?.(core.valueFromPercent(percent)), + onValueCommit: (percent) => onValueCommit?.(core.valueFromPercent(percent)), + onDragStart, + onDragEnd, + }); + + return ( + core.getAttrs(sliderState), + formatValue: undefined, + }} + > + {renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap: SliderDataAttrs, + ref: [forwardedRef, rootRef], + props: [{ style: cssVars }, rootProps, elementProps], + } + )} + + ); +}); + +export namespace SliderRoot { + export type Props = SliderRootProps; + export type State = SliderCore.State; +} diff --git a/packages/react/src/ui/slider/slider-thumb.tsx b/packages/react/src/ui/slider/slider-thumb.tsx new file mode 100644 index 00000000..62ebc167 --- /dev/null +++ b/packages/react/src/ui/slider/slider-thumb.tsx @@ -0,0 +1,39 @@ +'use client'; + +import type { SliderState } from '@videojs/core'; +import type { ForwardedRef } from 'react'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useSliderContext } from './slider-context'; + +export interface SliderThumbProps extends UIComponentProps<'div', SliderState> {} + +export const SliderThumb = forwardRef(function SliderThumb( + componentProps: SliderThumbProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, ...elementProps } = componentProps; + + const context = useSliderContext(); + if (!context) return null; + + const { state, thumbRef, thumbProps, getAttrs } = context; + const attrs = getAttrs(state); + + return renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap: context.stateAttrMap, + ref: [forwardedRef, thumbRef], + props: [attrs, thumbProps, elementProps], + } + ); +}); + +export namespace SliderThumb { + export type Props = SliderThumbProps; +} diff --git a/packages/react/src/ui/slider/slider-track.tsx b/packages/react/src/ui/slider/slider-track.tsx new file mode 100644 index 00000000..76105bf2 --- /dev/null +++ b/packages/react/src/ui/slider/slider-track.tsx @@ -0,0 +1,36 @@ +'use client'; + +import type { SliderState } from '@videojs/core'; +import type { ForwardedRef } from 'react'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useSliderContext } from './slider-context'; + +export interface SliderTrackProps extends UIComponentProps<'div', SliderState> {} + +export const SliderTrack = forwardRef(function SliderTrack( + componentProps: SliderTrackProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, ...elementProps } = componentProps; + + const context = useSliderContext(); + if (!context) return null; + + return renderElement( + 'div', + { render, className, style }, + { + state: context.state, + stateAttrMap: context.stateAttrMap, + ref: forwardedRef, + props: [elementProps], + } + ); +}); + +export namespace SliderTrack { + export type Props = SliderTrackProps; +} diff --git a/packages/react/src/ui/slider/slider-value.tsx b/packages/react/src/ui/slider/slider-value.tsx new file mode 100644 index 00000000..a9d9e196 --- /dev/null +++ b/packages/react/src/ui/slider/slider-value.tsx @@ -0,0 +1,45 @@ +'use client'; + +import type { SliderState } from '@videojs/core'; +import type { ForwardedRef } from 'react'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useSliderContext } from './slider-context'; + +export interface SliderValueProps extends UIComponentProps<'output', SliderState> { + type?: 'current' | 'pointer' | undefined; + format?: ((value: number) => string) | undefined; +} + +export const SliderValue = forwardRef(function SliderValue( + componentProps: SliderValueProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, type = 'current', format, ...elementProps } = componentProps; + + const context = useSliderContext(); + if (!context) return null; + + const { state, pointerValue, formatValue } = context; + + const rawValue = type === 'pointer' ? pointerValue : state.value; + + const text = format ? format(rawValue) : formatValue ? formatValue(rawValue, type) : String(Math.round(rawValue)); + + return renderElement( + 'output', + { render, className, style }, + { + state, + stateAttrMap: context.stateAttrMap, + ref: forwardedRef, + props: [{ 'aria-live': 'off', children: text }, elementProps], + } + ); +}); + +export namespace SliderValue { + export type Props = SliderValueProps; +} diff --git a/packages/react/src/ui/slider/tests/slider.test.tsx b/packages/react/src/ui/slider/tests/slider.test.tsx new file mode 100644 index 00000000..8ee56dea --- /dev/null +++ b/packages/react/src/ui/slider/tests/slider.test.tsx @@ -0,0 +1,324 @@ +import { cleanup, render } from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { SliderBuffer } from '../slider-buffer'; +import { SliderFill } from '../slider-fill'; +import { SliderRoot } from '../slider-root'; +import { SliderThumb } from '../slider-thumb'; +import { SliderTrack } from '../slider-track'; +import { SliderValue } from '../slider-value'; + +const { mockSliderHandle } = 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(), + }), +})); + +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(), +})); + +afterEach(cleanup); + +describe('SliderRoot', () => { + it('renders a div element', () => { + const { container } = render(); + const el = container.firstElementChild; + + expect(el).toBeTruthy(); + expect(el?.tagName).toBe('DIV'); + }); + + it('forwards ref to the root element', () => { + const ref = createRef(); + render(); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it('spreads additional props onto the root element', () => { + const { container } = render(); + const el = container.firstElementChild; + + expect(el?.getAttribute('data-testid')).toBe('slider'); + }); + + it('sets data-orientation attribute', () => { + const { container } = render(); + const el = container.firstElementChild; + + expect(el?.getAttribute('data-orientation')).toBe('horizontal'); + }); + + it('sets CSS custom properties as inline styles', () => { + const { container } = render(); + const el = container.firstElementChild as HTMLElement; + + expect(el.style.getPropertyValue('--media-slider-fill')).toBeTruthy(); + expect(el.style.getPropertyValue('--media-slider-pointer')).toBeTruthy(); + }); +}); + +describe('SliderTrack', () => { + it('renders inside SliderRoot context', () => { + const { container } = render( + + + + ); + + expect(container.querySelector('[data-testid="track"]')).toBeTruthy(); + }); + + it('returns null outside of SliderRoot', () => { + const { container } = render(); + + expect(container.firstElementChild).toBeNull(); + }); + + it('forwards ref', () => { + const ref = createRef(); + render( + + + + ); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); +}); + +describe('SliderFill', () => { + it('renders inside SliderRoot context', () => { + const { container } = render( + + + + ); + + expect(container.querySelector('[data-testid="fill"]')).toBeTruthy(); + }); + + it('returns null outside of SliderRoot', () => { + const { container } = render(); + + expect(container.firstElementChild).toBeNull(); + }); +}); + +describe('SliderBuffer', () => { + it('renders inside SliderRoot context', () => { + const { container } = render( + + + + ); + + expect(container.querySelector('[data-testid="buffer"]')).toBeTruthy(); + }); + + it('returns null outside of SliderRoot', () => { + const { container } = render(); + + expect(container.firstElementChild).toBeNull(); + }); +}); + +describe('SliderThumb', () => { + it('renders inside SliderRoot context', () => { + const { container } = render( + + + + ); + + expect(container.querySelector('[data-testid="thumb"]')).toBeTruthy(); + }); + + it('returns null outside of SliderRoot', () => { + const { container } = render(); + + expect(container.firstElementChild).toBeNull(); + }); + + it('forwards ref', () => { + const ref = createRef(); + render( + + + + ); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it('applies ARIA attributes from core', () => { + const { container } = render( + + + + ); + + const thumb = container.querySelector('[data-testid="thumb"]'); + expect(thumb?.getAttribute('role')).toBe('slider'); + }); +}); + +describe('SliderValue', () => { + it('renders an output element', () => { + const { container } = render( + + + + ); + + const el = container.querySelector('[data-testid="value"]'); + expect(el?.tagName).toBe('OUTPUT'); + }); + + it('returns null outside of SliderRoot', () => { + const { container } = render(); + + expect(container.firstElementChild).toBeNull(); + }); + + it('displays rounded value by default', () => { + const { container } = render( + + + + ); + + const output = container.querySelector('output'); + expect(output?.textContent).toBe('42'); + }); + + it('accepts a custom format function', () => { + const format = (v: number) => `${v}%`; + const { container } = render( + + + + ); + + const output = container.querySelector('output'); + expect(output?.textContent).toBe('75%'); + }); + + it('sets aria-live to off', () => { + const { container } = render( + + + + ); + + const output = container.querySelector('output'); + expect(output?.getAttribute('aria-live')).toBe('off'); + }); +}); + +describe('thumbAlignment', () => { + it('does not adjust CSS vars for center alignment (default)', () => { + const { container } = render(); + const root = container.firstElementChild as HTMLElement; + + expect(root.style.getPropertyValue('--media-slider-fill')).toBe('0.000%'); + }); + + it('adjusts CSS vars for edge alignment', () => { + const { container, rerender } = render( + + + + ); + + const root = container.firstElementChild as HTMLElement; + const thumb = root.querySelector('[role="slider"]') as HTMLElement; + + // Mock DOM measurements (jsdom reports 0 for all dimensions). + Object.defineProperty(root, 'offsetWidth', { value: 200, configurable: true }); + Object.defineProperty(thumb, 'offsetWidth', { value: 20, configurable: true }); + + // Re-render so the root reads the now-available element measurements. + rerender( + + + + ); + + // thumbHalf = (20/200 * 100) / 2 = 5%. Adjusted 0% → 5%. + expect(root.style.getPropertyValue('--media-slider-fill')).toBe('5.000%'); + }); + + it('adjusts CSS vars at max value for edge alignment', () => { + const { container, rerender } = render( + + + + ); + + const root = container.firstElementChild as HTMLElement; + const thumb = root.querySelector('[role="slider"]') as HTMLElement; + + Object.defineProperty(root, 'offsetWidth', { value: 200, configurable: true }); + Object.defineProperty(thumb, 'offsetWidth', { value: 20, configurable: true }); + + rerender( + + + + ); + + // thumbHalf = 5%. Adjusted 100% → 95%. + expect(root.style.getPropertyValue('--media-slider-fill')).toBe('95.000%'); + }); +}); + +describe('Slider compound', () => { + it('renders all parts together', () => { + 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(); + }); +}); diff --git a/packages/utils/src/function/index.ts b/packages/utils/src/function/index.ts index 45c9292d..84890d8d 100644 --- a/packages/utils/src/function/index.ts +++ b/packages/utils/src/function/index.ts @@ -1,4 +1,5 @@ export { composeCallbacks } from './compose-callbacks'; export { identity } from './identity'; export { noop } from './noop'; +export { type Throttled, throttle } from './throttle'; export { tryCatch } from './try-catch'; diff --git a/packages/utils/src/function/tests/throttle.test.ts b/packages/utils/src/function/tests/throttle.test.ts new file mode 100644 index 00000000..eec5b649 --- /dev/null +++ b/packages/utils/src/function/tests/throttle.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { throttle } from '../throttle'; + +describe('throttle', () => { + it('fires on trailing edge after the specified delay', () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const throttled = throttle(callback, 100); + + throttled('first'); + + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith('first'); + + vi.useRealTimers(); + }); + + it('coalesces rapid calls and uses the latest arguments', () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const throttled = throttle(callback, 100); + + throttled('first'); + throttled('second'); + throttled('third'); + + vi.advanceTimersByTime(100); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith('third'); + + vi.useRealTimers(); + }); + + it('cancel prevents the pending invocation', () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const throttled = throttle(callback, 100); + + throttled('value'); + throttled.cancel(); + + vi.advanceTimersByTime(200); + + expect(callback).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('accepts new calls after cancel', () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const throttled = throttle(callback, 100); + + throttled('before-cancel'); + throttled.cancel(); + + throttled('after-cancel'); + vi.advanceTimersByTime(100); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith('after-cancel'); + + vi.useRealTimers(); + }); + + it('schedules a new timer after the previous one fires', () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const throttled = throttle(callback, 100); + + throttled('first-batch'); + vi.advanceTimersByTime(100); + expect(callback).toHaveBeenCalledOnce(); + + throttled('second-batch'); + vi.advanceTimersByTime(100); + + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith('second-batch'); + + vi.useRealTimers(); + }); + + it('cancel is a no-op when no timer is pending', () => { + const callback = vi.fn(); + const throttled = throttle(callback, 100); + + expect(() => throttled.cancel()).not.toThrow(); + }); +}); diff --git a/packages/utils/src/function/throttle.ts b/packages/utils/src/function/throttle.ts new file mode 100644 index 00000000..e6b26326 --- /dev/null +++ b/packages/utils/src/function/throttle.ts @@ -0,0 +1,34 @@ +/** A throttled function that can be cancelled. */ +export interface Throttled { + (...args: Args): void; + /** Cancel any pending trailing-edge invocation. */ + cancel(): void; +} + +/** + * 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. + */ +export function throttle(fn: (...args: Args) => void, ms: number): Throttled { + let timerId: ReturnType | null = null; + let latestArgs: Args; + + const throttled = (...args: Args): void => { + latestArgs = args; + if (timerId !== null) return; + timerId = setTimeout(() => { + timerId = null; + fn(...latestArgs); + }, ms); + }; + + throttled.cancel = (): void => { + if (timerId !== null) { + clearTimeout(timerId); + timerId = null; + } + }; + + return throttled; +}