From 92b7c2ac184d12c780ba1a1bb6f064782b77422a Mon Sep 17 00:00:00 2001 From: rahim Date: Sat, 28 Feb 2026 21:09:13 -0800 Subject: [PATCH] feat(html): add volume slider element (#657) --- .../core/src/core/ui/slider/slider-core.ts | 18 +++ .../core/ui/slider/tests/slider-core.test.ts | 28 ++++ .../slider/tests/volume-slider-core.test.ts | 13 +- .../src/core/ui/slider/time-slider-core.ts | 7 +- .../src/core/ui/slider/volume-slider-core.ts | 26 ++-- packages/html/src/define/ui/volume-slider.ts | 11 ++ packages/html/src/index.ts | 1 + packages/html/src/ui/slider/slider-element.ts | 2 + .../ui/slider/tests/slider-element.test.ts | 1 + .../tests/volume-slider-element.test.ts | 87 +++++++++++ .../ui/volume-slider/volume-slider-element.ts | 140 ++++++++++++++++++ packages/react/src/ui/slider/slider-root.tsx | 3 +- .../ui/volume-slider/volume-slider-root.tsx | 8 +- 13 files changed, 320 insertions(+), 25 deletions(-) create mode 100644 packages/html/src/define/ui/volume-slider.ts create mode 100644 packages/html/src/ui/volume-slider/tests/volume-slider-element.test.ts create mode 100644 packages/html/src/ui/volume-slider/volume-slider-element.ts diff --git a/packages/core/src/core/ui/slider/slider-core.ts b/packages/core/src/core/ui/slider/slider-core.ts index f708d37e..ef50c740 100644 --- a/packages/core/src/core/ui/slider/slider-core.ts +++ b/packages/core/src/core/ui/slider/slider-core.ts @@ -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 = { + 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, diff --git a/packages/core/src/core/ui/slider/tests/slider-core.test.ts b/packages/core/src/core/ui/slider/tests/slider-core.test.ts index 7ede8ca8..4ac91c60 100644 --- a/packages/core/src/core/ui/slider/tests/slider-core.test.ts +++ b/packages/core/src/core/ui/slider/tests/slider-core.test.ts @@ -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); diff --git a/packages/core/src/core/ui/slider/tests/volume-slider-core.test.ts b/packages/core/src/core/ui/slider/tests/volume-slider-core.test.ts index fa81aff3..3239cce6 100644 --- a/packages/core/src/core/ui/slider/tests/volume-slider-core.test.ts +++ b/packages/core/src/core/ui/slider/tests/volume-slider-core.test.ts @@ -28,10 +28,15 @@ function createMediaState(overrides: Partial = {}): 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', + }); }); }); diff --git a/packages/core/src/core/ui/slider/time-slider-core.ts b/packages/core/src/core/ui/slider/time-slider-core.ts index e86a34d4..1197fb3f 100644 --- a/packages/core/src/core/ui/slider/time-slider-core.ts +++ b/packages/core/src/core/ui/slider/time-slider-core.ts @@ -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, }; } diff --git a/packages/core/src/core/ui/slider/volume-slider-core.ts b/packages/core/src/core/ui/slider/volume-slider-core.ts index e7d31819..d9fc6f6a 100644 --- a/packages/core/src/core/ui/slider/volume-slider-core.ts +++ b/packages/core/src/core/ui/slider/volume-slider-core.ts @@ -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 {} +// @ts-expect-error — defaultProps shape differs from base (domain sliders omit value/min/max) export class VolumeSliderCore extends SliderCore { - static override readonly defaultProps: NonNullableObject = { - ...SliderCore.defaultProps, + static readonly defaultProps: NonNullableObject = { 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, }; } diff --git a/packages/html/src/define/ui/volume-slider.ts b/packages/html/src/define/ui/volume-slider.ts new file mode 100644 index 00000000..c5e15afd --- /dev/null +++ b/packages/html/src/define/ui/volume-slider.ts @@ -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; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 5eb20199..2a7591e3 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -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'; diff --git a/packages/html/src/ui/slider/slider-element.ts b/packages/html/src/ui/slider/slider-element.ts index c7025691..95b947ca 100644 --- a/packages/html/src/ui/slider/slider-element.ts +++ b/packages/html/src/ui/slider/slider-element.ts @@ -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; + label = SliderCore.defaultProps.label; value = SliderCore.defaultProps.value; min = SliderCore.defaultProps.min; max = SliderCore.defaultProps.max; diff --git a/packages/html/src/ui/slider/tests/slider-element.test.ts b/packages/html/src/ui/slider/tests/slider-element.test.ts index 95606829..f7bf10d5 100644 --- a/packages/html/src/ui/slider/tests/slider-element.test.ts +++ b/packages/html/src/ui/slider/tests/slider-element.test.ts @@ -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); diff --git a/packages/html/src/ui/volume-slider/tests/volume-slider-element.test.ts b/packages/html/src/ui/volume-slider/tests/volume-slider-element.test.ts new file mode 100644 index 00000000..5a3b5afe --- /dev/null +++ b/packages/html/src/ui/volume-slider/tests/volume-slider-element.test.ts @@ -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(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); + }); +}); diff --git a/packages/html/src/ui/volume-slider/volume-slider-element.ts b/packages/html/src/ui/volume-slider/volume-slider-element.ts new file mode 100644 index 00000000..121238f7 --- /dev/null +++ b/packages/html/src/ui/volume-slider/volume-slider-element.ts @@ -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; + + 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('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); + } +} diff --git a/packages/react/src/ui/slider/slider-root.tsx b/packages/react/src/ui/slider/slider-root.tsx index ca751ee8..7c224136 100644 --- a/packages/react/src/ui/slider/slider-root.tsx +++ b/packages/react/src/ui/slider/slider-root.tsx @@ -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; 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 925e5f24..a0454889 100644 --- a/packages/react/src/ui/volume-slider/volume-slider-root.tsx +++ b/packages/react/src/ui/volume-slider/volume-slider-root.tsx @@ -19,9 +19,7 @@ const noopVolume = { toggleMute: () => false, }; -export interface VolumeSliderRootProps - extends UIComponentProps<'div', VolumeSliderCore.State>, - Pick { +export interface VolumeSliderRootProps extends UIComponentProps<'div', VolumeSliderCore.State>, VolumeSliderCore.Props { onDragStart?: (() => void) | undefined; onDragEnd?: (() => void) | undefined; } @@ -34,8 +32,8 @@ export const VolumeSliderRoot = forwardRef