diff --git a/packages/core/src/core/ui/manifest.ts b/packages/core/src/core/ui/manifest.ts index a7f23336..287f6148 100644 --- a/packages/core/src/core/ui/manifest.ts +++ b/packages/core/src/core/ui/manifest.ts @@ -9,7 +9,11 @@ export interface ComponentPart { readonly [__PROPS_BRAND__]?: Props; } -export type ComponentPartRecord = Record>; +export interface ComponentPartGroup { + parts: Parts; +} + +export type ComponentPartRecord = Record | ComponentPartGroup>; export interface ComponentManifest { name: string; @@ -36,7 +40,12 @@ interface DefineComponentFactory { export type InferProps = T extends ComponentManifest ? Props : T extends ComponentPart ? Props : never; -export type InferParts = T extends ComponentGroupManifest ? keyof Parts : never; +export type InferParts = + T extends ComponentGroupManifest + ? keyof Parts + : T extends ComponentPartGroup + ? keyof Parts + : never; export type InferPartProps = T extends ComponentGroupManifest ? (K extends keyof Parts ? InferProps : never) : never; @@ -45,6 +54,12 @@ export function defineComponentPart(): Compon return {} as ComponentPart; } +export function defineComponentPartGroup( + parts: Parts +): ComponentPartGroup { + return { parts }; +} + /** Define a component manifest. */ export function defineComponent(): DefineComponentFactory { return ((manifest: ComponentDefinition | ComponentGroupDefinition) => diff --git a/packages/core/src/core/ui/slider/slider-component.ts b/packages/core/src/core/ui/slider/slider-component.ts index 5cf1dea3..b9c7ad37 100644 --- a/packages/core/src/core/ui/slider/slider-component.ts +++ b/packages/core/src/core/ui/slider/slider-component.ts @@ -1,4 +1,4 @@ -import { defineComponent, defineComponentPart } from '../manifest'; +import { defineComponent, defineComponentPart, defineComponentPartGroup } from '../manifest'; import type { SliderProps, SliderValueProps } from './props'; import { SliderDataAttrs } from './slider-data-attrs'; @@ -10,7 +10,10 @@ export default defineComponent()({ Fill: defineComponentPart(), Buffer: defineComponentPart(), Thumb: defineComponentPart(), - Thumbnail: defineComponentPart(), + Thumbnail: defineComponentPartGroup({ + Root: defineComponentPart(), + Image: defineComponentPart(), + }), Preview: defineComponentPart(), Value: defineComponentPart(), }, diff --git a/packages/core/src/core/ui/tests/jsx-runtime.test.ts b/packages/core/src/core/ui/tests/jsx-runtime.test.ts new file mode 100644 index 00000000..fe35b940 --- /dev/null +++ b/packages/core/src/core/ui/tests/jsx-runtime.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { createComponent } from '../../../jsx-runtime'; +import { defineComponent, defineComponentPart, defineComponentPartGroup } from '../manifest'; + +describe('createComponent', () => { + it('creates nested component parts with dotted part paths', () => { + const Slider = createComponent( + defineComponent()({ + name: 'Slider', + parts: { + Root: defineComponentPart(), + Thumbnail: defineComponentPartGroup({ + Root: defineComponentPart(), + Image: defineComponentPart(), + }), + }, + }) + ); + + expect(Slider.Root.$$component).toEqual({ name: 'Slider', part: 'Root' }); + expect(Slider.Thumbnail.Root.$$component).toEqual({ name: 'Slider', part: 'Thumbnail.Root' }); + expect(Slider.Thumbnail.Image.$$component).toEqual({ name: 'Slider', part: 'Thumbnail.Image' }); + }); +}); diff --git a/packages/core/src/jsx-runtime.ts b/packages/core/src/jsx-runtime.ts index 7a2acf5a..1d8f4a1a 100644 --- a/packages/core/src/jsx-runtime.ts +++ b/packages/core/src/jsx-runtime.ts @@ -1,8 +1,9 @@ import type { AnyComponentManifest, ComponentGroupManifest, - InferPartProps, - InferParts, + ComponentPart, + ComponentPartGroup, + ComponentPartRecord, InferProps, } from './core/ui/manifest'; @@ -40,18 +41,20 @@ export interface Component { readonly $$component: { name: string; part: string | null }; } -type PartComponentProps = K extends 'Root' - ? NonNullable> - : [NonNullable>] extends [never] - ? EmptyProps - : NonNullable>; +type InferPartNodeProps = Node extends ComponentPart ? Props : never; -type CompoundComponent = { - [K in InferParts & string]: Component>; +type PartComponentProps = [NonNullable>] extends [never] + ? EmptyProps + : NonNullable>; + +type CompoundComponent = { + [K in keyof Parts & string]: Parts[K] extends ComponentPartGroup + ? CompoundComponent + : Component>; }; export type CreateComponentResult = M extends ComponentGroupManifest - ? CompoundComponent + ? CompoundComponent : Component>; function createRuntimeComponentPart(name: string, part: string | null): Component { @@ -71,13 +74,21 @@ export function createComponent(manifest: M): Cr return createRuntimeComponentPart(manifest.name, null) as CreateComponentResult; } - const compound: Record> = {}; + return createComponentParts(manifest.name, manifest.parts) as CreateComponentResult; +} - for (const part of Object.keys(manifest.parts)) { - compound[part] = createRuntimeComponentPart(manifest.name, part); +function createComponentParts(name: string, parts: ComponentPartRecord, prefix = ''): Record { + const compound: Record = {}; + + for (const part of Object.keys(parts)) { + const path = prefix ? `${prefix}.${part}` : part; + const value = parts[part]!; + + compound[part] = + 'parts' in value ? createComponentParts(name, value.parts, path) : createRuntimeComponentPart(name, path); } - return compound as CreateComponentResult; + return compound; } function createNode(type: ComponentType, props: Record, key?: string | number | null): ComponentNode { diff --git a/packages/react/src/presets/tests/skins-compiler-config.test.ts b/packages/react/src/presets/tests/skins-compiler-config.test.ts index 475c9343..7c4469c4 100644 --- a/packages/react/src/presets/tests/skins-compiler-config.test.ts +++ b/packages/react/src/presets/tests/skins-compiler-config.test.ts @@ -29,10 +29,10 @@ describe('skins compiler config', () => { expect(code).toContain('from "@/ui/play-button"'); expect(code).toContain('from "@/ui/poster"'); expect(code).toContain('from "@/ui/seek-indicator"'); + expect(code).toContain('from "@/ui/slider"'); expect(code).toContain('from "@/ui/status-indicator"'); expect(code).toContain('from "@/ui/volume-indicator"'); expect(code).not.toContain('from "@/ui/text"'); - expect(code).not.toContain('from "@/ui/slider"'); expect(code).toContain('from "@/utils/use-render"'); expect(code).toContain('from "@videojs/utils/predicate"'); expect(code).toContain('from "@/icons"'); @@ -123,6 +123,10 @@ describe('skins compiler config', () => { expect(compactCode).toContain( compact('') ); + expect(compactCode).toContain(compact('')); + expect(compactCode).toContain(compact('')); + expect(compactCode).toContain(compact('')); + expect(compactCode).toContain(compact('')); expect(compactCode).toContain(compact('')); expect(compactCode).toContain(compact('')); expect(compactCode).not.toContain('SliderTrack'); diff --git a/packages/react/src/presets/video/default-video.generated.tailwind.tsx b/packages/react/src/presets/video/default-video.generated.tailwind.tsx index cf6e219a..07b21a88 100644 --- a/packages/react/src/presets/video/default-video.generated.tailwind.tsx +++ b/packages/react/src/presets/video/default-video.generated.tailwind.tsx @@ -21,6 +21,7 @@ import { poster as posterClass, seek, slider, + thumbnail, time, } from '@videojs/skins/default/tailwind/video.tailwind'; import { isString } from '@videojs/utils/predicate'; @@ -62,6 +63,7 @@ import { PlayButton } from '@/ui/play-button'; import { Poster } from '@/ui/poster'; import { SeekButton } from '@/ui/seek-button'; import { SeekIndicator } from '@/ui/seek-indicator'; +import { Slider } from '@/ui/slider'; import { StatusAnnouncer } from '@/ui/status-announcer'; import { StatusIndicator } from '@/ui/status-indicator'; import { Time } from '@/ui/time'; @@ -167,6 +169,11 @@ export function DefaultVideoSkin({ className, children, poster, ...rest }: Defau + + + + + 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 00000000..d50274d3 --- /dev/null +++ b/packages/react/src/ui/slider/slider-thumbnail-root.tsx @@ -0,0 +1,19 @@ +'use client'; + +import type { SliderState } from '@videojs/core'; + +import type { UIComponentProps } from '../../utils/types'; +import { createContextPart } from '../create-context-part'; +import { useSliderContext } from './context'; + +export interface SliderThumbnailRootProps extends UIComponentProps<'div', SliderState> {} + +export const SliderThumbnailRoot = createContextPart({ + displayName: 'SliderThumbnailRoot', + tag: 'div', + useContext: useSliderContext, +}); + +export namespace SliderThumbnailRoot { + export type Props = SliderThumbnailRootProps; +} diff --git a/packages/react/src/ui/slider/slider-thumbnail.tsx b/packages/react/src/ui/slider/slider-thumbnail.tsx index da38a4d6..5a406fdc 100644 --- a/packages/react/src/ui/slider/slider-thumbnail.tsx +++ b/packages/react/src/ui/slider/slider-thumbnail.tsx @@ -5,17 +5,25 @@ import { forwardRef } from 'react'; import { Thumbnail, type ThumbnailProps } from '../thumbnail/thumbnail'; import { useSliderContext } from './context'; +import { SliderThumbnailRoot, type SliderThumbnailRootProps } from './slider-thumbnail-root'; export interface SliderThumbnailProps extends Omit {} -export const SliderThumbnail = forwardRef( +const SliderThumbnailImage = forwardRef( function SliderThumbnail(componentProps, forwardedRef) { const { pointerValue } = useSliderContext(); return ; } ); +export const SliderThumbnail = Object.assign(SliderThumbnailImage, { + Root: SliderThumbnailRoot, + Image: SliderThumbnailImage, +}); + export namespace SliderThumbnail { export type Props = SliderThumbnailProps; + export type RootProps = SliderThumbnailRootProps; + export type ImageProps = 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 index f6c013ea..0f91800c 100644 --- a/packages/react/src/ui/slider/tests/slider-thumbnail.test.tsx +++ b/packages/react/src/ui/slider/tests/slider-thumbnail.test.tsx @@ -74,6 +74,19 @@ describe('SliderThumbnail', () => { expect(container.querySelector('[data-testid="thumbnail"]')).toBeTruthy(); }); + it('renders namespaced root and image parts', () => { + const { container } = render( + + + + + + ); + + expect(container.querySelector('[data-testid="thumbnail-root"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="thumbnail-image"]')).toBeTruthy(); + }); + it('throws outside of SliderRoot', () => { expect(() => render()).toThrow('Slider compound components must be used within a Slider.Root'); }); diff --git a/packages/skins/src/default/video.skin.tsx b/packages/skins/src/default/video.skin.tsx index 68bdb104..2b199444 100644 --- a/packages/skins/src/default/video.skin.tsx +++ b/packages/skins/src/default/video.skin.tsx @@ -14,6 +14,7 @@ import { Poster, SeekButton, SeekIndicator, + Slider, StatusAnnouncer, StatusIndicator, Text, @@ -64,6 +65,7 @@ import { poster as posterClass, seek, slider, + thumbnail, time, } from './tailwind/video.tailwind'; @@ -148,6 +150,11 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps) + + + + +