From 99613ad768560302ce0809dc13243db6b6e4b094 Mon Sep 17 00:00:00 2001 From: Rahim Date: Sat, 20 Jun 2026 15:37:34 -0700 Subject: [PATCH] refactor(core)!: tighten constrained jsx boundary --- .../decisions/constrained-jsx-boundaries.md | 35 +++ packages/compiler/src/tests/compile.test.ts | 42 ++-- .../src/tests/fixtures/video-skin.tsx | 233 ------------------ .../compiler/src/tests/integration.test.ts | 80 +++--- packages/core/src/core/index.ts | 4 + .../core/src/core/ui/components.generated.ts | 9 + .../core/ui/container/container-component.ts | 6 + .../src/core/ui/container/container-core.ts | 2 + .../src/core/ui/gesture/gesture-component.ts | 6 + .../core/src/core/ui/gesture/gesture-core.ts | 16 ++ .../src/core/ui/hotkey/hotkey-component.ts | 6 + .../core/src/core/ui/hotkey/hotkey-core.ts | 11 + packages/core/src/core/ui/input-action.ts | 24 ++ .../core/src/core/ui/input-feedback/status.ts | 23 +- .../src/core/ui/tests/jsx-runtime.test-d.tsx | 68 ++++- packages/core/src/dom/gesture/actions.ts | 5 +- packages/core/src/dom/gesture/gesture.ts | 6 +- packages/core/src/dom/hotkey/coordinator.ts | 8 +- packages/core/src/dom/hotkey/hotkey.ts | 5 +- .../src/dom/hotkey/tests/coordinator.test.ts | 14 +- packages/core/src/dom/utils/layout.ts | 3 +- packages/core/src/jsx-runtime.ts | 11 +- .../html/src/ui/gesture/gesture-element.ts | 20 +- packages/html/src/ui/hotkey/hotkey-element.ts | 11 +- packages/react/src/player/context.tsx | 4 +- packages/react/src/ui/gesture/gesture.tsx | 22 +- packages/react/src/ui/hotkey/hotkey.tsx | 19 +- packages/react/src/ui/hotkey/use-hotkey.ts | 3 +- packages/utils/src/dom/types.ts | 4 +- packages/utils/src/types/tests/types.test.ts | 12 + packages/utils/src/types/types.ts | 2 + 31 files changed, 314 insertions(+), 400 deletions(-) create mode 100644 internal/decisions/constrained-jsx-boundaries.md delete mode 100644 packages/compiler/src/tests/fixtures/video-skin.tsx create mode 100644 packages/core/src/core/ui/container/container-component.ts create mode 100644 packages/core/src/core/ui/container/container-core.ts create mode 100644 packages/core/src/core/ui/gesture/gesture-component.ts create mode 100644 packages/core/src/core/ui/gesture/gesture-core.ts create mode 100644 packages/core/src/core/ui/hotkey/hotkey-component.ts create mode 100644 packages/core/src/core/ui/hotkey/hotkey-core.ts create mode 100644 packages/core/src/core/ui/input-action.ts create mode 100644 packages/utils/src/types/tests/types.test.ts diff --git a/internal/decisions/constrained-jsx-boundaries.md b/internal/decisions/constrained-jsx-boundaries.md new file mode 100644 index 00000000..bf7cde2d --- /dev/null +++ b/internal/decisions/constrained-jsx-boundaries.md @@ -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. diff --git a/packages/compiler/src/tests/compile.test.ts b/packages/compiler/src/tests/compile.test.ts index 43b5d96f..572b2695 100644 --- a/packages/compiler/src/tests/compile.test.ts +++ b/packages/compiler/src/tests/compile.test.ts @@ -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 ; }`; + const source = `function App({ values }){ return ; }`; const { code } = await compileJsx(source, { transforms: [ replaceJsxChild({ @@ -164,68 +164,64 @@ describe('replaceJsxChild', () => { ], }); - expect(collapse(code)).toContain(collapse(`{values["poster-image"]}`)); + expect(collapse(code)).toContain(collapse(`{values["poster-image"]}`)); }); }); describe('addProp', () => { it('emits a JSX value by default and adds the import', async () => { - const source = `function App(){ return ; }`; + const source = `function App(){ return ; }`; 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(`}/>`)); + expect(collapse(code)).toContain(collapse(`}/>`)); expect(code).toContain(`import { Button } from "./button"`); }); it('emits a bare reference when kind is "ref"', async () => { - const source = `function App(){ return ; }`; + const source = `function App(){ return ; }`; 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(``)); + expect(collapse(code)).toContain(collapse(``)); }); it('skips elements where the prop is already set', async () => { - const source = `function App(){ return }/>; }`; + const source = `function App(){ return }/>; }`; 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(``)); expect(code).not.toContain('import { Button }'); }); it('overwrites the existing prop when overwrite is true', async () => { - const source = `function App(){ return }/>; }`; + const source = `function App(){ return }/>; }`; 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(`}/>`)); + expect(collapse(code)).toContain(collapse(`}/>`)); }); }); describe('matchers', () => { it('byTag supports dotted tags', async () => { - const source = `function App(){ return ; }`; + const source = `function App(){ return ; }`; 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(` - - - -
- -
-
- - - -
-
- Something went wrong. - -
-
- OK -
-
-
-
- - - -
- - - - - - - - - - - - - - - - - {SEEK_TIME} - - - - Seek backward {SEEK_TIME} seconds - - - - - - - - {SEEK_TIME} - - - - Seek forward {SEEK_TIME} seconds - -
- -
- - - - - - - - -
- - - -
-
- -
- -
- - - - - Toggle playback rate - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- -
- - {/* Hotkeys */} - - - - - - - - - - - - - - - - - - - {/* Gestures */} - - - - - - - ); -} diff --git a/packages/compiler/src/tests/integration.test.ts b/packages/compiler/src/tests/integration.test.ts index 29f1c843..b1678921 100644 --- a/packages/compiler/src/tests/integration.test.ts +++ b/packages/compiler/src/tests/integration.test.ts @@ -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 ( + + + + + + + + + + + + + + ); +} +`; let code = ''; const imports: Record = { - '@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(' { + expect(code).toContain(' { - expect(code).toMatch(/ { + expect(code).toMatch(/ { - expect(code).toContain('@videojs/skins/default/tailwind'); + it('keeps unrelated relative token imports re-routed by config', () => { + expect(code).toContain('@fixture/tokens'); }); }); diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 274d9597..e64a8232 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -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'; diff --git a/packages/core/src/core/ui/components.generated.ts b/packages/core/src/core/ui/components.generated.ts index d58b2e2f..dabde488 100644 --- a/packages/core/src/core/ui/components.generated.ts +++ b/packages/core/src/core/ui/components.generated.ts @@ -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, diff --git a/packages/core/src/core/ui/container/container-component.ts b/packages/core/src/core/ui/container/container-component.ts new file mode 100644 index 00000000..03ef76b1 --- /dev/null +++ b/packages/core/src/core/ui/container/container-component.ts @@ -0,0 +1,6 @@ +import { defineComponent } from '../manifest'; +import type { ContainerProps } from './container-core'; + +export default defineComponent()({ + name: 'Container', +}); diff --git a/packages/core/src/core/ui/container/container-core.ts b/packages/core/src/core/ui/container/container-core.ts new file mode 100644 index 00000000..ce2b26c0 --- /dev/null +++ b/packages/core/src/core/ui/container/container-core.ts @@ -0,0 +1,2 @@ +// biome-ignore lint/suspicious/noEmptyInterface: Container currently owns no source props. +export interface ContainerProps {} diff --git a/packages/core/src/core/ui/gesture/gesture-component.ts b/packages/core/src/core/ui/gesture/gesture-component.ts new file mode 100644 index 00000000..e0b46930 --- /dev/null +++ b/packages/core/src/core/ui/gesture/gesture-component.ts @@ -0,0 +1,6 @@ +import { defineComponent } from '../manifest'; +import type { GestureProps } from './gesture-core'; + +export default defineComponent()({ + name: 'Gesture', +}); diff --git a/packages/core/src/core/ui/gesture/gesture-core.ts b/packages/core/src/core/ui/gesture/gesture-core.ts new file mode 100644 index 00000000..017ec288 --- /dev/null +++ b/packages/core/src/core/ui/gesture/gesture-core.ts @@ -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; + action: InputAction; + value?: number | undefined; + pointer?: GesturePointerType | undefined; + region?: GestureRegion | undefined; + disabled?: boolean | undefined; +} diff --git a/packages/core/src/core/ui/hotkey/hotkey-component.ts b/packages/core/src/core/ui/hotkey/hotkey-component.ts new file mode 100644 index 00000000..3a79b60a --- /dev/null +++ b/packages/core/src/core/ui/hotkey/hotkey-component.ts @@ -0,0 +1,6 @@ +import { defineComponent } from '../manifest'; +import type { HotkeyProps } from './hotkey-core'; + +export default defineComponent()({ + name: 'Hotkey', +}); diff --git a/packages/core/src/core/ui/hotkey/hotkey-core.ts b/packages/core/src/core/ui/hotkey/hotkey-core.ts new file mode 100644 index 00000000..bda52621 --- /dev/null +++ b/packages/core/src/core/ui/hotkey/hotkey-core.ts @@ -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; +} diff --git a/packages/core/src/core/ui/input-action.ts b/packages/core/src/core/ui/input-action.ts new file mode 100644 index 00000000..fb3a02e1 --- /dev/null +++ b/packages/core/src/core/ui/input-action.ts @@ -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; +} diff --git a/packages/core/src/core/ui/input-feedback/status.ts b/packages/core/src/core/ui/input-feedback/status.ts index aa6d346d..333fe01c 100644 --- a/packages/core/src/core/ui/input-feedback/status.ts +++ b/packages/core/src/core/ui/input-feedback/status.ts @@ -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; diff --git a/packages/core/src/core/ui/tests/jsx-runtime.test-d.tsx b/packages/core/src/core/ui/tests/jsx-runtime.test-d.tsx index e74c5fc3..942c23f1 100644 --- a/packages/core/src/core/ui/tests/jsx-runtime.test-d.tsx +++ b/packages/core/src/core/ui/tests/jsx-runtime.test-d.tsx @@ -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()({ + name: 'Container', + }) +); + +const Hotkey = createComponent( + defineComponent()({ + name: 'Hotkey', + }) +); + +const Gesture = createComponent( + defineComponent()({ + name: 'Gesture', + }) +); + describe('constrained JSX', () => { it('accepts a single component', () => { void (); + void (); }); it('rejects invalid props on a single component', () => { // @ts-expect-error - className must be a string void (); + // @ts-expect-error - id is a target-specific attr, not a core JSX prop + void (); + // @ts-expect-error - hidden is a target-specific attr, not a core JSX prop + void (