From a6405e9278bc6e627cc1186f2af227941a1e55c0 Mon Sep 17 00:00:00 2001 From: rahim Date: Wed, 4 Mar 2026 15:35:35 -0800 Subject: [PATCH] feat(react): add slider thumbnail component (#722) --- packages/react/src/index.ts | 1 + packages/react/src/ui/slider/index.parts.ts | 1 + .../react/src/ui/slider/slider-thumbnail.tsx | 21 +++ .../ui/slider/tests/slider-thumbnail.test.tsx | 163 ++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 packages/react/src/ui/slider/slider-thumbnail.tsx create mode 100644 packages/react/src/ui/slider/tests/slider-thumbnail.test.tsx diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index de905942..befab9e8 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -47,6 +47,7 @@ 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 { SliderThumbnailProps } from './ui/slider/slider-thumbnail'; export type { SliderTrackProps } from './ui/slider/slider-track'; export type { SliderValueProps } from './ui/slider/slider-value'; export { Thumbnail, type ThumbnailProps } from './ui/thumbnail/thumbnail'; diff --git a/packages/react/src/ui/slider/index.parts.ts b/packages/react/src/ui/slider/index.parts.ts index 2a3a8458..291a316b 100644 --- a/packages/react/src/ui/slider/index.parts.ts +++ b/packages/react/src/ui/slider/index.parts.ts @@ -2,5 +2,6 @@ export { SliderBuffer as Buffer, type SliderBufferProps as BufferProps } from '. 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 { SliderThumbnail as Thumbnail, type SliderThumbnailProps as ThumbnailProps } from './slider-thumbnail'; export { SliderTrack as Track, type SliderTrackProps as TrackProps } from './slider-track'; export { SliderValue as Value, type SliderValueProps as ValueProps } from './slider-value'; diff --git a/packages/react/src/ui/slider/slider-thumbnail.tsx b/packages/react/src/ui/slider/slider-thumbnail.tsx new file mode 100644 index 00000000..da38a4d6 --- /dev/null +++ b/packages/react/src/ui/slider/slider-thumbnail.tsx @@ -0,0 +1,21 @@ +'use client'; + +import type { ThumbnailCore } from '@videojs/core'; +import { forwardRef } from 'react'; + +import { Thumbnail, type ThumbnailProps } from '../thumbnail/thumbnail'; +import { useSliderContext } from './context'; + +export interface SliderThumbnailProps extends Omit {} + +export const SliderThumbnail = forwardRef( + function SliderThumbnail(componentProps, forwardedRef) { + const { pointerValue } = useSliderContext(); + return ; + } +); + +export namespace SliderThumbnail { + export type Props = SliderThumbnailProps; + export type State = ThumbnailCore.State; +} diff --git a/packages/react/src/ui/slider/tests/slider-thumbnail.test.tsx b/packages/react/src/ui/slider/tests/slider-thumbnail.test.tsx new file mode 100644 index 00000000..7b3d6375 --- /dev/null +++ b/packages/react/src/ui/slider/tests/slider-thumbnail.test.tsx @@ -0,0 +1,163 @@ +import { cleanup, render } from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { SliderRoot } from '../slider-root'; +import { SliderThumbnail } from '../slider-thumbnail'; + +const { mockSliderApi, mockThumbnailApi } = vi.hoisted(() => ({ + mockSliderApi: () => ({ + input: { + current: { + pointerPercent: 50, + dragPercent: 0, + dragging: false, + pointing: true, + 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(), + }), + mockThumbnailApi: () => ({ + loading: false, + error: false, + naturalWidth: 0, + naturalHeight: 0, + readConstraints: vi.fn(() => ({ + minWidth: 0, + maxWidth: Infinity, + minHeight: 0, + maxHeight: Infinity, + })), + updateSrc: vi.fn(), + connect: vi.fn(), + destroy: vi.fn(), + }), +})); + +vi.mock('@videojs/core/dom', async (importOriginal) => { + const orig: Record = await importOriginal(); + return { + ...orig, + createSlider: vi.fn(mockSliderApi), + createThumbnail: vi.fn(mockThumbnailApi), + }; +}); + +vi.mock('@videojs/store/react', () => ({ + useSnapshot: vi.fn((state: { current: unknown }) => state.current), + useStore: vi.fn(), +})); + +afterEach(cleanup); + +describe('SliderThumbnail', () => { + it('renders inside SliderRoot context', () => { + const { container } = render( + + + + ); + + expect(container.querySelector('[data-testid="thumbnail"]')).toBeTruthy(); + }); + + 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('renders a div with thumbnail ARIA attributes', () => { + const { container } = render( + + + + ); + + const el = container.querySelector('[data-testid="thumbnail"]'); + expect(el?.tagName).toBe('DIV'); + expect(el?.getAttribute('role')).toBe('img'); + expect(el?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('applies data-hidden when no thumbnails are available', () => { + const { container } = render( + + + + ); + + const el = container.querySelector('[data-testid="thumbnail"]'); + expect(el?.hasAttribute('data-hidden')).toBe(true); + }); + + it('renders an img child element', () => { + const { container } = render( + + + + ); + + const el = container.querySelector('[data-testid="thumbnail"]'); + const img = el?.querySelector('img'); + expect(img).toBeTruthy(); + expect(img?.getAttribute('aria-hidden')).toBe('true'); + expect(img?.getAttribute('decoding')).toBe('async'); + }); + + it('accepts thumbnails prop', () => { + const thumbnails = [ + { url: 'thumb-0.jpg', startTime: 0 }, + { url: 'thumb-5.jpg', startTime: 5 }, + ]; + + const { container } = render( + + + + ); + + const el = container.querySelector('[data-testid="thumbnail"]'); + const img = el?.querySelector('img'); + + // pointerPercent is 50 → pointerValue = 50 (generic slider, min=0, max=100). + // findActiveThumbnail(thumbnails, 50) → 'thumb-5.jpg' (startTime 5 ≤ 50). + expect(img?.getAttribute('src')).toBe('thumb-5.jpg'); + }); + + it('forwards crossOrigin to inner img', () => { + const { container } = render( + + + + ); + + const img = container.querySelector('[data-testid="thumbnail"] img'); + expect(img?.getAttribute('crossorigin')).toBe('anonymous'); + }); +});