mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(react): add slider preview component (#710)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user