From d5df0150b3aef21c15d3a65c015bb6058e95ce53 Mon Sep 17 00:00:00 2001 From: rahim Date: Sat, 28 Feb 2026 20:13:08 -0800 Subject: [PATCH] feat(html): add slider element (#655) --- .../core/src/core/ui/slider/slider-core.ts | 17 +- .../core/ui/slider/tests/slider-core.test.ts | 1 + packages/html/src/define/ui/slider.ts | 24 ++ packages/html/src/index.ts | 8 + .../src/ui/slider/slider-buffer-element.ts | 22 ++ packages/html/src/ui/slider/slider-context.ts | 20 ++ packages/html/src/ui/slider/slider-element.ts | 119 ++++++++ packages/html/src/ui/slider/slider-events.ts | 15 + .../html/src/ui/slider/slider-fill-element.ts | 22 ++ .../src/ui/slider/slider-thumb-element.ts | 51 ++++ .../src/ui/slider/slider-track-element.ts | 22 ++ .../src/ui/slider/slider-value-element.ts | 40 +++ .../ui/slider/tests/slider-element.test.ts | 282 ++++++++++++++++++ packages/react/src/ui/slider/slider-root.tsx | 1 - 14 files changed, 638 insertions(+), 6 deletions(-) create mode 100644 packages/html/src/define/ui/slider.ts create mode 100644 packages/html/src/ui/slider/slider-buffer-element.ts create mode 100644 packages/html/src/ui/slider/slider-context.ts create mode 100644 packages/html/src/ui/slider/slider-element.ts create mode 100644 packages/html/src/ui/slider/slider-events.ts create mode 100644 packages/html/src/ui/slider/slider-fill-element.ts create mode 100644 packages/html/src/ui/slider/slider-thumb-element.ts create mode 100644 packages/html/src/ui/slider/slider-track-element.ts create mode 100644 packages/html/src/ui/slider/slider-value-element.ts create mode 100644 packages/html/src/ui/slider/tests/slider-element.test.ts diff --git a/packages/core/src/core/ui/slider/slider-core.ts b/packages/core/src/core/ui/slider/slider-core.ts index 3528ebd2..f708d37e 100644 --- a/packages/core/src/core/ui/slider/slider-core.ts +++ b/packages/core/src/core/ui/slider/slider-core.ts @@ -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 = { + value: 0, min: 0, max: 100, step: 1, 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 9ede30b0..7ede8ca8 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({ + value: 0, min: 0, max: 100, step: 1, diff --git a/packages/html/src/define/ui/slider.ts b/packages/html/src/define/ui/slider.ts new file mode 100644 index 00000000..88b37af2 --- /dev/null +++ b/packages/html/src/define/ui/slider.ts @@ -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; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 55e4cf9c..db9c0198 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -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'; diff --git a/packages/html/src/ui/slider/slider-buffer-element.ts b/packages/html/src/ui/slider/slider-buffer-element.ts new file mode 100644 index 00000000..11261c89 --- /dev/null +++ b/packages/html/src/ui/slider/slider-buffer-element.ts @@ -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); + } +} diff --git a/packages/html/src/ui/slider/slider-context.ts b/packages/html/src/ui/slider/slider-context.ts new file mode 100644 index 00000000..78287e62 --- /dev/null +++ b/packages/html/src/ui/slider/slider-context.ts @@ -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; + /** 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(SLIDER_CONTEXT_KEY); diff --git a/packages/html/src/ui/slider/slider-element.ts b/packages/html/src/ui/slider/slider-element.ts new file mode 100644 index 00000000..c7025691 --- /dev/null +++ b/packages/html/src/ui/slider/slider-element.ts @@ -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; + + 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('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, + }); + } +} diff --git a/packages/html/src/ui/slider/slider-events.ts b/packages/html/src/ui/slider/slider-events.ts new file mode 100644 index 00000000..5bba13b8 --- /dev/null +++ b/packages/html/src/ui/slider/slider-events.ts @@ -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; + /** Fires when the user commits a value — on pointer up or keyboard release. */ + 'value-commit': CustomEvent; + /** Fires when a drag interaction begins (pointer down on the slider). */ + 'drag-start': CustomEvent; + /** Fires when a drag interaction ends (pointer up after dragging). */ + 'drag-end': CustomEvent; +} diff --git a/packages/html/src/ui/slider/slider-fill-element.ts b/packages/html/src/ui/slider/slider-fill-element.ts new file mode 100644 index 00000000..42c7d0e7 --- /dev/null +++ b/packages/html/src/ui/slider/slider-fill-element.ts @@ -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); + } +} diff --git a/packages/html/src/ui/slider/slider-thumb-element.ts b/packages/html/src/ui/slider/slider-thumb-element.ts new file mode 100644 index 00000000..e7edc7d0 --- /dev/null +++ b/packages/html/src/ui/slider/slider-thumb-element.ts @@ -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); + } +} diff --git a/packages/html/src/ui/slider/slider-track-element.ts b/packages/html/src/ui/slider/slider-track-element.ts new file mode 100644 index 00000000..a8181199 --- /dev/null +++ b/packages/html/src/ui/slider/slider-track-element.ts @@ -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); + } +} diff --git a/packages/html/src/ui/slider/slider-value-element.ts b/packages/html/src/ui/slider/slider-value-element.ts new file mode 100644 index 00000000..4f424079 --- /dev/null +++ b/packages/html/src/ui/slider/slider-value-element.ts @@ -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); + } +} diff --git a/packages/html/src/ui/slider/tests/slider-element.test.ts b/packages/html/src/ui/slider/tests/slider-element.test.ts new file mode 100644 index 00000000..95606829 --- /dev/null +++ b/packages/html/src/ui/slider/tests/slider-element.test.ts @@ -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(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'); + }); +}); diff --git a/packages/react/src/ui/slider/slider-root.tsx b/packages/react/src/ui/slider/slider-root.tsx index 40b82316..ca751ee8 100644 --- a/packages/react/src/ui/slider/slider-root.tsx +++ b/packages/react/src/ui/slider/slider-root.tsx @@ -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;