feat(html): add volume slider element (#657)

This commit is contained in:
rahim
2026-02-28 21:09:13 -08:00
committed by GitHub
parent 26c7395cd0
commit 92b7c2ac18
13 changed files with 320 additions and 25 deletions
@@ -1,9 +1,12 @@
import { clamp, roundToStep } from '@videojs/utils/number';
import { defaults } from '@videojs/utils/object';
import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
/** Shared configuration for all slider variants. */
export interface SliderBaseProps {
/** Custom label for the slider. */
label?: string | ((state: SliderState) => string) | undefined;
/** Step increment for value changes (arrow keys). */
step?: number | undefined;
/** Large step increment (Page Up/Down keys). */
@@ -62,6 +65,7 @@ export interface SliderState {
export class SliderCore {
static readonly defaultProps: NonNullableObject<SliderProps> = {
label: '',
value: 0,
min: 0,
max: 100,
@@ -102,11 +106,25 @@ export class SliderCore {
};
}
getLabel(state: SliderState): string {
const { label } = this.#props;
if (isFunction(label)) {
const customLabel = label(state);
if (customLabel) return customLabel;
} else if (label) {
return label;
}
return '';
}
getAttrs(state: SliderState) {
return {
role: 'slider',
tabindex: state.disabled ? -1 : 0,
autocomplete: 'off',
'aria-label': this.getLabel(state),
'aria-valuemin': this.#props.min,
'aria-valuemax': this.#props.max,
'aria-valuenow': state.value,
@@ -17,6 +17,7 @@ describe('SliderCore', () => {
describe('defaultProps', () => {
it('has expected defaults', () => {
expect(SliderCore.defaultProps).toEqual({
label: '',
value: 0,
min: 0,
max: 100,
@@ -88,6 +89,32 @@ describe('SliderCore', () => {
});
});
describe('getLabel', () => {
it('returns empty string by default', () => {
const core = new SliderCore();
const state = core.getState(createInteraction(), 50);
expect(core.getLabel(state)).toBe('');
});
it('returns custom string label', () => {
const core = new SliderCore({ label: 'Brightness' });
const state = core.getState(createInteraction(), 50);
expect(core.getLabel(state)).toBe('Brightness');
});
it('calls function label with state', () => {
const core = new SliderCore({ label: (state) => (state.dragging ? 'Dragging' : 'Idle') });
expect(core.getLabel(core.getState(createInteraction({ dragging: true }), 0))).toBe('Dragging');
expect(core.getLabel(core.getState(createInteraction(), 0))).toBe('Idle');
});
it('falls through when function returns empty string', () => {
const core = new SliderCore({ label: () => '' });
const state = core.getState(createInteraction(), 0);
expect(core.getLabel(state)).toBe('');
});
});
describe('getAttrs', () => {
it('returns aria attributes', () => {
const core = new SliderCore();
@@ -97,6 +124,7 @@ describe('SliderCore', () => {
expect(attrs.role).toBe('slider');
expect(attrs.tabindex).toBe(0);
expect(attrs.autocomplete).toBe('off');
expect(attrs['aria-label']).toBe('');
expect(attrs['aria-valuemin']).toBe(0);
expect(attrs['aria-valuemax']).toBe(100);
expect(attrs['aria-valuenow']).toBe(50);
@@ -28,10 +28,15 @@ function createMediaState(overrides: Partial<MediaVolumeState> = {}): MediaVolum
describe('VolumeSliderCore', () => {
describe('defaultProps', () => {
it('extends SliderCore defaults with label', () => {
expect(VolumeSliderCore.defaultProps.label).toBe('Volume');
expect(VolumeSliderCore.defaultProps.min).toBe(0);
expect(VolumeSliderCore.defaultProps.max).toBe(100);
it('has expected defaults', () => {
expect(VolumeSliderCore.defaultProps).toEqual({
step: 1,
largeStep: 10,
orientation: 'horizontal',
disabled: false,
thumbAlignment: 'center',
label: 'Volume',
});
});
});
@@ -7,8 +7,6 @@ import type { MediaBufferState, MediaTimeState } from '../../media/state';
import { type SliderBaseProps, SliderCore, type SliderInteraction, type SliderState } from './slider-core';
export interface TimeSliderProps extends SliderBaseProps {
/** Accessible label for the slider. */
label?: string | undefined;
/** Trailing-edge throttle (ms) for seek requests during drag. */
commitThrottle?: number | undefined;
}
@@ -65,6 +63,10 @@ export class TimeSliderCore extends SliderCore {
};
}
override getLabel(state: SliderState): string {
return super.getLabel(state) || 'Seek';
}
override getAttrs(state: TimeSliderState) {
const base = super.getAttrs(state);
const currentPhrase = formatTimeAsPhrase(state.value);
@@ -73,7 +75,6 @@ export class TimeSliderCore extends SliderCore {
return {
...base,
'aria-label': this.#props.label,
'aria-valuetext': valuetext,
};
}
@@ -2,31 +2,30 @@ import { defaults } from '@videojs/utils/object';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaVolumeState } from '../../media/state';
import { SliderCore, type SliderInteraction, type SliderProps, type SliderState } from './slider-core';
import { type SliderBaseProps, SliderCore, type SliderInteraction, type SliderState } from './slider-core';
export interface VolumeSliderProps extends SliderProps {
/** Accessible label for the slider. */
label?: string | undefined;
}
export interface VolumeSliderProps extends SliderBaseProps {}
export interface VolumeSliderState extends SliderState, Pick<MediaVolumeState, 'volume' | 'muted'> {}
// @ts-expect-error — defaultProps shape differs from base (domain sliders omit value/min/max)
export class VolumeSliderCore extends SliderCore {
static override readonly defaultProps: NonNullableObject<VolumeSliderProps> = {
...SliderCore.defaultProps,
static readonly defaultProps: NonNullableObject<VolumeSliderProps> = {
label: 'Volume',
step: SliderCore.defaultProps.step,
largeStep: SliderCore.defaultProps.largeStep,
orientation: SliderCore.defaultProps.orientation,
disabled: SliderCore.defaultProps.disabled,
thumbAlignment: SliderCore.defaultProps.thumbAlignment,
};
#props = { ...VolumeSliderCore.defaultProps };
constructor(props?: VolumeSliderProps) {
super();
if (props) this.setProps(props);
}
override setProps(props: VolumeSliderProps): void {
this.#props = defaults(props, VolumeSliderCore.defaultProps);
super.setProps(props);
super.setProps(defaults(props, VolumeSliderCore.defaultProps));
}
getVolumeState(media: MediaVolumeState, interaction: SliderInteraction): VolumeSliderState {
@@ -43,13 +42,16 @@ export class VolumeSliderCore extends SliderCore {
};
}
override getLabel(state: SliderState): string {
return super.getLabel(state) || 'Volume';
}
override getAttrs(state: VolumeSliderState) {
const base = super.getAttrs(state);
const valuetext = `${Math.round(state.value)} percent${state.muted ? ', muted' : ''}`;
return {
...base,
'aria-label': this.#props.label,
'aria-valuetext': valuetext,
};
}
@@ -0,0 +1,11 @@
import { VolumeSliderElement } from '../../ui/volume-slider/volume-slider-element';
import './slider';
customElements.define(VolumeSliderElement.tagName, VolumeSliderElement);
declare global {
interface HTMLElementTagNameMap {
[VolumeSliderElement.tagName]: VolumeSliderElement;
}
}
+1
View File
@@ -39,3 +39,4 @@ export { TimeElement } from './ui/time/time-element';
export { TimeGroupElement } from './ui/time/time-group-element';
export { TimeSeparatorElement } from './ui/time/time-separator-element';
export { TimeSliderElement } from './ui/time-slider/time-slider-element';
export { VolumeSliderElement } from './ui/volume-slider/volume-slider-element';
@@ -11,6 +11,7 @@ export class SliderElement extends MediaElement {
static readonly tagName = 'media-slider';
static override properties = {
label: { type: String },
value: { type: Number },
min: { type: Number },
max: { type: Number },
@@ -21,6 +22,7 @@ export class SliderElement extends MediaElement {
thumbAlignment: { type: String, attribute: 'thumb-alignment' },
} satisfies PropertyDeclarationMap<keyof SliderCore.Props>;
label = SliderCore.defaultProps.label;
value = SliderCore.defaultProps.value;
min = SliderCore.defaultProps.min;
max = SliderCore.defaultProps.max;
@@ -30,6 +30,7 @@ describe('SliderElement', () => {
it('initializes with default property values', () => {
const slider = createElement(SliderElement);
expect(slider.label).toBe('');
expect(slider.value).toBe(0);
expect(slider.min).toBe(0);
expect(slider.max).toBe(100);
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it } from 'vitest';
import { SliderThumbElement } from '../../slider/slider-thumb-element';
import { VolumeSliderElement } from '../volume-slider-element';
let tagCounter = 0;
function uniqueTag(base: string): string {
return `${base}-${tagCounter++}`;
}
function createElement<Element extends HTMLElement>(Base: abstract new () => Element): Element {
const tag = uniqueTag('test-el');
customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {});
return document.createElement(tag) as Element;
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('VolumeSliderElement', () => {
it('has the correct tag name', () => {
expect(VolumeSliderElement.tagName).toBe('media-volume-slider');
});
it('initializes with default property values', () => {
const slider = createElement(VolumeSliderElement);
expect(slider.label).toBe('Volume');
expect(slider.step).toBe(1);
expect(slider.largeStep).toBe(10);
expect(slider.orientation).toBe('horizontal');
expect(slider.disabled).toBe(false);
expect(slider.thumbAlignment).toBe('center');
});
it('sets touch-action and user-select styles on connect', async () => {
const slider = createElement(VolumeSliderElement);
document.body.appendChild(slider);
await slider.updateComplete;
expect(slider.style.touchAction).toBe('none');
expect(slider.style.userSelect).toBe('none');
});
it('supports vertical orientation', () => {
const slider = createElement(VolumeSliderElement);
slider.orientation = 'vertical';
expect(slider.orientation).toBe('vertical');
});
it('does not set CSS vars without player context', async () => {
const slider = createElement(VolumeSliderElement);
document.body.appendChild(slider);
await slider.updateComplete;
// Without player store providing volume state, the element guards early.
expect(slider.style.getPropertyValue('--media-slider-fill')).toBe('');
});
it('connects without errors when no store is available', async () => {
const slider = createElement(VolumeSliderElement);
const thumb = createElement(SliderThumbElement);
slider.appendChild(thumb);
document.body.appendChild(slider);
await slider.updateComplete;
await thumb.updateComplete;
expect(slider.isConnected).toBe(true);
expect(thumb.isConnected).toBe(true);
});
it('cleans up on disconnect', async () => {
const slider = createElement(VolumeSliderElement);
document.body.appendChild(slider);
await slider.updateComplete;
document.body.removeChild(slider);
// Verifies no errors during disconnect/cleanup.
expect(slider.isConnected).toBe(false);
});
});
@@ -0,0 +1,140 @@
import { SliderDataAttrs, VolumeSliderCore } from '@videojs/core';
import {
applyStateDataAttrs,
createSlider,
getSliderCSSVars,
logMissingFeature,
type SliderHandle,
selectVolume,
} from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextProvider } from '@videojs/element/context';
import { applyStyles, isRTL } from '@videojs/utils/dom';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MediaElement } from '../media-element';
import { sliderContext } from '../slider/slider-context';
export class VolumeSliderElement extends MediaElement {
static readonly tagName = 'media-volume-slider';
static override properties = {
label: { type: String },
step: { type: Number },
largeStep: { type: Number, attribute: 'large-step' },
orientation: { type: String },
disabled: { type: Boolean },
thumbAlignment: { type: String, attribute: 'thumb-alignment' },
} satisfies PropertyDeclarationMap<keyof VolumeSliderCore.Props>;
label = VolumeSliderCore.defaultProps.label;
step = VolumeSliderCore.defaultProps.step;
largeStep = VolumeSliderCore.defaultProps.largeStep;
orientation = VolumeSliderCore.defaultProps.orientation;
disabled = VolumeSliderCore.defaultProps.disabled;
thumbAlignment = VolumeSliderCore.defaultProps.thumbAlignment;
readonly #core = new VolumeSliderCore();
readonly #provider = new ContextProvider(this, { context: sliderContext });
readonly #volumeState = new PlayerController(this, playerContext, selectVolume);
#slider: SliderHandle | null = null;
#disconnect: AbortController | null = null;
override connectedCallback(): void {
super.connectedCallback();
this.#disconnect = new AbortController();
const signal = this.#disconnect.signal;
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: () => {
const { step, min, max } = this.#core.props;
const range = max - min;
return range > 0 ? (step / range) * 100 : 0;
},
getLargeStepPercent: () => {
const { largeStep, min, max } = this.#core.props;
const range = max - min;
return range > 0 ? (largeStep / range) * 100 : 0;
},
onValueChange: (percent) => {
this.#setVolume(percent);
},
onValueCommit: (percent) => {
this.#setVolume(percent);
},
onDragStart: () => {
this.dispatchEvent(new CustomEvent('drag-start', { bubbles: true }));
},
onDragEnd: () => {
this.dispatchEvent(new CustomEvent('drag-end', { bubbles: true }));
},
});
this.#slider.interaction.subscribe(() => this.requestUpdate(), { signal });
// Prevent default touch gestures and text selection during interaction.
this.style.touchAction = 'none';
this.style.userSelect = 'none';
if (__DEV__ && !this.#volumeState.value) {
logMissingFeature(VolumeSliderElement.tagName, 'volume');
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#slider?.destroy();
this.#slider = null;
this.#disconnect?.abort();
this.#disconnect = null;
}
protected override willUpdate(_changed: PropertyValues): void {
super.willUpdate(_changed);
this.#core.setProps(this);
}
protected override update(_changed: PropertyValues): void {
super.update(_changed);
if (!this.#slider) return;
const media = this.#volumeState.value;
if (!media) return;
const interaction = this.#slider.interaction.current;
const state = this.#core.getVolumeState(media, interaction);
const cssVars = getSliderCSSVars(state);
applyStyles(this, cssVars);
// Apply data attributes to root.
applyStateDataAttrs(this, state, SliderDataAttrs);
// Provide context to child elements.
this.#provider.setValue({
state,
pointerValue: this.#core.valueFromPercent(state.pointerPercent),
thumbAttrs: this.#core.getAttrs(state),
thumbProps: this.#slider.thumbProps,
formatValue: (value) => `${Math.round(value)}%`,
});
}
#setVolume(percent: number): void {
const media = this.#volumeState.value;
media?.changeVolume(this.#core.valueFromPercent(percent) / 100);
}
}
+2 -1
View File
@@ -25,6 +25,7 @@ export const SliderRoot = forwardRef(function SliderRoot(
render,
className,
style,
label,
min,
max,
step,
@@ -41,7 +42,7 @@ export const SliderRoot = forwardRef(function SliderRoot(
} = componentProps;
const [core] = useState(() => new SliderCore());
core.setProps({ min, max, step, largeStep, orientation, disabled, thumbAlignment });
core.setProps({ label, min, max, step, largeStep, orientation, disabled, thumbAlignment });
const { min: resolvedMin, max: resolvedMax, step: resolvedStep, largeStep: resolvedLargeStep } = core.props;
const range = resolvedMax - resolvedMin || 1;
@@ -19,9 +19,7 @@ const noopVolume = {
toggleMute: () => false,
};
export interface VolumeSliderRootProps
extends UIComponentProps<'div', VolumeSliderCore.State>,
Pick<VolumeSliderCore.Props, 'label' | 'orientation' | 'step' | 'largeStep' | 'disabled' | 'thumbAlignment'> {
export interface VolumeSliderRootProps extends UIComponentProps<'div', VolumeSliderCore.State>, VolumeSliderCore.Props {
onDragStart?: (() => void) | undefined;
onDragEnd?: (() => void) | undefined;
}
@@ -34,8 +32,8 @@ export const VolumeSliderRoot = forwardRef<HTMLDivElement, VolumeSliderRootProps
style,
label,
orientation,
step = 1,
largeStep = 10,
step = VolumeSliderCore.defaultProps.step,
largeStep = VolumeSliderCore.defaultProps.largeStep,
disabled,
thumbAlignment,
onDragStart,