fix(html): slider interaction and edge alignment broken (#721)

This commit is contained in:
rahim
2026-03-04 21:13:37 -08:00
committed by GitHub
parent 7548a8e4a1
commit ff12296355
13 changed files with 126 additions and 25 deletions
+28 -2
View File
@@ -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 0100 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<SliderInput>;
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: <S extends SliderState>(state: S) => S;
destroy: () => void;
}
@@ -281,12 +288,31 @@ export function createSlider(options: SliderOptions): SliderApi {
},
};
function adjustForAlignment<S extends SliderState>(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();
},
+11 -2
View File
@@ -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);
@@ -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';
@@ -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);
@@ -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);
@@ -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);
@@ -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);
+3 -17
View File
@@ -72,6 +72,7 @@ export function useSlider<State extends SliderState = SliderState>(
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 extends SliderState = SliderState>(
}
}, [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) => {
@@ -36,6 +36,7 @@ const { mockSliderApi } = vi.hoisted(() => ({
onFocus: vi.fn(),
onBlur: vi.fn(),
},
adjustForAlignment: <S,>(state: S): S => state,
destroy: vi.fn(),
}),
}));
@@ -27,6 +27,7 @@ const { mockSliderApi, mockThumbnailApi } = vi.hoisted(() => ({
onFocus: vi.fn(),
onBlur: vi.fn(),
},
adjustForAlignment: <S,>(state: S): S => state,
destroy: vi.fn(),
}),
mockThumbnailApi: () => ({
@@ -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(),
}),
}));
@@ -34,6 +34,7 @@ const { mockSliderApi, mockTimeState, mockBufferState } = vi.hoisted(() => ({
onFocus: vi.fn(),
onBlur: vi.fn(),
},
adjustForAlignment: <S,>(state: S): S => state,
destroy: vi.fn(),
}),
mockTimeState: {
@@ -33,6 +33,7 @@ const { mockSliderApi, mockVolumeState } = vi.hoisted(() => ({
onFocus: vi.fn(),
onBlur: vi.fn(),
},
adjustForAlignment: <S,>(state: S): S => state,
destroy: vi.fn(),
}),
mockVolumeState: {