From 390b004d809272fd453b8a73e1969c59410c3620 Mon Sep 17 00:00:00 2001 From: rahim Date: Wed, 1 Apr 2026 20:17:29 -0700 Subject: [PATCH] feat(packages): volume slider scroll support (#1175) Co-authored-by: Claude Opus 4.6 (1M context) --- .../tests/volume-slider-core.test.ts | 1 + .../ui/volume-slider/volume-slider-core.ts | 10 + packages/core/src/dom/index.ts | 1 + packages/core/src/dom/ui/event.ts | 4 + .../core/src/dom/ui/tests/wheel-step.test.ts | 107 +++++++++++ packages/core/src/dom/ui/wheel-step.ts | 32 ++++ .../ui/volume-slider/volume-slider-element.ts | 34 ++-- .../tests/volume-slider.test.tsx | 172 ++++++++++++++---- .../ui/volume-slider/volume-slider-root.tsx | 52 ++++-- 9 files changed, 352 insertions(+), 61 deletions(-) create mode 100644 packages/core/src/dom/ui/tests/wheel-step.test.ts create mode 100644 packages/core/src/dom/ui/wheel-step.ts diff --git a/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts b/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts index 2f0abead..08403362 100644 --- a/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts +++ b/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts @@ -33,6 +33,7 @@ describe('VolumeSliderCore', () => { label: 'Volume', step: 1, largeStep: 10, + wheelStep: 5, orientation: 'horizontal', disabled: false, thumbAlignment: 'center', diff --git a/packages/core/src/core/ui/volume-slider/volume-slider-core.ts b/packages/core/src/core/ui/volume-slider/volume-slider-core.ts index 48594a60..809938c0 100644 --- a/packages/core/src/core/ui/volume-slider/volume-slider-core.ts +++ b/packages/core/src/core/ui/volume-slider/volume-slider-core.ts @@ -5,6 +5,8 @@ import type { MediaFeatureAvailability, MediaVolumeState } from '../../media/sta import { SliderCore, type SliderProps, type SliderState } from '../slider/slider-core'; export interface VolumeSliderProps extends SliderProps { + /** Step increment for wheel scrolling. */ + wheelStep?: number | undefined; /** @internal Derived from `volume` (0–100) — not user-settable. */ value?: number | undefined; /** @internal Always 0 — not user-settable. */ @@ -22,6 +24,7 @@ export class VolumeSliderCore extends SliderCore { static override readonly defaultProps: NonNullableObject = { ...SliderCore.defaultProps, label: 'Volume', + wheelStep: 5, }; #media: MediaVolumeState | null = null; @@ -57,6 +60,13 @@ export class VolumeSliderCore extends SliderCore { }; } + /** Wheel step as a percentage of the slider range. */ + getWheelStepPercent(): number { + const props = this.props as NonNullableObject; + const range = props.max - props.min; + return range > 0 ? (props.wheelStep / range) * 100 : 0; + } + override getLabel(state: SliderState): string { return super.getLabel(state) || 'Volume'; } diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index c0e5d391..489f4b27 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -14,4 +14,5 @@ export * from './ui/slider-css-vars'; export * from './ui/thumbnail'; export * from './ui/tooltip/tooltip'; export * from './ui/transition'; +export * from './ui/wheel-step'; export * from './utils'; diff --git a/packages/core/src/dom/ui/event.ts b/packages/core/src/dom/ui/event.ts index 3ac95712..33a6bc9e 100644 --- a/packages/core/src/dom/ui/event.ts +++ b/packages/core/src/dom/ui/event.ts @@ -20,6 +20,10 @@ export interface UIPointerEvent extends UIEvent { buttons: number; } +export interface UIWheelEvent extends UIEvent { + deltaY: number; +} + export interface UIFocusEvent extends UIEvent { relatedTarget: EventTarget | null; } diff --git a/packages/core/src/dom/ui/tests/wheel-step.test.ts b/packages/core/src/dom/ui/tests/wheel-step.test.ts new file mode 100644 index 00000000..323ab1a3 --- /dev/null +++ b/packages/core/src/dom/ui/tests/wheel-step.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { UIWheelEvent } from '../event'; +import { createWheelStep, type WheelStepOptions } from '../wheel-step'; + +function wheelEvent(deltaY: number): UIWheelEvent { + return { + deltaY, + preventDefault: vi.fn(), + }; +} + +function createOptions(overrides: Partial = {}): WheelStepOptions { + return { + isDisabled: () => false, + getPercent: () => 50, + getStepPercent: () => 1, + onValueChange: vi.fn(), + ...overrides, + }; +} + +describe('createWheelStep', () => { + it('scroll up increments by step', () => { + const onValueChange = vi.fn(); + const { onWheel } = createWheelStep( + createOptions({ getPercent: () => 50, getStepPercent: () => 1, onValueChange }) + ); + + onWheel(wheelEvent(-1)); + + expect(onValueChange).toHaveBeenCalledWith(51); + }); + + it('scroll down decrements by step', () => { + const onValueChange = vi.fn(); + const { onWheel } = createWheelStep( + createOptions({ getPercent: () => 50, getStepPercent: () => 1, onValueChange }) + ); + + onWheel(wheelEvent(1)); + + expect(onValueChange).toHaveBeenCalledWith(49); + }); + + it('uses step percent from options', () => { + const onValueChange = vi.fn(); + const { onWheel } = createWheelStep( + createOptions({ getPercent: () => 50, getStepPercent: () => 5, onValueChange }) + ); + + onWheel(wheelEvent(-1)); + + expect(onValueChange).toHaveBeenCalledWith(55); + }); + + it('clamps to 0 on scroll down at minimum', () => { + const onValueChange = vi.fn(); + const { onWheel } = createWheelStep(createOptions({ getPercent: () => 0, getStepPercent: () => 5, onValueChange })); + + onWheel(wheelEvent(1)); + + expect(onValueChange).toHaveBeenCalledWith(0); + }); + + it('clamps to 100 on scroll up at maximum', () => { + const onValueChange = vi.fn(); + const { onWheel } = createWheelStep( + createOptions({ getPercent: () => 100, getStepPercent: () => 5, onValueChange }) + ); + + onWheel(wheelEvent(-1)); + + expect(onValueChange).toHaveBeenCalledWith(100); + }); + + it('no-ops when disabled', () => { + const onValueChange = vi.fn(); + const { onWheel } = createWheelStep(createOptions({ isDisabled: () => true, onValueChange })); + + const event = wheelEvent(-1); + onWheel(event); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('calls preventDefault on scroll', () => { + const { onWheel } = createWheelStep(createOptions()); + + const event = wheelEvent(-1); + onWheel(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('ignores zero deltaY', () => { + const onValueChange = vi.fn(); + const { onWheel } = createWheelStep(createOptions({ onValueChange })); + + const event = wheelEvent(0); + onWheel(event); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/dom/ui/wheel-step.ts b/packages/core/src/dom/ui/wheel-step.ts new file mode 100644 index 00000000..f6df9d83 --- /dev/null +++ b/packages/core/src/dom/ui/wheel-step.ts @@ -0,0 +1,32 @@ +import { clamp } from '@videojs/utils/number'; +import type { UIWheelEvent } from './event'; + +export interface WheelStepOptions { + isDisabled: () => boolean; + getPercent: () => number; + getStepPercent: () => number; + onValueChange?: ((percent: number) => void) | undefined; +} + +export interface WheelStepProps { + onWheel: (event: UIWheelEvent) => void; +} + +export function createWheelStep(options: WheelStepOptions): WheelStepProps { + return { + onWheel(event) { + if (options.isDisabled()) return; + + const direction = Math.sign(event.deltaY); + if (direction === 0) return; + + event.preventDefault(); + + const stepPercent = options.getStepPercent(); + const currentPercent = options.getPercent(); + const newPercent = clamp(currentPercent - direction * stepPercent, 0, 100); + + options.onValueChange?.(newPercent); + }, + }; +} diff --git a/packages/html/src/ui/volume-slider/volume-slider-element.ts b/packages/html/src/ui/volume-slider/volume-slider-element.ts index 9bf4b06e..61bfa15e 100644 --- a/packages/html/src/ui/volume-slider/volume-slider-element.ts +++ b/packages/html/src/ui/volume-slider/volume-slider-element.ts @@ -3,6 +3,7 @@ import { applyElementProps, applyStateDataAttrs, createSlider, + createWheelStep, getSliderCSSVars, logMissingFeature, type SliderApi, @@ -24,6 +25,7 @@ export class VolumeSliderElement extends MediaElement { label: { type: String }, step: { type: Number }, largeStep: { type: Number, attribute: 'large-step' }, + wheelStep: { type: Number, attribute: 'wheel-step' }, orientation: { type: String }, disabled: { type: Boolean }, thumbAlignment: { type: String, attribute: 'thumb-alignment' }, @@ -32,6 +34,7 @@ export class VolumeSliderElement extends MediaElement { label = VolumeSliderCore.defaultProps.label; step = VolumeSliderCore.defaultProps.step; largeStep = VolumeSliderCore.defaultProps.largeStep; + wheelStep = VolumeSliderCore.defaultProps.wheelStep; orientation = VolumeSliderCore.defaultProps.orientation; disabled = VolumeSliderCore.defaultProps.disabled; thumbAlignment = VolumeSliderCore.defaultProps.thumbAlignment; @@ -50,25 +53,22 @@ export class VolumeSliderElement extends MediaElement { this.#disconnect = new AbortController(); const signal = this.#disconnect.signal; + const isDisabled = () => this.disabled || !this.#volumeState.value; + const getPercent = () => (this.#volumeState.value?.volume ?? 0) * 100; + const getStepPercent = () => this.#core.getStepPercent(); + const setVolume = (percent: number) => this.#setVolume(percent); + this.#slider = createSlider({ getElement: () => this, getThumbElement: () => this.querySelector('media-slider-thumb'), getOrientation: () => this.orientation, isRTL: () => isRTL(this), - isDisabled: () => this.disabled || !this.#volumeState.value, - getPercent: () => { - const media = this.#volumeState.value; - if (!media) return 0; - return media.volume * 100; - }, - getStepPercent: () => this.#core.getStepPercent(), + isDisabled, + getPercent, + getStepPercent, getLargeStepPercent: () => this.#core.getLargeStepPercent(), - onValueChange: (percent) => { - this.#setVolume(percent); - }, - onValueCommit: (percent) => { - this.#setVolume(percent); - }, + onValueChange: setVolume, + onValueCommit: setVolume, onDragStart: () => { this.dispatchEvent(new CustomEvent('drag-start', { bubbles: true })); }, @@ -79,7 +79,15 @@ export class VolumeSliderElement extends MediaElement { onResize: () => this.requestUpdate(), }); + const wheelProps = createWheelStep({ + isDisabled, + getPercent, + getStepPercent: () => this.#core.getWheelStepPercent(), + onValueChange: setVolume, + }); + applyElementProps(this, this.#slider.rootProps, { signal }); + applyElementProps(this, wheelProps, { signal }); applyStyles(this, this.#slider.rootStyle); this.#slider.input.subscribe(() => this.requestUpdate(), { signal }); 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 index 32299757..9972029e 100644 --- a/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx +++ b/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx @@ -11,39 +11,45 @@ import { VolumeSliderRoot } from '../volume-slider-root'; // --- Hoisted mock data (available inside vi.mock factories) --- -const { mockSliderApi, mockVolumeState } = vi.hoisted(() => ({ - mockSliderApi: () => ({ - 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: (state: S): S => state, - destroy: vi.fn(), - }), - mockVolumeState: { +const { mockSliderApi, mockVolumeState, mutableVolume } = vi.hoisted(() => { + const volumeState = { volume: 0.8, muted: false, volumeAvailability: 'available' as const, setVolume: vi.fn(), toggleMuted: vi.fn(), - }, -})); + }; + + return { + mockSliderApi: () => ({ + 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: (state: S): S => state, + destroy: vi.fn(), + }), + mockVolumeState: volumeState, + // Mutable holder so tests can swap between null and available volume. + mutableVolume: { current: volumeState as typeof volumeState | null }, + }; +}); // --- Module mocks --- @@ -56,21 +62,28 @@ 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; + + // Return the mutable volume state directly for volume selectors. + const vol = mutableVolume.current; + if (!vol) return undefined; + try { - const result = selector({ volume: mockVolumeState }); + const result = selector(vol); if (result !== undefined) return result; } catch { // fall through } - try { - return selector(mockVolumeState); - } catch { - return undefined; - } + + return undefined; }), })); -afterEach(cleanup); +afterEach(() => { + cleanup(); + mutableVolume.current = mockVolumeState; + mockVolumeState.setVolume.mockClear(); + mockVolumeState.toggleMuted.mockClear(); +}); // --- Tests --- @@ -188,3 +201,92 @@ describe('VolumeSlider compound', () => { expect(output?.textContent).toContain('%'); }); }); + +describe('VolumeSliderRoot wheel handling', () => { + it('attaches a non-passive wheel listener on the root element', () => { + // Capture the raw options before jsdom normalizes them. + const capturedOptions: AddEventListenerOptions[] = []; + const origAdd = HTMLDivElement.prototype.addEventListener; + const addSpy = vi.fn(function ( + this: HTMLDivElement, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions + ) { + if (type === 'wheel' && typeof options === 'object') { + capturedOptions.push({ ...options }); + } + return origAdd.call(this, type, listener, options as AddEventListenerOptions); + }); + HTMLDivElement.prototype.addEventListener = addSpy as typeof origAdd; + + const { Wrapper } = createPlayerWrapper(); + render( + + + + ); + + HTMLDivElement.prototype.addEventListener = origAdd; + + expect(capturedOptions.length).toBeGreaterThanOrEqual(1); + expect(capturedOptions.some((opts) => opts.passive === false)).toBe(true); + }); + + it('honors disabled prop changes after initial render', () => { + const { Wrapper } = createPlayerWrapper(); + + // Render with disabled=true, dispatch wheel — setVolume should not be called. + const { container, rerender } = render( + + + + ); + + const el = container.querySelector('[data-orientation]') as HTMLElement; + expect(el).toBeTruthy(); + + el.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + expect(mockVolumeState.setVolume).not.toHaveBeenCalled(); + + // Rerender with disabled=false, dispatch wheel — setVolume should be called. + rerender( + + + + ); + + el.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + expect(mockVolumeState.setVolume).toHaveBeenCalled(); + }); + + it('attaches wheel handling when volume appears after initial null', () => { + // Start with no volume — component returns null. + mutableVolume.current = null; + + const { Wrapper } = createPlayerWrapper(); + const { container, rerender } = render( + + + + ); + + // Should not render when volume is null. + expect(container.querySelector('[data-orientation]')).toBeNull(); + + // Simulate volume becoming available. + mutableVolume.current = mockVolumeState; + rerender( + + + + ); + + const el = container.querySelector('[data-orientation]') as HTMLElement; + expect(el).toBeTruthy(); + + // Wheel on the newly mounted root should call setVolume. + el.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + expect(mockVolumeState.setVolume).toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/ui/volume-slider/volume-slider-root.tsx b/packages/react/src/ui/volume-slider/volume-slider-root.tsx index 2c5dd7d8..d6b37ada 100644 --- a/packages/react/src/ui/volume-slider/volume-slider-root.tsx +++ b/packages/react/src/ui/volume-slider/volume-slider-root.tsx @@ -1,8 +1,9 @@ 'use client'; import { VolumeSliderCore, VolumeSliderDataAttrs } from '@videojs/core'; -import { getSliderCSSVars, logMissingFeature, selectVolume } from '@videojs/core/dom'; -import { forwardRef, useState } from 'react'; +import { createWheelStep, getSliderCSSVars, logMissingFeature, selectVolume } from '@videojs/core/dom'; +import { listen } from '@videojs/utils/dom'; +import { forwardRef, useCallback, useRef, useState } from 'react'; import { usePlayer } from '../../player/context'; import type { UIComponentProps } from '../../utils/types'; @@ -34,6 +35,7 @@ export const VolumeSliderRoot = forwardRef new VolumeSliderCore()); - core.setProps({ label, orientation, step, largeStep, disabled, thumbAlignment }); + core.setProps({ label, orientation, step, largeStep, wheelStep, disabled, thumbAlignment }); - // Keep a ref to the latest volume state for callbacks. + // Keep refs to the latest dynamic values for stable closures. const volumeRef = useLatestRef(volume); + const disabledRef = useLatestRef(disabled); + + const getPercent = () => (volumeRef.current?.volume ?? 0) * 100; + const getStepPercent = () => core.getStepPercent(); + const setVolume = (percent: number) => volumeRef.current?.setVolume(percent / 100); const { state, cssVars, rootRef, thumbRef, rootProps, rootStyle, thumbProps } = useSlider({ computeState: (input) => { @@ -55,24 +62,43 @@ export const VolumeSliderRoot = forwardRef (volume ? volume.volume * 100 : 0), - getStepPercent: () => core.getStepPercent(), + getPercent, + getStepPercent, getLargeStepPercent: () => core.getLargeStepPercent(), orientation, disabled, adjustPercent: (rawPercent, thumbSize, trackSize) => core.adjustPercentForAlignment(rawPercent, thumbSize, trackSize), getCSSVars: getSliderCSSVars, - onValueChange: (percent) => { - volumeRef.current?.setVolume(percent / 100); - }, - onValueCommit: (percent) => { - volumeRef.current?.setVolume(percent / 100); - }, + onValueChange: setVolume, + onValueCommit: setVolume, onDragStart, onDragEnd, }); + const [wheelHandler] = useState(() => + createWheelStep({ + isDisabled: () => !!disabledRef.current || !volumeRef.current, + getPercent: () => (volumeRef.current?.volume ?? 0) * 100, + getStepPercent: () => core.getWheelStepPercent(), + onValueChange: (percent) => volumeRef.current?.setVolume(percent / 100), + }) + ); + + // Attach non-passive wheel listener via callback ref so it covers + // late-mounted elements (null → mounted after volume appears). + const wheelCleanupRef = useRef<(() => void) | null>(null); + const wheelRef = useCallback( + (element: HTMLDivElement | null) => { + wheelCleanupRef.current?.(); + wheelCleanupRef.current = null; + if (element) { + wheelCleanupRef.current = listen(element, 'wheel', wheelHandler.onWheel, { passive: false }); + } + }, + [wheelHandler] + ); + if (!volume) { if (__DEV__) logMissingFeature('VolumeSlider', 'volume'); return null; @@ -96,7 +122,7 @@ export const VolumeSliderRoot = forwardRef