mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(react): add slider component (#644)
This commit is contained in:
@@ -34,6 +34,7 @@ export type { ControlsGroupProps } from './ui/controls/controls-group';
|
||||
export type { ControlsRootProps } from './ui/controls/controls-root';
|
||||
export { FullscreenButton, type FullscreenButtonProps } from './ui/fullscreen-button/fullscreen-button';
|
||||
export { useButton } from './ui/hooks/use-button';
|
||||
export { useSlider } from './ui/hooks/use-slider';
|
||||
export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button';
|
||||
export { PiPButton, type PiPButtonProps } from './ui/pip-button/pip-button';
|
||||
export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button';
|
||||
@@ -41,6 +42,13 @@ export { PlaybackRateButton, type PlaybackRateButtonProps } from './ui/playback-
|
||||
export { Popover } from './ui/popover';
|
||||
export { Poster, type PosterProps } from './ui/poster/poster';
|
||||
export { SeekButton, type SeekButtonProps } from './ui/seek-button/seek-button';
|
||||
export { Slider } from './ui/slider';
|
||||
export type { SliderBufferProps } from './ui/slider/slider-buffer';
|
||||
export type { SliderFillProps } from './ui/slider/slider-fill';
|
||||
export type { SliderRootProps } from './ui/slider/slider-root';
|
||||
export type { SliderThumbProps } from './ui/slider/slider-thumb';
|
||||
export type { SliderTrackProps } from './ui/slider/slider-track';
|
||||
export type { SliderValueProps } from './ui/slider/slider-value';
|
||||
export { Thumbnail, type ThumbnailProps } from './ui/thumbnail/thumbnail';
|
||||
export { Time } from './ui/time';
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render, renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createMockStore } from '../../testing/mocks';
|
||||
import {
|
||||
Container,
|
||||
PlayerContextProvider,
|
||||
@@ -13,15 +14,6 @@ import {
|
||||
usePlayerContext,
|
||||
} from '../context';
|
||||
|
||||
function createMockStore() {
|
||||
return {
|
||||
state: { paused: true, volume: 1 },
|
||||
attach: vi.fn(() => vi.fn()),
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createWrapper(value: PlayerContextValue) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <PlayerContextProvider value={value}>{children}</PlayerContextProvider>;
|
||||
@@ -114,7 +106,7 @@ describe('useOptionalPlayer', () => {
|
||||
});
|
||||
|
||||
it('returns selected state inside Provider', () => {
|
||||
const store = createMockStore();
|
||||
const store = createMockStore({ paused: true });
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
|
||||
|
||||
const { result } = renderHook(() => useOptionalPlayer((state: any) => state.paused), {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Shared test helpers for `@videojs/react`.
|
||||
*
|
||||
* Provides factory functions for common mock objects used across component
|
||||
* tests. `vi.mock()` blocks must still live in each test file (Vitest hoists
|
||||
* them before imports), but non-mock helpers like store and wrapper factories
|
||||
* can be shared here.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { PlayerContextProvider, type PlayerContextValue } from '../player/context';
|
||||
|
||||
/**
|
||||
* Create a minimal mock store compatible with `PlayerContextValue`.
|
||||
*
|
||||
* Pass optional `state` to seed the store's state snapshot.
|
||||
*/
|
||||
export function createMockStore(state: Record<string, unknown> = {}) {
|
||||
return {
|
||||
state,
|
||||
attach: vi.fn(() => vi.fn()),
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a React wrapper that provides `PlayerContextProvider`.
|
||||
*
|
||||
* Accepts an optional store state seed. Returns the wrapper component,
|
||||
* the mock store, and the context value for assertions.
|
||||
*/
|
||||
export function createPlayerWrapper(storeState: Record<string, unknown> = {}) {
|
||||
const store = createMockStore(storeState);
|
||||
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
|
||||
|
||||
return {
|
||||
store,
|
||||
value,
|
||||
Wrapper({ children }: { children: ReactNode }) {
|
||||
return <PlayerContextProvider value={value}>{children}</PlayerContextProvider>;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import type { SliderInteraction, SliderState } from '@videojs/core';
|
||||
import {
|
||||
createSlider,
|
||||
type SliderHandle,
|
||||
type SliderOptions,
|
||||
type SliderRootProps,
|
||||
type SliderThumbProps,
|
||||
} from '@videojs/core/dom';
|
||||
import { useSnapshot } from '@videojs/store/react';
|
||||
import { isRTL } from '@videojs/utils/dom';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLatestRef } from '../../utils/use-latest-ref';
|
||||
|
||||
export interface UseSliderOptions<State extends SliderState = SliderState>
|
||||
extends Pick<
|
||||
SliderOptions,
|
||||
| 'getPercent'
|
||||
| 'getStepPercent'
|
||||
| 'getLargeStepPercent'
|
||||
| 'commitThrottle'
|
||||
| 'onValueChange'
|
||||
| 'onValueCommit'
|
||||
| 'onDragStart'
|
||||
| 'onDragEnd'
|
||||
> {
|
||||
computeState: (interaction: SliderInteraction) => State;
|
||||
orientation?: 'horizontal' | 'vertical' | undefined;
|
||||
disabled?: boolean | undefined;
|
||||
/** Adjust a raw 0–100 percent for thumb alignment. Called for fill and pointer percents. */
|
||||
adjustPercent?: ((rawPercent: number, thumbSize: number, trackSize: number) => number) | undefined;
|
||||
/** Compute CSS variable map from the (possibly alignment-adjusted) state. */
|
||||
getCSSVars: (state: State) => Record<string, string>;
|
||||
}
|
||||
|
||||
export interface UseSliderReturnValue<State extends SliderState = SliderState> {
|
||||
state: State;
|
||||
cssVars: Record<string, string>;
|
||||
rootRef: React.RefCallback<HTMLElement>;
|
||||
thumbRef: React.RefCallback<HTMLElement>;
|
||||
rootProps: SliderRootProps;
|
||||
thumbProps: SliderThumbProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages slider interaction lifecycle for React.
|
||||
*
|
||||
* Wraps `createSlider()` from `@videojs/core/dom` and subscribes to its
|
||||
* interaction state via `useSnapshot`. Returns split props for the root
|
||||
* (pointer events) and thumb (keyboard/focus) elements.
|
||||
*/
|
||||
export function useSlider<State extends SliderState = SliderState>(
|
||||
options: UseSliderOptions<State>
|
||||
): UseSliderReturnValue<State> {
|
||||
const optionsRef = useLatestRef(options);
|
||||
|
||||
const rootElementRef = useRef<HTMLElement | null>(null);
|
||||
const thumbElementRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Lazy-init the slider handle. Stable across re-renders.
|
||||
const [slider] = useState<SliderHandle>(() => {
|
||||
const stableOptions: SliderOptions = {
|
||||
getElement: () => rootElementRef.current!,
|
||||
getThumbElement: () => thumbElementRef.current,
|
||||
getOrientation: () => optionsRef.current.orientation ?? 'horizontal',
|
||||
isRTL: () => (rootElementRef.current ? isRTL(rootElementRef.current) : false),
|
||||
isDisabled: () => optionsRef.current.disabled ?? false,
|
||||
getPercent: () => optionsRef.current.getPercent(),
|
||||
getStepPercent: () => optionsRef.current.getStepPercent(),
|
||||
getLargeStepPercent: () => optionsRef.current.getLargeStepPercent(),
|
||||
commitThrottle: optionsRef.current.commitThrottle,
|
||||
onValueChange: (percent) => optionsRef.current.onValueChange?.(percent),
|
||||
onValueCommit: (percent) => optionsRef.current.onValueCommit?.(percent),
|
||||
onDragStart: () => optionsRef.current.onDragStart?.(),
|
||||
onDragEnd: () => optionsRef.current.onDragEnd?.(),
|
||||
};
|
||||
|
||||
return createSlider(stableOptions);
|
||||
});
|
||||
|
||||
// Cleanup on unmount.
|
||||
useEffect(() => () => slider.destroy(), [slider]);
|
||||
|
||||
// Subscribe to interaction state.
|
||||
const interaction = useSnapshot(slider.interaction);
|
||||
|
||||
// Compute derived state from interaction + caller-provided projection.
|
||||
const state = options.computeState(interaction);
|
||||
|
||||
// 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);
|
||||
|
||||
// Ref callbacks for root and thumb elements.
|
||||
const rootRef = useCallback((element: HTMLElement | null) => {
|
||||
rootElementRef.current = element;
|
||||
}, []);
|
||||
|
||||
const thumbRef = useCallback((element: HTMLElement | null) => {
|
||||
thumbElementRef.current = element;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
cssVars,
|
||||
rootRef,
|
||||
thumbRef,
|
||||
rootProps: slider.rootProps,
|
||||
thumbProps: slider.thumbProps,
|
||||
};
|
||||
}
|
||||
|
||||
export namespace useSlider {
|
||||
export type Options = UseSliderOptions;
|
||||
export type ReturnValue = UseSliderReturnValue;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { SliderBuffer as Buffer, type SliderBufferProps as BufferProps } from './slider-buffer';
|
||||
export { SliderFill as Fill, type SliderFillProps as FillProps } from './slider-fill';
|
||||
export { SliderRoot as Root, type SliderRootProps as RootProps } from './slider-root';
|
||||
export { SliderThumb as Thumb, type SliderThumbProps as ThumbProps } from './slider-thumb';
|
||||
export { SliderTrack as Track, type SliderTrackProps as TrackProps } from './slider-track';
|
||||
export { SliderValue as Value, type SliderValueProps as ValueProps } from './slider-value';
|
||||
@@ -0,0 +1 @@
|
||||
export * as Slider from './index.parts';
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import type { SliderState } from '@videojs/core';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import type { UIComponentProps } from '../../utils/types';
|
||||
import { renderElement } from '../../utils/use-render';
|
||||
import { useSliderContext } from './slider-context';
|
||||
|
||||
export interface SliderBufferProps extends UIComponentProps<'div', SliderState> {}
|
||||
|
||||
export const SliderBuffer = forwardRef(function SliderBuffer(
|
||||
componentProps: SliderBufferProps,
|
||||
forwardedRef: ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
const { render, className, style, ...elementProps } = componentProps;
|
||||
|
||||
const context = useSliderContext();
|
||||
if (!context) return null;
|
||||
|
||||
return renderElement(
|
||||
'div',
|
||||
{ render, className, style },
|
||||
{
|
||||
state: context.state,
|
||||
stateAttrMap: context.stateAttrMap,
|
||||
ref: forwardedRef,
|
||||
props: [elementProps],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export namespace SliderBuffer {
|
||||
export type Props = SliderBufferProps;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import type { SliderState, StateAttrMap } from '@videojs/core';
|
||||
import type { SliderThumbProps } from '@videojs/core/dom';
|
||||
import type { RefCallback } from 'react';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface SliderContextValue {
|
||||
state: SliderState;
|
||||
/** Pointer position converted to the value domain (not 0–100 percent). */
|
||||
pointerValue: number;
|
||||
thumbRef: RefCallback<HTMLElement>;
|
||||
thumbProps: SliderThumbProps;
|
||||
stateAttrMap: StateAttrMap<SliderState>;
|
||||
getAttrs: (state: SliderState) => object;
|
||||
formatValue?: ((value: number, type: 'current' | 'pointer') => string) | undefined;
|
||||
}
|
||||
|
||||
const SliderContext = createContext<SliderContextValue | undefined>(undefined);
|
||||
|
||||
export function SliderProvider({ value, children }: { value: SliderContextValue; children: React.ReactNode }) {
|
||||
return <SliderContext.Provider value={value}>{children}</SliderContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSliderContext(): SliderContextValue | undefined {
|
||||
return useContext(SliderContext);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import type { SliderState } from '@videojs/core';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import type { UIComponentProps } from '../../utils/types';
|
||||
import { renderElement } from '../../utils/use-render';
|
||||
import { useSliderContext } from './slider-context';
|
||||
|
||||
export interface SliderFillProps extends UIComponentProps<'div', SliderState> {}
|
||||
|
||||
export const SliderFill = forwardRef(function SliderFill(
|
||||
componentProps: SliderFillProps,
|
||||
forwardedRef: ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
const { render, className, style, ...elementProps } = componentProps;
|
||||
|
||||
const context = useSliderContext();
|
||||
if (!context) return null;
|
||||
|
||||
return renderElement(
|
||||
'div',
|
||||
{ render, className, style },
|
||||
{
|
||||
state: context.state,
|
||||
stateAttrMap: context.stateAttrMap,
|
||||
ref: forwardedRef,
|
||||
props: [elementProps],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export namespace SliderFill {
|
||||
export type Props = SliderFillProps;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { SliderCore, SliderDataAttrs } from '@videojs/core';
|
||||
import { getSliderCSSVars } from '@videojs/core/dom';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef, useState } from 'react';
|
||||
|
||||
import type { UIComponentProps } from '../../utils/types';
|
||||
import { renderElement } from '../../utils/use-render';
|
||||
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;
|
||||
onDragEnd?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
export const SliderRoot = forwardRef(function SliderRoot(
|
||||
componentProps: SliderRootProps,
|
||||
forwardedRef: ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
const {
|
||||
render,
|
||||
className,
|
||||
style,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
largeStep,
|
||||
orientation,
|
||||
disabled,
|
||||
thumbAlignment,
|
||||
value = 0,
|
||||
onValueChange,
|
||||
onValueCommit,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
...elementProps
|
||||
} = componentProps;
|
||||
|
||||
const [core] = useState(() => new SliderCore());
|
||||
core.setProps({ min, max, step, largeStep, orientation, disabled, thumbAlignment });
|
||||
|
||||
const { min: resolvedMin, max: resolvedMax, step: resolvedStep, largeStep: resolvedLargeStep } = core.props;
|
||||
const range = resolvedMax - resolvedMin || 1;
|
||||
|
||||
const {
|
||||
state,
|
||||
cssVars,
|
||||
rootRef,
|
||||
thumbRef: sliderThumbRef,
|
||||
rootProps,
|
||||
thumbProps,
|
||||
} = useSlider({
|
||||
computeState: (interaction) => core.getState(interaction, value),
|
||||
getPercent: () => core.percentFromValue(value),
|
||||
getStepPercent: () => (resolvedStep / range) * 100,
|
||||
getLargeStepPercent: () => (resolvedLargeStep / range) * 100,
|
||||
orientation,
|
||||
disabled,
|
||||
adjustPercent: (rawPercent, thumbSize, trackSize) =>
|
||||
core.adjustPercentForAlignment(rawPercent, thumbSize, trackSize),
|
||||
getCSSVars: getSliderCSSVars,
|
||||
onValueChange: (percent) => onValueChange?.(core.valueFromPercent(percent)),
|
||||
onValueCommit: (percent) => onValueCommit?.(core.valueFromPercent(percent)),
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
});
|
||||
|
||||
return (
|
||||
<SliderProvider
|
||||
value={{
|
||||
state,
|
||||
pointerValue: core.valueFromPercent(state.pointerPercent),
|
||||
thumbRef: sliderThumbRef,
|
||||
thumbProps,
|
||||
stateAttrMap: SliderDataAttrs,
|
||||
getAttrs: (sliderState) => core.getAttrs(sliderState),
|
||||
formatValue: undefined,
|
||||
}}
|
||||
>
|
||||
{renderElement(
|
||||
'div',
|
||||
{ render, className, style },
|
||||
{
|
||||
state,
|
||||
stateAttrMap: SliderDataAttrs,
|
||||
ref: [forwardedRef, rootRef],
|
||||
props: [{ style: cssVars }, rootProps, elementProps],
|
||||
}
|
||||
)}
|
||||
</SliderProvider>
|
||||
);
|
||||
});
|
||||
|
||||
export namespace SliderRoot {
|
||||
export type Props = SliderRootProps;
|
||||
export type State = SliderCore.State;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import type { SliderState } from '@videojs/core';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import type { UIComponentProps } from '../../utils/types';
|
||||
import { renderElement } from '../../utils/use-render';
|
||||
import { useSliderContext } from './slider-context';
|
||||
|
||||
export interface SliderThumbProps extends UIComponentProps<'div', SliderState> {}
|
||||
|
||||
export const SliderThumb = forwardRef(function SliderThumb(
|
||||
componentProps: SliderThumbProps,
|
||||
forwardedRef: ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
const { render, className, style, ...elementProps } = componentProps;
|
||||
|
||||
const context = useSliderContext();
|
||||
if (!context) return null;
|
||||
|
||||
const { state, thumbRef, thumbProps, getAttrs } = context;
|
||||
const attrs = getAttrs(state);
|
||||
|
||||
return renderElement(
|
||||
'div',
|
||||
{ render, className, style },
|
||||
{
|
||||
state,
|
||||
stateAttrMap: context.stateAttrMap,
|
||||
ref: [forwardedRef, thumbRef],
|
||||
props: [attrs, thumbProps, elementProps],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export namespace SliderThumb {
|
||||
export type Props = SliderThumbProps;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import type { SliderState } from '@videojs/core';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import type { UIComponentProps } from '../../utils/types';
|
||||
import { renderElement } from '../../utils/use-render';
|
||||
import { useSliderContext } from './slider-context';
|
||||
|
||||
export interface SliderTrackProps extends UIComponentProps<'div', SliderState> {}
|
||||
|
||||
export const SliderTrack = forwardRef(function SliderTrack(
|
||||
componentProps: SliderTrackProps,
|
||||
forwardedRef: ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
const { render, className, style, ...elementProps } = componentProps;
|
||||
|
||||
const context = useSliderContext();
|
||||
if (!context) return null;
|
||||
|
||||
return renderElement(
|
||||
'div',
|
||||
{ render, className, style },
|
||||
{
|
||||
state: context.state,
|
||||
stateAttrMap: context.stateAttrMap,
|
||||
ref: forwardedRef,
|
||||
props: [elementProps],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export namespace SliderTrack {
|
||||
export type Props = SliderTrackProps;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import type { SliderState } from '@videojs/core';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import type { UIComponentProps } from '../../utils/types';
|
||||
import { renderElement } from '../../utils/use-render';
|
||||
import { useSliderContext } from './slider-context';
|
||||
|
||||
export interface SliderValueProps extends UIComponentProps<'output', SliderState> {
|
||||
type?: 'current' | 'pointer' | undefined;
|
||||
format?: ((value: number) => string) | undefined;
|
||||
}
|
||||
|
||||
export const SliderValue = forwardRef(function SliderValue(
|
||||
componentProps: SliderValueProps,
|
||||
forwardedRef: ForwardedRef<HTMLOutputElement>
|
||||
) {
|
||||
const { render, className, style, type = 'current', format, ...elementProps } = componentProps;
|
||||
|
||||
const context = useSliderContext();
|
||||
if (!context) return null;
|
||||
|
||||
const { state, pointerValue, formatValue } = context;
|
||||
|
||||
const rawValue = type === 'pointer' ? pointerValue : state.value;
|
||||
|
||||
const text = format ? format(rawValue) : formatValue ? formatValue(rawValue, type) : String(Math.round(rawValue));
|
||||
|
||||
return renderElement(
|
||||
'output',
|
||||
{ render, className, style },
|
||||
{
|
||||
state,
|
||||
stateAttrMap: context.stateAttrMap,
|
||||
ref: forwardedRef,
|
||||
props: [{ 'aria-live': 'off', children: text }, elementProps],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export namespace SliderValue {
|
||||
export type Props = SliderValueProps;
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import { createRef } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SliderBuffer } from '../slider-buffer';
|
||||
import { SliderFill } from '../slider-fill';
|
||||
import { SliderRoot } from '../slider-root';
|
||||
import { SliderThumb } from '../slider-thumb';
|
||||
import { SliderTrack } from '../slider-track';
|
||||
import { SliderValue } from '../slider-value';
|
||||
|
||||
const { mockSliderHandle } = vi.hoisted(() => ({
|
||||
mockSliderHandle: () => ({
|
||||
interaction: {
|
||||
current: {
|
||||
pointerPercent: 0,
|
||||
dragPercent: 0,
|
||||
dragging: false,
|
||||
pointing: false,
|
||||
focused: false,
|
||||
},
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
},
|
||||
rootProps: {
|
||||
onPointerDown: vi.fn(),
|
||||
onPointerMove: vi.fn(),
|
||||
onPointerLeave: vi.fn(),
|
||||
},
|
||||
thumbProps: {
|
||||
onKeyDown: vi.fn(),
|
||||
onFocus: vi.fn(),
|
||||
onBlur: vi.fn(),
|
||||
},
|
||||
destroy: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@videojs/core/dom', async (importOriginal) => {
|
||||
const orig: Record<string, unknown> = await importOriginal();
|
||||
return { ...orig, createSlider: vi.fn(mockSliderHandle) };
|
||||
});
|
||||
|
||||
vi.mock('@videojs/store/react', () => ({
|
||||
useSnapshot: vi.fn((state: { current: unknown }) => state.current),
|
||||
useStore: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('SliderRoot', () => {
|
||||
it('renders a div element', () => {
|
||||
const { container } = render(<SliderRoot />);
|
||||
const el = container.firstElementChild;
|
||||
|
||||
expect(el).toBeTruthy();
|
||||
expect(el?.tagName).toBe('DIV');
|
||||
});
|
||||
|
||||
it('forwards ref to the root element', () => {
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
render(<SliderRoot ref={ref} />);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
|
||||
it('spreads additional props onto the root element', () => {
|
||||
const { container } = render(<SliderRoot data-testid="slider" />);
|
||||
const el = container.firstElementChild;
|
||||
|
||||
expect(el?.getAttribute('data-testid')).toBe('slider');
|
||||
});
|
||||
|
||||
it('sets data-orientation attribute', () => {
|
||||
const { container } = render(<SliderRoot orientation="horizontal" />);
|
||||
const el = container.firstElementChild;
|
||||
|
||||
expect(el?.getAttribute('data-orientation')).toBe('horizontal');
|
||||
});
|
||||
|
||||
it('sets CSS custom properties as inline styles', () => {
|
||||
const { container } = render(<SliderRoot value={50} />);
|
||||
const el = container.firstElementChild as HTMLElement;
|
||||
|
||||
expect(el.style.getPropertyValue('--media-slider-fill')).toBeTruthy();
|
||||
expect(el.style.getPropertyValue('--media-slider-pointer')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SliderTrack', () => {
|
||||
it('renders inside SliderRoot context', () => {
|
||||
const { container } = render(
|
||||
<SliderRoot>
|
||||
<SliderTrack data-testid="track" />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="track"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns null outside of SliderRoot', () => {
|
||||
const { container } = render(<SliderTrack />);
|
||||
|
||||
expect(container.firstElementChild).toBeNull();
|
||||
});
|
||||
|
||||
it('forwards ref', () => {
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
render(
|
||||
<SliderRoot>
|
||||
<SliderTrack ref={ref} />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SliderFill', () => {
|
||||
it('renders inside SliderRoot context', () => {
|
||||
const { container } = render(
|
||||
<SliderRoot>
|
||||
<SliderFill data-testid="fill" />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="fill"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns null outside of SliderRoot', () => {
|
||||
const { container } = render(<SliderFill />);
|
||||
|
||||
expect(container.firstElementChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SliderBuffer', () => {
|
||||
it('renders inside SliderRoot context', () => {
|
||||
const { container } = render(
|
||||
<SliderRoot>
|
||||
<SliderBuffer data-testid="buffer" />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="buffer"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns null outside of SliderRoot', () => {
|
||||
const { container } = render(<SliderBuffer />);
|
||||
|
||||
expect(container.firstElementChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SliderThumb', () => {
|
||||
it('renders inside SliderRoot context', () => {
|
||||
const { container } = render(
|
||||
<SliderRoot>
|
||||
<SliderThumb data-testid="thumb" />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="thumb"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns null outside of SliderRoot', () => {
|
||||
const { container } = render(<SliderThumb />);
|
||||
|
||||
expect(container.firstElementChild).toBeNull();
|
||||
});
|
||||
|
||||
it('forwards ref', () => {
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
render(
|
||||
<SliderRoot>
|
||||
<SliderThumb ref={ref} />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
|
||||
it('applies ARIA attributes from core', () => {
|
||||
const { container } = render(
|
||||
<SliderRoot>
|
||||
<SliderThumb data-testid="thumb" />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
const thumb = container.querySelector('[data-testid="thumb"]');
|
||||
expect(thumb?.getAttribute('role')).toBe('slider');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SliderValue', () => {
|
||||
it('renders an output element', () => {
|
||||
const { container } = render(
|
||||
<SliderRoot>
|
||||
<SliderValue data-testid="value" />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
const el = container.querySelector('[data-testid="value"]');
|
||||
expect(el?.tagName).toBe('OUTPUT');
|
||||
});
|
||||
|
||||
it('returns null outside of SliderRoot', () => {
|
||||
const { container } = render(<SliderValue />);
|
||||
|
||||
expect(container.firstElementChild).toBeNull();
|
||||
});
|
||||
|
||||
it('displays rounded value by default', () => {
|
||||
const { container } = render(
|
||||
<SliderRoot value={42}>
|
||||
<SliderValue />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
const output = container.querySelector('output');
|
||||
expect(output?.textContent).toBe('42');
|
||||
});
|
||||
|
||||
it('accepts a custom format function', () => {
|
||||
const format = (v: number) => `${v}%`;
|
||||
const { container } = render(
|
||||
<SliderRoot value={75}>
|
||||
<SliderValue format={format} />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
const output = container.querySelector('output');
|
||||
expect(output?.textContent).toBe('75%');
|
||||
});
|
||||
|
||||
it('sets aria-live to off', () => {
|
||||
const { container } = render(
|
||||
<SliderRoot>
|
||||
<SliderValue />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
const output = container.querySelector('output');
|
||||
expect(output?.getAttribute('aria-live')).toBe('off');
|
||||
});
|
||||
});
|
||||
|
||||
describe('thumbAlignment', () => {
|
||||
it('does not adjust CSS vars for center alignment (default)', () => {
|
||||
const { container } = render(<SliderRoot value={0} />);
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
|
||||
expect(root.style.getPropertyValue('--media-slider-fill')).toBe('0.000%');
|
||||
});
|
||||
|
||||
it('adjusts CSS vars for edge alignment', () => {
|
||||
const { container, rerender } = render(
|
||||
<SliderRoot value={0} thumbAlignment="edge">
|
||||
<SliderThumb />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
const thumb = root.querySelector('[role="slider"]') as HTMLElement;
|
||||
|
||||
// Mock DOM measurements (jsdom reports 0 for all dimensions).
|
||||
Object.defineProperty(root, 'offsetWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(thumb, 'offsetWidth', { value: 20, configurable: true });
|
||||
|
||||
// Re-render so the root reads the now-available element measurements.
|
||||
rerender(
|
||||
<SliderRoot value={0} thumbAlignment="edge">
|
||||
<SliderThumb />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
// thumbHalf = (20/200 * 100) / 2 = 5%. Adjusted 0% → 5%.
|
||||
expect(root.style.getPropertyValue('--media-slider-fill')).toBe('5.000%');
|
||||
});
|
||||
|
||||
it('adjusts CSS vars at max value for edge alignment', () => {
|
||||
const { container, rerender } = render(
|
||||
<SliderRoot value={100} thumbAlignment="edge">
|
||||
<SliderThumb />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
const thumb = root.querySelector('[role="slider"]') as HTMLElement;
|
||||
|
||||
Object.defineProperty(root, 'offsetWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(thumb, 'offsetWidth', { value: 20, configurable: true });
|
||||
|
||||
rerender(
|
||||
<SliderRoot value={100} thumbAlignment="edge">
|
||||
<SliderThumb />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
// thumbHalf = 5%. Adjusted 100% → 95%.
|
||||
expect(root.style.getPropertyValue('--media-slider-fill')).toBe('95.000%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Slider compound', () => {
|
||||
it('renders all parts together', () => {
|
||||
const { container } = render(
|
||||
<SliderRoot data-testid="root">
|
||||
<SliderTrack data-testid="track">
|
||||
<SliderFill data-testid="fill" />
|
||||
<SliderBuffer data-testid="buffer" />
|
||||
<SliderThumb data-testid="thumb" />
|
||||
</SliderTrack>
|
||||
<SliderValue data-testid="value" />
|
||||
</SliderRoot>
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="root"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="track"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="fill"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="buffer"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="thumb"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="value"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user