mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(html): add slider element (#655)
This commit is contained in:
@@ -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 (0–100). */
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, 0–1 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;
|
||||
|
||||
Reference in New Issue
Block a user