feat(packages): volume slider scroll support (#1175)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
rahim
2026-04-01 20:17:29 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 979d37af2b
commit 390b004d80
9 changed files with 352 additions and 61 deletions
@@ -33,6 +33,7 @@ describe('VolumeSliderCore', () => {
label: 'Volume',
step: 1,
largeStep: 10,
wheelStep: 5,
orientation: 'horizontal',
disabled: false,
thumbAlignment: 'center',
@@ -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` (0100) — 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<VolumeSliderProps> = {
...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<VolumeSliderProps>;
const range = props.max - props.min;
return range > 0 ? (props.wheelStep / range) * 100 : 0;
}
override getLabel(state: SliderState): string {
return super.getLabel(state) || 'Volume';
}
+1
View File
@@ -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';
+4
View File
@@ -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;
}
@@ -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> = {}): 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();
});
});
+32
View File
@@ -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);
},
};
}
@@ -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<HTMLElement>('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 });
@@ -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: <S,>(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: <S,>(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(
<Wrapper>
<VolumeSliderRoot />
</Wrapper>
);
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(
<Wrapper>
<VolumeSliderRoot disabled />
</Wrapper>
);
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(
<Wrapper>
<VolumeSliderRoot disabled={false} />
</Wrapper>
);
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(
<Wrapper>
<VolumeSliderRoot />
</Wrapper>
);
// Should not render when volume is null.
expect(container.querySelector('[data-orientation]')).toBeNull();
// Simulate volume becoming available.
mutableVolume.current = mockVolumeState;
rerender(
<Wrapper>
<VolumeSliderRoot />
</Wrapper>
);
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();
});
});
@@ -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<HTMLDivElement, VolumeSliderRootProps
orientation,
step = VolumeSliderCore.defaultProps.step,
largeStep = VolumeSliderCore.defaultProps.largeStep,
wheelStep = VolumeSliderCore.defaultProps.wheelStep,
disabled,
thumbAlignment,
onDragStart,
@@ -44,10 +46,15 @@ export const VolumeSliderRoot = forwardRef<HTMLDivElement, VolumeSliderRootProps
const volume = usePlayer(selectVolume);
const [core] = useState(() => 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<VolumeSliderCore.State>({
computeState: (input) => {
@@ -55,24 +62,43 @@ export const VolumeSliderRoot = forwardRef<HTMLDivElement, VolumeSliderRootProps
core.setMedia(volume ?? noopVolume);
return core.getState();
},
getPercent: () => (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<HTMLDivElement, VolumeSliderRootProps
{
state,
stateAttrMap: VolumeSliderDataAttrs,
ref: [forwardedRef, rootRef],
ref: [forwardedRef, rootRef, wheelRef],
props: [{ style: { ...cssVars, ...rootStyle } }, rootProps, elementProps],
}
)}