feat(core): add text primitive

This commit is contained in:
Rahim
2026-06-24 12:12:58 -07:00
parent ce4178778e
commit e07db4f7b2
9 changed files with 279 additions and 13 deletions
+240 -2
View File
@@ -109,7 +109,7 @@ Steps:
- Import Tailwind token objects from existing `packages/skins/src/default/tailwind/video.tailwind.ts`.
- Keep skin source target-neutral:
- no `div`, `span`, `button`, `sup`
- no `render`
- no React `render` props; the only exception is the restricted settings radio-group children template documented below
- no `aria-*`, `data-*`, `id`, `role`, `tabIndex`, or `style`
- Replace obvious wrappers with semantic Core parts where they already exist.
- Capture every missing semantic wrapper/lowering need in a gap list before inventing new API.
@@ -333,6 +333,244 @@ Next implementation sequence:
- Add `default/audio.skin.tsx`, `minimal/video.skin.tsx`, and `minimal/audio.skin.tsx`.
- Defer live/background variants until base audio/video generation is stable.
### Current Default Video Parity Decisions
This section records the current direction for closing the remaining gap between `packages/skins/src/default/video.skin.tsx` and the manual React Tailwind skin.
Baseline decisions:
- Source skin stays target-neutral: no lowercase intrinsic elements (`div`, `span`, `sup`, etc.) and no React hooks/render props. The settings radio-group children template is a compile-time template exception, not a general React escape hatch.
- Source skin owns styling decisions and token placement. React lowering should not hide major styling/markup decisions in opaque helper components unless the behavior is inherently React-specific.
- React lowering may still own React-specific mechanics: `render` props, `type="button"`, DOM attribute names, rest props, and imports.
- Do not chase the current manual skin exactly when the manual shape only exists because older code needed wrappers. Prefer the simpler source shape when it preserves behavior and styling.
Buffering indicator:
- Do not keep the manual inner buffering `container` wrapper.
- Move any still-needed `bufferingIndicator.container` styles into `bufferingIndicator.root`.
- Generated/source target shape:
```tsx
<BufferingIndicator className={bufferingIndicator.root}>
<SpinnerIcon className={icon} />
</BufferingIndicator>
```
Error dialog:
- Keep the simplified generated/source structure.
- Do not add `ErrorDialog.Dialog`, `ErrorDialog.Content`, or `ErrorDialog.Actions` solely to match the current manual React skin.
- Generated/source target shape:
```tsx
<ErrorDialog.Root>
<ErrorDialog.Popup className={error.popup}>
<ErrorDialog.Title className={error.title}>Something went wrong.</ErrorDialog.Title>
<ErrorDialog.Description className={error.description} />
<ErrorDialog.Close className={[button.base, button.primary, error.close]}>OK</ErrorDialog.Close>
</ErrorDialog.Popup>
</ErrorDialog.Root>
```
Seek buttons:
- Keep the visible seek label, but do not use an extra icon-container wrapper span.
- Add a source-neutral `Text` primitive that React lowers to `span`.
- Move `iconContainer`-style grid/relative placement onto the seek button token.
- Source target shape:
```tsx
<SeekButton seconds={-SEEK_TIME} className={[iconButton, seek.button]}>
<SeekIcon className={[icon, iconFlipped]} />
<Text className={[seek.label, seek.labelBackward]}>{SEEK_TIME}</Text>
</SeekButton>
<SeekButton seconds={SEEK_TIME} className={[iconButton, seek.button]}>
<SeekIcon className={icon} />
<Text className={[seek.label, seek.labelForward]}>{SEEK_TIME}</Text>
</SeekButton>
```
React generated shape:
```tsx
<SeekButton seconds={-SEEK_TIME} className={cn(iconButton, seek.button)} type="button">
<SeekIcon className={cn(icon, iconFlipped)} />
<span className={cn(seek.label, seek.labelBackward)}>{SEEK_TIME}</span>
</SeekButton>
```
Thumbnail preview:
- We want nested thumbnail parts because the current thumbnail implementation already has root/image semantics internally.
- Desired source/API shape:
```tsx
<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>
```
- This requires nested component part support in the manifest/generated component system. Current manifests only support one level like `Slider.Thumbnail`.
- Keep existing `<Slider.Thumbnail />` behavior as an alias to the image part if possible.
Volume popover:
- Use a special `VolumePopover.Root`, but reuse regular `Popover` parts as children.
- The root owns volume availability/null behavior, similar to how `VolumeSlider.Root` owns the volume feature dependency.
- Source target shape:
```tsx
<VolumePopover.Root openOnHover delay={200} closeDelay={100} side="top">
<Popover.Trigger>
<MuteButton className={muteIcon.button}>
<VolumeOffIcon className={[icon, muteIcon.volumeOff]} />
<VolumeLowIcon className={[icon, muteIcon.volumeLow]} />
<VolumeHighIcon className={[icon, muteIcon.volumeHigh]} />
</MuteButton>
</Popover.Trigger>
<Popover.Popup className={[popup.popover, popup.volume]}>
<VolumeSlider.Root orientation="vertical" thumbAlignment="edge" className={slider.root}>
<VolumeSlider.Track className={slider.track}>
<VolumeSlider.Fill className={[slider.fill.base, slider.fill.fill]} />
</VolumeSlider.Track>
<VolumeSlider.Thumb className={[slider.thumb.base, slider.thumb.persistent]} />
</VolumeSlider.Root>
</Popover.Popup>
</VolumePopover.Root>
```
- React lowering can continue converting `Popover.Trigger` children into `render` props.
Settings menu:
- The settings menu must remain style-owned by source. Do not solve it with an opaque React helper that contains all the styling/markup.
- The radio groups are the right data boundary: `QualityRadioGroup`, `PlaybackRateRadioGroup`, and `CaptionsRadioGroup` already have Core state and React hooks that produce option records.
- Do not add tailored compound parts like `QualityRadioGroup.Label`, `QualityRadioGroup.Tier`, or `QualityRadioGroup.Badge`. Those values are already returned by the React hooks as fields on each option.
- Use a restricted source-level children render function as the option-row template. The group owns the hook data and the generated `map(...)`; the source owns the row markup/styling.
- Treat the render function as a compile-time template language, not as a general React render prop in source. React can execute the generated render body, but HTML lowering must statically translate it to `<template>` placeholders.
- Do not use `Menu.ItemIndicator` for generated settings rows. `Menu.RadioItem` / `<media-menu-radio-item>` already own checked state via `aria-checked`; the check icon should receive `menu.indicator` directly and use parent `group-aria-checked/menu-item` styles.
- Keep a generic availability boundary around each setting section so React can conditionally render the whole submenu section while HTML can rely on existing `type="quality|playback-rate|captions"` / `data-availability` behavior. Placeholder name: `Menu.Setting`; final naming still needs confirmation.
Source-template target shape:
```tsx
<Menu.Setting type="quality">
<Menu.Root>
<Menu.Trigger type="quality" className={[menu.item, 'media-menu__item--submenu']}>
<SwitchesIcon className={icon} />
<Text>Quality</Text>
<Text className={menu.hint}>
<Menu.ItemValue className={menu.hintLabel} />
<ChevronIcon className={[icon, menu.chevron]} />
</Text>
</Menu.Trigger>
<Menu.Content className={menu.submenuPanel}>
<Menu.Back className={menu.back}>
<ChevronIcon className={[icon, menu.chevron, iconFlipped]} />
<Text>Quality</Text>
</Menu.Back>
<Menu.Separator className={menu.separator} />
<QualityRadioGroup className={menu.group} label="Quality">
{({ option }) => (
<Menu.RadioItem className={menu.item} value={option.value} disabled={option.disabled}>
<Text>
<Text>{option.label}</Text>
{option.tier ? <Text className={menu.tier}>{option.tier}</Text> : null}
</Text>
{option.badge ? <Text className={[badge, menu.badge]}>{option.badge}</Text> : null}
<CheckIcon className={[icon, menu.indicator]} />
</Menu.RadioItem>
)}
</QualityRadioGroup>
</Menu.Content>
</Menu.Root>
</Menu.Setting>
```
React lowering target concept:
```tsx
const quality = useQualityOptions();
{quality?.state.availability === 'available' ? (
<Menu.Root>
{/* source-owned trigger/content markup */}
<Menu.RadioGroup className={menu.group} value={quality.value} onValueChange={quality.setValue} aria-label="Quality">
{quality.options.map((option) => (
<Menu.RadioItem key={option.value} className={menu.item} value={option.value} disabled={option.disabled}>
<span>
<span>{option.label}</span>
{option.tier ? <span className={menu.tier}>{option.tier}</span> : null}
</span>
{option.badge ? <span className={cn(badge, menu.badge)}>{option.badge}</span> : null}
<CheckIcon className={cn(icon, menu.indicator)} />
</Menu.RadioItem>
))}
</Menu.RadioGroup>
</Menu.Root>
) : null}
```
HTML lowering target concept:
```html
<media-quality-radio-group class="...">
<template>
<media-menu-radio-item class="...">
<span>
<span data-part="label"></span>
<span data-part="tier" class="..."></span>
</span>
<span data-part="badge" class="..."></span>
<!-- check icon with menu.indicator classes, no media-menu-item-indicator wrapper -->
</media-menu-radio-item>
</template>
</media-quality-radio-group>
```
Template-lowering constraints:
- Allow a restricted children render function only as the direct child of `QualityRadioGroup`, `PlaybackRateRadioGroup`, and `CaptionsRadioGroup`.
- The render function returns one `Menu.RadioItem` row template. `value={option.value}` and `disabled={option.disabled}` are allowed for React lowering and dropped for HTML template lowering because the HTML group sets those on cloned items.
- Allow static JSX, static props, source-neutral `Text`, icons, `className` values, and `className` arrays.
- Allow direct option field reads: `{option.label}`, `{option.tier}`, `{option.badge}`, `{option.value}`, and `{option.disabled}`.
- Allow simple optional field guards such as `{option.tier ? <Text>{option.tier}</Text> : null}` and `{option.badge ? <Text>{option.badge}</Text> : null}`.
- Disallow `options.map(...)` in source, function calls inside the template, spreads, event handlers, refs, hooks, computed expressions, dynamic component selection, and arbitrary `state` usage until a concrete need appears.
- Unsupported template expressions should produce compiler diagnostics rather than silently generating incorrect React or HTML output.
Input feedback:
- Avoid adding `InputFeedback.Root` if we can move wrapper positioning styles onto the actual indicator roots.
- Current root styles own absolute placement/grid. Move equivalent placement into `inputFeedback.island.*` and `inputFeedback.bubble.*` tokens.
- Source target shape:
```tsx
<VolumeIndicator.Root className={[inputFeedback.island.base, inputFeedback.island.volume, inputFeedback.island.shownVolume]}>
{/* fill/value/icon children */}
</VolumeIndicator.Root>
<StatusIndicator.Root actions={TOP_STATUS_ACTIONS} className={[inputFeedback.island.base, inputFeedback.island.status, inputFeedback.island.shownStatus]}>
{/* status icon/value children */}
</StatusIndicator.Root>
<SeekIndicator.Root className={[inputFeedback.bubble.base, inputFeedback.bubble.seek]}>
{/* seek icon/value children */}
</SeekIndicator.Root>
<StatusIndicator.Root actions={CENTER_STATUS_ACTIONS} className={[inputFeedback.bubble.base, inputFeedback.bubble.center]}>
{/* play/pause icon children */}
</StatusIndicator.Root>
```
### Revised Near-Term Order
1. Add `Text` primitive and React lowering to `span`.
2. Update seek labels to use `Text`, and move `iconContainer` styles onto the seek button token.
3. Simplify buffering token/source shape by merging container styles into root.
4. Add poster class parity.
5. Move input-feedback root positioning onto indicator roots.
6. Add nested component part support for `Slider.Thumbnail.Root` / `Slider.Thumbnail.Image`.
7. Add `VolumePopover.Root` and source volume popover structure.
8. Implement settings menu render-function template lowering for React first, then HTML `<template>` lowering using the validated subset above.
## Phase 3 - Expand React Tailwind Coverage
Purpose: move from one slice to all supported React Tailwind skins.
@@ -403,4 +641,4 @@ Gate question:
## Immediate Next Step
Continue expanding `default/video` source parity one structural gap at a time. The next likely gap is overlay after deciding whether the missing shell belongs in Core parts, source abstractions, or React lowering.
Continue expanding `default/video` source parity one structural gap at a time. The next implementation chunk should be `Text` plus seek-label parity, followed by buffering/poster simplification. Settings menu lowering now has a recorded direction: restricted render-function option templates, React hook/map generation, HTML `<template>` placeholder generation, and no `Menu.ItemIndicator` wrapper.
@@ -29,6 +29,7 @@ import SeekIndicatorDef from './seek-indicator/seek-indicator-component';
import SliderDef from './slider/slider-component';
import StatusAnnouncerDef from './status-announcer/status-announcer-component';
import StatusIndicatorDef from './status-indicator/status-indicator-component';
import TextDef from './text/text-component';
import ThumbnailDef from './thumbnail/thumbnail-component';
import TimeDef from './time/time-component';
import TimeSliderDef from './time-slider/time-slider-component';
@@ -64,6 +65,7 @@ export const SeekIndicator = createComponent(SeekIndicatorDef);
export const Slider = createComponent(SliderDef);
export const StatusAnnouncer = createComponent(StatusAnnouncerDef);
export const StatusIndicator = createComponent(StatusIndicatorDef);
export const Text = createComponent(TextDef);
export const Thumbnail = createComponent(ThumbnailDef);
export const Time = createComponent(TimeDef);
export const TimeSlider = createComponent(TimeSliderDef);
@@ -100,6 +102,7 @@ export const COMPONENTS = {
Slider: SliderDef,
StatusAnnouncer: StatusAnnouncerDef,
StatusIndicator: StatusIndicatorDef,
Text: TextDef,
Thumbnail: ThumbnailDef,
Time: TimeDef,
TimeSlider: TimeSliderDef,
@@ -0,0 +1,5 @@
import { defineComponent } from '../manifest';
export default defineComponent()({
name: 'Text',
});
+4
View File
@@ -50,6 +50,10 @@ export default defineConfig({
when: code.match.jsx.tag('Container'),
transform: code.edit.jsx.addPropsSpread('rest'),
}),
code.edit.jsx.element({
when: code.match.jsx.tag('Text'),
transform: code.edit.jsx.replaceTag('span'),
}),
code.edit.jsx.element({
when: code.match.jsx.tag('Poster'),
transform: () => {
@@ -28,6 +28,7 @@ describe('skins compiler config', () => {
expect(code).toContain('from "@/ui/pip-button"');
expect(code).toContain('from "@/ui/play-button"');
expect(code).toContain('from "@/ui/poster"');
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"');
@@ -69,8 +70,16 @@ describe('skins compiler config', () => {
expect(compactCode).toContain(compact('<Tooltip.Trigger render={<PlayButton'));
expect(compactCode).toContain(compact('<PlayButton className={cn(iconButton, playIcon.button)} type="button">'));
expect(compactCode).toContain(
compact('<SeekButton seconds={-SEEK_TIME} className={cn(iconButton)} type="button">')
compact('<SeekButton seconds={-SEEK_TIME} className={cn(iconButton, seek.button)} type="button">')
);
expect(compactCode).toContain(compact('<SeekIcon className={cn(icon, iconFlipped)} />'));
expect(compactCode).toContain(compact('<span className={cn(seek.label, seek.labelBackward)}>{SEEK_TIME}</span>'));
expect(compactCode).toContain(
compact('<SeekButton seconds={SEEK_TIME} className={cn(iconButton, seek.button)} type="button">')
);
expect(compactCode).toContain(compact('<span className={cn(seek.label, seek.labelForward)}>{SEEK_TIME}</span>'));
expect(compactCode).not.toContain('iconContainer');
expect(compactCode).not.toContain('<Text');
expect(compactCode).not.toContain(compact('render={<Button />}'));
expect(compactCode).toContain(compact('<TimeSlider.Root className={slider.root}>'));
expect(compactCode).toContain(compact('<TimeSlider.Track className={slider.track}>'));
@@ -12,12 +12,12 @@ import {
error,
fullscreenIcon,
icon,
iconContainer,
iconFlipped,
overlay,
pipIcon,
playIcon,
popup,
seek,
slider,
time,
} from '@videojs/skins/default/tailwind/video.tailwind';
@@ -112,8 +112,9 @@ export function DefaultVideoSkin({ className, children, poster, ...rest }: Defau
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<SeekButton seconds={-SEEK_TIME} className={cn(iconButton)} type="button">
<SeekIcon className={cn(icon, iconContainer, iconFlipped)} />
<SeekButton seconds={-SEEK_TIME} className={cn(iconButton, seek.button)} type="button">
<SeekIcon className={cn(icon, iconFlipped)} />
<span className={cn(seek.label, seek.labelBackward)}>{SEEK_TIME}</span>
</SeekButton>
}
/>
@@ -126,8 +127,9 @@ export function DefaultVideoSkin({ className, children, poster, ...rest }: Defau
<Tooltip.Root side="top">
<Tooltip.Trigger
render={
<SeekButton seconds={SEEK_TIME} className={cn(iconButton)} type="button">
<SeekIcon className={cn(icon, iconContainer)} />
<SeekButton seconds={SEEK_TIME} className={cn(iconButton, seek.button)} type="button">
<SeekIcon className={icon} />
<span className={cn(seek.label, seek.labelForward)}>{SEEK_TIME}</span>
</SeekButton>
}
/>
@@ -1,4 +1,5 @@
export const seek = {
button: 'relative grid',
label: 'text-[10px] font-medium tracking-tighter tabular-nums',
labelForward: 'absolute -right-px -bottom-0.75',
labelBackward: 'absolute -left-px -bottom-0.75',
+8 -5
View File
@@ -14,6 +14,7 @@ import {
Poster,
SeekButton,
StatusAnnouncer,
Text,
Time,
TimeSlider,
Tooltip,
@@ -45,12 +46,12 @@ import {
error,
fullscreenIcon,
icon,
iconContainer,
iconFlipped,
overlay,
pipIcon,
playIcon,
popup,
seek,
slider,
time,
} from './tailwind/video.tailwind';
@@ -101,8 +102,9 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
<Tooltip.Root side="top">
<Tooltip.Trigger>
<SeekButton seconds={-SEEK_TIME} className={[iconButton]}>
<SeekIcon className={[icon, iconContainer, iconFlipped]} />
<SeekButton seconds={-SEEK_TIME} className={[iconButton, seek.button]}>
<SeekIcon className={[icon, iconFlipped]} />
<Text className={[seek.label, seek.labelBackward]}>{SEEK_TIME}</Text>
</SeekButton>
</Tooltip.Trigger>
<Tooltip.Popup className={popup.tooltip}>
@@ -113,8 +115,9 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps)
<Tooltip.Root side="top">
<Tooltip.Trigger>
<SeekButton seconds={SEEK_TIME} className={[iconButton]}>
<SeekIcon className={[icon, iconContainer]} />
<SeekButton seconds={SEEK_TIME} className={[iconButton, seek.button]}>
<SeekIcon className={icon} />
<Text className={[seek.label, seek.labelForward]}>{SEEK_TIME}</Text>
</SeekButton>
</Tooltip.Trigger>
<Tooltip.Popup className={popup.tooltip}>
@@ -1,4 +1,5 @@
export const seek = {
button: 'relative grid',
label: 'text-[10px] font-medium tracking-tighter tabular-nums',
labelForward: 'absolute -right-px -bottom-0.75',
labelBackward: 'absolute -left-px -bottom-0.75',