feat(core): support nested component parts

This commit is contained in:
Rahim
2026-06-24 13:07:47 -07:00
parent ddccbd89d4
commit 69d5afecdd
10 changed files with 132 additions and 20 deletions
+17 -2
View File
@@ -9,7 +9,11 @@ export interface ComponentPart<Props extends object = EmptyProps> {
readonly [__PROPS_BRAND__]?: Props;
}
export type ComponentPartRecord = Record<string, ComponentPart<any>>;
export interface ComponentPartGroup<Parts extends ComponentPartRecord = ComponentPartRecord> {
parts: Parts;
}
export type ComponentPartRecord = Record<string, ComponentPart<any> | ComponentPartGroup>;
export interface ComponentManifest<Props extends object = EmptyProps> {
name: string;
@@ -36,7 +40,12 @@ interface DefineComponentFactory<Props extends object> {
export type InferProps<T> =
T extends ComponentManifest<infer Props> ? Props : T extends ComponentPart<infer Props> ? Props : never;
export type InferParts<T> = T extends ComponentGroupManifest<infer Parts> ? keyof Parts : never;
export type InferParts<T> =
T extends ComponentGroupManifest<infer Parts>
? keyof Parts
: T extends ComponentPartGroup<infer Parts>
? keyof Parts
: never;
export type InferPartProps<T, K extends string> =
T extends ComponentGroupManifest<infer Parts> ? (K extends keyof Parts ? InferProps<Parts[K]> : never) : never;
@@ -45,6 +54,12 @@ export function defineComponentPart<Props extends object = EmptyProps>(): Compon
return {} as ComponentPart<Props>;
}
export function defineComponentPartGroup<const Parts extends ComponentPartRecord>(
parts: Parts
): ComponentPartGroup<Parts> {
return { parts };
}
/** Define a component manifest. */
export function defineComponent<Props extends object = EmptyProps>(): DefineComponentFactory<Props> {
return ((manifest: ComponentDefinition<Props> | ComponentGroupDefinition<ComponentPartRecord>) =>
@@ -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<SliderValueProps>(),
},
@@ -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' });
});
});
+25 -14
View File
@@ -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<Props extends object> {
readonly $$component: { name: string; part: string | null };
}
type PartComponentProps<M, K extends string> = K extends 'Root'
? NonNullable<InferPartProps<M, K>>
: [NonNullable<InferPartProps<M, K>>] extends [never]
? EmptyProps
: NonNullable<InferPartProps<M, K>>;
type InferPartNodeProps<Node> = Node extends ComponentPart<infer Props> ? Props : never;
type CompoundComponent<M> = {
[K in InferParts<M> & string]: Component<PartComponentProps<M, K>>;
type PartComponentProps<Node> = [NonNullable<InferPartNodeProps<Node>>] extends [never]
? EmptyProps
: NonNullable<InferPartNodeProps<Node>>;
type CompoundComponent<Parts extends ComponentPartRecord> = {
[K in keyof Parts & string]: Parts[K] extends ComponentPartGroup<infer ChildParts>
? CompoundComponent<ChildParts>
: Component<PartComponentProps<Parts[K]>>;
};
export type CreateComponentResult<M> = M extends ComponentGroupManifest
? CompoundComponent<M>
? CompoundComponent<M['parts']>
: Component<InferProps<M>>;
function createRuntimeComponentPart<Props extends object>(name: string, part: string | null): Component<Props> {
@@ -71,13 +74,21 @@ export function createComponent<M extends AnyComponentManifest>(manifest: M): Cr
return createRuntimeComponentPart(manifest.name, null) as CreateComponentResult<M>;
}
const compound: Record<string, Component<never>> = {};
return createComponentParts(manifest.name, manifest.parts) as CreateComponentResult<M>;
}
for (const part of Object.keys(manifest.parts)) {
compound[part] = createRuntimeComponentPart(manifest.name, part);
function createComponentParts(name: string, parts: ComponentPartRecord, prefix = ''): Record<string, unknown> {
const compound: Record<string, unknown> = {};
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<M>;
return compound;
}
function createNode(type: ComponentType, props: Record<string, unknown>, key?: string | number | null): ComponentNode {
@@ -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('<TimeSlider.Thumb className={cn(slider.thumb.base, slider.thumb.interactive)} />')
);
expect(compactCode).toContain(compact('<Slider.Thumbnail.Root className={thumbnail.root}>'));
expect(compactCode).toContain(compact('<Slider.Thumbnail.Image className={thumbnail.image} />'));
expect(compactCode).toContain(compact('<TimeSlider.Value type="pointer" className={thumbnail.time} />'));
expect(compactCode).toContain(compact('<SpinnerIcon className={cn(icon, thumbnail.spinner)} />'));
expect(compactCode).toContain(compact('<TimeSlider.Preview className={slider.preview}>'));
expect(compactCode).toContain(compact('<TimeSlider.Value type="pointer" className={slider.value} />'));
expect(compactCode).not.toContain('SliderTrack');
@@ -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
<TimeSlider.Buffer className={cn(slider.fill.base, slider.fill.buffer)} />
</TimeSlider.Track>
<TimeSlider.Thumb className={cn(slider.thumb.base, slider.thumb.interactive)} />
<Slider.Thumbnail.Root className={thumbnail.root}>
<Slider.Thumbnail.Image className={thumbnail.image} />
<TimeSlider.Value type="pointer" className={thumbnail.time} />
<SpinnerIcon className={cn(icon, thumbnail.spinner)} />
</Slider.Thumbnail.Root>
<TimeSlider.Preview className={slider.preview}>
<TimeSlider.Value type="pointer" className={slider.value} />
</TimeSlider.Preview>
@@ -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<SliderThumbnailRootProps, SliderState>({
displayName: 'SliderThumbnailRoot',
tag: 'div',
useContext: useSliderContext,
});
export namespace SliderThumbnailRoot {
export type Props = SliderThumbnailRootProps;
}
@@ -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<ThumbnailProps, 'time'> {}
export const SliderThumbnail = forwardRef<HTMLDivElement, SliderThumbnailProps>(
const SliderThumbnailImage = forwardRef<HTMLDivElement, SliderThumbnailProps>(
function SliderThumbnail(componentProps, forwardedRef) {
const { pointerValue } = useSliderContext();
return <Thumbnail ref={forwardedRef} {...componentProps} time={pointerValue} />;
}
);
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;
}
@@ -74,6 +74,19 @@ describe('SliderThumbnail', () => {
expect(container.querySelector('[data-testid="thumbnail"]')).toBeTruthy();
});
it('renders namespaced root and image parts', () => {
const { container } = render(
<SliderRoot>
<SliderThumbnail.Root data-testid="thumbnail-root">
<SliderThumbnail.Image data-testid="thumbnail-image" />
</SliderThumbnail.Root>
</SliderRoot>
);
expect(container.querySelector('[data-testid="thumbnail-root"]')).toBeTruthy();
expect(container.querySelector('[data-testid="thumbnail-image"]')).toBeTruthy();
});
it('throws outside of SliderRoot', () => {
expect(() => render(<SliderThumbnail />)).toThrow('Slider compound components must be used within a Slider.Root');
});
@@ -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)
<TimeSlider.Buffer className={[slider.fill.base, slider.fill.buffer]} />
</TimeSlider.Track>
<TimeSlider.Thumb className={[slider.thumb.base, slider.thumb.interactive]} />
<Slider.Thumbnail.Root className={thumbnail.root}>
<Slider.Thumbnail.Image className={thumbnail.image} />
<TimeSlider.Value type="pointer" className={thumbnail.time} />
<SpinnerIcon className={[icon, thumbnail.spinner]} />
</Slider.Thumbnail.Root>
<TimeSlider.Preview className={slider.preview}>
<TimeSlider.Value type="pointer" className={slider.value} />
</TimeSlider.Preview>