diff --git a/packages/core/src/dom/ui/slider.ts b/packages/core/src/dom/ui/slider.ts index 6993626b..486f9e95 100644 --- a/packages/core/src/dom/ui/slider.ts +++ b/packages/core/src/dom/ui/slider.ts @@ -3,7 +3,7 @@ import { listen } from '@videojs/utils/dom'; import { throttle } from '@videojs/utils/function'; import { clamp, roundToStep } from '@videojs/utils/number'; import { isNull } from '@videojs/utils/predicate'; -import type { SliderInput } from '../../core/ui/slider/slider-core'; +import type { SliderInput, SliderState } from '../../core/ui/slider/slider-core'; import { getPercentFromPointerEvent } from '../utils/pointer'; import type { UIKeyboardEvent, UIPointerEvent } from './event'; @@ -31,7 +31,8 @@ export interface SliderOptions { * commit fires on pointer release. `0` (default) disables — commits only on release. */ commitThrottle?: number | undefined; - + /** Adjust a raw 0–100 percent for thumb alignment. Enables `adjustForAlignment()`. */ + adjustPercent?: ((rawPercent: number, thumbSize: number, trackSize: number) => number) | undefined; onValueChange?: ((percent: number) => void) | undefined; onValueCommit?: ((percent: number) => void) | undefined; onDragStart?: (() => void) | undefined; @@ -54,6 +55,12 @@ export interface SliderApi { input: State; rootProps: SliderRootProps; thumbProps: SliderThumbProps; + /** + * Adjust `fillPercent` and `pointerPercent` for edge thumb alignment using + * live DOM measurements from the root/thumb elements. No-op when + * `adjustPercent` was not provided or `thumbAlignment` is not `'edge'`. + */ + adjustForAlignment: (state: S) => S; destroy: () => void; } @@ -281,12 +288,31 @@ export function createSlider(options: SliderOptions): SliderApi { }, }; + function adjustForAlignment(state: S): S { + if (!options.adjustPercent || state.thumbAlignment !== 'edge') return state; + + const rootEl = options.getElement(); + const thumbEl = options.getThumbElement?.(); + if (!thumbEl) return state; + + const isHorizontal = state.orientation === 'horizontal'; + const thumbSize = isHorizontal ? thumbEl.offsetWidth : thumbEl.offsetHeight; + const trackSize = isHorizontal ? rootEl.offsetWidth : rootEl.offsetHeight; + + return { + ...state, + fillPercent: options.adjustPercent(state.fillPercent, thumbSize, trackSize), + pointerPercent: options.adjustPercent(state.pointerPercent, thumbSize, trackSize), + }; + } + listen(abort.signal, 'abort', cleanup, { once: true }); return { input, rootProps, thumbProps, + adjustForAlignment, destroy() { abort.abort(); }, diff --git a/packages/html/src/ui/slider/slider-element.ts b/packages/html/src/ui/slider/slider-element.ts index 01a6e60f..4dc9fba7 100644 --- a/packages/html/src/ui/slider/slider-element.ts +++ b/packages/html/src/ui/slider/slider-element.ts @@ -1,5 +1,11 @@ import { SliderCore, SliderDataAttrs } from '@videojs/core'; -import { applyStateDataAttrs, createSlider, getSliderCSSVars, type SliderApi } from '@videojs/core/dom'; +import { + applyElementProps, + applyStateDataAttrs, + createSlider, + getSliderCSSVars, + type SliderApi, +} from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextProvider } from '@videojs/element/context'; import { applyStyles, isRTL } from '@videojs/utils/dom'; @@ -67,8 +73,10 @@ export class SliderElement extends MediaElement { onDragEnd: () => { this.dispatchEvent(new CustomEvent('drag-end', { bubbles: true })); }, + adjustPercent: (raw, thumbSize, trackSize) => this.#core.adjustPercentForAlignment(raw, thumbSize, trackSize), }); + applyElementProps(this, this.#slider.rootProps, { signal }); this.#slider.input.subscribe(() => this.requestUpdate(), { signal }); // Prevent default touch gestures and text selection during interaction. @@ -95,7 +103,8 @@ export class SliderElement extends MediaElement { this.#core.setInput(this.#slider.input.current); const state = this.#core.getSliderState(this.value); - const cssVars = getSliderCSSVars(state); + + const cssVars = getSliderCSSVars(this.#slider.adjustForAlignment(state)); applyStyles(this, cssVars); diff --git a/packages/html/src/ui/slider/tests/slider-element.test.ts b/packages/html/src/ui/slider/tests/slider-element.test.ts index f7bf10d5..dada9676 100644 --- a/packages/html/src/ui/slider/tests/slider-element.test.ts +++ b/packages/html/src/ui/slider/tests/slider-element.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { SliderBufferElement } from '../slider-buffer-element'; import { SliderElement } from '../slider-element'; import { SliderFillElement } from '../slider-fill-element'; @@ -107,6 +107,27 @@ describe('SliderElement', () => { expect(slider.style.getPropertyValue('--media-slider-fill')).toBe('75.000%'); }); + it('binds rootProps pointer events on connect', async () => { + const slider = createElement(SliderElement); + slider.value = 0; + + document.body.appendChild(slider); + await slider.updateComplete; + + // Stub setPointerCapture/releasePointerCapture (not available in happy-dom). + slider.setPointerCapture = vi.fn(); + slider.releasePointerCapture = vi.fn(); + + const spy = vi.fn(); + slider.addEventListener('value-change', spy); + + // Simulate pointerdown on the slider element. + slider.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, clientX: 50, clientY: 0 })); + + // pointerdown triggers onValueChange via rootProps. + expect(spy).toHaveBeenCalled(); + }); + it('supports vertical orientation', async () => { const slider = createElement(SliderElement); slider.orientation = 'vertical'; diff --git a/packages/html/src/ui/time-slider/tests/time-slider-element.test.ts b/packages/html/src/ui/time-slider/tests/time-slider-element.test.ts index 526f8c22..85ee1a9d 100644 --- a/packages/html/src/ui/time-slider/tests/time-slider-element.test.ts +++ b/packages/html/src/ui/time-slider/tests/time-slider-element.test.ts @@ -36,6 +36,20 @@ describe('TimeSliderElement', () => { expect(slider.thumbAlignment).toBe('center'); }); + it('binds rootProps pointer events on connect', async () => { + const slider = createElement(TimeSliderElement); + + document.body.appendChild(slider); + await slider.updateComplete; + + // Without store, slider is disabled — but rootProps should still be bound. + // Verify by dispatching pointermove (which does not guard on disabled). + slider.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: 50, clientY: 0 })); + + // No errors thrown means rootProps were bound correctly. + expect(slider.isConnected).toBe(true); + }); + it('sets touch-action and user-select styles on connect', async () => { const slider = createElement(TimeSliderElement); diff --git a/packages/html/src/ui/time-slider/time-slider-element.ts b/packages/html/src/ui/time-slider/time-slider-element.ts index 41c86155..8c4c2ea1 100644 --- a/packages/html/src/ui/time-slider/time-slider-element.ts +++ b/packages/html/src/ui/time-slider/time-slider-element.ts @@ -1,5 +1,6 @@ import { TimeSliderCore, TimeSliderDataAttrs } from '@videojs/core'; import { + applyElementProps, applyStateDataAttrs, createSlider, getTimeSliderCSSVars, @@ -77,8 +78,10 @@ export class TimeSliderElement extends MediaElement { onDragEnd: () => { this.dispatchEvent(new CustomEvent('drag-end', { bubbles: true })); }, + adjustPercent: (raw, thumbSize, trackSize) => this.#core.adjustPercentForAlignment(raw, thumbSize, trackSize), }); + applyElementProps(this, this.#slider.rootProps, { signal }); this.#slider.input.subscribe(() => this.requestUpdate(), { signal }); // Prevent default touch gestures and text selection during interaction. @@ -115,7 +118,8 @@ export class TimeSliderElement extends MediaElement { const media = { ...time, ...(buffer ?? { buffered: [], seekable: [] }) }; this.#core.setMedia(media); const state = this.#core.getState(); - const cssVars = getTimeSliderCSSVars(state); + + const cssVars = getTimeSliderCSSVars(this.#slider.adjustForAlignment(state)); applyStyles(this, cssVars); diff --git a/packages/html/src/ui/volume-slider/tests/volume-slider-element.test.ts b/packages/html/src/ui/volume-slider/tests/volume-slider-element.test.ts index 5a3b5afe..c1eb1a3a 100644 --- a/packages/html/src/ui/volume-slider/tests/volume-slider-element.test.ts +++ b/packages/html/src/ui/volume-slider/tests/volume-slider-element.test.ts @@ -34,6 +34,19 @@ describe('VolumeSliderElement', () => { expect(slider.thumbAlignment).toBe('center'); }); + it('binds rootProps pointer events on connect', async () => { + const slider = createElement(VolumeSliderElement); + + document.body.appendChild(slider); + await slider.updateComplete; + + // Without store, slider is disabled — but rootProps should still be bound. + slider.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: 50, clientY: 0 })); + + // No errors thrown means rootProps were bound correctly. + expect(slider.isConnected).toBe(true); + }); + it('sets touch-action and user-select styles on connect', async () => { const slider = createElement(VolumeSliderElement); diff --git a/packages/html/src/ui/volume-slider/volume-slider-element.ts b/packages/html/src/ui/volume-slider/volume-slider-element.ts index 599d6ef7..6143e897 100644 --- a/packages/html/src/ui/volume-slider/volume-slider-element.ts +++ b/packages/html/src/ui/volume-slider/volume-slider-element.ts @@ -1,5 +1,6 @@ import { SliderDataAttrs, VolumeSliderCore } from '@videojs/core'; import { + applyElementProps, applyStateDataAttrs, createSlider, getSliderCSSVars, @@ -73,8 +74,10 @@ export class VolumeSliderElement extends MediaElement { onDragEnd: () => { this.dispatchEvent(new CustomEvent('drag-end', { bubbles: true })); }, + adjustPercent: (raw, thumbSize, trackSize) => this.#core.adjustPercentForAlignment(raw, thumbSize, trackSize), }); + applyElementProps(this, this.#slider.rootProps, { signal }); this.#slider.input.subscribe(() => this.requestUpdate(), { signal }); // Prevent default touch gestures and text selection during interaction. @@ -109,7 +112,8 @@ export class VolumeSliderElement extends MediaElement { this.#core.setInput(this.#slider.input.current); this.#core.setMedia(media); const state = this.#core.getState(); - const cssVars = getSliderCSSVars(state); + + const cssVars = getSliderCSSVars(this.#slider.adjustForAlignment(state)); applyStyles(this, cssVars); diff --git a/packages/react/src/ui/hooks/use-slider.ts b/packages/react/src/ui/hooks/use-slider.ts index 80cef296..f96cd83d 100644 --- a/packages/react/src/ui/hooks/use-slider.ts +++ b/packages/react/src/ui/hooks/use-slider.ts @@ -72,6 +72,7 @@ export function useSlider( getStepPercent: () => optionsRef.current.getStepPercent(), getLargeStepPercent: () => optionsRef.current.getLargeStepPercent(), commitThrottle: optionsRef.current.commitThrottle, + adjustPercent: optionsRef.current.adjustPercent, onValueChange: (percent) => optionsRef.current.onValueChange?.(percent), onValueCommit: (percent) => optionsRef.current.onValueCommit?.(percent), onDragStart: () => optionsRef.current.onDragStart?.(), @@ -98,23 +99,8 @@ export function useSlider( } }, [state.thumbAlignment]); - // Adjust CSS var percents for edge thumb alignment when DOM elements are available. - const rootEl = rootElementRef.current; - const thumbEl = thumbElementRef.current; - let cssState = state; - - if (state.thumbAlignment === 'edge' && rootEl && thumbEl && options.adjustPercent) { - const isHorizontal = state.orientation === 'horizontal'; - const thumbSize = isHorizontal ? thumbEl.offsetWidth : thumbEl.offsetHeight; - const trackSize = isHorizontal ? rootEl.offsetWidth : rootEl.offsetHeight; - cssState = { - ...state, - fillPercent: options.adjustPercent(state.fillPercent, thumbSize, trackSize), - pointerPercent: options.adjustPercent(state.pointerPercent, thumbSize, trackSize), - }; - } - - const cssVars = options.getCSSVars(cssState); + // Adjust CSS var percents for edge thumb alignment using live DOM measurements. + const cssVars = options.getCSSVars(slider.adjustForAlignment(state)); // Ref callbacks for root and thumb elements. const rootRef = useCallback((element: HTMLElement | null) => { diff --git a/packages/react/src/ui/slider/tests/slider-preview.test.tsx b/packages/react/src/ui/slider/tests/slider-preview.test.tsx index 4c69915b..8be3f040 100644 --- a/packages/react/src/ui/slider/tests/slider-preview.test.tsx +++ b/packages/react/src/ui/slider/tests/slider-preview.test.tsx @@ -36,6 +36,7 @@ const { mockSliderApi } = vi.hoisted(() => ({ onFocus: vi.fn(), onBlur: vi.fn(), }, + adjustForAlignment: (state: S): S => state, destroy: vi.fn(), }), })); diff --git a/packages/react/src/ui/slider/tests/slider-thumbnail.test.tsx b/packages/react/src/ui/slider/tests/slider-thumbnail.test.tsx index 7b3d6375..f6c013ea 100644 --- a/packages/react/src/ui/slider/tests/slider-thumbnail.test.tsx +++ b/packages/react/src/ui/slider/tests/slider-thumbnail.test.tsx @@ -27,6 +27,7 @@ const { mockSliderApi, mockThumbnailApi } = vi.hoisted(() => ({ onFocus: vi.fn(), onBlur: vi.fn(), }, + adjustForAlignment: (state: S): S => state, destroy: vi.fn(), }), mockThumbnailApi: () => ({ diff --git a/packages/react/src/ui/slider/tests/slider.test.tsx b/packages/react/src/ui/slider/tests/slider.test.tsx index f3c990cb..a1b67333 100644 --- a/packages/react/src/ui/slider/tests/slider.test.tsx +++ b/packages/react/src/ui/slider/tests/slider.test.tsx @@ -10,7 +10,11 @@ import { SliderTrack } from '../slider-track'; import { SliderValue } from '../slider-value'; const { mockSliderApi } = vi.hoisted(() => ({ - mockSliderApi: () => ({ + mockSliderApi: (options?: { + getElement?: () => HTMLElement; + getThumbElement?: () => HTMLElement | null; + adjustPercent?: (raw: number, thumb: number, track: number) => number; + }) => ({ input: { current: { pointerPercent: 0, @@ -31,6 +35,22 @@ const { mockSliderApi } = vi.hoisted(() => ({ onFocus: vi.fn(), onBlur: vi.fn(), }, + adjustForAlignment< + S extends { thumbAlignment?: string; orientation?: string; fillPercent: number; pointerPercent: number }, + >(state: S): S { + if (!options?.adjustPercent || state.thumbAlignment !== 'edge') return state; + const thumbEl = options.getThumbElement?.(); + if (!thumbEl) return state; + const rootEl = options.getElement!(); + const isHorizontal = state.orientation === 'horizontal'; + const thumbSize = isHorizontal ? thumbEl.offsetWidth : thumbEl.offsetHeight; + const trackSize = isHorizontal ? rootEl.offsetWidth : rootEl.offsetHeight; + return { + ...state, + fillPercent: options.adjustPercent(state.fillPercent, thumbSize, trackSize), + pointerPercent: options.adjustPercent(state.pointerPercent, thumbSize, trackSize), + }; + }, destroy: vi.fn(), }), })); diff --git a/packages/react/src/ui/time-slider/tests/time-slider.test.tsx b/packages/react/src/ui/time-slider/tests/time-slider.test.tsx index ff2a1e31..d16cdfba 100644 --- a/packages/react/src/ui/time-slider/tests/time-slider.test.tsx +++ b/packages/react/src/ui/time-slider/tests/time-slider.test.tsx @@ -34,6 +34,7 @@ const { mockSliderApi, mockTimeState, mockBufferState } = vi.hoisted(() => ({ onFocus: vi.fn(), onBlur: vi.fn(), }, + adjustForAlignment: (state: S): S => state, destroy: vi.fn(), }), mockTimeState: { diff --git a/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx b/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx index cbeb1048..32299757 100644 --- a/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx +++ b/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx @@ -33,6 +33,7 @@ const { mockSliderApi, mockVolumeState } = vi.hoisted(() => ({ onFocus: vi.fn(), onBlur: vi.fn(), }, + adjustForAlignment: (state: S): S => state, destroy: vi.fn(), }), mockVolumeState: {