feat(react): add slider component (#644)

This commit is contained in:
rahim
2026-02-28 00:48:51 -08:00
committed by GitHub
parent e63d591d6e
commit 2f8ca094ad
21 changed files with 1276 additions and 12 deletions
+8
View File
@@ -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), {
+46
View File
@@ -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>;
},
};
}
+131
View File
@@ -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 0100 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';
+1
View File
@@ -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 0100 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();
});
});