mirror of
https://github.com/zoriya/v10.git
synced 2026-08-14 09:59:44 +00:00
feat(core): support nested component parts
This commit is contained in:
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user