mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 13:48:14 +00:00
feat(html): add time slider element (#656)
This commit is contained in:
@@ -31,10 +31,16 @@ function createMediaState(overrides: Partial<TimeSliderMedia> = {}): TimeSliderM
|
||||
|
||||
describe('TimeSliderCore', () => {
|
||||
describe('defaultProps', () => {
|
||||
it('extends SliderCore defaults with label', () => {
|
||||
expect(TimeSliderCore.defaultProps.label).toBe('Seek');
|
||||
expect(TimeSliderCore.defaultProps.min).toBe(0);
|
||||
expect(TimeSliderCore.defaultProps.max).toBe(100);
|
||||
it('has expected defaults', () => {
|
||||
expect(TimeSliderCore.defaultProps).toEqual({
|
||||
step: 1,
|
||||
largeStep: 10,
|
||||
orientation: 'horizontal',
|
||||
disabled: false,
|
||||
thumbAlignment: 'center',
|
||||
label: 'Seek',
|
||||
commitThrottle: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import { formatTimeAsPhrase } from '@videojs/utils/time';
|
||||
import type { NonNullableObject } from '@videojs/utils/types';
|
||||
|
||||
import type { MediaBufferState, MediaTimeState } 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 TimeSliderProps extends SliderProps {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface TimeSliderState extends SliderState, Pick<MediaTimeState, 'currentTime' | 'duration' | 'seeking'> {
|
||||
@@ -16,10 +18,16 @@ export interface TimeSliderState extends SliderState, Pick<MediaTimeState, 'curr
|
||||
bufferPercent: number;
|
||||
}
|
||||
|
||||
// @ts-expect-error — defaultProps shape differs from base (domain sliders omit value/min/max)
|
||||
export class TimeSliderCore extends SliderCore {
|
||||
static override readonly defaultProps: NonNullableObject<TimeSliderProps> = {
|
||||
...SliderCore.defaultProps,
|
||||
static readonly defaultProps: NonNullableObject<TimeSliderProps> = {
|
||||
step: SliderCore.defaultProps.step,
|
||||
largeStep: SliderCore.defaultProps.largeStep,
|
||||
orientation: SliderCore.defaultProps.orientation,
|
||||
disabled: SliderCore.defaultProps.disabled,
|
||||
thumbAlignment: SliderCore.defaultProps.thumbAlignment,
|
||||
label: 'Seek',
|
||||
commitThrottle: 100,
|
||||
};
|
||||
|
||||
#props = { ...TimeSliderCore.defaultProps };
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { TimeSliderElement } from '../../ui/time-slider/time-slider-element';
|
||||
|
||||
import './slider';
|
||||
|
||||
customElements.define(TimeSliderElement.tagName, TimeSliderElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[TimeSliderElement.tagName]: TimeSliderElement;
|
||||
}
|
||||
}
|
||||
@@ -38,3 +38,4 @@ export { ThumbnailElement } from './ui/thumbnail/thumbnail-element';
|
||||
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';
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -14,11 +14,7 @@ import { SliderProvider } from '../slider/slider-context';
|
||||
|
||||
const noopSeek = (): Promise<number> => Promise.resolve(0);
|
||||
|
||||
export interface TimeSliderRootProps
|
||||
extends UIComponentProps<'div', TimeSliderCore.State>,
|
||||
Pick<TimeSliderCore.Props, 'label' | 'step' | 'largeStep' | 'disabled' | 'thumbAlignment'> {
|
||||
/** Trailing-edge throttle (ms) for seek requests during drag. Default `100`. `0` disables. */
|
||||
commitThrottle?: number | undefined;
|
||||
export interface TimeSliderRootProps extends UIComponentProps<'div', TimeSliderCore.State>, TimeSliderCore.Props {
|
||||
onDragStart?: (() => void) | undefined;
|
||||
onDragEnd?: (() => void) | undefined;
|
||||
}
|
||||
@@ -30,11 +26,12 @@ export const TimeSliderRoot = forwardRef<HTMLDivElement, TimeSliderRootProps>(
|
||||
className,
|
||||
style,
|
||||
label,
|
||||
step = 1,
|
||||
largeStep = 10,
|
||||
commitThrottle = TimeSliderCore.defaultProps.commitThrottle,
|
||||
step = TimeSliderCore.defaultProps.step,
|
||||
largeStep = TimeSliderCore.defaultProps.largeStep,
|
||||
orientation,
|
||||
disabled,
|
||||
thumbAlignment,
|
||||
commitThrottle = 100,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
...elementProps
|
||||
@@ -44,7 +41,7 @@ export const TimeSliderRoot = forwardRef<HTMLDivElement, TimeSliderRootProps>(
|
||||
const buffer = usePlayer(selectBuffer);
|
||||
|
||||
const [core] = useState(() => new TimeSliderCore());
|
||||
core.setProps({ label, step, largeStep, disabled, thumbAlignment });
|
||||
core.setProps({ label, step, largeStep, orientation, disabled, thumbAlignment });
|
||||
|
||||
// Keep a ref to the latest media state for callbacks that fire outside the render cycle.
|
||||
const mediaRef = useLatestRef(time && buffer ? { ...time, ...buffer } : null);
|
||||
@@ -86,7 +83,7 @@ export const TimeSliderRoot = forwardRef<HTMLDivElement, TimeSliderRootProps>(
|
||||
getPercent: () => (duration > 0 ? ((time?.currentTime ?? 0) / duration) * 100 : 0),
|
||||
getStepPercent: () => (step / range) * 100,
|
||||
getLargeStepPercent: () => (largeStep / range) * 100,
|
||||
orientation: 'horizontal',
|
||||
orientation,
|
||||
disabled,
|
||||
commitThrottle,
|
||||
adjustPercent: (rawPercent, thumbSize, trackSize) =>
|
||||
|
||||
Reference in New Issue
Block a user