diff --git a/packages/core/src/core/ui/thumbnail/core.ts b/packages/core/src/core/ui/thumbnail/core.ts index 302adaa2b7..12b64eee85 100644 --- a/packages/core/src/core/ui/thumbnail/core.ts +++ b/packages/core/src/core/ui/thumbnail/core.ts @@ -1,4 +1,5 @@ import { findLastAtOrBefore } from '@videojs/utils/array'; +import { isNull, isUndefined } from '@videojs/utils/predicate'; import type { ThumbnailConstraints, @@ -12,6 +13,11 @@ import type { export interface ThumbnailProps { /** Time in seconds to display the thumbnail for. */ time?: number | undefined; + /** Pre-parsed thumbnail images — bypasses the automatic `` detection. */ + thumbnails?: ThumbnailImage[] | undefined; +} + +export interface ThumbnailImageProps { /** * CORS setting forwarded to the inner ``. * @@ -127,6 +133,24 @@ export class ThumbnailCore { }; } + /** + * Resolve the CORS mode the image should request with. + * + * `null` opts out and drops the attribute. Any other explicit value wins, including `''`, which the CORS-settings + * attribute reads as Anonymous. Otherwise the inherited mode applies, which renderers supply only for + * ``-sourced thumbnails since a list set directly may point at a host unrelated to the media element. + */ + resolveCrossOrigin( + explicit: ThumbnailCrossOrigin | undefined, + inherited: ThumbnailCrossOrigin | undefined + ): Exclude | undefined { + if (isNull(explicit)) return undefined; + + if (!isUndefined(explicit)) return explicit; + + return inherited ?? undefined; + } + getState(loading: boolean, error: boolean, thumbnail: ThumbnailImage | undefined): ThumbnailState { return { loading, @@ -146,6 +170,8 @@ export class ThumbnailCore { } export namespace ThumbnailCore { - export type Props = ThumbnailProps; + export type Props = ThumbnailProps & ThumbnailImageProps; + export type RootProps = ThumbnailProps; + export type ImageProps = ThumbnailImageProps; export type State = ThumbnailState; } diff --git a/packages/core/src/core/ui/thumbnail/tests/core.test.ts b/packages/core/src/core/ui/thumbnail/tests/core.test.ts index 4d8cd1450e..9b7b749c6c 100644 --- a/packages/core/src/core/ui/thumbnail/tests/core.test.ts +++ b/packages/core/src/core/ui/thumbnail/tests/core.test.ts @@ -402,6 +402,40 @@ describe('ThumbnailCore', () => { }); }); + describe('resolveCrossOrigin', () => { + it('inherits when unset', () => { + const core = new ThumbnailCore(); + + expect(core.resolveCrossOrigin(undefined, 'anonymous')).toBe('anonymous'); + expect(core.resolveCrossOrigin(undefined, 'use-credentials')).toBe('use-credentials'); + }); + + it('returns nothing when there is nothing to inherit', () => { + const core = new ThumbnailCore(); + + expect(core.resolveCrossOrigin(undefined, undefined)).toBeUndefined(); + expect(core.resolveCrossOrigin(undefined, null)).toBeUndefined(); + }); + + it('prefers an explicit value over the inherited one', () => { + const core = new ThumbnailCore(); + + expect(core.resolveCrossOrigin('anonymous', 'use-credentials')).toBe('anonymous'); + }); + + it('opts out of inheritance for an explicit null', () => { + const core = new ThumbnailCore(); + + expect(core.resolveCrossOrigin(null, 'anonymous')).toBeUndefined(); + }); + + it('passes an empty value through rather than opting out', () => { + const core = new ThumbnailCore(); + + expect(core.resolveCrossOrigin('', 'use-credentials')).toBe(''); + }); + }); + describe('getState', () => { it('returns loading state', () => { const core = new ThumbnailCore(); diff --git a/packages/core/src/dom/ui/tests/thumbnail.test.ts b/packages/core/src/dom/ui/tests/thumbnail.test.ts index 56d46625bc..9761459cc9 100644 --- a/packages/core/src/dom/ui/tests/thumbnail.test.ts +++ b/packages/core/src/dom/ui/tests/thumbnail.test.ts @@ -187,6 +187,35 @@ describe('createThumbnail', () => { handle.destroy(); }); + it('moves event handling to a replacement image', () => { + const first = createMockImg(); + const second = createMockImg(); + const onStateChange = vi.fn(); + let img = first; + + const handle = createThumbnail( + createOptions({ + getImg: () => img, + onStateChange, + }) + ); + + handle.updateSrc('sprite.jpg'); + + img = second; + handle.connect(); + onStateChange.mockClear(); + + first.dispatchEvent(new Event('error')); + expect(onStateChange).not.toHaveBeenCalled(); + + second.dispatchEvent(new Event('load')); + expect(handle.loading).toBe(false); + expect(onStateChange).toHaveBeenCalledOnce(); + + handle.destroy(); + }); + it('sets error on img error', () => { const img = createMockImg(); const onStateChange = vi.fn(); @@ -472,6 +501,28 @@ describe('createThumbnail', () => { handle.destroy(); }); + + it('stays quiet when a settled image is handed back after a ref swap', () => { + const img = createMockImg(); + const onStateChange = vi.fn(); + + Object.defineProperty(img, 'complete', { value: true, configurable: true }); + + const handle = createThumbnail(createOptions({ getImg: () => img, onStateChange })); + + handle.updateSrc('sprite.jpg'); + handle.connect(); + expect(onStateChange).toHaveBeenCalledOnce(); + + // React detaches and reattaches the same node when a callback ref changes identity. + handle.disconnectImg(img); + handle.connect(); + + expect(handle.loading).toBe(false); + expect(onStateChange).toHaveBeenCalledOnce(); + + handle.destroy(); + }); }); describe('destroy', () => { diff --git a/packages/core/src/dom/ui/thumbnail.ts b/packages/core/src/dom/ui/thumbnail.ts index 352a94e268..fd74f77996 100644 --- a/packages/core/src/dom/ui/thumbnail.ts +++ b/packages/core/src/dom/ui/thumbnail.ts @@ -17,21 +17,22 @@ export interface ThumbnailApi { readConstraints(): ThumbnailConstraints; updateSrc(url: string | undefined): void; connect(): void; + disconnectImg(img: HTMLImageElement): void; destroy(): void; } export function createThumbnail(options: CreateThumbnailOptions): ThumbnailApi { const { getContainer, getImg, onStateChange } = options; const core = new ThumbnailCore(); - const abort = new AbortController(); - const signal = abort.signal; let loading = false; let error = false; let naturalWidth = 0; let naturalHeight = 0; let lastSrc = ''; - let imgBound = false; + let boundImg: HTMLImageElement | null = null; + let checkedImg: HTMLImageElement | null = null; + let stopListeningToImg: AbortController | null = null; let stopObservingResize: (() => void) | null = null; // Sprite sheets that have already failed, so re-entering one does not restart the loading state. @@ -67,20 +68,25 @@ export function createThumbnail(options: CreateThumbnailOptions): ThumbnailApi { } function bindImg(img: HTMLImageElement): void { - listen(img, 'load', onImgLoad, { signal }); - listen(img, 'error', onImgError, { signal }); + stopListeningToImg = new AbortController(); + + listen(img, 'load', onImgLoad, { signal: stopListeningToImg.signal }); + listen(img, 'error', onImgError, { signal: stopListeningToImg.signal }); } // --- Lazy binding --- function ensureBindings(): void { - if (!imgBound) { - const img = getImg(); + const img = getImg(); + const imageChanged = img !== boundImg; - if (img) { - bindImg(img); - imgBound = true; - } + if (imageChanged) { + stopListeningToImg?.abort(); + stopListeningToImg = null; + boundImg = img; + checkedImg = null; + + if (img) bindImg(img); } if (!stopObservingResize) { @@ -126,23 +132,48 @@ export function createThumbnail(options: CreateThumbnailOptions): ThumbnailApi { // Handle the case where the img already loaded or errored before listeners // were bound (e.g., cached image in React where mount happens before useEffect). const img = getImg(); + if (!img || img === checkedImg) return; - if (img?.complete && lastSrc) { - if (img.naturalWidth > 0) { - naturalWidth = img.naturalWidth; - naturalHeight = img.naturalHeight; - loading = false; - error = false; - } else { - markFailed(); - } + checkedImg = img; - onStateChange(); + if (!img.complete || !lastSrc) return; + + const previous = { loading, error, naturalWidth, naturalHeight }; + + if (img.naturalWidth > 0) { + naturalWidth = img.naturalWidth; + naturalHeight = img.naturalHeight; + loading = false; + error = false; + } else { + markFailed(); } + + // A renderer may hand the same settled image back after a ref swap. Announcing an + // unchanged state would schedule another render, whose ref swap lands right back here. + const changed = + previous.loading !== loading || + previous.error !== error || + previous.naturalWidth !== naturalWidth || + previous.naturalHeight !== naturalHeight; + + if (changed) onStateChange(); + } + + function disconnectImg(img: HTMLImageElement): void { + if (img !== boundImg) return; + + stopListeningToImg?.abort(); + stopListeningToImg = null; + boundImg = null; + checkedImg = null; } function destroy(): void { - abort.abort(); + stopListeningToImg?.abort(); + stopListeningToImg = null; + boundImg = null; + checkedImg = null; stopObservingResize?.(); stopObservingResize = null; } @@ -170,6 +201,7 @@ export function createThumbnail(options: CreateThumbnailOptions): ThumbnailApi { updateSrc, connect, + disconnectImg, destroy, }; } diff --git a/packages/html/src/ui/thumbnail/thumbnail-element.ts b/packages/html/src/ui/thumbnail/thumbnail-element.ts index 53b729630e..bfbdae42ea 100644 --- a/packages/html/src/ui/thumbnail/thumbnail-element.ts +++ b/packages/html/src/ui/thumbnail/thumbnail-element.ts @@ -9,7 +9,6 @@ import type { ThumbnailApi } from '@videojs/core/dom'; import { applyElementProps, applyStateDataAttrs, createThumbnail, selectTextTrack } from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import type { MediaTextTrackState } from '@videojs/media'; -import { isNull, isUndefined } from '@videojs/utils/predicate'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; @@ -32,7 +31,7 @@ export class ThumbnailElement extends UIElement { crossOrigin: { type: String, attribute: 'crossorigin' }, loading: { type: String }, fetchPriority: { type: String, attribute: 'fetchpriority' }, - } satisfies PropertyDeclarationMap; + } satisfies PropertyDeclarationMap>; time = 0; crossOrigin: ThumbnailCore.Props['crossOrigin']; @@ -119,7 +118,7 @@ export class ThumbnailElement extends UIElement { // Sync img attributes from element properties. applyElementProps(this.#img, { - crossorigin: this.#resolveCrossOrigin(textTrack), + crossorigin: this.#core.resolveCrossOrigin(this.crossOrigin, this.#inheritedCrossOrigin(textTrack)), loading: this.loading, fetchpriority: this.fetchPriority, }); @@ -161,21 +160,11 @@ export class ThumbnailElement extends UIElement { /** * Leaving `crossOrigin` unset means "follow the media element", so thumbnails keep working on a CORS-enabled player - * without a skin having to thread an attribute through. `null` opts out and fetches the sprites no-CORS, which is - * also what removing the attribute produces. A bare `crossorigin` is passed straight through, since the CORS-settings - * attribute reads it as Anonymous. - * - * Only the `` path inherits: `thumbnails` set directly may point at a host that has nothing to do with the - * media element. + * without a skin having to thread an attribute through. Only the `` path inherits: `thumbnails` set directly + * may point at a host that has nothing to do with the media element. */ - #resolveCrossOrigin(textTrack: MediaTextTrackState | undefined): string | undefined { - if (isNull(this.crossOrigin)) return undefined; - - if (!isUndefined(this.crossOrigin)) return this.crossOrigin; - - if (this.#externalThumbnails) return undefined; - - return textTrack?.thumbnailTrackCrossOrigin ?? undefined; + #inheritedCrossOrigin(textTrack: MediaTextTrackState | undefined): ThumbnailCore.Props['crossOrigin'] { + return this.#externalThumbnails ? undefined : textTrack?.thumbnailTrackCrossOrigin; } #applyResize(result: ThumbnailResizeResult): void { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 40b5c895d2..4b942bd33c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -148,7 +148,9 @@ export { StatusAnnouncer, type StatusAnnouncerProps } from './ui/status-announce export { StatusIndicator } from './ui/status-indicator'; export type { StatusIndicatorRootProps } from './ui/status-indicator/status-indicator-root'; export type { StatusIndicatorValueProps } from './ui/status-indicator/status-indicator-value'; -export { Thumbnail, type ThumbnailProps } from './ui/thumbnail/thumbnail'; +export { Thumbnail } from './ui/thumbnail'; +export type { ThumbnailImageProps } from './ui/thumbnail/thumbnail-image'; +export type { ThumbnailRootProps } from './ui/thumbnail/thumbnail-root'; export { Time } from './ui/time'; export { TimeSlider } from './ui/time-slider'; export type { diff --git a/packages/react/src/ui/slider/index.parts.ts b/packages/react/src/ui/slider/index.parts.ts index cbd469ab81..372bec4fbf 100644 --- a/packages/react/src/ui/slider/index.parts.ts +++ b/packages/react/src/ui/slider/index.parts.ts @@ -3,6 +3,6 @@ export { SliderFill as Fill, type SliderFillProps as FillProps } from './slider- 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'; +export * as Thumbnail from './slider-thumbnail.parts'; 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-root.tsx b/packages/react/src/ui/slider/slider-thumbnail-root.tsx new file mode 100644 index 0000000000..5ca3bf7e10 --- /dev/null +++ b/packages/react/src/ui/slider/slider-thumbnail-root.tsx @@ -0,0 +1,22 @@ +import type { ThumbnailCore } from '@videojs/core'; +import type { ForwardedRef } from 'react'; +import { forwardRef } from 'react'; + +import { ThumbnailRoot, type ThumbnailRootProps } from '../thumbnail/thumbnail-root'; +import { useSliderPointerValue } from './context'; + +export interface SliderThumbnailRootProps extends Omit {} + +export const SliderThumbnailRoot = forwardRef(function SliderThumbnailRoot( + componentProps: SliderThumbnailRootProps, + forwardedRef: ForwardedRef +) { + const pointerValue = useSliderPointerValue(); + + return ; +}); + +export namespace SliderThumbnailRoot { + export type Props = SliderThumbnailRootProps; + export type State = ThumbnailCore.State; +} diff --git a/packages/react/src/ui/slider/slider-thumbnail.parts.ts b/packages/react/src/ui/slider/slider-thumbnail.parts.ts new file mode 100644 index 0000000000..eef88f25ec --- /dev/null +++ b/packages/react/src/ui/slider/slider-thumbnail.parts.ts @@ -0,0 +1,2 @@ +export { ThumbnailImage as Image, type ThumbnailImageProps as ImageProps } from '../thumbnail/thumbnail-image'; +export { SliderThumbnailRoot as Root, type SliderThumbnailRootProps as RootProps } from './slider-thumbnail-root'; diff --git a/packages/react/src/ui/slider/slider-thumbnail.tsx b/packages/react/src/ui/slider/slider-thumbnail.tsx index 7d7708962e..e4e308dbd8 100644 --- a/packages/react/src/ui/slider/slider-thumbnail.tsx +++ b/packages/react/src/ui/slider/slider-thumbnail.tsx @@ -1,20 +1,4 @@ -import type { ThumbnailCore } from '@videojs/core'; -import { forwardRef } from 'react'; - -import { Thumbnail, type ThumbnailProps } from '../thumbnail/thumbnail'; -import { useSliderPointerValue } from './context'; - -export interface SliderThumbnailProps extends Omit {} - -export const SliderThumbnail = forwardRef( - function SliderThumbnail(componentProps, forwardedRef) { - const pointerValue = useSliderPointerValue(); - - return ; - } -); - -export namespace SliderThumbnail { - export type Props = SliderThumbnailProps; - export type State = ThumbnailCore.State; -} +export { + SliderThumbnailRoot as SliderThumbnail, + type SliderThumbnailRootProps as SliderThumbnailProps, +} from './slider-thumbnail-root'; diff --git a/packages/react/src/ui/slider/tests/slider-thumbnail.test.tsx b/packages/react/src/ui/slider/tests/slider-thumbnail.test.tsx index cf452e17b6..79e005e0d0 100644 --- a/packages/react/src/ui/slider/tests/slider-thumbnail.test.tsx +++ b/packages/react/src/ui/slider/tests/slider-thumbnail.test.tsx @@ -2,8 +2,8 @@ import { cleanup, render } from '@testing-library/react'; import { createRef } from 'react'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import * as Slider from '../index.parts'; import { SliderRoot } from '../slider-root'; -import { SliderThumbnail } from '../slider-thumbnail'; const { mockSliderApi, mockThumbnailApi } = vi.hoisted(() => ({ mockSliderApi: () => ({ @@ -43,6 +43,7 @@ const { mockSliderApi, mockThumbnailApi } = vi.hoisted(() => ({ })), updateSrc: vi.fn(), connect: vi.fn(), + disconnectImg: vi.fn(), destroy: vi.fn(), }), })); @@ -64,11 +65,11 @@ vi.mock('@videojs/store/react', () => ({ afterEach(cleanup); -describe('SliderThumbnail', () => { +describe('Slider.Thumbnail', () => { it('renders inside SliderRoot context', () => { const { container } = render( - + ); @@ -76,7 +77,9 @@ describe('SliderThumbnail', () => { }); it('throws outside of SliderRoot', () => { - expect(() => render()).toThrow('Slider compound components must be used within a Slider.Root'); + expect(() => render()).toThrow( + 'Slider compound components must be used within a Slider.Root' + ); }); it('forwards ref', () => { @@ -84,7 +87,7 @@ describe('SliderThumbnail', () => { render( - + ); @@ -94,7 +97,7 @@ describe('SliderThumbnail', () => { it('renders a div with thumbnail ARIA attributes', () => { const { container } = render( - + ); @@ -108,7 +111,7 @@ describe('SliderThumbnail', () => { it('applies data-hidden when no thumbnails are available', () => { const { container } = render( - + ); @@ -117,19 +120,30 @@ describe('SliderThumbnail', () => { expect(el?.hasAttribute('data-hidden')).toBe(true); }); - it('renders an img child element', () => { + it('leaves image composition to the caller', () => { const { container } = render( - + + + ); + + expect(container.querySelector('[data-testid="thumbnail"] img')).toBeNull(); + }); + + it('renders sibling presentation layers beside the image', () => { + 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'); + expect(el?.querySelector('img')).toBeTruthy(); + expect(el?.querySelector('[data-testid="overlay"]')).toBeTruthy(); }); it('accepts thumbnails prop', () => { @@ -140,7 +154,9 @@ describe('SliderThumbnail', () => { const { container } = render( - + + + ); @@ -155,11 +171,9 @@ describe('SliderThumbnail', () => { it('forwards crossOrigin to inner img', () => { const { container } = render( - + + + ); @@ -167,4 +181,19 @@ describe('SliderThumbnail', () => { expect(img?.getAttribute('crossorigin')).toBe('anonymous'); }); + + it('renders a shared image part when composed explicitly', () => { + const { container } = render( + + + + + + ); + + const img = container.querySelector('[data-testid="thumbnail-image"]'); + + expect(img?.tagName).toBe('IMG'); + expect(container.querySelectorAll('[data-testid="thumbnail-image"]')).toHaveLength(1); + }); }); diff --git a/packages/react/src/ui/thumbnail/context.tsx b/packages/react/src/ui/thumbnail/context.tsx new file mode 100644 index 0000000000..25ee60d625 --- /dev/null +++ b/packages/react/src/ui/thumbnail/context.tsx @@ -0,0 +1,26 @@ +import type { ThumbnailCore, ThumbnailState } from '@videojs/core'; +import { createContext, type CSSProperties, type ProviderProps, type RefCallback, useContext } from 'react'; + +export interface ThumbnailContextValue { + core: ThumbnailCore; + state: ThumbnailState; + src: string | undefined; + imageStyle: CSSProperties | undefined; + /** CORS mode inherited from the media element, supplied only for ``-sourced thumbnails. */ + inheritedCrossOrigin: ThumbnailCore.ImageProps['crossOrigin']; + /** Attach to the image so the root can track its loading lifecycle. */ + imageRef: RefCallback; +} + +const ThumbnailContext = createContext(null); + +export function ThumbnailProvider({ value, children }: ProviderProps) { + return {children}; +} + +export function useThumbnailContext(): ThumbnailContextValue { + const ctx = useContext(ThumbnailContext); + if (!ctx) throw new Error('Thumbnail compound components must be used within a Thumbnail.Root'); + + return ctx; +} diff --git a/packages/react/src/ui/thumbnail/index.parts.ts b/packages/react/src/ui/thumbnail/index.parts.ts new file mode 100644 index 0000000000..b037ae3182 --- /dev/null +++ b/packages/react/src/ui/thumbnail/index.parts.ts @@ -0,0 +1,2 @@ +export { ThumbnailImage as Image, type ThumbnailImageProps as ImageProps } from './thumbnail-image'; +export { ThumbnailRoot as Root, type ThumbnailRootProps as RootProps } from './thumbnail-root'; diff --git a/packages/react/src/ui/thumbnail/index.ts b/packages/react/src/ui/thumbnail/index.ts index d4ab7a500a..44232ad7ee 100644 --- a/packages/react/src/ui/thumbnail/index.ts +++ b/packages/react/src/ui/thumbnail/index.ts @@ -1 +1 @@ -export * from './thumbnail'; +export * as Thumbnail from './index.parts'; diff --git a/packages/react/src/ui/thumbnail/tests/thumbnail.test.tsx b/packages/react/src/ui/thumbnail/tests/thumbnail.test.tsx index 5953d1c97d..a9e1c5b36f 100644 --- a/packages/react/src/ui/thumbnail/tests/thumbnail.test.tsx +++ b/packages/react/src/ui/thumbnail/tests/thumbnail.test.tsx @@ -1,21 +1,15 @@ import { cleanup, render } from '@testing-library/react'; import type { MediaTextTrackState } from '@videojs/media'; +import { createRef, type ReactNode } from 'react'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { Thumbnail } from '..'; import { createPlayerWrapper } from '../../../testing/mocks'; -import { Thumbnail, type ThumbnailProps } from '../thumbnail'; afterEach(cleanup); -/** - * Render a thumbnail inside a player reporting the given media CORS mode and return the `crossorigin` attribute its - * inner `` ends up with. - */ -function renderCrossOrigin( - thumbnailTrackCrossOrigin: MediaTextTrackState['thumbnailTrackCrossOrigin'], - props: ThumbnailProps = {} -): string | null { - const { Wrapper } = createPlayerWrapper({ +function wrapper(thumbnailTrackCrossOrigin: MediaTextTrackState['thumbnailTrackCrossOrigin'] = null) { + return createPlayerWrapper({ chaptersCues: [], thumbnailCues: [], thumbnailTrackSrc: null, @@ -24,14 +18,134 @@ function renderCrossOrigin( subtitlesShowing: false, toggleSubtitles: vi.fn(), selectSubtitlesTrack: vi.fn(), - }); + }).Wrapper; +} - const { container } = render(, { wrapper: Wrapper }); +function DefaultThumbnail({ + rootProps = {}, + imageProps = {}, + children, +}: { + rootProps?: Thumbnail.RootProps | undefined; + imageProps?: Thumbnail.ImageProps | undefined; + children?: ReactNode | undefined; +}) { + return ( + + + {children} + + ); +} + +/** Render a thumbnail and return the `crossorigin` attribute on its image. */ +function renderCrossOrigin( + thumbnailTrackCrossOrigin: MediaTextTrackState['thumbnailTrackCrossOrigin'], + imageProps: Thumbnail.ImageProps = {}, + rootProps: Thumbnail.RootProps = {} +): string | null { + const { container } = render(, { + wrapper: wrapper(thumbnailTrackCrossOrigin), + }); - return container.querySelector('[data-testid="thumbnail"] img')!.getAttribute('crossorigin'); + return container.querySelector('[data-testid="image"]')!.getAttribute('crossorigin'); } describe('Thumbnail', () => { + it('renders a root around the selected image', () => { + const { getByTestId } = render( + , + { wrapper: wrapper() } + ); + + expect(getByTestId('thumbnail').tagName).toBe('DIV'); + expect(getByTestId('thumbnail').getAttribute('role')).toBe('img'); + expect(getByTestId('thumbnail').getAttribute('aria-hidden')).toBe('true'); + expect(getByTestId('image').tagName).toBe('IMG'); + expect(getByTestId('image').getAttribute('src')).toBe('thumbnail.jpg'); + expect(getByTestId('image').getAttribute('decoding')).toBe('async'); + }); + + it('reports state on the root', () => { + const { getByTestId } = render(, { wrapper: wrapper() }); + + expect(getByTestId('thumbnail').hasAttribute('data-hidden')).toBe(true); + expect(getByTestId('image').hasAttribute('data-hidden')).toBe(false); + }); + + it('accepts sibling presentation layers', () => { + const { getByTestId } = render( + +
+ , + { wrapper: wrapper() } + ); + + expect(getByTestId('thumbnail').contains(getByTestId('overlay'))).toBe(true); + }); + + it('forwards root and image refs', () => { + const rootRef = createRef(); + const imgRef = createRef(); + + render( + + + , + { wrapper: wrapper() } + ); + + expect(rootRef.current).toBeInstanceOf(HTMLDivElement); + expect(imgRef.current).toBeInstanceOf(HTMLImageElement); + }); + + it('supports an image render override without replacing the root', () => { + const { getByTestId } = render( + + } /> + , + { wrapper: wrapper() } + ); + + expect(getByTestId('thumbnail').tagName).toBe('DIV'); + expect(getByTestId('custom-image').getAttribute('src')).toBe('thumbnail.jpg'); + }); + + it('requires the image to be inside a root', () => { + expect(() => render()).toThrow( + 'Thumbnail compound components must be used within a Thumbnail.Root' + ); + }); + + it('settles a cached image once, even when a render override swaps refs every render', () => { + const complete = vi.spyOn(HTMLImageElement.prototype, 'complete', 'get').mockReturnValue(true); + const naturalWidth = vi.spyOn(HTMLImageElement.prototype, 'naturalWidth', 'get').mockReturnValue(1280); + const naturalHeight = vi.spyOn(HTMLImageElement.prototype, 'naturalHeight', 'get').mockReturnValue(720); + const thumbnails = [{ url: 'thumbnail.jpg', startTime: 0 }]; + + // An inline ref on the rendered element composes into a new callback each + // render, so React detaches and reattaches the same image every time. + const { getByTestId, rerender } = render( + + void node} data-testid="image" />} /> + , + { wrapper: wrapper() } + ); + + rerender( + + void node} data-testid="image" />} /> + + ); + + expect(getByTestId('thumbnail').hasAttribute('data-loading')).toBe(false); + expect(getByTestId('image').getAttribute('src')).toBe('thumbnail.jpg'); + + complete.mockRestore(); + naturalWidth.mockRestore(); + naturalHeight.mockRestore(); + }); + describe('crossOrigin', () => { it('inherits the media element CORS mode when unset', () => { expect(renderCrossOrigin('anonymous')).toBe('anonymous'); @@ -51,17 +165,15 @@ describe('Thumbnail', () => { }); it('passes an empty crossOrigin through rather than opting out', () => { - // The CORS-settings attribute reads an empty value as Anonymous, so it is - // a value like any other and must not be mistaken for "no CORS". expect(renderCrossOrigin('use-credentials', { crossOrigin: '' })).toBe(''); }); it('does not inherit for thumbnails supplied directly', () => { - // Images passed as a prop may live anywhere, so they carry no - // relationship to the media element's CORS mode. - const attribute = renderCrossOrigin('anonymous', { - thumbnails: [{ url: 'https://images.example.com/sprite.jpg', startTime: 0 }], - }); + const attribute = renderCrossOrigin( + 'anonymous', + {}, + { thumbnails: [{ url: 'https://images.example.com/sprite.jpg', startTime: 0 }] } + ); expect(attribute).toBeNull(); }); diff --git a/packages/react/src/ui/thumbnail/thumbnail-image.tsx b/packages/react/src/ui/thumbnail/thumbnail-image.tsx new file mode 100644 index 0000000000..7cfcb744d6 --- /dev/null +++ b/packages/react/src/ui/thumbnail/thumbnail-image.tsx @@ -0,0 +1,63 @@ +import type { ThumbnailCore, ThumbnailFetchPriority } from '@videojs/core'; +import type { ForwardedRef } from 'react'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { useComposedRefs } from '../../utils/use-composed-refs'; +import { renderElement } from '../../utils/use-render'; +import { useThumbnailContext } from './context'; + +export interface ThumbnailImageProps extends Omit< + UIComponentProps<'img', ThumbnailCore.State>, + 'crossOrigin' | 'fetchPriority' | 'loading' | 'src' +> { + /** CORS setting for the selected image. Leave unset to follow the media element, or pass `null` to opt out. */ + crossOrigin?: ThumbnailCore.ImageProps['crossOrigin']; + /** Image loading strategy. */ + loading?: ThumbnailCore.ImageProps['loading']; + /** Image fetch priority hint. */ + fetchPriority?: ThumbnailCore.ImageProps['fetchPriority']; +} + +/** + * Displays the image selected and measured by `Thumbnail.Root`. + * + * Renders an `img`, so native image attributes and the `render` escape hatch remain available without replacing the + * root that owns thumbnail state. + */ +export const ThumbnailImage = forwardRef(function ThumbnailImage( + componentProps: ThumbnailImageProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, crossOrigin, loading, fetchPriority, ...elementProps } = componentProps; + const { core, state, src, imageStyle, inheritedCrossOrigin, imageRef } = useThumbnailContext(); + + // One stable callback, so React does not detach and reattach the image on every render + // and the root only hears about mounts, unmounts, and `render` swaps. + const ref = useComposedRefs(forwardedRef, imageRef); + + return renderElement( + 'img', + { render, className, style }, + { + state, + ref, + props: [ + { alt: '', 'aria-hidden': 'true', decoding: 'async' }, + elementProps, + { + src, + crossOrigin: core.resolveCrossOrigin(crossOrigin, inheritedCrossOrigin), + loading, + style: imageStyle, + // SAFETY: The core and React types contain the same fetch-priority literals; React alone omits `undefined`. + fetchPriority: fetchPriority as ThumbnailFetchPriority, + }, + ], + } + ); +}); + +export namespace ThumbnailImage { + export type Props = ThumbnailImageProps; +} diff --git a/packages/react/src/ui/thumbnail/thumbnail-root.tsx b/packages/react/src/ui/thumbnail/thumbnail-root.tsx new file mode 100644 index 0000000000..d192c3dd62 --- /dev/null +++ b/packages/react/src/ui/thumbnail/thumbnail-root.tsx @@ -0,0 +1,123 @@ +import { mapCuesToThumbnails, ThumbnailCore, ThumbnailDataAttrs } from '@videojs/core'; +import { createThumbnail, selectTextTrack } from '@videojs/core/dom'; +import type { CSSProperties, ForwardedRef } from 'react'; +import { forwardRef, useCallback, useMemo, useRef, useState } from 'react'; + +import { useOptionalPlayer } from '../../player/context'; +import type { UIComponentProps } from '../../utils/types'; +import { useDestroy } from '../../utils/use-destroy'; +import { renderElement } from '../../utils/use-render'; +import { ThumbnailProvider } from './context'; + +export interface ThumbnailRootProps extends UIComponentProps<'div', ThumbnailCore.State>, ThumbnailCore.RootProps {} + +/** + * Resolves, sizes, and clips a thumbnail for a point in time. + * + * Renders a `div` and exposes `data-hidden`, `data-loading`, and `data-error` for styling every layer in the preview. + * Render `Thumbnail.Image` inside it for the image the root controls and measures. + */ +export const ThumbnailRoot = forwardRef(function ThumbnailRoot( + componentProps: ThumbnailRootProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, time = 0, thumbnails: externalThumbnails, ...elementProps } = componentProps; + + const [core] = useState(() => new ThumbnailCore()); + const divRef = useRef(null); + const imgRef = useRef(null); + const textTrack = useOptionalPlayer(selectTextTrack); + + // Force a render when the image loads, fails, or the root is resized. + const [, setRenderToken] = useState(0); + const [handle] = useState(() => + createThumbnail({ + getContainer: () => divRef.current, + getImg: () => imgRef.current, + onStateChange: () => setRenderToken((token) => token + 1), + }) + ); + + useDestroy(handle, () => handle.connect()); + + // The image reports itself through this ref, so a mount, unmount, or `render` swap rebinds without an effect. + const imageRef = useCallback( + (img: HTMLImageElement | null) => { + const previous = imgRef.current; + + imgRef.current = img; + + if (img) handle.connect(); + else if (previous) handle.disconnectImg(previous); + }, + [handle] + ); + + // A supplied list takes priority over automatic detection. + const thumbnails = useMemo(() => { + if (externalThumbnails && externalThumbnails.length > 0) return externalThumbnails; + + return textTrack && textTrack.thumbnailCues.length > 0 + ? mapCuesToThumbnails(textTrack.thumbnailCues, textTrack.thumbnailTrackSrc ?? undefined) + : []; + }, [externalThumbnails, textTrack]); + + const thumbnail = useMemo(() => core.findActiveThumbnail(thumbnails, time), [core, thumbnails, time]); + + handle.updateSrc(thumbnail?.url); + + const state = core.getState(handle.loading, handle.error, thumbnail); + + let containerStyle: CSSProperties = { overflow: 'hidden' }; + let imageStyle: CSSProperties | undefined; + + if (thumbnail && handle.naturalWidth && handle.naturalHeight) { + const constraints = handle.readConstraints(); + const result = core.resize(thumbnail, handle.naturalWidth, handle.naturalHeight, constraints); + + if (result) { + containerStyle = { + overflow: 'hidden', + width: result.containerWidth, + height: result.containerHeight, + }; + imageStyle = { + width: result.imageWidth, + height: result.imageHeight, + maxWidth: 'none', + transform: + result.offsetX || result.offsetY ? `translate(-${result.offsetX}px, -${result.offsetY}px)` : undefined, + }; + } + } + + return ( + `-sourced thumbnails follow the media element's CORS mode. + inheritedCrossOrigin: externalThumbnails?.length ? undefined : textTrack?.thumbnailTrackCrossOrigin, + imageRef, + }} + > + {renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap: ThumbnailDataAttrs, + ref: [forwardedRef, divRef], + props: [core.getAttrs(state), { style: containerStyle }, elementProps], + } + )} + + ); +}); + +export namespace ThumbnailRoot { + export type Props = ThumbnailRootProps; + export type State = ThumbnailCore.State; +} diff --git a/packages/react/src/ui/thumbnail/thumbnail.tsx b/packages/react/src/ui/thumbnail/thumbnail.tsx deleted file mode 100644 index a8ad46bb09..0000000000 --- a/packages/react/src/ui/thumbnail/thumbnail.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { - mapCuesToThumbnails, - ThumbnailCore, - ThumbnailDataAttrs, - type ThumbnailFetchPriority, - type ThumbnailImage, -} from '@videojs/core'; -import { createThumbnail, selectTextTrack } from '@videojs/core/dom'; -import type { MediaTextTrackState } from '@videojs/media'; -import { isNull, isUndefined } from '@videojs/utils/predicate'; -import type { CSSProperties } from 'react'; -import { forwardRef, useMemo, useRef, useState } from 'react'; - -import { useOptionalPlayer } from '../../player/context'; -import type { UIComponentProps } from '../../utils/types'; -import { useDestroy } from '../../utils/use-destroy'; -import { renderElement } from '../../utils/use-render'; - -export interface ThumbnailProps extends UIComponentProps<'div', ThumbnailCore.State>, ThumbnailCore.Props { - /** Pre-parsed thumbnail images — bypasses the automatic `` detection. */ - thumbnails?: ThumbnailImage[] | undefined; -} - -/** - * Leaving `crossOrigin` unset means "follow the media element", so thumbnails keep working on a CORS-enabled player - * without a skin having to thread a prop through. `null` opts out and fetches the sprites no-CORS. `''` is passed - * straight through, since the CORS-settings attribute reads it as Anonymous. - * - * Only the `` path inherits: `thumbnails` passed directly may point at a host that has nothing to do with the - * media element. - */ -function resolveCrossOrigin( - explicit: ThumbnailCore.Props['crossOrigin'], - external: ThumbnailImage[] | undefined, - inherited: MediaTextTrackState['thumbnailTrackCrossOrigin'] | undefined -) { - if (isNull(explicit)) return undefined; - - if (!isUndefined(explicit)) return explicit; - - if (external?.length) return undefined; - - return inherited ?? undefined; -} - -export const Thumbnail = forwardRef(function Thumbnail(componentProps, forwardedRef) { - const { - render, - className, - style, - time = 0, - thumbnails: externalThumbnails, - crossOrigin, - loading, - fetchPriority, - ...elementProps - } = componentProps; - - const [core] = useState(() => new ThumbnailCore()); - - const divRef = useRef(null); - const imgRef = useRef(null); - - const textTrack = useOptionalPlayer(selectTextTrack); - - // Force re-render when the handle's state changes (img load/error, resize). - const [, setRenderToken] = useState(0); - - const [handle] = useState(() => - createThumbnail({ - getContainer: () => divRef.current, - getImg: () => imgRef.current, - onStateChange: () => setRenderToken((n) => n + 1), - }) - ); - - useDestroy(handle, () => handle.connect()); - - // Resolve thumbnails: external prop takes priority over auto path. - const thumbnails = useMemo(() => { - if (externalThumbnails && externalThumbnails.length > 0) return externalThumbnails; - - return textTrack && textTrack.thumbnailCues.length > 0 - ? mapCuesToThumbnails(textTrack.thumbnailCues, textTrack.thumbnailTrackSrc ?? undefined) - : []; - }, [externalThumbnails, textTrack]); - - const thumbnail = useMemo(() => core.findActiveThumbnail(thumbnails, time), [core, thumbnails, time]); - - const resolvedCrossOrigin = resolveCrossOrigin(crossOrigin, externalThumbnails, textTrack?.thumbnailTrackCrossOrigin); - - // Track src changes via the handle. - handle.updateSrc(thumbnail?.url); - - const state = core.getState(handle.loading, handle.error, thumbnail); - - // Compute styles declaratively from resize result. - let containerStyle: CSSProperties = { overflow: 'hidden' }; - let imgStyle: CSSProperties | undefined; - - if (thumbnail && handle.naturalWidth && handle.naturalHeight) { - const constraints = handle.readConstraints(); - const result = core.resize(thumbnail, handle.naturalWidth, handle.naturalHeight, constraints); - - if (result) { - containerStyle = { - overflow: 'hidden', - width: result.containerWidth, - height: result.containerHeight, - }; - imgStyle = { - width: result.imageWidth, - height: result.imageHeight, - maxWidth: 'none', - transform: - result.offsetX || result.offsetY ? `translate(-${result.offsetX}px, -${result.offsetY}px)` : undefined, - }; - } - } - - return renderElement( - 'div', - { render, className, style }, - { - state, - stateAttrMap: ThumbnailDataAttrs, - ref: [forwardedRef, divRef], - props: [ - core.getAttrs(state), - { style: containerStyle }, - elementProps, - { - children: ( - - ), - }, - ], - } - ); -}); - -export namespace Thumbnail { - export type Props = ThumbnailProps; - export type State = ThumbnailCore.State; -} diff --git a/packages/skins/build/target/react.tsx b/packages/skins/build/target/react.tsx index d0c2a5b39c..7128037363 100644 --- a/packages/skins/build/target/react.tsx +++ b/packages/skins/build/target/react.tsx @@ -89,15 +89,24 @@ export const reactComponentTarget: ComponentTarget = defineComponent Poster: ({ props, children }) => , Slider: { Thumbnail: { - Root: Div, + Root: imported({ + from: '@videojs/react', + name: 'Slider', + path: ['Thumbnail', 'Root'], + props: { + from: '@videojs/react', + name: 'Slider', + path: ['Thumbnail', 'RootProps'], + }, + }), Image: imported({ from: '@videojs/react', name: 'Slider', - path: ['Thumbnail'], + path: ['Thumbnail', 'Image'], props: { from: '@videojs/react', name: 'Slider', - path: ['ThumbnailProps'], + path: ['Thumbnail', 'ImageProps'], }, }), }, diff --git a/packages/skins/src/gaps.md b/packages/skins/src/gaps.md index 15f6e62813..0daa767c76 100644 --- a/packages/skins/src/gaps.md +++ b/packages/skins/src/gaps.md @@ -30,6 +30,6 @@ These selectors currently preserve observable parity. Keep them as known ownersh ### Thumbnail loading ownership - Source: `e20e54255` / #2259 and `packages/skins/src/styles/sliders/thumbnail.styles.ts` -- Gap: No observable parity gap is known, but VJSC infers thumbnail loading from descendant image state with `has-*` and `group-has-*` selectors. Isolated transforms can emit these local selectors, though the styles remain coupled to rendered child markup. -- Affected: Default and Minimal skins; HTML and React targets; CSS and Tailwind outputs. -- Recommendation: Hold new anatomy until loading behavior or target markup needs to change. Then consider propagating loading state to the Thumbnail root or adding explicit image and spinner parts, with generated-output and matrix verification. +- Gap: No observable parity gap is known. React now reports `data-loading` on `Slider.Thumbnail.Root`, but the HTML root is still a plain wrapper around ``, so the styles keep `has-*` and `group-has-*` selectors beside the root-state variants until both targets share one anatomy. +- Affected: Default and Minimal skins; HTML target; CSS and Tailwind outputs. +- Recommendation: Once `` adopts a supplied `` child, map the HTML root to that element, drop the descendant selectors, and verify generated output for both targets. diff --git a/packages/skins/src/styles/sliders/thumbnail.styles.ts b/packages/skins/src/styles/sliders/thumbnail.styles.ts index cff740f496..f3c669281d 100644 --- a/packages/skins/src/styles/sliders/thumbnail.styles.ts +++ b/packages/skins/src/styles/sliders/thumbnail.styles.ts @@ -4,34 +4,43 @@ export default styles({ file: 'sliders.css', prefix: 'media-slider-thumbnail', rules: { + // The root is the box the thumbnail core measures, so it carries the size limits. React reports + // `data-loading` on `Slider.Thumbnail.Root` itself; the HTML root is still a plain wrapper around + // ``, which reports loading and measures itself, so the descendant + // selectors and the Shadow DOM image variant stay until that element becomes the root. root: { utilities: [ 'group/thumbnail pointer-events-none overflow-hidden rounded-media-popup bg-media-backdrop/90', 'bottom-[calc(100%+var(--media-slider-preview-offset))]', + 'max-h-(--media-slider-preview-max-height)', + 'data-loading:aspect-video data-loading:w-(--media-slider-preview-max-width)', 'has-[[data-loading]]:aspect-video has-[[data-loading]]:w-(--media-slider-preview-max-width)', ], variants: { - default: 'left-1/2', + // The gradient draws from the root: the React image part is a real ``, which has no `::after`. + default: [ + 'left-1/2', + 'after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-(image:--media-thumbnail-gradient)', + ], minimal: '[left:var(--media-preview-left,var(--media-slider-pointer))]', }, }, image: { utilities: [ - 'relative block max-h-(--media-slider-preview-max-height) max-w-(--media-slider-preview-max-width) overflow-clip rounded-[inherit]', - 'transition-opacity duration-media-base ease-out', - 'data-loading:opacity-0', + 'block transition-opacity duration-media-base ease-out', + 'group-data-loading/thumbnail:opacity-0 data-loading:opacity-0', ], variants: { - default: - 'after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-(image:--media-thumbnail-gradient)', + 'shadow-dom': + 'relative max-h-(--media-slider-preview-max-height) max-w-(--media-slider-preview-max-width) overflow-clip rounded-[inherit]', }, }, spinnerIcon: { utilities: [ - 'absolute top-1/2 left-1/2 size-media-icon -translate-x-1/2 -translate-y-1/2 opacity-0', + 'absolute top-1/2 left-1/2 z-10 size-media-icon -translate-x-1/2 -translate-y-1/2 opacity-0', 'transition-opacity duration-media-base ease-out', - 'group-not-has-[[role=img][data-loading]]/thumbnail:[--media-spinner-animation:none]', - 'group-has-[[role=img][data-loading]]/thumbnail:opacity-100', + 'group-not-data-loading/thumbnail:group-not-has-[[role=img][data-loading]]/thumbnail:[--media-spinner-animation:none]', + 'group-data-loading/thumbnail:opacity-100 group-has-[[role=img][data-loading]]/thumbnail:opacity-100', 'drop-shadow-media-icon', ], }, diff --git a/site/src/components/docs/demos/seek-preview/react/css/BasicUsage.tsx b/site/src/components/docs/demos/seek-preview/react/css/BasicUsage.tsx index 16b160f636..031108aa62 100644 --- a/site/src/components/docs/demos/seek-preview/react/css/BasicUsage.tsx +++ b/site/src/components/docs/demos/seek-preview/react/css/BasicUsage.tsx @@ -14,7 +14,9 @@ export default function BasicUsage() { - + + + diff --git a/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.css b/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.css index 30f3c07d3f..ea498a574e 100644 --- a/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.css +++ b/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.css @@ -18,6 +18,10 @@ max-width: 240px; } +.media-thumbnail-image { + display: block; +} + .media-thumbnail[data-hidden] { display: none; } diff --git a/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.tsx b/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.tsx index 7b48e83bf0..3577c154f8 100644 --- a/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.tsx +++ b/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.tsx @@ -17,7 +17,9 @@ export default function TextTrackUsage() { > - + + + ); diff --git a/site/src/components/docs/demos/thumbnail/react/css/JsonSpriteUsage.tsx b/site/src/components/docs/demos/thumbnail/react/css/JsonSpriteUsage.tsx index 58afe02a7e..01111d4cab 100644 --- a/site/src/components/docs/demos/thumbnail/react/css/JsonSpriteUsage.tsx +++ b/site/src/components/docs/demos/thumbnail/react/css/JsonSpriteUsage.tsx @@ -27,5 +27,9 @@ const THUMBNAILS = [ ]; export default function JsonSpriteUsage() { - return ; + return ( + + + + ); } diff --git a/site/src/components/docs/demos/thumbnail/react/css/JsonUsage.tsx b/site/src/components/docs/demos/thumbnail/react/css/JsonUsage.tsx index c7680992fe..4322a35b0e 100644 --- a/site/src/components/docs/demos/thumbnail/react/css/JsonUsage.tsx +++ b/site/src/components/docs/demos/thumbnail/react/css/JsonUsage.tsx @@ -18,5 +18,9 @@ const THUMBNAILS = [ ]; export default function JsonUsage() { - return ; + return ( + + + + ); } diff --git a/site/src/content/docs/reference/thumbnail.mdx b/site/src/content/docs/reference/thumbnail.mdx index d5561a98f7..b35ad0eac5 100644 --- a/site/src/content/docs/reference/thumbnail.mdx +++ b/site/src/content/docs/reference/thumbnail.mdx @@ -52,7 +52,9 @@ That track is cross-origin, and a cross-origin `` only loads when the med default /> - + + + ``` @@ -80,19 +82,21 @@ A same-origin track needs none of this. ```tsx - + + + ``` ```html - + ``` ## Behavior -`Thumbnail` resolves an image for the current `time`. +`Thumbnail.Root` resolves an image for the current `time`. `Thumbnail.Image` renders the selected image and reports its loading lifecycle to the root. Supported source formats: @@ -112,7 +116,9 @@ Opt out to fetch them without CORS: ```tsx - + + + ``` @@ -150,18 +156,36 @@ media-thumbnail[data-error] { -React renders a `
` element. Add a `className` to style it: +React renders a root `
` around an ``. State attributes belong to `Thumbnail.Root`, while image attributes and `render` belong to `Thumbnail.Image`: + +```tsx + + +
+ +``` + +The root clips to the selected tile while the image inside spans the whole sprite sheet, so anything after the image in flow lands past the clip edge. Position the root and lay overlays over it: ```css -.thumbnail[data-hidden] { +.media-thumbnail { + position: relative; +} + +.media-thumbnail-overlay { + position: absolute; + inset: 0; +} + +.media-thumbnail[data-hidden] { display: none; } -.thumbnail[data-loading] { +.media-thumbnail[data-loading] { opacity: 0.6; } -.thumbnail[data-error] { +.media-thumbnail[data-error] { outline: 1px solid #ef4444; } ``` @@ -169,7 +193,7 @@ React renders a `
` element. Add a `className` to style it: ## Accessibility -`Thumbnail` is decorative by default (`aria-hidden="true"`). It is intended for visual preview UX (for example, timeline hover previews) rather than primary accessible content. +`Thumbnail.Root` is decorative by default (`aria-hidden="true"`). It is intended for visual preview UX (for example, timeline hover previews) rather than primary accessible content. ## Examples