diff --git a/packages/core/src/dom/ui/slider-css-vars.ts b/packages/core/src/dom/ui/slider-css-vars.ts index 3a22526c..4ce8d2e7 100644 --- a/packages/core/src/dom/ui/slider-css-vars.ts +++ b/packages/core/src/dom/ui/slider-css-vars.ts @@ -15,3 +15,24 @@ export function getTimeSliderCSSVars(state: TimeSliderState): Record { 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'); + }); +}); diff --git a/packages/react/src/ui/slider/index.parts.ts b/packages/react/src/ui/slider/index.parts.ts index 291a316b..cbd469ab 100644 --- a/packages/react/src/ui/slider/index.parts.ts +++ b/packages/react/src/ui/slider/index.parts.ts @@ -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'; diff --git a/packages/react/src/ui/slider/slider-preview.tsx b/packages/react/src/ui/slider/slider-preview.tsx new file mode 100644 index 00000000..9a2188ab --- /dev/null +++ b/packages/react/src/ui/slider/slider-preview.tsx @@ -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 +) { + const { render, className, style, overflow = 'clamp', ...elementProps } = componentProps; + + const context = useSliderContext(); + const { state } = context; + + const measureRef = useRef(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; +} diff --git a/packages/react/src/ui/slider/tests/slider-preview.test.tsx b/packages/react/src/ui/slider/tests/slider-preview.test.tsx new file mode 100644 index 00000000..4c69915b --- /dev/null +++ b/packages/react/src/ui/slider/tests/slider-preview.test.tsx @@ -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 = 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( + + + + ); + + const el = container.querySelector('[data-testid="preview"]'); + expect(el).toBeTruthy(); + expect(el?.tagName).toBe('DIV'); + }); + + it('throws outside of SliderRoot', () => { + expect(() => render()).toThrow('Slider compound components must be used within a Slider.Root'); + }); + + it('forwards ref', () => { + const ref = createRef(); + render( + + + + ); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it('sets structural positioning styles', () => { + const { container } = render( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + const el = container.querySelector('[data-testid="preview"]'); + expect(el?.getAttribute('data-orientation')).toBe('horizontal'); + }); + + it('spreads additional props onto the element', () => { + const { container } = render( + + + + ); + + const el = container.querySelector('[aria-label="Preview"]'); + expect(el).toBeTruthy(); + }); + + it('renders children', () => { + const { container } = render( + + + Preview content + + + ); + + expect(container.querySelector('[data-testid="child"]')).toBeTruthy(); + }); + + it('accepts className as a function of state', () => { + const { container } = render( + + (state.interactive ? 'active' : 'idle')} /> + + ); + + const el = container.querySelector('[data-testid="preview"]'); + expect(el?.className).toContain('idle'); + }); + + it('accepts style as a function of state', () => { + const { container } = render( + + ({ opacity: 0.5 })} /> + + ); + + 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( + + + Time value + + + ); + + expect(container.querySelector('[data-testid="root"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="preview"]')).toBeTruthy(); + }); +}); diff --git a/packages/react/src/ui/time-slider/index.parts.ts b/packages/react/src/ui/time-slider/index.parts.ts index 5ea7bda3..cf3156a8 100644 --- a/packages/react/src/ui/time-slider/index.parts.ts +++ b/packages/react/src/ui/time-slider/index.parts.ts @@ -3,6 +3,8 @@ export { type BufferProps, Fill, type FillProps, + Preview, + type PreviewProps, Thumb, type ThumbProps, Track, diff --git a/packages/react/src/ui/volume-slider/index.parts.ts b/packages/react/src/ui/volume-slider/index.parts.ts index 62dc9cd9..91ba0851 100644 --- a/packages/react/src/ui/volume-slider/index.parts.ts +++ b/packages/react/src/ui/volume-slider/index.parts.ts @@ -1,6 +1,8 @@ export { Fill, type FillProps, + Preview, + type PreviewProps, Thumb, type ThumbProps, Track,