feat(html): add slider element (#655)

This commit is contained in:
rahim
2026-02-28 20:13:08 -08:00
committed by GitHub
parent 010e93ff34
commit d5df0150b3
14 changed files with 638 additions and 6 deletions
@@ -2,11 +2,8 @@ import { clamp, roundToStep } from '@videojs/utils/number';
import { defaults } from '@videojs/utils/object';
import type { NonNullableObject } from '@videojs/utils/types';
export interface SliderProps {
/** Minimum value of the slider range. */
min?: number | undefined;
/** Maximum value of the slider range. */
max?: number | undefined;
/** Shared configuration for all slider variants. */
export interface SliderBaseProps {
/** Step increment for value changes (arrow keys). */
step?: number | undefined;
/** Large step increment (Page Up/Down keys). */
@@ -19,6 +16,15 @@ export interface SliderProps {
thumbAlignment?: 'center' | 'edge' | undefined;
}
export interface SliderProps extends SliderBaseProps {
/** Current slider value. */
value?: number | undefined;
/** Minimum value of the slider range. */
min?: number | undefined;
/** Maximum value of the slider range. */
max?: number | undefined;
}
/** Current pointer/drag interaction state, typically provided by a DOM controller. */
export interface SliderInteraction {
/** Pointer position as a percentage of the track (0100). */
@@ -56,6 +62,7 @@ export interface SliderState {
export class SliderCore {
static readonly defaultProps: NonNullableObject<SliderProps> = {
value: 0,
min: 0,
max: 100,
step: 1,
@@ -17,6 +17,7 @@ describe('SliderCore', () => {
describe('defaultProps', () => {
it('has expected defaults', () => {
expect(SliderCore.defaultProps).toEqual({
value: 0,
min: 0,
max: 100,
step: 1,
+24
View File
@@ -0,0 +1,24 @@
import { SliderBufferElement } from '../../ui/slider/slider-buffer-element';
import { SliderElement } from '../../ui/slider/slider-element';
import { SliderFillElement } from '../../ui/slider/slider-fill-element';
import { SliderThumbElement } from '../../ui/slider/slider-thumb-element';
import { SliderTrackElement } from '../../ui/slider/slider-track-element';
import { SliderValueElement } from '../../ui/slider/slider-value-element';
customElements.define(SliderElement.tagName, SliderElement);
customElements.define(SliderTrackElement.tagName, SliderTrackElement);
customElements.define(SliderFillElement.tagName, SliderFillElement);
customElements.define(SliderBufferElement.tagName, SliderBufferElement);
customElements.define(SliderThumbElement.tagName, SliderThumbElement);
customElements.define(SliderValueElement.tagName, SliderValueElement);
declare global {
interface HTMLElementTagNameMap {
[SliderElement.tagName]: SliderElement;
[SliderTrackElement.tagName]: SliderTrackElement;
[SliderFillElement.tagName]: SliderFillElement;
[SliderBufferElement.tagName]: SliderBufferElement;
[SliderThumbElement.tagName]: SliderThumbElement;
[SliderValueElement.tagName]: SliderValueElement;
}
}
+8
View File
@@ -26,6 +26,14 @@ export { PlaybackRateButtonElement } from './ui/playback-rate-button/playback-ra
export { PopoverElement } from './ui/popover/popover-element';
export { PosterElement } from './ui/poster/poster-element';
export { SeekButtonElement } from './ui/seek-button/seek-button-element';
export { SliderBufferElement } from './ui/slider/slider-buffer-element';
export { type SliderContextValue, sliderContext } from './ui/slider/slider-context';
export { SliderElement } from './ui/slider/slider-element';
export type { SliderEventMap, SliderValueEventDetail } from './ui/slider/slider-events';
export { SliderFillElement } from './ui/slider/slider-fill-element';
export { SliderThumbElement } from './ui/slider/slider-thumb-element';
export { SliderTrackElement } from './ui/slider/slider-track-element';
export { SliderValueElement } from './ui/slider/slider-value-element';
export { ThumbnailElement } from './ui/thumbnail/thumbnail-element';
export { TimeElement } from './ui/time/time-element';
export { TimeGroupElement } from './ui/time/time-group-element';
@@ -0,0 +1,22 @@
import { SliderDataAttrs } from '@videojs/core';
import { applyStateDataAttrs } from '@videojs/core/dom';
import type { PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
import { MediaElement } from '../media-element';
import { sliderContext } from './slider-context';
export class SliderBufferElement extends MediaElement {
static readonly tagName = 'media-slider-buffer';
readonly #ctx = new ContextConsumer(this, {
context: sliderContext,
subscribe: true,
});
protected override update(_changed: PropertyValues): void {
super.update(_changed);
const ctx = this.#ctx.value;
if (ctx) applyStateDataAttrs(this, ctx.state, SliderDataAttrs);
}
}
@@ -0,0 +1,20 @@
import type { SliderState } from '@videojs/core';
import type { SliderThumbProps } from '@videojs/core/dom';
import { createContext } from '@videojs/element/context';
export interface SliderContextValue {
/** Base slider state — children use this for data attributes and value display. */
state: SliderState;
/** Domain value at the current pointer position (e.g., seconds for time, percent for volume). */
pointerValue: number;
/** ARIA attributes for the thumb element (role, tabindex, aria-value*, etc.). */
thumbAttrs: Record<string, string | number | boolean | undefined>;
/** Keyboard and focus event handlers for the thumb element. */
thumbProps: SliderThumbProps;
/** Optional formatter for value display. Receives domain value and value type. */
formatValue?: ((value: number, type: 'current' | 'pointer') => string) | undefined;
}
const SLIDER_CONTEXT_KEY = Symbol('@videojs/slider');
export const sliderContext = createContext<SliderContextValue>(SLIDER_CONTEXT_KEY);
@@ -0,0 +1,119 @@
import { SliderCore, SliderDataAttrs } from '@videojs/core';
import { applyStateDataAttrs, createSlider, getSliderCSSVars, type SliderHandle } 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 { MediaElement } from '../media-element';
import { sliderContext } from './slider-context';
export class SliderElement extends MediaElement {
static readonly tagName = 'media-slider';
static override properties = {
value: { type: Number },
min: { type: Number },
max: { type: Number },
step: { type: Number },
largeStep: { type: Number, attribute: 'large-step' },
orientation: { type: String },
disabled: { type: Boolean },
thumbAlignment: { type: String, attribute: 'thumb-alignment' },
} satisfies PropertyDeclarationMap<keyof SliderCore.Props>;
value = SliderCore.defaultProps.value;
min = SliderCore.defaultProps.min;
max = SliderCore.defaultProps.max;
step = SliderCore.defaultProps.step;
largeStep = SliderCore.defaultProps.largeStep;
orientation = SliderCore.defaultProps.orientation;
disabled = SliderCore.defaultProps.disabled;
thumbAlignment = SliderCore.defaultProps.thumbAlignment;
readonly #core = new SliderCore();
readonly #provider = new ContextProvider(this, { context: sliderContext });
#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,
getPercent: () => this.#core.percentFromValue(this.value),
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.value = this.#core.valueFromPercent(percent);
this.dispatchEvent(new CustomEvent('value-change', { detail: { value: this.value }, bubbles: true }));
},
onValueCommit: (percent) => {
this.value = this.#core.valueFromPercent(percent);
this.dispatchEvent(new CustomEvent('value-commit', { detail: { value: this.value }, bubbles: true }));
},
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';
}
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 interaction = this.#slider.interaction.current;
const state = this.#core.getState(interaction, this.value);
const cssVars = getSliderCSSVars(state);
applyStyles(this, cssVars);
// Apply state data attributes to the root element.
applyStateDataAttrs(this, state, SliderDataAttrs);
// Provide context to child elements (thumb, value, track, etc.).
this.#provider.setValue({
state,
pointerValue: this.#core.valueFromPercent(state.pointerPercent),
thumbAttrs: this.#core.getAttrs(state),
thumbProps: this.#slider.thumbProps,
});
}
}
@@ -0,0 +1,15 @@
export interface SliderValueEventDetail {
/** The current slider value in the domain range (e.g., seconds for time, 01 for volume). */
value: number;
}
export interface SliderEventMap {
/** Fires continuously as the slider value changes during drag or keyboard interaction. */
'value-change': CustomEvent<SliderValueEventDetail>;
/** Fires when the user commits a value — on pointer up or keyboard release. */
'value-commit': CustomEvent<SliderValueEventDetail>;
/** Fires when a drag interaction begins (pointer down on the slider). */
'drag-start': CustomEvent<void>;
/** Fires when a drag interaction ends (pointer up after dragging). */
'drag-end': CustomEvent<void>;
}
@@ -0,0 +1,22 @@
import { SliderDataAttrs } from '@videojs/core';
import { applyStateDataAttrs } from '@videojs/core/dom';
import type { PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
import { MediaElement } from '../media-element';
import { sliderContext } from './slider-context';
export class SliderFillElement extends MediaElement {
static readonly tagName = 'media-slider-fill';
readonly #ctx = new ContextConsumer(this, {
context: sliderContext,
subscribe: true,
});
protected override update(_changed: PropertyValues): void {
super.update(_changed);
const ctx = this.#ctx.value;
if (ctx) applyStateDataAttrs(this, ctx.state, SliderDataAttrs);
}
}
@@ -0,0 +1,51 @@
import { SliderDataAttrs } from '@videojs/core';
import { applyElementProps, applyStateDataAttrs } from '@videojs/core/dom';
import type { PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
import { MediaElement } from '../media-element';
import { sliderContext } from './slider-context';
export class SliderThumbElement extends MediaElement {
static readonly tagName = 'media-slider-thumb';
readonly #ctx = new ContextConsumer(this, {
context: sliderContext,
subscribe: true,
});
#disconnect: AbortController | null = null;
#thumbPropsApplied = false;
override connectedCallback(): void {
super.connectedCallback();
this.#disconnect = new AbortController();
this.#thumbPropsApplied = false;
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#disconnect?.abort();
this.#disconnect = null;
this.#thumbPropsApplied = false;
}
protected override update(_changed: PropertyValues): void {
super.update(_changed);
const ctx = this.#ctx.value;
if (!ctx) return;
// Apply keyboard and focus handlers once — they don't change per slider instance.
if (!this.#thumbPropsApplied && this.#disconnect) {
applyElementProps(this, ctx.thumbProps, this.#disconnect.signal);
this.#thumbPropsApplied = true;
}
// Apply ARIA attributes every update (values change as slider moves).
applyElementProps(this, ctx.thumbAttrs);
// Apply state data attributes.
applyStateDataAttrs(this, ctx.state, SliderDataAttrs);
}
}
@@ -0,0 +1,22 @@
import { SliderDataAttrs } from '@videojs/core';
import { applyStateDataAttrs } from '@videojs/core/dom';
import type { PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
import { MediaElement } from '../media-element';
import { sliderContext } from './slider-context';
export class SliderTrackElement extends MediaElement {
static readonly tagName = 'media-slider-track';
readonly #ctx = new ContextConsumer(this, {
context: sliderContext,
subscribe: true,
});
protected override update(_changed: PropertyValues): void {
super.update(_changed);
const ctx = this.#ctx.value;
if (ctx) applyStateDataAttrs(this, ctx.state, SliderDataAttrs);
}
}
@@ -0,0 +1,40 @@
import { SliderDataAttrs } from '@videojs/core';
import { applyStateDataAttrs } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
import { MediaElement } from '../media-element';
import { sliderContext } from './slider-context';
export class SliderValueElement extends MediaElement {
static readonly tagName = 'media-slider-value';
static override properties = {
type: { type: String },
} satisfies PropertyDeclarationMap<'type'>;
type: 'current' | 'pointer' = 'current';
readonly #ctx = new ContextConsumer(this, {
context: sliderContext,
subscribe: true,
});
override connectedCallback(): void {
super.connectedCallback();
this.setAttribute('aria-live', 'off');
}
protected override update(_changed: PropertyValues): void {
super.update(_changed);
const ctx = this.#ctx.value;
if (!ctx) return;
const value = this.type === 'pointer' ? ctx.pointerValue : ctx.state.value;
this.textContent = ctx.formatValue ? ctx.formatValue(value, this.type) : String(Math.round(value));
applyStateDataAttrs(this, ctx.state, SliderDataAttrs);
}
}
@@ -0,0 +1,282 @@
import { afterEach, describe, expect, it } from 'vitest';
import { SliderBufferElement } from '../slider-buffer-element';
import { SliderElement } from '../slider-element';
import { SliderFillElement } from '../slider-fill-element';
import { SliderThumbElement } from '../slider-thumb-element';
import { SliderTrackElement } from '../slider-track-element';
import { SliderValueElement } from '../slider-value-element';
// Unique tag names to avoid customElements.define collisions across tests.
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('SliderElement', () => {
it('has the correct tag name', () => {
expect(SliderElement.tagName).toBe('media-slider');
});
it('initializes with default property values', () => {
const slider = createElement(SliderElement);
expect(slider.value).toBe(0);
expect(slider.min).toBe(0);
expect(slider.max).toBe(100);
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 CSS custom properties after update', async () => {
const slider = createElement(SliderElement);
slider.value = 50;
document.body.appendChild(slider);
await slider.updateComplete;
expect(slider.style.getPropertyValue('--media-slider-fill')).toBe('50.000%');
expect(slider.style.getPropertyValue('--media-slider-pointer')).toBe('0.000%');
});
it('sets data-orientation attribute', async () => {
const slider = createElement(SliderElement);
document.body.appendChild(slider);
await slider.updateComplete;
expect(slider.getAttribute('data-orientation')).toBe('horizontal');
});
it('does not set data-dragging or data-pointing in idle state', async () => {
const slider = createElement(SliderElement);
document.body.appendChild(slider);
await slider.updateComplete;
expect(slider.hasAttribute('data-dragging')).toBe(false);
expect(slider.hasAttribute('data-pointing')).toBe(false);
expect(slider.hasAttribute('data-interactive')).toBe(false);
});
it('reflects disabled state as data-disabled', async () => {
const slider = createElement(SliderElement);
slider.disabled = true;
document.body.appendChild(slider);
await slider.updateComplete;
expect(slider.hasAttribute('data-disabled')).toBe(true);
});
it('sets touch-action and user-select styles on connect', async () => {
const slider = createElement(SliderElement);
document.body.appendChild(slider);
await slider.updateComplete;
expect(slider.style.touchAction).toBe('none');
expect(slider.style.userSelect).toBe('none');
});
it('updates CSS vars when value changes', async () => {
const slider = createElement(SliderElement);
slider.value = 25;
document.body.appendChild(slider);
await slider.updateComplete;
expect(slider.style.getPropertyValue('--media-slider-fill')).toBe('25.000%');
slider.value = 75;
await slider.updateComplete;
expect(slider.style.getPropertyValue('--media-slider-fill')).toBe('75.000%');
});
it('supports vertical orientation', async () => {
const slider = createElement(SliderElement);
slider.orientation = 'vertical';
document.body.appendChild(slider);
await slider.updateComplete;
expect(slider.getAttribute('data-orientation')).toBe('vertical');
});
it('dispatches value-change on value-commit as CustomEvent', async () => {
const slider = createElement(SliderElement);
document.body.appendChild(slider);
await slider.updateComplete;
// Events are dispatched by the createSlider handle during interaction.
// We verify the element can dispatch events with the correct shape.
const received: CustomEvent[] = [];
slider.addEventListener('value-change', ((event: CustomEvent) => {
received.push(event);
}) as EventListener);
slider.dispatchEvent(new CustomEvent('value-change', { detail: { value: 42 }, bubbles: true }));
expect(received).toHaveLength(1);
expect(received[0]!.detail).toEqual({ value: 42 });
expect(received[0]!.bubbles).toBe(true);
});
});
describe('SliderThumbElement', () => {
it('has the correct tag name', () => {
expect(SliderThumbElement.tagName).toBe('media-slider-thumb');
});
it('receives ARIA attributes from slider context', async () => {
const slider = createElement(SliderElement);
const thumb = createElement(SliderThumbElement);
slider.value = 30;
slider.appendChild(thumb);
document.body.appendChild(slider);
await slider.updateComplete;
await thumb.updateComplete;
expect(thumb.getAttribute('role')).toBe('slider');
expect(thumb.getAttribute('tabindex')).toBe('0');
expect(thumb.getAttribute('autocomplete')).toBe('off');
expect(thumb.getAttribute('aria-valuemin')).toBe('0');
expect(thumb.getAttribute('aria-valuemax')).toBe('100');
expect(thumb.getAttribute('aria-valuenow')).toBe('30');
expect(thumb.getAttribute('aria-orientation')).toBe('horizontal');
});
it('updates ARIA when slider value changes', async () => {
const slider = createElement(SliderElement);
const thumb = createElement(SliderThumbElement);
slider.appendChild(thumb);
document.body.appendChild(slider);
await slider.updateComplete;
await thumb.updateComplete;
expect(thumb.getAttribute('aria-valuenow')).toBe('0');
slider.value = 60;
await slider.updateComplete;
await thumb.updateComplete;
expect(thumb.getAttribute('aria-valuenow')).toBe('60');
});
it('sets aria-disabled when slider is disabled', async () => {
const slider = createElement(SliderElement);
const thumb = createElement(SliderThumbElement);
slider.disabled = true;
slider.appendChild(thumb);
document.body.appendChild(slider);
await slider.updateComplete;
await thumb.updateComplete;
expect(thumb.getAttribute('aria-disabled')).toBe('true');
expect(thumb.getAttribute('tabindex')).toBe('-1');
});
it('propagates data attributes from context', async () => {
const slider = createElement(SliderElement);
const thumb = createElement(SliderThumbElement);
slider.appendChild(thumb);
document.body.appendChild(slider);
await slider.updateComplete;
await thumb.updateComplete;
expect(thumb.getAttribute('data-orientation')).toBe('horizontal');
});
});
describe('SliderTrackElement', () => {
it('has the correct tag name', () => {
expect(SliderTrackElement.tagName).toBe('media-slider-track');
});
it('receives data attributes from slider context', async () => {
const slider = createElement(SliderElement);
const track = createElement(SliderTrackElement);
slider.appendChild(track);
document.body.appendChild(slider);
await slider.updateComplete;
await track.updateComplete;
expect(track.getAttribute('data-orientation')).toBe('horizontal');
});
});
describe('SliderFillElement', () => {
it('has the correct tag name', () => {
expect(SliderFillElement.tagName).toBe('media-slider-fill');
});
});
describe('SliderBufferElement', () => {
it('has the correct tag name', () => {
expect(SliderBufferElement.tagName).toBe('media-slider-buffer');
});
});
describe('SliderValueElement', () => {
it('has the correct tag name', () => {
expect(SliderValueElement.tagName).toBe('media-slider-value');
});
it('displays the current value from context', async () => {
const slider = createElement(SliderElement);
const valueEl = createElement(SliderValueElement);
slider.value = 42;
slider.appendChild(valueEl);
document.body.appendChild(slider);
await slider.updateComplete;
await valueEl.updateComplete;
expect(valueEl.textContent).toBe('42');
});
it('displays rounded value by default', async () => {
const slider = createElement(SliderElement);
const valueEl = createElement(SliderValueElement);
slider.value = 33;
slider.min = 0;
slider.max = 100;
slider.appendChild(valueEl);
document.body.appendChild(slider);
await slider.updateComplete;
await valueEl.updateComplete;
expect(valueEl.textContent).toBe('33');
});
it('sets aria-live="off"', async () => {
const slider = createElement(SliderElement);
const valueEl = createElement(SliderValueElement);
slider.appendChild(valueEl);
document.body.appendChild(slider);
await slider.updateComplete;
expect(valueEl.getAttribute('aria-live')).toBe('off');
});
});
@@ -11,7 +11,6 @@ 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;