mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix(html): slider interaction and edge alignment broken (#721)
This commit is contained in:
@@ -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<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();
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user