refactor(core)!: tighten constrained jsx boundary

This commit is contained in:
Rahim
2026-06-20 15:37:34 -07:00
parent 5fcab69d34
commit 99613ad768
31 changed files with 314 additions and 400 deletions
@@ -0,0 +1,35 @@
---
status: decided
date: 2026-06-20
---
# Constrained JSX Boundaries
## Decision
Core owns the Video.js constrained JSX surface: component manifests, generated component symbols, `defineComponent`, `createComponent`, `Slot`, and the JSX runtime/dev-runtime.
The compiler package remains generic. It may parse, match, transform, rewrite imports, and emit JSX, CSS, or diagnostics, but it must not contain Video.js component names, skin fixtures, runtime concepts, or platform assumptions.
Source JSX in core is target-neutral. It exposes only Video.js components and explicitly modeled component props. Shared base props are limited to `className` and `children`. Lowercase HTML intrinsics and generic platform attributes such as `id`, `role`, `tabIndex`, `hidden`, `aria-*`, `data-*`, `commandfor`, and `render` are not part of the core JSX source surface.
Target-specific lowering and adapter layers own platform output. HTML/React/native adapters can set ARIA, data attributes, focusability, command wiring, slots, and native element details as implementation output.
## Context
The skin port introduced pressure to share JSX across React, HTML, and future React Native targets. Allowing web-shaped source JSX in core would make the authoring surface look portable while quietly baking in DOM-only details.
At the same time, component generation had drifted toward the compiler package. That coupled a generic transform tool to Video.js UI manifests and made compiler tests depend on skin-specific fixtures.
## Alternatives Considered
- **Keep component generation in compiler** - Rejected because it makes `@videojs/compiler` aware of Video.js UI semantics and fixtures.
- **Allow HTML intrinsics and global attrs in core JSX** - Rejected because it leaks DOM shape into code that should lower to multiple targets.
- **Introduce generic layout primitives now** - Rejected because no shared primitive API has been designed yet; source JSX should use real Video.js components until that need is proven.
- **Let source authors set ARIA/data/focus attrs directly** - Rejected because those attributes are target output and should be derived from component state, defaults, or adapter behavior.
## Rationale
This keeps boundaries simple and enforceable. Core defines the portable component contract. Compiler stays reusable and mechanically testable. Target packages remain free to emit platform-specific markup without turning those details into cross-target source API.
The constrained JSX type surface also makes accidental DOM leakage fail at author time instead of during a later platform port.
+19 -23
View File
@@ -66,14 +66,14 @@ describe('compile (transformImports — bare-string rule)', () => {
describe('compile (transformImports — function rule)', () => {
it('rewrites per-identifier source and bucket-merges by resolved target', async () => {
const source = `import { Alpha, Beta } from '@fixture/components';\nconst _ = [Alpha, Beta];`;
const source = `import { Alpha, Beta } from '@fixture/widgets';\nconst _ = [Alpha, Beta];`;
const { code } = await compileJsx(source, {
imports: {
'@fixture/components': (name) => ({ source: `./ui/${name.toLowerCase()}`, name }),
'@fixture/widgets': (name) => ({ source: `./widgets/${name.toLowerCase()}`, name }),
},
});
expect(code).toContain(`import { Alpha } from "./ui/alpha"`);
expect(code).toContain(`import { Beta } from "./ui/beta"`);
expect(code).toContain(`import { Alpha } from "./widgets/alpha"`);
expect(code).toContain(`import { Beta } from "./widgets/beta"`);
});
it('renames identifiers when the rule returns a different `name`', async () => {
@@ -154,7 +154,7 @@ describe('childAsProp', () => {
describe('replaceJsxChild', () => {
it('replaces matched JSX children with expression helpers', async () => {
const source = `function App({ values }){ return <Container><Token name="poster-image"/></Container>; }`;
const source = `function App({ values }){ return <Host><Token name="poster-image"/></Host>; }`;
const { code } = await compileJsx(source, {
transforms: [
replaceJsxChild({
@@ -164,68 +164,64 @@ describe('replaceJsxChild', () => {
],
});
expect(collapse(code)).toContain(collapse(`<Container>{values["poster-image"]}</Container>`));
expect(collapse(code)).toContain(collapse(`<Host>{values["poster-image"]}</Host>`));
});
});
describe('addProp', () => {
it('emits a JSX value by default and adds the import', async () => {
const source = `function App(){ return <PlayButton/>; }`;
const source = `function App(){ return <Action/>; }`;
const { code } = await compileJsx(source, {
transforms: [
addProp({ match: byTag('PlayButton'), prop: 'render', value: { source: './button', name: 'Button' } }),
],
transforms: [addProp({ match: byTag('Action'), prop: 'render', value: { source: './button', name: 'Button' } })],
});
expect(collapse(code)).toContain(collapse(`<PlayButton render={<Button/>}/>`));
expect(collapse(code)).toContain(collapse(`<Action render={<Button/>}/>`));
expect(code).toContain(`import { Button } from "./button"`);
});
it('emits a bare reference when kind is "ref"', async () => {
const source = `function App(){ return <PlayButton/>; }`;
const source = `function App(){ return <Action/>; }`;
const { code } = await compileJsx(source, {
transforms: [
addProp({
match: byTag('PlayButton'),
match: byTag('Action'),
prop: 'as',
value: { source: './button', name: 'Button', kind: 'ref' },
}),
],
});
expect(collapse(code)).toContain(collapse(`<PlayButton as={Button}/>`));
expect(collapse(code)).toContain(collapse(`<Action as={Button}/>`));
});
it('skips elements where the prop is already set', async () => {
const source = `function App(){ return <PlayButton render={<X/>}/>; }`;
const source = `function App(){ return <Action render={<X/>}/>; }`;
const { code } = await compileJsx(source, {
transforms: [
addProp({ match: byTag('PlayButton'), prop: 'render', value: { source: './button', name: 'Button' } }),
],
transforms: [addProp({ match: byTag('Action'), prop: 'render', value: { source: './button', name: 'Button' } })],
});
expect(collapse(code)).toContain(collapse(`<X/>`));
expect(code).not.toContain('import { Button }');
});
it('overwrites the existing prop when overwrite is true', async () => {
const source = `function App(){ return <PlayButton render={<X/>}/>; }`;
const source = `function App(){ return <Action render={<X/>}/>; }`;
const { code } = await compileJsx(source, {
transforms: [
addProp({
match: byTag('PlayButton'),
match: byTag('Action'),
prop: 'render',
overwrite: true,
value: { source: './button', name: 'Button' },
}),
],
});
expect(collapse(code)).toContain(collapse(`<PlayButton render={<Button/>}/>`));
expect(collapse(code)).toContain(collapse(`<Action render={<Button/>}/>`));
});
});
describe('matchers', () => {
it('byTag supports dotted tags', async () => {
const source = `function App(){ return <Popover.Root foo="bar"/>; }`;
const source = `function App(){ return <Alpha.Root foo="bar"/>; }`;
const { code } = await compileJsx(source, {
transforms: [replace({ match: byTag('Popover.Root'), with: { source: 'pkg', name: 'NewRoot' } })],
transforms: [replace({ match: byTag('Alpha.Root'), with: { source: 'pkg', name: 'NewRoot' } })],
});
expect(code).toContain(`<NewRoot`);
});
-233
View File
@@ -1,233 +0,0 @@
import {
BufferingIndicator,
CaptionsButton,
CastButton,
Container,
Controls,
ErrorDialog,
FullscreenButton,
Gesture,
Hotkey,
MuteButton,
PiPButton,
PlayButton,
PlaybackRateButton,
Popover,
Poster,
SeekButton,
Slider,
Time,
TimeSlider,
Tooltip,
VolumeSlider,
} from '@fixture/components';
import {
CaptionsOffIcon,
CaptionsOnIcon,
CastEnterIcon,
CastExitIcon,
FullscreenEnterIcon,
FullscreenExitIcon,
PauseIcon,
PipEnterIcon,
PipExitIcon,
PlayIcon,
RestartIcon,
SeekIcon,
SpinnerIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@fixture/icons/components';
import { cn } from '@videojs/utils/style';
import { video as styles } from '../tailwind';
const SEEK_TIME = 10;
const iconButton = cn(styles.button.base, styles.button.subtle, styles.button.icon);
export interface VideoSkinProps {
className?: string;
}
export function VideoSkin({ className }: VideoSkinProps) {
return (
<Container data-skin="default-video" className={cn(styles.container, className)}>
<Poster className={styles.poster} />
<BufferingIndicator className={styles.bufferingIndicator.root}>
<div className={styles.bufferingIndicator.container}>
<SpinnerIcon className={styles.icon} />
</div>
</BufferingIndicator>
<ErrorDialog.Root>
<ErrorDialog.Popup className={styles.error.root}>
<div className={styles.error.dialog}>
<div className={styles.error.content}>
<ErrorDialog.Title className={styles.error.title}>Something went wrong.</ErrorDialog.Title>
<ErrorDialog.Description className={styles.error.description} />
</div>
<div className={styles.error.actions}>
<ErrorDialog.Close className={cn(styles.button.base, styles.button.primary)}>OK</ErrorDialog.Close>
</div>
</div>
</ErrorDialog.Popup>
</ErrorDialog.Root>
<Controls.Root className={styles.controls}>
<Tooltip.Provider>
<div className={styles.buttonGroupStart}>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<PlayButton className={cn(iconButton, 'group')}>
<RestartIcon className={cn(styles.icon, 'hidden group-data-ended:block')} />
<PlayIcon className={cn(styles.icon, 'hidden group-not-data-ended:group-data-paused:block')} />
<PauseIcon className={cn(styles.icon, 'hidden group-not-data-paused:group-not-data-ended:block')} />
</PlayButton>
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip} />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<SeekButton seconds={-SEEK_TIME} className={iconButton}>
<span className={styles.iconContainer}>
<SeekIcon className={cn(styles.icon, styles.iconFlipped)} />
<span className={styles.seek.labelBackward}>{SEEK_TIME}</span>
</span>
</SeekButton>
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip}>Seek backward {SEEK_TIME} seconds</Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<SeekButton seconds={SEEK_TIME} className={iconButton}>
<span className={styles.iconContainer}>
<SeekIcon className={styles.icon} />
<span className={styles.seek.labelForward}>{SEEK_TIME}</span>
</span>
</SeekButton>
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip}>Seek forward {SEEK_TIME} seconds</Tooltip.Popup>
</Tooltip.Root>
</div>
<div className={styles.time.group}>
<Time.Value type="current" className={styles.time.current} />
<TimeSlider.Root className={styles.slider.root}>
<TimeSlider.Track className={styles.slider.track}>
<TimeSlider.Fill className={cn(styles.slider.fill.base, styles.slider.fill.fill)} />
<TimeSlider.Buffer className={cn(styles.slider.fill.base, styles.slider.fill.buffer)} />
</TimeSlider.Track>
<TimeSlider.Thumb className={cn(styles.slider.thumb.base, styles.slider.thumb.interactive)} />
<div className={styles.preview.root}>
<Slider.Thumbnail className={styles.preview.thumbnail} />
<TimeSlider.Value className={styles.preview.time} />
<SpinnerIcon className={cn(styles.icon, styles.preview.spinner)} />
</div>
</TimeSlider.Root>
<Time.Value type="duration" className={styles.time.duration} />
</div>
<div className={styles.buttonGroupEnd}>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<PlaybackRateButton className={cn(iconButton, styles.playbackRate.button)} />
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip}>Toggle playback rate</Tooltip.Popup>
</Tooltip.Root>
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
<Popover.Trigger>
<MuteButton className={cn(iconButton, 'group')}>
<VolumeOffIcon className={cn(styles.icon, 'hidden group-data-[volume-level=off]:block')} />
<VolumeLowIcon className={cn(styles.icon, 'hidden group-data-[volume-level=low]:block')} />
<VolumeHighIcon className={cn(styles.icon, 'hidden group-data-[volume-level=high]:block')} />
</MuteButton>
</Popover.Trigger>
<Popover.Popup className={cn(styles.popup.popover, styles.popup.volume)}>
<VolumeSlider.Root className={styles.slider.root} orientation="vertical" thumbAlignment="edge">
<VolumeSlider.Track className={styles.slider.track}>
<VolumeSlider.Fill className={cn(styles.slider.fill.base, styles.slider.fill.fill)} />
</VolumeSlider.Track>
<VolumeSlider.Thumb className={cn(styles.slider.thumb.base, styles.slider.thumb.persistent)} />
</VolumeSlider.Root>
</Popover.Popup>
</Popover.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<CaptionsButton className={cn(iconButton, 'group')}>
<CaptionsOffIcon className={cn(styles.icon, 'hidden group-not-data-active:block')} />
<CaptionsOnIcon className={cn(styles.icon, 'hidden group-data-active:block')} />
</CaptionsButton>
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip} />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<CastButton className={cn(iconButton, 'group')}>
<CastEnterIcon className={cn(styles.icon, 'hidden group-not-data-[cast-state=connected]:block')} />
<CastExitIcon className={cn(styles.icon, 'hidden group-data-[cast-state=connected]:block')} />
</CastButton>
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip} />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<PiPButton className={cn(iconButton, 'group')}>
<PipEnterIcon className={cn(styles.icon, 'hidden group-not-data-pip:block')} />
<PipExitIcon className={cn(styles.icon, 'hidden group-data-pip:block')} />
</PiPButton>
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip} />
</Tooltip.Root>
<Tooltip.Root side="top">
<Tooltip.Trigger>
<FullscreenButton className={cn(iconButton, 'group')}>
<FullscreenEnterIcon className={cn(styles.icon, 'hidden group-not-data-fullscreen:block')} />
<FullscreenExitIcon className={cn(styles.icon, 'hidden group-data-fullscreen:block')} />
</FullscreenButton>
</Tooltip.Trigger>
<Tooltip.Popup className={styles.popup.tooltip} />
</Tooltip.Root>
</div>
</Tooltip.Provider>
</Controls.Root>
<div className={styles.overlay} />
{/* Hotkeys */}
<Hotkey keys="Space" action="togglePaused" />
<Hotkey keys="k" action="togglePaused" />
<Hotkey keys="m" action="toggleMuted" />
<Hotkey keys="f" action="toggleFullscreen" />
<Hotkey keys="c" action="toggleSubtitles" />
<Hotkey keys="i" action="togglePictureInPicture" />
<Hotkey keys="ArrowRight" action="seekStep" value={5} />
<Hotkey keys="ArrowLeft" action="seekStep" value={-5} />
<Hotkey keys="l" action="seekStep" value={10} />
<Hotkey keys="j" action="seekStep" value={-10} />
<Hotkey keys="ArrowUp" action="volumeStep" value={0.05} />
<Hotkey keys="ArrowDown" action="volumeStep" value={-0.05} />
<Hotkey keys="0-9" action="seekToPercent" />
<Hotkey keys="Home" action="seekToPercent" value={0} />
<Hotkey keys="End" action="seekToPercent" value={100} />
<Hotkey keys=">" action="speedUp" />
<Hotkey keys="<" action="speedDown" />
{/* Gestures */}
<Gesture type="tap" action="togglePaused" pointer="mouse" region="center" />
<Gesture type="tap" action="toggleControls" pointer="touch" />
<Gesture type="doubletap" action="seekStep" value={-10} region="left" />
<Gesture type="doubletap" action="toggleFullscreen" region="center" />
<Gesture type="doubletap" action="seekStep" value={10} region="right" />
</Container>
);
}
+46 -34
View File
@@ -1,33 +1,46 @@
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { beforeAll, describe, expect, it } from 'vitest';
import { compile } from '..';
import { anyTag, byTag, childAsProp, hasChild, jsx, replace } from '../jsx';
import type { ImportRule } from '../transforms';
const __dirname = dirname(fileURLToPath(import.meta.url));
const skinSource = resolve(__dirname, 'fixtures/video-skin.tsx');
/**
* End-to-end smoke test: feed a representative constrained-JSX video skin
* (vendored under `fixtures/`) through `compile()` with the same shape
* a package build hook uses, and sanity-check the output's structural
* shape. Snapshot-style assertions intentionally use `.toContain` over a full
* snapshot to keep the test resilient to incidental whitespace differences
* from the TS printer.
* End-to-end smoke test for the generic JSX transform pipeline. The compiler
* package deliberately avoids Video.js UI semantics here: transforms operate on
* JSX tags and imports only.
*/
describe('integration: default/video skin → JSX', () => {
const source = readFileSync(skinSource, 'utf8');
describe('integration: JSX transform pipeline', () => {
const source = `
import { Alpha, Beta, Gamma } from '@fixture/widgets';
import { Icon } from '@fixture/icons/components';
import { tokens } from '../tokens';
export function Example() {
return (
<Alpha.Root className={tokens.root}>
<Alpha.Trigger>
<Beta className={tokens.action}>
<Icon className={tokens.icon} />
</Beta>
</Alpha.Trigger>
<Gamma.Root>
<Gamma.Trigger>
<Beta className={tokens.secondary} />
</Gamma.Trigger>
<Gamma.Panel className={tokens.panel} />
</Gamma.Root>
</Alpha.Root>
);
}
`;
let code = '';
const imports: Record<string, ImportRule> = {
'@fixture/components': (name) => ({
source: `./src/ui/${name.replace(/^[A-Z]/, (m) => m.toLowerCase()).replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}`,
'@fixture/widgets': (name) => ({
source: `./widgets/${name.replace(/^[A-Z]/, (m) => m.toLowerCase()).replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}`,
name,
}),
'@fixture/icons/components': '@fixture/icons/jsx',
'../tailwind': '@videojs/skins/default/tailwind',
'../tokens': '@fixture/tokens',
};
beforeAll(async () => {
@@ -37,13 +50,13 @@ describe('integration: default/video skin → JSX', () => {
imports,
transforms: [
replace({
match: byTag('Popover.Root', {
when: hasChild(byTag('Popover.Trigger', { when: hasChild(byTag('MuteButton')) })),
match: byTag('Gamma.Root', {
when: hasChild(byTag('Gamma.Trigger', { when: hasChild(byTag('Beta')) })),
}),
with: { source: './volume-popover', name: 'VolumePopover' },
with: { source: './replacement', name: 'Replacement' },
mapChildren: () => [],
}),
childAsProp({ match: anyTag(['Tooltip.Trigger', 'Popover.Trigger']), prop: 'render' }),
childAsProp({ match: anyTag(['Alpha.Trigger', 'Gamma.Trigger']), prop: 'render' }),
],
}),
},
@@ -51,12 +64,11 @@ describe('integration: default/video skin → JSX', () => {
code = result.code;
});
it('rewrites component imports to per-identifier UI sources', () => {
expect(code).toMatch(/import \{ PlayButton \} from "\.\/src\/ui\/play-button"/);
// MuteButton lives under the volume Popover.Root subtree, which is replaced
// wholesale by VolumePopover — its import is correctly dropped by the
// unused-imports cleanup pass.
expect(code).not.toMatch(/import \{ MuteButton \}/);
it('rewrites component imports to per-identifier sources', () => {
expect(code).toMatch(/import \{ Alpha \} from "\.\/widgets\/alpha"/);
expect(code).toMatch(/import \{ Beta \} from "\.\/widgets\/beta"/);
// Gamma only appears under the replaced subtree, so cleanup drops it.
expect(code).not.toMatch(/import \{ Gamma \}/);
});
it('rewrites icon component imports', () => {
@@ -64,16 +76,16 @@ describe('integration: default/video skin → JSX', () => {
expect(code).not.toContain('@fixture/icons/components');
});
it('substitutes the volume Popover.Root with VolumePopover', () => {
expect(code).toContain('<VolumePopover');
expect(code).toContain('import { VolumePopover }');
it('substitutes the matched subtree with the replacement component', () => {
expect(code).toContain('<Replacement');
expect(code).toContain('import { Replacement }');
});
it('lifts Tooltip.Trigger / Popover.Trigger children into render props', () => {
expect(code).toMatch(/<Tooltip\.Trigger render=\{<PlayButton/);
it('lifts matched trigger children into render props', () => {
expect(code).toMatch(/<Alpha\.Trigger render=\{<Beta/);
});
it('keeps unrelated relative tailwind import re-routed to the skins package', () => {
expect(code).toContain('@videojs/skins/default/tailwind');
it('keeps unrelated relative token imports re-routed by config', () => {
expect(code).toContain('@fixture/tokens');
});
});
+4
View File
@@ -12,12 +12,16 @@ export * from './ui/captions-radio-group/captions-radio-group-core';
export * from './ui/captions-radio-group/captions-radio-group-data-attrs';
export * from './ui/cast-button/cast-button-core';
export * from './ui/cast-button/cast-button-data-attrs';
export * from './ui/container/container-core';
export * from './ui/controls/controls-core';
export * from './ui/controls/controls-data-attrs';
export * from './ui/error-dialog/error-dialog-core';
export * from './ui/error-dialog/error-dialog-data-attrs';
export * from './ui/fullscreen-button/fullscreen-button-core';
export * from './ui/fullscreen-button/fullscreen-button-data-attrs';
export * from './ui/gesture/gesture-core';
export * from './ui/hotkey/hotkey-core';
export * from './ui/input-action';
export * from './ui/input-feedback/indicator-lifecycle';
export * from './ui/input-feedback/seek-indicator-core';
export * from './ui/input-feedback/seek-indicator-data-attrs';
@@ -7,9 +7,12 @@ import BufferingIndicatorDef from './buffering-indicator/buffering-indicator-com
import CaptionsButtonDef from './captions-button/captions-button-component';
import CaptionsRadioGroupDef from './captions-radio-group/captions-radio-group-component';
import CastButtonDef from './cast-button/cast-button-component';
import ContainerDef from './container/container-component';
import ControlsDef from './controls/controls-component';
import ErrorDialogDef from './error-dialog/error-dialog-component';
import FullscreenButtonDef from './fullscreen-button/fullscreen-button-component';
import GestureDef from './gesture/gesture-component';
import HotkeyDef from './hotkey/hotkey-component';
import LiveButtonDef from './live-button/live-button-component';
import MenuDef from './menu/menu-component';
import MuteButtonDef from './mute-button/mute-button-component';
@@ -38,9 +41,12 @@ export const BufferingIndicator = createComponent(BufferingIndicatorDef);
export const CaptionsButton = createComponent(CaptionsButtonDef);
export const CaptionsRadioGroup = createComponent(CaptionsRadioGroupDef);
export const CastButton = createComponent(CastButtonDef);
export const Container = createComponent(ContainerDef);
export const Controls = createComponent(ControlsDef);
export const ErrorDialog = createComponent(ErrorDialogDef);
export const FullscreenButton = createComponent(FullscreenButtonDef);
export const Gesture = createComponent(GestureDef);
export const Hotkey = createComponent(HotkeyDef);
export const LiveButton = createComponent(LiveButtonDef);
export const Menu = createComponent(MenuDef);
export const MuteButton = createComponent(MuteButtonDef);
@@ -70,9 +76,12 @@ export const COMPONENTS = {
CaptionsButton: CaptionsButtonDef,
CaptionsRadioGroup: CaptionsRadioGroupDef,
CastButton: CastButtonDef,
Container: ContainerDef,
Controls: ControlsDef,
ErrorDialog: ErrorDialogDef,
FullscreenButton: FullscreenButtonDef,
Gesture: GestureDef,
Hotkey: HotkeyDef,
LiveButton: LiveButtonDef,
Menu: MenuDef,
MuteButton: MuteButtonDef,
@@ -0,0 +1,6 @@
import { defineComponent } from '../manifest';
import type { ContainerProps } from './container-core';
export default defineComponent<ContainerProps>()({
name: 'Container',
});
@@ -0,0 +1,2 @@
// biome-ignore lint/suspicious/noEmptyInterface: Container currently owns no source props.
export interface ContainerProps {}
@@ -0,0 +1,6 @@
import { defineComponent } from '../manifest';
import type { GestureProps } from './gesture-core';
export default defineComponent<GestureProps>()({
name: 'Gesture',
});
@@ -0,0 +1,16 @@
import type { StringWithSuggestions } from '@videojs/utils/types';
import type { InputAction } from '../input-action';
export type GesturePointerType = 'mouse' | 'touch' | 'pen';
export type GestureRegion = 'left' | 'center' | 'right';
export type GestureType = 'tap' | 'doubletap';
export interface GestureProps {
type: StringWithSuggestions<GestureType>;
action: InputAction;
value?: number | undefined;
pointer?: GesturePointerType | undefined;
region?: GestureRegion | undefined;
disabled?: boolean | undefined;
}
@@ -0,0 +1,6 @@
import { defineComponent } from '../manifest';
import type { HotkeyProps } from './hotkey-core';
export default defineComponent<HotkeyProps>()({
name: 'Hotkey',
});
@@ -0,0 +1,11 @@
import type { InputAction } from '../input-action';
export type HotkeyTarget = 'player' | 'global';
export interface HotkeyProps {
keys: string;
action: InputAction;
value?: number | undefined;
disabled?: boolean | undefined;
target?: HotkeyTarget | undefined;
}
+24
View File
@@ -0,0 +1,24 @@
import type { StringWithSuggestions } from '@videojs/utils/types';
export type InputActionSource = 'gesture' | 'hotkey';
export type InputAction = StringWithSuggestions<
| 'togglePaused'
| 'toggleMuted'
| 'toggleFullscreen'
| 'toggleSubtitles'
| 'togglePictureInPicture'
| 'toggleControls'
| 'seekStep'
| 'seekToPercent'
| 'volumeStep'
| 'speedUp'
| 'speedDown'
>;
export interface InputActionEvent {
action?: string | undefined;
value?: number | undefined;
source?: InputActionSource | undefined;
key?: string | undefined;
}
@@ -1,21 +1,9 @@
import { clamp } from '@videojs/utils/number';
import { formatTime } from '@videojs/utils/time';
export type InputActionSource = 'gesture' | 'hotkey';
import type { InputAction, InputActionEvent } from '../input-action';
export type InputAction =
| 'togglePaused'
| 'toggleMuted'
| 'toggleFullscreen'
| 'toggleSubtitles'
| 'togglePictureInPicture'
| 'toggleControls'
| 'seekStep'
| 'seekToPercent'
| 'volumeStep'
| 'speedUp'
| 'speedDown'
| (string & {});
export type { InputAction, InputActionEvent, InputActionSource } from '../input-action';
export type IndicatorDirection = 'forward' | 'backward';
export type IndicatorVolumeLevel = 'off' | 'low' | 'high';
@@ -33,13 +21,6 @@ export type IndicatorStatus =
| 'pip'
| 'exit-pip';
export interface InputActionEvent {
action?: string | undefined;
value?: number | undefined;
source?: InputActionSource | undefined;
key?: string | undefined;
}
export interface MediaSnapshot {
paused?: boolean | undefined;
volume?: number | undefined;
@@ -2,6 +2,9 @@
import { describe, it } from 'vitest';
import { createComponent, Slot } from '../../../jsx-runtime';
import type { ContainerProps } from '../container/container-core';
import type { GestureProps } from '../gesture/gesture-core';
import type { HotkeyProps } from '../hotkey/hotkey-core';
import { defineComponent } from '../manifest';
const PlayButton = createComponent(
@@ -27,14 +30,41 @@ const Time = createComponent(
})
);
const Container = createComponent(
defineComponent<ContainerProps>()({
name: 'Container',
})
);
const Hotkey = createComponent(
defineComponent<HotkeyProps>()({
name: 'Hotkey',
})
);
const Gesture = createComponent(
defineComponent<GestureProps>()({
name: 'Gesture',
})
);
describe('constrained JSX', () => {
it('accepts a single component', () => {
void (<PlayButton className="x" />);
void (<PlayButton key="play" />);
});
it('rejects invalid props on a single component', () => {
// @ts-expect-error - className must be a string
void (<PlayButton className={5} />);
// @ts-expect-error - id is a target-specific attr, not a core JSX prop
void (<PlayButton id="play" />);
// @ts-expect-error - hidden is a target-specific attr, not a core JSX prop
void (<PlayButton hidden />);
// @ts-expect-error - commandfor is HTML-specific wiring
void (<PlayButton commandfor="play-tooltip" />);
// @ts-expect-error - render is a React adapter prop, not a core JSX prop
void (<PlayButton render={<PlayButton />} />);
});
it('accepts compound parts inside their root', () => {
@@ -51,6 +81,27 @@ describe('constrained JSX', () => {
it('rejects invalid compound root props', () => {
// @ts-expect-error - `bogus` is not a valid orientation
void (<Slider.Root orientation="bogus" />);
// @ts-expect-error - boundary is target-specific positioning, not a core prop
void (<Slider.Root boundary="viewport" />);
});
it('accepts explicitly modeled input props', () => {
void (<Hotkey keys="k" action="togglePaused" />);
void (<Hotkey keys="f" action="toggleFullscreen" target="global" />);
void (<Gesture type="doubletap" action="seekStep" value={10} pointer="touch" region="right" />);
});
it('rejects invalid input props', () => {
// @ts-expect-error - global hotkeys use `global`, not DOM-specific `document`
void (<Hotkey keys="k" action="togglePaused" target="document" />);
// @ts-expect-error - invalid gesture region
void (<Gesture type="tap" action="togglePaused" region="outside" />);
});
it('keeps container props target-neutral', () => {
void (<Container className="skin" />);
// @ts-expect-error - focusability is target output behavior
void (<Container tabIndex={0} />);
});
it('rejects invalid Time.Value props', () => {
@@ -59,21 +110,20 @@ describe('constrained JSX', () => {
void (<Time.Value type="bogus" />);
});
it('accepts div and span as layout intrinsics', () => {
void (
<div className="row">
<span className="label">hello</span>
</div>
);
// @ts-expect-error - arbitrary HTML attributes (id) are not allowed on layout intrinsics
void (<div id="foo" />);
it('rejects platform-specific intrinsic elements', () => {
// @ts-expect-error - source JSX only exposes Video.js components
void (<div className="row" />);
// @ts-expect-error - source JSX only exposes Video.js components
void (<span className="label" />);
// @ts-expect-error - source JSX only exposes Video.js components
void (<button type="button" />);
});
it('accepts slot primitives', () => {
void (<Slot />);
void (
<Slot name="poster">
<span className="fallback" />
<PlayButton className="fallback" />
</Slot>
);
// @ts-expect-error - slot name must be a string
+4 -1
View File
@@ -1,4 +1,5 @@
import { isFunction } from '@videojs/utils/predicate';
import type { StringWithSuggestions } from '@videojs/utils/types';
import type { AnyPlayerStore } from '../media/types';
import { MEDIA_INPUT_ACTION_OVERRIDES } from '../media-actions';
@@ -33,7 +34,9 @@ const GESTURE_ACTION_OVERRIDES: Partial<Record<GestureActionName, GestureActionR
speedDown: MEDIA_INPUT_ACTION_OVERRIDES.speedDown,
};
export function resolveGestureAction(name: GestureActionName | (string & {})): GestureActionResolver | undefined {
export function resolveGestureAction(
name: StringWithSuggestions<GestureActionName>
): GestureActionResolver | undefined {
const override = GESTURE_ACTION_OVERRIDES[name as GestureActionName];
if (override) return override;
+2 -4
View File
@@ -1,8 +1,6 @@
export type GesturePointerType = 'mouse' | 'touch' | 'pen';
import type { GesturePointerType, GestureRegion, GestureType } from '../../core/ui/gesture/gesture-core';
export type GestureType = 'tap' | 'doubletap';
export type GestureRegion = 'left' | 'center' | 'right';
export type { GesturePointerType, GestureRegion, GestureType } from '../../core/ui/gesture/gesture-core';
export interface GestureOptions {
pointer?: GesturePointerType | undefined;
+4 -4
View File
@@ -56,7 +56,7 @@ export class HotkeyCoordinator {
this.#sortBindings();
// Lazily connect listeners.
if (options.target === 'document') {
if (options.target === 'global') {
this.#connectDocument();
} else {
this.#connect();
@@ -132,8 +132,8 @@ export class HotkeyCoordinator {
}
#maybeDisconnect(): void {
const hasPlayer = this.#bindings.some((b) => b.options.target !== 'document');
const hasDoc = this.#bindings.some((b) => b.options.target === 'document');
const hasPlayer = this.#bindings.some((b) => b.options.target !== 'global');
const hasDoc = this.#bindings.some((b) => b.options.target === 'global');
if (!hasPlayer) {
this.#disconnect?.abort();
@@ -164,7 +164,7 @@ export class HotkeyCoordinator {
if (event.repeat && options.repeatable === false) continue;
// Only consider bindings matching the event's target scope.
const isDocBinding = options.target === 'document';
const isDocBinding = options.target === 'global';
const isDocEvent = event.currentTarget === document;
if (isDocBinding !== isDocEvent) continue;
+3 -2
View File
@@ -1,5 +1,6 @@
import { isMacOS } from '@videojs/utils/dom';
import type { HotkeyTarget } from '../../core/ui/hotkey/hotkey-core';
import { HotkeyCoordinator } from './coordinator';
export type HotkeyModifierKey = 'shift' | 'ctrl' | 'alt' | 'meta';
@@ -15,8 +16,8 @@ export interface ParsedHotkeyBinding {
export interface HotkeyOptions {
keys: string;
onActivate: (event: KeyboardEvent, key: string) => void;
/** Where to listen — `'player'` (container) or `'document'`. */
target?: 'player' | 'document' | undefined;
/** Where to listen — `'player'` (container) or `'global'`. */
target?: HotkeyTarget | undefined;
/** Whether `event.repeat` should fire the callback. */
repeatable?: boolean | undefined;
disabled?: boolean | undefined;
@@ -291,21 +291,21 @@ describe('HotkeyCoordinator', () => {
});
});
describe('document target', () => {
it('listens on document for document-scoped bindings', () => {
describe('global target', () => {
it('listens on document for global bindings', () => {
const c = setup();
const onActivate = vi.fn();
c.add({ keys: 'k', onActivate, target: 'document' });
c.add({ keys: 'k', onActivate, target: 'global' });
keydown(document, 'k');
expect(onActivate).toHaveBeenCalledOnce();
});
it('cleans up document listener when last doc binding removed', () => {
it('cleans up document listener when last global binding removed', () => {
const c = setup();
const onActivate = vi.fn();
const remove = c.add({ keys: 'k', onActivate, target: 'document' });
const remove = c.add({ keys: 'k', onActivate, target: 'global' });
remove();
keydown(document, 'k');
@@ -313,10 +313,10 @@ describe('HotkeyCoordinator', () => {
expect(onActivate).not.toHaveBeenCalled();
});
it('fires document-scoped binding once when key originates in container', () => {
it('fires global binding once when key originates in container', () => {
const c = setup();
const onActivate = vi.fn();
c.add({ keys: 'k', onActivate, target: 'document' });
c.add({ keys: 'k', onActivate, target: 'global' });
// Key in container bubbles to document — doc listener fires once.
keydown(container, 'k');
+2 -1
View File
@@ -1,10 +1,11 @@
import { isString } from '@videojs/utils/predicate';
import type { StringWithSuggestions } from '@videojs/utils/types';
export function forceLayout(element: HTMLElement | null): void {
element?.getBoundingClientRect();
}
export type PositioningBoundary = 'viewport' | 'container' | (string & {}) | Element | null | undefined;
export type PositioningBoundary = StringWithSuggestions<'viewport' | 'container'> | Element | null | undefined;
export interface ResolvePositioningBoundaryOptions {
container?: Element | null;
+5 -6
View File
@@ -46,7 +46,7 @@ export type CreateComponentResult<M> = [InferParts<M>] extends [never]
? Component<InferProps<M>>
: CompoundComponent<M>;
function makePart<Props extends object>(name: string, part: string | null): Component<Props> {
function createComponentPart<Props extends object>(name: string, part: string | null): Component<Props> {
const fn = (_props: BaseProps & Props): ComponentNode => {
throw new Error(`@videojs/core: <${name}${part ? `.${part}` : ''}> can only be evaluated by the compiler.`);
};
@@ -56,7 +56,7 @@ function makePart<Props extends object>(name: string, part: string | null): Comp
return fn as Component<Props>;
}
export const Slot = makePart<SlotProps>('Slot', null);
export const Slot = createComponentPart<SlotProps>('Slot', null);
export function createComponent<
M extends ComponentManifest<object, readonly string[], Partial<Record<string, object>>>,
@@ -64,13 +64,13 @@ export function createComponent<
const parts = manifest.parts ?? [];
if (parts.length === 0) {
return makePart(manifest.name, null) as CreateComponentResult<M>;
return createComponentPart(manifest.name, null) as CreateComponentResult<M>;
}
const compound: Record<string, Component<never>> = {};
for (const part of parts) {
compound[part] = makePart(manifest.name, part);
compound[part] = createComponentPart(manifest.name, part);
}
return compound as CreateComponentResult<M>;
@@ -107,7 +107,6 @@ export namespace JSX {
}
export interface IntrinsicElements {
div: BaseProps;
span: BaseProps;
readonly [intrinsicElement: string]: never;
}
}
@@ -1,11 +1,5 @@
import {
createDoubleTapGesture,
createTapGesture,
type GestureActionName,
type GesturePointerType,
type GestureRegion,
resolveGestureAction,
} from '@videojs/core/dom';
import type { GestureProps } from '@videojs/core';
import { createDoubleTapGesture, createTapGesture, resolveGestureAction } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
@@ -25,11 +19,11 @@ export class GestureElement extends MediaElement {
disabled: { type: Boolean },
};
type: 'tap' | 'doubletap' | (string & {}) = '';
action: GestureActionName | (string & {}) = '';
value: number | undefined = undefined;
pointer: GesturePointerType | undefined = undefined;
region: GestureRegion | undefined = undefined;
type: GestureProps['type'] = '';
action: GestureProps['action'] = '';
value: GestureProps['value'] = undefined;
pointer: GestureProps['pointer'] = undefined;
region: GestureProps['region'] = undefined;
disabled = false;
readonly #player = new PlayerController(this, playerContext);
@@ -1,4 +1,5 @@
import { createHotkey, type HotkeyActionName, isHotkeyToggleAction, resolveHotkeyAction } from '@videojs/core/dom';
import type { HotkeyProps } from '@videojs/core';
import { createHotkey, isHotkeyToggleAction, resolveHotkeyAction } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
@@ -17,11 +18,11 @@ export class HotkeyElement extends MediaElement {
target: { type: String },
};
keys = '';
action: HotkeyActionName | (string & {}) = '';
value: number | undefined = undefined;
keys: HotkeyProps['keys'] = '';
action: HotkeyProps['action'] = '';
value: HotkeyProps['value'] = undefined;
disabled = false;
target: 'player' | 'document' = 'player';
target: NonNullable<HotkeyProps['target']> = 'player';
readonly #player = new PlayerController(this, playerContext);
readonly #container = new ContextConsumer(this, {
+2 -2
View File
@@ -1,6 +1,6 @@
'use client';
import type { Media } from '@videojs/core';
import type { ContainerProps as CoreContainerProps, Media } from '@videojs/core';
import type { MediaContainer, PopupGroup } from '@videojs/core/dom';
import type { UnknownState, UnknownStore } from '@videojs/store';
import { useStore } from '@videojs/store/react';
@@ -113,7 +113,7 @@ export function useContainerAttach(): Dispatch<SetStateAction<HTMLElement | null
return ctx?.setContainer;
}
export interface ContainerProps extends HTMLAttributes<HTMLDivElement> {
export interface ContainerProps extends HTMLAttributes<HTMLDivElement>, CoreContainerProps {
children?: ReactNode;
}
+4 -18
View File
@@ -1,28 +1,12 @@
'use client';
import {
type AnyPlayerStore,
createDoubleTapGesture,
createTapGesture,
type GestureActionName,
type GesturePointerType,
type GestureRegion,
resolveGestureAction,
} from '@videojs/core/dom';
import type { GestureProps } from '@videojs/core';
import { type AnyPlayerStore, createDoubleTapGesture, createTapGesture, resolveGestureAction } from '@videojs/core/dom';
import type { ReactNode } from 'react';
import { useEffect } from 'react';
import { useContainer, usePlayer } from '../../player/context';
export interface GestureProps {
type: 'tap' | 'doubletap' | (string & {});
action: GestureActionName | (string & {});
value?: number;
pointer?: GesturePointerType;
region?: GestureRegion;
disabled?: boolean;
}
export function Gesture({ type, action, value, pointer, region, disabled }: GestureProps): ReactNode {
const store = usePlayer() as AnyPlayerStore;
const container = useContainer();
@@ -53,6 +37,8 @@ export namespace Gesture {
export type Props = GestureProps;
}
export type { GestureProps };
/** @deprecated Use `GestureProps` instead. */
export type MediaGestureProps = GestureProps;
+4 -15
View File
@@ -1,25 +1,12 @@
'use client';
import {
type AnyPlayerStore,
createHotkey,
type HotkeyActionName,
isHotkeyToggleAction,
resolveHotkeyAction,
} from '@videojs/core/dom';
import type { HotkeyProps } from '@videojs/core';
import { type AnyPlayerStore, createHotkey, isHotkeyToggleAction, resolveHotkeyAction } from '@videojs/core/dom';
import type { ReactNode } from 'react';
import { useEffect } from 'react';
import { useContainer, usePlayer } from '../../player/context';
export interface HotkeyProps {
keys: string;
action: HotkeyActionName | (string & {});
value?: number;
disabled?: boolean;
target?: 'player' | 'document';
}
export function Hotkey({ keys, action, value, disabled, target }: HotkeyProps): ReactNode {
const store = usePlayer() as AnyPlayerStore;
const container = useContainer();
@@ -50,6 +37,8 @@ export namespace Hotkey {
export type Props = HotkeyProps;
}
export type { HotkeyProps };
/** @deprecated Use `HotkeyProps` instead. */
export type MediaHotkeyProps = HotkeyProps;
+2 -1
View File
@@ -1,5 +1,6 @@
'use client';
import type { HotkeyTarget } from '@videojs/core';
import { createHotkey } from '@videojs/core/dom';
import { useEffect } from 'react';
@@ -9,7 +10,7 @@ import { useLatestRef } from '../../utils/use-latest-ref';
export interface UseHotkeyOptions {
keys: string;
onActivate: (event: KeyboardEvent, key: string) => void;
target?: 'player' | 'document';
target?: HotkeyTarget;
repeatable?: boolean;
disabled?: boolean;
}
+3 -1
View File
@@ -1,3 +1,5 @@
import type { StringWithSuggestions } from '../types';
// Method syntax is required here for TypeScript's class inheritance checking.
// Using property syntax (e.g., `connectedCallback?: () => void`) causes TS2425
// when a class extends a generic mixin that defines lifecycle callbacks.
@@ -18,7 +20,7 @@ export type QueriedElement<S extends string, E extends Element> = S extends keyo
? HTMLElementTagNameMap[S]
: E;
export type EventType<Events> = (keyof Events & string) | (string & {});
export type EventType<Events> = StringWithSuggestions<keyof Events & string>;
export type EventListenerFor<Events, K> =
| ((event: K extends keyof Events ? Events[K] : Event) => void)
@@ -0,0 +1,12 @@
import { describe, expectTypeOf, it } from 'vitest';
import type { StringWithSuggestions } from '../types';
describe('StringWithSuggestions', () => {
it('accepts arbitrary strings without widening literal unions', () => {
type Action = StringWithSuggestions<'play' | 'pause'>;
expectTypeOf<Action>().toMatchTypeOf<string>();
expectTypeOf<string>().toMatchTypeOf<Action>();
expectTypeOf<Extract<Action, 'play'>>().toEqualTypeOf<'play'>();
});
});
+2
View File
@@ -15,6 +15,8 @@ export type MixinReturn<Base extends AnyConstructor<any>, Props> = Constructor<I
export type Falsy<T> = T | false | null | undefined;
export type StringWithSuggestions<Value extends string> = Value | (string & {});
export type EnsureFunction<T> = T extends (...args: any[]) => any ? T : never;
export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};