feat(react): add slider preview component (#710)

This commit is contained in:
rahim
2026-03-04 19:38:56 -08:00
committed by GitHub
parent a6405e9278
commit db7569711e
7 changed files with 308 additions and 1 deletions
@@ -15,3 +15,24 @@ export function getTimeSliderCSSVars(state: TimeSliderState): Record<string, str
[SliderCSSVars.buffer]: `${state.bufferPercent.toFixed(3)}%`,
};
}
// ---------------------------------------------------------------------------
// Slider Preview
// ---------------------------------------------------------------------------
export type SliderPreviewOverflow = 'clamp' | 'visible';
/** Compute structural positioning styles for a slider preview element. */
export function getSliderPreviewStyle(width: number, overflow: SliderPreviewOverflow) {
const halfWidth = width / 2;
return {
position: 'absolute',
left:
overflow === 'visible'
? `calc(var(${SliderCSSVars.pointer}) - ${halfWidth}px)`
: `min(max(0px, calc(var(${SliderCSSVars.pointer}) - ${halfWidth}px)), calc(100% - ${width}px))`,
width: 'max-content',
pointerEvents: 'none',
};
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { createSliderState, createTimeSliderState } from '../../tests/test-helpers';
import { getSliderCSSVars, getTimeSliderCSSVars } from '../slider-css-vars';
import { getSliderCSSVars, getSliderPreviewStyle, getTimeSliderCSSVars } from '../slider-css-vars';
describe('getSliderCSSVars', () => {
it('returns fill and pointer CSS vars with 3-decimal precision', () => {
@@ -55,3 +55,36 @@ describe('getTimeSliderCSSVars', () => {
expect(vars['--media-slider-buffer']).toBe('33.333%');
});
});
describe('getSliderPreviewStyle', () => {
it('returns structural positioning properties', () => {
const style = getSliderPreviewStyle(100, 'clamp');
expect(style.position).toBe('absolute');
expect(style.width).toBe('max-content');
expect(style.pointerEvents).toBe('none');
});
it('clamps left within slider bounds by default', () => {
const style = getSliderPreviewStyle(100, 'clamp');
expect(style.left).toContain('min(');
expect(style.left).toContain('max(');
expect(style.left).toContain('var(--media-slider-pointer)');
expect(style.left).toContain('50px');
expect(style.left).toContain('100px');
});
it('uses unclamped calc when overflow is visible', () => {
const style = getSliderPreviewStyle(100, 'visible');
expect(style.left).toBe('calc(var(--media-slider-pointer) - 50px)');
expect(style.left).not.toContain('min(');
});
it('handles zero width', () => {
const style = getSliderPreviewStyle(0, 'clamp');
expect(style.left).toContain('0px');
});
});
@@ -1,5 +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 { SliderPreview as Preview, type SliderPreviewProps as PreviewProps } from './slider-preview';
export { SliderRoot as Root, type SliderRootProps as RootProps } from './slider-root';
export { SliderThumb as Thumb, type SliderThumbProps as ThumbProps } from './slider-thumb';
export { SliderThumbnail as Thumbnail, type SliderThumbnailProps as ThumbnailProps } from './slider-thumbnail';
@@ -0,0 +1,59 @@
'use client';
import type { SliderState } from '@videojs/core';
import type { SliderPreviewOverflow } from '@videojs/core/dom';
import { getSliderPreviewStyle } from '@videojs/core/dom';
import type { ForwardedRef } from 'react';
import { forwardRef, useEffect, useRef, useState } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useSliderContext } from './context';
export interface SliderPreviewProps extends UIComponentProps<'div', SliderState> {
/** How the preview handles the slider boundaries. `'clamp'` keeps the preview within bounds, `'visible'` allows it to extend beyond the edges. */
overflow?: SliderPreviewOverflow | undefined;
}
/** Positioning container for preview content that tracks the pointer along the slider. */
export const SliderPreview = forwardRef(function SliderPreview(
componentProps: SliderPreviewProps,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
const { render, className, style, overflow = 'clamp', ...elementProps } = componentProps;
const context = useSliderContext();
const { state } = context;
const measureRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = measureRef.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) => {
setWidth(entry!.contentRect.width);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const positionStyle = getSliderPreviewStyle(width, overflow);
return renderElement(
'div',
{ render, className, style },
{
state,
stateAttrMap: context.stateAttrMap,
ref: [forwardedRef, measureRef],
props: [{ style: positionStyle }, elementProps],
}
);
});
export namespace SliderPreview {
export type Props = SliderPreviewProps;
}
@@ -0,0 +1,189 @@
import { cleanup, render } from '@testing-library/react';
import { createRef } from 'react';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { SliderPreview } from '../slider-preview';
import { SliderRoot } from '../slider-root';
// jsdom doesn't provide ResizeObserver.
beforeAll(() => {
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof globalThis.ResizeObserver;
});
const { mockSliderApi } = vi.hoisted(() => ({
mockSliderApi: () => ({
input: {
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(mockSliderApi) };
});
vi.mock('@videojs/store/react', () => ({
useSnapshot: vi.fn((state: { current: unknown }) => state.current),
useStore: vi.fn(),
}));
afterEach(cleanup);
describe('SliderPreview', () => {
it('renders a div element inside SliderRoot context', () => {
const { container } = render(
<SliderRoot>
<SliderPreview data-testid="preview" />
</SliderRoot>
);
const el = container.querySelector('[data-testid="preview"]');
expect(el).toBeTruthy();
expect(el?.tagName).toBe('DIV');
});
it('throws outside of SliderRoot', () => {
expect(() => render(<SliderPreview />)).toThrow('Slider compound components must be used within a Slider.Root');
});
it('forwards ref', () => {
const ref = createRef<HTMLDivElement>();
render(
<SliderRoot>
<SliderPreview ref={ref} />
</SliderRoot>
);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
});
it('sets structural positioning styles', () => {
const { container } = render(
<SliderRoot>
<SliderPreview data-testid="preview" />
</SliderRoot>
);
const el = container.querySelector('[data-testid="preview"]') as HTMLElement;
expect(el.style.position).toBe('absolute');
expect(el.style.pointerEvents).toBe('none');
expect(el.style.width).toBe('max-content');
});
it('applies clamped left style by default', () => {
const { container } = render(
<SliderRoot>
<SliderPreview data-testid="preview" />
</SliderRoot>
);
const el = container.querySelector('[data-testid="preview"]') as HTMLElement;
// Before ResizeObserver fires, width is 0 so halfWidth is 0
expect(el.style.left).toContain('min(');
expect(el.style.left).toContain('max(');
});
it('applies unclamped left style when overflow is visible', () => {
const { container } = render(
<SliderRoot>
<SliderPreview data-testid="preview" overflow="visible" />
</SliderRoot>
);
const el = container.querySelector('[data-testid="preview"]') as HTMLElement;
expect(el.style.left).toContain('calc(var(--media-slider-pointer)');
expect(el.style.left).not.toContain('min(');
});
it('propagates data attributes from slider state', () => {
const { container } = render(
<SliderRoot orientation="horizontal">
<SliderPreview data-testid="preview" />
</SliderRoot>
);
const el = container.querySelector('[data-testid="preview"]');
expect(el?.getAttribute('data-orientation')).toBe('horizontal');
});
it('spreads additional props onto the element', () => {
const { container } = render(
<SliderRoot>
<SliderPreview aria-label="Preview" />
</SliderRoot>
);
const el = container.querySelector('[aria-label="Preview"]');
expect(el).toBeTruthy();
});
it('renders children', () => {
const { container } = render(
<SliderRoot>
<SliderPreview>
<span data-testid="child">Preview content</span>
</SliderPreview>
</SliderRoot>
);
expect(container.querySelector('[data-testid="child"]')).toBeTruthy();
});
it('accepts className as a function of state', () => {
const { container } = render(
<SliderRoot>
<SliderPreview data-testid="preview" className={(state) => (state.interactive ? 'active' : 'idle')} />
</SliderRoot>
);
const el = container.querySelector('[data-testid="preview"]');
expect(el?.className).toContain('idle');
});
it('accepts style as a function of state', () => {
const { container } = render(
<SliderRoot>
<SliderPreview data-testid="preview" style={() => ({ opacity: 0.5 })} />
</SliderRoot>
);
const el = container.querySelector('[data-testid="preview"]') as HTMLElement;
expect(el.style.opacity).toBe('0.5');
});
it('renders within compound slider with all parts', () => {
const { container } = render(
<SliderRoot data-testid="root">
<SliderPreview data-testid="preview">
<span>Time value</span>
</SliderPreview>
</SliderRoot>
);
expect(container.querySelector('[data-testid="root"]')).toBeTruthy();
expect(container.querySelector('[data-testid="preview"]')).toBeTruthy();
});
});
@@ -3,6 +3,8 @@ export {
type BufferProps,
Fill,
type FillProps,
Preview,
type PreviewProps,
Thumb,
type ThumbProps,
Track,
@@ -1,6 +1,8 @@
export {
Fill,
type FillProps,
Preview,
type PreviewProps,
Thumb,
type ThumbProps,
Track,