feat(html): add time slider element (#656)

This commit is contained in:
rahim
2026-02-28 20:52:27 -08:00
committed by GitHub
parent d5df0150b3
commit 26c7395cd0
7 changed files with 297 additions and 18 deletions
@@ -0,0 +1,100 @@
import { afterEach, describe, expect, it } from 'vitest';
import { SliderThumbElement } from '../../slider/slider-thumb-element';
import { SliderValueElement } from '../../slider/slider-value-element';
import { TimeSliderElement } from '../time-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('TimeSliderElement', () => {
it('has the correct tag name', () => {
expect(TimeSliderElement.tagName).toBe('media-time-slider');
});
it('initializes with default property values', () => {
const slider = createElement(TimeSliderElement);
expect(slider.label).toBe('Seek');
expect(slider.commitThrottle).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 touch-action and user-select styles on connect', async () => {
const slider = createElement(TimeSliderElement);
document.body.appendChild(slider);
await slider.updateComplete;
expect(slider.style.touchAction).toBe('none');
expect(slider.style.userSelect).toBe('none');
});
it('does not set CSS vars without player context', async () => {
const slider = createElement(TimeSliderElement);
document.body.appendChild(slider);
await slider.updateComplete;
// Without player store providing time state, the element guards early.
expect(slider.style.getPropertyValue('--media-slider-fill')).toBe('');
});
it('sets data-orientation to horizontal by default', async () => {
// Without store, data attrs are not applied (early return in update).
const slider = createElement(TimeSliderElement);
document.body.appendChild(slider);
await slider.updateComplete;
// Without store the update guard returns early, so no data attrs.
// This confirms the element connects and runs without errors.
expect(slider.isConnected).toBe(true);
});
it('provides time-formatted values to SliderValueElement via context', async () => {
// Without a real player store, context isn't populated.
// This test verifies the element structure and connection works.
const slider = createElement(TimeSliderElement);
const valueEl = createElement(SliderValueElement);
slider.appendChild(valueEl);
document.body.appendChild(slider);
await slider.updateComplete;
await valueEl.updateComplete;
// Without store, formatValue isn't available to children.
// Verifies no runtime errors in the parent-child context chain.
expect(valueEl.isConnected).toBe(true);
});
it('provides ARIA attributes to SliderThumbElement via context', async () => {
const slider = createElement(TimeSliderElement);
const thumb = createElement(SliderThumbElement);
slider.appendChild(thumb);
document.body.appendChild(slider);
await slider.updateComplete;
await thumb.updateComplete;
// Without store, context is not populated so thumb has no ARIA.
// This verifies no errors occur in the context chain.
expect(thumb.isConnected).toBe(true);
});
});
@@ -0,0 +1,156 @@
import { TimeSliderCore, TimeSliderDataAttrs } from '@videojs/core';
import {
applyStateDataAttrs,
createSlider,
getTimeSliderCSSVars,
logMissingFeature,
type SliderHandle,
selectBuffer,
selectTime,
} 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 TimeSliderElement extends MediaElement {
static readonly tagName = 'media-time-slider';
static override properties = {
label: { type: String },
commitThrottle: { type: Number, attribute: 'commit-throttle' },
step: { type: Number },
largeStep: { type: Number, attribute: 'large-step' },
orientation: { type: String },
disabled: { type: Boolean },
thumbAlignment: { type: String, attribute: 'thumb-alignment' },
} satisfies PropertyDeclarationMap<keyof TimeSliderCore.Props>;
label = TimeSliderCore.defaultProps.label;
commitThrottle = TimeSliderCore.defaultProps.commitThrottle;
step = TimeSliderCore.defaultProps.step;
largeStep = TimeSliderCore.defaultProps.largeStep;
orientation = TimeSliderCore.defaultProps.orientation;
disabled = TimeSliderCore.defaultProps.disabled;
thumbAlignment = TimeSliderCore.defaultProps.thumbAlignment;
readonly #core = new TimeSliderCore();
readonly #provider = new ContextProvider(this, { context: sliderContext });
readonly #timeState = new PlayerController(this, playerContext, selectTime);
readonly #bufferState = new PlayerController(this, playerContext, selectBuffer);
#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.#timeState.value,
getPercent: () => {
const media = this.#timeState.value;
if (!media) return 0;
return this.#core.percentFromValue(media.currentTime);
},
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: () => {
// Visual update only — CSS vars are refreshed in update().
},
onValueCommit: (percent) => {
this.#seek(percent);
},
commitThrottle: this.commitThrottle,
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.#timeState.value) {
logMissingFeature(TimeSliderElement.tagName, 'time');
}
}
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 time = this.#timeState.value;
const buffer = this.#bufferState.value;
if (!time) return;
const interaction = this.#slider.interaction.current;
const media = { ...time, ...(buffer ?? { buffered: [], seekable: [] }) };
const state = this.#core.getTimeState(media, interaction);
const cssVars = getTimeSliderCSSVars(state);
applyStyles(this, cssVars);
// Domain-specific data attributes on root (includes data-seeking).
applyStateDataAttrs(this, state, TimeSliderDataAttrs);
// Provide context to child elements with base slider data attrs.
this.#provider.setValue({
state,
pointerValue: this.#core.valueFromPercent(state.pointerPercent),
thumbAttrs: this.#core.getAttrs(state),
thumbProps: this.#slider.thumbProps,
formatValue: (value) => formatTime(value),
});
}
#seek(percent: number): void {
const media = this.#timeState.value;
if (!media) return;
const time = this.#core.valueFromPercent(percent);
media.seek(time);
}
}
function formatTime(seconds: number): string {
const total = Math.round(seconds);
const minutes = Math.floor(total / 60);
const secs = total % 60;
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}