diff --git a/.claude/plans/skin-jsx-migration.md b/.claude/plans/skin-jsx-migration.md new file mode 100644 index 00000000..35254aff --- /dev/null +++ b/.claude/plans/skin-jsx-migration.md @@ -0,0 +1,241 @@ +# Skin JSX Migration Plan + +## Goal + +Move skin structure out of the manual React and HTML skin implementations and into canonical constrained JSX source files in `packages/skins/src`, using Core's component and icon JSX surfaces. Then reintroduce React-owned compiler generation that reads those source skins and generates React Tailwind skins first. Vanilla CSS generation follows later after the Tailwind design-system layer is cleaner. + +## Non-Goals For The First Pass + +- Do not move Video.js-specific lowering into `@videojs/compiler`. +- Do not expose new public `@videojs/skins` source JSX exports until we explicitly decide that API. +- Do not generate HTML skins in this pass. +- Do not attempt vanilla CSS output until React Tailwind generation is stable. +- Do not copy current React skin source directly into `packages/skins/src`; it contains React and DOM-specific details that violate constrained JSX boundaries. + +## Existing Baseline + +- `packages/skins/__old__` contains legacy CSS and Tailwind token/style references only. It has no JSX/TSX skin structure. +- `packages/skins/src` contains the active Tailwind token modules and `shared/tailwind.css`, but no source JSX skin trees. +- Current structure lives in duplicated manual package code: + - React: `packages/react/src/presets/**/{skin,minimal-skin}.tsx` and `*.tailwind.tsx` + - HTML: `packages/html/src/define/**/{skin,minimal-skin}.ts` and `*.tailwind.ts` +- Core already owns constrained JSX: + - `@videojs/core/components` + - `@videojs/core/jsx-runtime` + - component manifests in `packages/core/src/core/ui/**/**/*-component.ts` + - compound parts are declared with `defineComponentPart()` in each component manifest + - component prop contracts and default prop constants live in each component folder's `props.ts` +- Icons already expose constrained component exports: + - `@videojs/icons/components` + - `@videojs/icons/components/minimal` +- `@videojs/compiler` already provides generic import rewrites, JSX transforms, and Tailwind style modes, but React has no current compiler build hook. + +## Architecture Principles + +- `packages/skins/src` becomes the canonical authored skin structure. +- Source skin JSX stays target-neutral and uses only constrained Video.js components plus explicitly modeled props. +- Lowercase HTML intrinsics, generic DOM attributes, ARIA attributes, `data-*`, and React `render` props remain target output details. +- React owns React-specific lowering, build integration, helper wrappers, hooks, and generated output shape. +- `@videojs/compiler` stays generic. New Video.js skin knowledge belongs in React package scripts/config or Core manifests, not in compiler internals. +- `__old__` is a parity/reference source for legacy visual behavior, not the new source of truth. + +## Phase 0 - Source Shape And First Slice + +Purpose: make the smallest explicit decisions before creating source JSX files. + +Decisions: +- First slice: `default/video` only. +- Source file: `packages/skins/src/default/video.skin.tsx`. +- Build behavior: source-only for now; keep current `@videojs/skins` package output unchanged. +- Wrapper policy: use Core parts first and capture gaps before adding abstractions. + +Decisions to confirm: +- Source file layout in `packages/skins/src`. +- First vertical slice scope. +- Whether source-only JSX files are checked into `@videojs/skins` but excluded from public build output. +- Whether skin-local abstract layout components are allowed, or whether source JSX must only use Core components and target lowering wrappers. + +Recommended defaults: +- Start with `default/video` only. +- Put authored source at `packages/skins/src/default/video.tsx` or `packages/skins/src/default/video.skin.tsx`. +- Use `/** @jsxImportSource @videojs/core */` per source file unless package-level JSX config is safer. +- Keep `packages/skins/tsdown.config.ts` entries limited to current `.tailwind.ts` modules until generation is wired. +- Avoid skin-local layout primitives unless a specific repeated need is proven. + +Verification: +- Add typecheck coverage for the source JSX without changing public output. +- Run `pnpm -F @videojs/skins build` and `pnpm typecheck` once the source TSX is included. + +Gate question: +- Which file layout and first slice do we want? + +## Phase 1 - Port One Tailwind Source Skin To Constrained JSX + +Purpose: create one canonical source skin tree that can be compiled later. + +Status: +- Started with `packages/skins/src/default/video.skin.tsx`. +- Current scaffold covers container, children slot, controls root/groups, play button, seek buttons, time slider without thumbnail preview, cast, AirPlay, PiP, fullscreen button, hotkeys, gestures, and status announcer. +- Current scaffold typechecks and does not change `@videojs/skins` package output. +- Core manifests now use `parts: { Part: defineComponentPart() }` instead of `parts: [...]` plus `partProps`. +- Core JSX runtime now infers props from every part descriptor, including `Root`. +- Core UI props/defaults have been moved into sibling `props.ts` files while preserving the existing `Core.defaultProps` static API. + +Gap inventory for the remaining `default/video` port: + +| Area | Current Manual Shape | Likely Owner | Notes | +| --- | --- | --- | --- | +| Tooltip trigger composition | React uses `render={}` to avoid nested buttons. | React lowering | Source can use ``; React generator must lift the single child into `render`. Same for seek/fullscreen/cast/airplay/pip and popover triggers. | +| Controls marker | Manual React sets `data-controls=""` on `Controls.Root` for Tailwind `has-*` selectors. | Target lowering | Source JSX cannot set `data-*`; React/HTML lowering should add the marker to `Controls.Root`. | +| Cast, AirPlay, PiP buttons | Manual React repeats tooltip + button + icon patterns. | Existing Core parts | Added to source scaffold; still depends on trigger lowering for generated React parity. | +| Poster | Manual React skin prop supports `poster?: string | renderProp`. Core `Poster` has state only; source JSX cannot pass React render props. | Needs design decision | Options: model target-neutral poster source props, use a named `Slot`, or keep poster handling in React generated wrapper. | +| Buffering indicator shell | Manual React uses `BufferingIndicator render` with an outer root and inner surface container. | Core part candidate | Consider changing `BufferingIndicator` to compound parts such as `Root` and `Container`, or define a target lowering wrapper. | +| Error dialog shell | Manual React uses `ErrorDialog.Popup` plus raw dialog/content/actions wrappers. | Core part candidate | Consider adding semantic parts such as `Panel`, `Content`, and `Actions` to `ErrorDialog` rather than source-local wrappers. | +| Overlay | Manual React uses a raw `
`. | Needs design decision | Could become a Core `Overlay` component, target-lowered visual element, or a constrained skin-local abstraction if we allow those later. | +| Thumbnail preview shell | Manual React wraps `Slider.Thumbnail`, pointer time, and spinner in a raw thumbnail root. | Core part candidate | Existing `Slider.Thumbnail` is the thumbnail image component, not the visual preview shell. Need a semantic wrapper/part before source can represent this without `div`. | +| Volume popover | Manual React conditionally skips the popover when volume is unsupported. | React lowering or Core component | Existing `MuteButton`, `Popover`, and `VolumeSlider` can express most structure, but the unsupported branch currently depends on React state selection. | +| Settings menu | Manual React builds dynamic quality, speed, and captions submenus from React hooks. | Largest design gap | Core has `QualityRadioGroup`, `PlaybackRateRadioGroup`, and `CaptionsRadioGroup` manifests, but React currently exposes hooks, not component renderers. Decide whether to add React components for these Core radio groups or lower source components into hook-backed generated code. | +| Menu visual labels/hints | Manual React uses raw `div`, `span`, and `sup` wrappers for labels, hint areas, tiers, and badges. | Core part or source abstraction candidate | Some wrappers may become `Menu` parts (`ItemLabel`, `ItemHint`, `ItemBadge`, `ItemTier`) if they are semantic enough. | +| Input feedback shell | Manual React uses raw root/content wrappers around volume/status/seek indicators and icons. | Core part candidate | Existing indicator components cover state, but not all visual shell parts used by the skin. | + +Recommended next coding order: +- Define React lowering for child-as-trigger composition and `Controls.Root` marker. +- Resolve one structural Core-part gap at a time, starting with ErrorDialog or BufferingIndicator because they are small and isolated. + +Steps: +- Use `packages/react/src/presets/video/skin.tailwind.tsx` as the structural reference for `default/video`. +- Import Core components from `@videojs/core/components`. +- Import default icon components from `@videojs/icons/components`. +- 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 `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. + +Expected gap areas: +- Generic visual wrappers currently expressed as `div` or `span`. +- React render wrappers for buttons and slider pieces. +- Dynamic settings menu option lists that currently use React hooks. +- Poster render/source handling. +- Error dialog inner content/action shells. +- Buffering and input-feedback inner shells. +- Thumbnail wrappers and status/icon group wrappers. + +Verification: +- Source JSX typechecks under Core constrained JSX. +- No package output or public imports change yet. +- Gap list is explicit enough to drive Phase 2. +- Completed checks for current source/API work: + - `pnpm exec tsgo --build packages/core` + - `pnpm exec tsgo --build packages/skins` + - `pnpm -F @videojs/core exec vitest run --project types` + - `pnpm -F @videojs/core test` + - `pnpm -F @videojs/core build` + - `pnpm -F @videojs/skins build` + - `pnpm typecheck` + - `pnpm check:workspace` + - targeted Biome check for touched Core files + +Gate question: +- For each gap, should we model it as a Core semantic part, a skin source abstraction, or target lowering output? + +## Phase 2 - React Lowering Prototype + +Purpose: compile the Phase 1 source skin into a React Tailwind module that matches the current manual React output closely enough for focused tests/review. + +Steps: +- Add a React-owned generation script, likely `packages/react/scripts/compile-skins.ts`. +- Call `compile()` from `@videojs/compiler` programmatically. +- Configure import rewrites: + - `@videojs/core/components` to React UI modules or package-local barrel targets. + - `@videojs/icons/components` to React icon modules. + - `@videojs/skins/*/tailwind/*` preserved or re-routed as needed. +- Use Tailwind mode `preserve` first. +- Add React-owned transforms for required output details: + - trigger child to `render` prop where necessary + - add render wrappers for default buttons and slider shell elements + - inject React helper components only in generated output + - map Core `Container` to React `Container` from player context +- Write output to a temporary generated path first, then decide whether to replace current preset files. + +Verification: +- Generated `default/video` React Tailwind skin typechecks. +- Compare generated output against `packages/react/src/presets/video/skin.tailwind.tsx` for structural parity. +- Run focused React package typecheck/build once hooked into the package. + +Gate question: +- Should generated files be committed into `packages/react/src/presets/**`, generated into `__generated__` and re-exported, or generated only at build time? + +## Phase 3 - Expand React Tailwind Coverage + +Purpose: move from one slice to all supported React Tailwind skins. + +Steps: +- Port and generate: + - `default/audio` + - `minimal/video` + - `minimal/audio` +- Decide how live variants compose from base video/audio source skins. +- Decide whether background skin belongs in this pipeline or remains separate. +- Replace manual React Tailwind skins only after generated output is stable. + +Verification: +- `pnpm -F @videojs/react build` +- focused React preset tests if present +- sandbox visual smoke for generated skins + +Gate question: +- Should live and background skins be modeled as variants in source JSX now, or deferred until base skins are stable? + +## Phase 4 - Tailwind Design-System Cleanup + +Purpose: make the style layer more idiomatic before extracting vanilla CSS. + +Steps: +- Classify every relevant `--media-*` variable as: + - public theming API + - runtime state variable + - skin-private default +- Keep public/runtime `--media-*` names stable. +- Move repeated static values into `packages/skins/src/shared/tailwind.css` via: + - `@theme` + - `@utility` + - `@custom-variant` +- Replace repeated arbitrary utilities in token modules with theme-backed utilities. +- Keep `__old__` CSS as a parity checklist. + +Verification: +- Tailwind skins still render in sandbox. +- No accidental removal of public `--media-*` theming hooks. +- Compiler Tailwind token evaluation still succeeds. + +Gate question: +- Which `--media-*` variables are public API versus skin-private implementation details? + +## Phase 5 - Vanilla CSS Generation + +Purpose: generate CSS skins from the canonical JSX and Tailwind token source after React Tailwind generation works. + +Steps: +- Switch compiler Tailwind mode to `extract` for CSS generation. +- Use a Tailwind v4 input that imports `tailwindcss` plus `@videojs/skins/shared/tailwind.css`. +- Configure naming overrides for known collision risks. +- Decide class naming compatibility: + - preserve existing `media-*` class names where they are public/ejection-facing, or + - accept generated semantic class names if public compatibility is not required. +- Wire generated CSS into existing React preset CSS paths. +- Later, adapt HTML generation/lowering separately. + +Verification: +- CSS output visual parity against current/old skins. +- `site/scripts/build-ejected-skins.ts` still emits useful CSS snippets. +- `pnpm build:packages` and `pnpm check:workspace` pass. + +Gate question: +- Must vanilla CSS preserve old `media-*` class names exactly? + +## Immediate Next Step + +Prototype React lowering for the existing `default/video` source scaffold, starting with child-as-trigger composition and the `Controls.Root` marker. Resolve the first structural Core-part gap only after that lowering path is proven. diff --git a/packages/core/scripts/tests/generate-components.test.ts b/packages/core/scripts/tests/generate-components.test.ts index e4659bf2..e4c24671 100644 --- a/packages/core/scripts/tests/generate-components.test.ts +++ b/packages/core/scripts/tests/generate-components.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import { generateComponents } from '../generate-components'; -const STUB = 'const defineComponent: any = () => (m: any) => m;'; +const STUB = 'const defineComponent: any = () => (m: any) => m; const defineComponentPart: any = () => ({});'; function setup(): { dir: string; output: string; pattern: string } { const dir = mkdtempSync(join(tmpdir(), 'videojs-components-')); @@ -27,16 +27,17 @@ function setup(): { dir: string; output: string; pattern: string } { });` ); - writeFileSync(join(dir, 'slider', 'slider-parts.ts'), `export const SliderParts = ['Root', 'Track'] as const;`); writeFileSync(join(dir, 'slider', 'slider-data-attrs.ts'), `export const SliderDataAttrs = {} as const;`); writeFileSync( join(dir, 'slider', 'slider-component.ts'), `import { SliderDataAttrs } from './slider-data-attrs'; - import { SliderParts } from './slider-parts'; ${STUB} - export default defineComponent<{ orientation?: 'horizontal' | 'vertical' }>()({ + export default defineComponent()({ name: 'Slider', - parts: SliderParts, + parts: { + Root: defineComponentPart<{ orientation?: 'horizontal' | 'vertical' }>(), + Track: defineComponentPart(), + }, dataAttrs: SliderDataAttrs, });` ); diff --git a/packages/core/src/core/ui/airplay-button/airplay-button-component.ts b/packages/core/src/core/ui/airplay-button/airplay-button-component.ts index 293b4fd8..e70ed0bc 100644 --- a/packages/core/src/core/ui/airplay-button/airplay-button-component.ts +++ b/packages/core/src/core/ui/airplay-button/airplay-button-component.ts @@ -1,6 +1,6 @@ import { defineComponent } from '../manifest'; -import type { AirPlayButtonProps } from './airplay-button-core'; import { AirPlayButtonDataAttrs } from './airplay-button-data-attrs'; +import type { AirPlayButtonProps } from './props'; export default defineComponent()({ name: 'AirPlayButton', diff --git a/packages/core/src/core/ui/airplay-button/airplay-button-core.ts b/packages/core/src/core/ui/airplay-button/airplay-button-core.ts index 8224de37..ece70d8c 100644 --- a/packages/core/src/core/ui/airplay-button/airplay-button-core.ts +++ b/packages/core/src/core/ui/airplay-button/airplay-button-core.ts @@ -2,18 +2,11 @@ import { createState } from '@videojs/store'; import { supportsWebKitAirPlay } from '@videojs/utils/dom'; import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaRemotePlaybackState, RemotePlaybackConnectionState } from '../../media/state'; import type { MediaFeatureAvailability } from '../../media/types'; import type { ButtonState } from '../types'; - -export interface AirPlayButtonProps { - /** Custom label for the button. */ - label?: string | ((state: AirPlayButtonState) => string) | undefined; - /** Whether the button is disabled. */ - disabled?: boolean | undefined; -} +import { AIRPLAY_BUTTON_DEFAULT_PROPS, type AirPlayButtonProps } from './props'; export interface AirPlayButtonState extends ButtonState { /** Current AirPlay connection state. */ @@ -22,10 +15,7 @@ export interface AirPlayButtonState extends ButtonState { availability: MediaFeatureAvailability; } export class AirPlayButtonCore { - static readonly defaultProps: NonNullableObject = { - label: '', - disabled: false, - }; + static readonly defaultProps = AIRPLAY_BUTTON_DEFAULT_PROPS; readonly state = createState({ state: 'disconnected', @@ -101,3 +91,5 @@ export namespace AirPlayButtonCore { export type Props = AirPlayButtonProps; export type State = AirPlayButtonState; } + +export type { AirPlayButtonProps } from './props'; diff --git a/packages/core/src/core/ui/airplay-button/props.ts b/packages/core/src/core/ui/airplay-button/props.ts new file mode 100644 index 00000000..c5b6ade9 --- /dev/null +++ b/packages/core/src/core/ui/airplay-button/props.ts @@ -0,0 +1,15 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { AirPlayButtonState } from './airplay-button-core'; + +export interface AirPlayButtonProps { + /** Custom label for the button. */ + label?: string | ((state: AirPlayButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; +} + +export const AIRPLAY_BUTTON_DEFAULT_PROPS: NonNullableObject = { + label: '', + disabled: false, +}; diff --git a/packages/core/src/core/ui/alert-dialog/alert-dialog-component.ts b/packages/core/src/core/ui/alert-dialog/alert-dialog-component.ts index 110760eb..b27e3c65 100644 --- a/packages/core/src/core/ui/alert-dialog/alert-dialog-component.ts +++ b/packages/core/src/core/ui/alert-dialog/alert-dialog-component.ts @@ -1,9 +1,15 @@ -import { defineComponent } from '../manifest'; -import type { AlertDialogProps } from './alert-dialog-core'; +import { defineComponent, defineComponentPart } from '../manifest'; import { AlertDialogDataAttrs } from './alert-dialog-data-attrs'; +import type { AlertDialogProps } from './props'; -export default defineComponent()({ +export default defineComponent()({ name: 'AlertDialog', - parts: ['Root', 'Popup', 'Title', 'Description', 'Close'] as const, + parts: { + Root: defineComponentPart(), + Popup: defineComponentPart(), + Title: defineComponentPart(), + Description: defineComponentPart(), + Close: defineComponentPart(), + }, dataAttrs: AlertDialogDataAttrs, }); diff --git a/packages/core/src/core/ui/alert-dialog/alert-dialog-core.ts b/packages/core/src/core/ui/alert-dialog/alert-dialog-core.ts index d195b343..c526cae9 100644 --- a/packages/core/src/core/ui/alert-dialog/alert-dialog-core.ts +++ b/packages/core/src/core/ui/alert-dialog/alert-dialog-core.ts @@ -1,14 +1,6 @@ -import type { NonNullableObject } from '@videojs/utils/types'; - import type { TransitionFlags, TransitionState, TransitionStatus } from '../transition'; import { getTransitionFlags } from '../transition'; - -export interface AlertDialogProps { - /** Controlled open state. When set, the consumer is responsible for toggling. */ - open?: boolean | undefined; - /** Initial open state for uncontrolled usage. */ - defaultOpen?: boolean | undefined; -} +import { ALERT_DIALOG_DEFAULT_PROPS, type AlertDialogProps } from './props'; export interface AlertDialogInput extends TransitionState {} @@ -24,10 +16,7 @@ export interface AlertDialogState extends TransitionFlags { } export class AlertDialogCore { - static readonly defaultProps: NonNullableObject = { - open: false, - defaultOpen: false, - }; + static readonly defaultProps = ALERT_DIALOG_DEFAULT_PROPS; /** Accept props for API consistency. Props are consumed by platform layers. */ setProps(_props: AlertDialogProps): void {} @@ -74,3 +63,5 @@ export namespace AlertDialogCore { export type State = AlertDialogState; export type Input = AlertDialogInput; } + +export type { AlertDialogProps } from './props'; diff --git a/packages/core/src/core/ui/alert-dialog/props.ts b/packages/core/src/core/ui/alert-dialog/props.ts new file mode 100644 index 00000000..85e00920 --- /dev/null +++ b/packages/core/src/core/ui/alert-dialog/props.ts @@ -0,0 +1,13 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +export interface AlertDialogProps { + /** Controlled open state. When set, the consumer is responsible for toggling. */ + open?: boolean | undefined; + /** Initial open state for uncontrolled usage. */ + defaultOpen?: boolean | undefined; +} + +export const ALERT_DIALOG_DEFAULT_PROPS: NonNullableObject = { + open: false, + defaultOpen: false, +}; diff --git a/packages/core/src/core/ui/buffering-indicator/buffering-indicator-component.ts b/packages/core/src/core/ui/buffering-indicator/buffering-indicator-component.ts index de0eea4d..db4dfdd0 100644 --- a/packages/core/src/core/ui/buffering-indicator/buffering-indicator-component.ts +++ b/packages/core/src/core/ui/buffering-indicator/buffering-indicator-component.ts @@ -1,6 +1,6 @@ import { defineComponent } from '../manifest'; -import type { BufferingIndicatorProps } from './buffering-indicator-core'; import { BufferingIndicatorDataAttrs } from './buffering-indicator-data-attrs'; +import type { BufferingIndicatorProps } from './props'; export default defineComponent()({ name: 'BufferingIndicator', diff --git a/packages/core/src/core/ui/buffering-indicator/buffering-indicator-core.ts b/packages/core/src/core/ui/buffering-indicator/buffering-indicator-core.ts index 2fd404f5..59ebaf20 100644 --- a/packages/core/src/core/ui/buffering-indicator/buffering-indicator-core.ts +++ b/packages/core/src/core/ui/buffering-indicator/buffering-indicator-core.ts @@ -1,13 +1,8 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaPlaybackState } from '../../media/state'; - -export interface BufferingIndicatorProps { - /** Delay in milliseconds before the indicator becomes visible. */ - delay?: number | undefined; -} +import { BUFFERING_INDICATOR_DEFAULT_PROPS, type BufferingIndicatorProps } from './props'; export interface BufferingIndicatorState { /** Whether the indicator should be visible. True after the delay elapses while media is waiting and not paused. */ @@ -15,9 +10,7 @@ export interface BufferingIndicatorState { } export class BufferingIndicatorCore { - static readonly defaultProps: NonNullableObject = { - delay: 500, - }; + static readonly defaultProps = BUFFERING_INDICATOR_DEFAULT_PROPS; readonly state = createState({ visible: false }); @@ -58,3 +51,5 @@ export namespace BufferingIndicatorCore { export type Props = BufferingIndicatorProps; export type State = BufferingIndicatorState; } + +export type { BufferingIndicatorProps } from './props'; diff --git a/packages/core/src/core/ui/buffering-indicator/props.ts b/packages/core/src/core/ui/buffering-indicator/props.ts new file mode 100644 index 00000000..ca0863af --- /dev/null +++ b/packages/core/src/core/ui/buffering-indicator/props.ts @@ -0,0 +1,10 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +export interface BufferingIndicatorProps { + /** Delay in milliseconds before the indicator becomes visible. */ + delay?: number | undefined; +} + +export const BUFFERING_INDICATOR_DEFAULT_PROPS: NonNullableObject = { + delay: 500, +}; diff --git a/packages/core/src/core/ui/captions-button/captions-button-component.ts b/packages/core/src/core/ui/captions-button/captions-button-component.ts index da408b0f..144071c9 100644 --- a/packages/core/src/core/ui/captions-button/captions-button-component.ts +++ b/packages/core/src/core/ui/captions-button/captions-button-component.ts @@ -1,6 +1,6 @@ import { defineComponent } from '../manifest'; -import type { CaptionsButtonProps } from './captions-button-core'; import { CaptionsButtonDataAttrs } from './captions-button-data-attrs'; +import type { CaptionsButtonProps } from './props'; export default defineComponent()({ name: 'CaptionsButton', diff --git a/packages/core/src/core/ui/captions-button/captions-button-core.ts b/packages/core/src/core/ui/captions-button/captions-button-core.ts index 7bb009b3..a948ae28 100644 --- a/packages/core/src/core/ui/captions-button/captions-button-core.ts +++ b/packages/core/src/core/ui/captions-button/captions-button-core.ts @@ -2,30 +2,17 @@ import { createState } from '@videojs/store'; import { isCaptionOrSubtitleTrack } from '@videojs/utils/dom'; import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaTextTrackState } from '../../media/state'; import type { ButtonState } from '../types'; - -export interface CaptionsButtonProps { - /** Custom label for the button. */ - label?: string | ((state: CaptionsButtonState) => string) | undefined; - /** Whether the button is disabled. */ - disabled?: boolean | undefined; - /** When true with multiple tracks, pointer activation opens a menu instead of toggling. React sets this automatically inside `Menu.Trigger`. */ - menuTrigger?: boolean | undefined; -} +import { CAPTIONS_BUTTON_DEFAULT_PROPS, type CaptionsButtonProps } from './props'; export interface CaptionsButtonState extends Pick, ButtonState { availability: 'available' | 'unavailable'; } export class CaptionsButtonCore { - static readonly defaultProps: NonNullableObject = { - label: '', - disabled: false, - menuTrigger: false, - }; + static readonly defaultProps = CAPTIONS_BUTTON_DEFAULT_PROPS; readonly state = createState({ subtitlesShowing: false, @@ -95,3 +82,5 @@ export namespace CaptionsButtonCore { export type Props = CaptionsButtonProps; export type State = CaptionsButtonState; } + +export type { CaptionsButtonProps } from './props'; diff --git a/packages/core/src/core/ui/captions-button/props.ts b/packages/core/src/core/ui/captions-button/props.ts new file mode 100644 index 00000000..2416a725 --- /dev/null +++ b/packages/core/src/core/ui/captions-button/props.ts @@ -0,0 +1,18 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { CaptionsButtonState } from './captions-button-core'; + +export interface CaptionsButtonProps { + /** Custom label for the button. */ + label?: string | ((state: CaptionsButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; + /** When true with multiple tracks, pointer activation opens a menu instead of toggling. React sets this automatically inside `Menu.Trigger`. */ + menuTrigger?: boolean | undefined; +} + +export const CAPTIONS_BUTTON_DEFAULT_PROPS: NonNullableObject = { + label: '', + disabled: false, + menuTrigger: false, +}; diff --git a/packages/core/src/core/ui/captions-radio-group/captions-radio-group-component.ts b/packages/core/src/core/ui/captions-radio-group/captions-radio-group-component.ts index 721cf003..787f70db 100644 --- a/packages/core/src/core/ui/captions-radio-group/captions-radio-group-component.ts +++ b/packages/core/src/core/ui/captions-radio-group/captions-radio-group-component.ts @@ -1,6 +1,6 @@ import { defineComponent } from '../manifest'; -import type { CaptionsRadioGroupProps } from './captions-radio-group-core'; import { CaptionsRadioGroupDataAttrs } from './captions-radio-group-data-attrs'; +import type { CaptionsRadioGroupProps } from './props'; export default defineComponent()({ name: 'CaptionsRadioGroup', diff --git a/packages/core/src/core/ui/captions-radio-group/captions-radio-group-core.ts b/packages/core/src/core/ui/captions-radio-group/captions-radio-group-core.ts index 2de30b8a..f46cef60 100644 --- a/packages/core/src/core/ui/captions-radio-group/captions-radio-group-core.ts +++ b/packages/core/src/core/ui/captions-radio-group/captions-radio-group-core.ts @@ -2,19 +2,10 @@ import { createState } from '@videojs/store'; import { isCaptionOrSubtitleTrack } from '@videojs/utils/dom'; import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaTextTrack, MediaTextTrackState } from '../../media/state'; import type { ButtonState } from '../types'; - -export interface CaptionsRadioGroupProps { - /** Custom label for the menu trigger. */ - label?: string | ((state: CaptionsRadioGroupState) => string) | undefined; - /** Custom formatter for visible track labels. */ - formatTrack?: ((track: MediaTextTrack) => string) | undefined; - /** Whether track selection is disabled. */ - disabled?: boolean | undefined; -} +import { CAPTIONS_RADIO_GROUP_DEFAULT_PROPS, type CaptionsRadioGroupProps } from './props'; export interface CaptionsRadioGroupTrack { value: string; @@ -30,12 +21,6 @@ export interface CaptionsRadioGroupState extends Pick b.kind ? 1 : a.kind < b.kind ? -1 : 0; } @@ -45,11 +30,7 @@ function getCaptionTracks(textTrackList: readonly MediaTextTrack[]): MediaTextTr } export class CaptionsRadioGroupCore { - static readonly defaultProps: NonNullableObject = { - label: '', - formatTrack: formatTrackLabel, - disabled: false, - }; + static readonly defaultProps = CAPTIONS_RADIO_GROUP_DEFAULT_PROPS; readonly state = createState({ tracks: [], @@ -148,3 +129,5 @@ export namespace CaptionsRadioGroupCore { export type Props = CaptionsRadioGroupProps; export type State = CaptionsRadioGroupState; } + +export type { CaptionsRadioGroupProps } from './props'; diff --git a/packages/core/src/core/ui/captions-radio-group/props.ts b/packages/core/src/core/ui/captions-radio-group/props.ts new file mode 100644 index 00000000..f5185ff6 --- /dev/null +++ b/packages/core/src/core/ui/captions-radio-group/props.ts @@ -0,0 +1,25 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { MediaTextTrack } from '../../media/state'; +import type { CaptionsRadioGroupState } from './captions-radio-group-core'; + +export interface CaptionsRadioGroupProps { + /** Custom label for the menu trigger. */ + label?: string | ((state: CaptionsRadioGroupState) => string) | undefined; + /** Custom formatter for visible track labels. */ + formatTrack?: ((track: MediaTextTrack) => string) | undefined; + /** Whether track selection is disabled. */ + disabled?: boolean | undefined; +} + +function formatTrackLabel(track: MediaTextTrack): string { + if (track.label) return track.label; + if (track.language) return track.language; + return track.kind === 'captions' ? 'Captions' : 'Subtitles'; +} + +export const CAPTIONS_RADIO_GROUP_DEFAULT_PROPS: NonNullableObject = { + label: '', + formatTrack: formatTrackLabel, + disabled: false, +}; diff --git a/packages/core/src/core/ui/cast-button/cast-button-component.ts b/packages/core/src/core/ui/cast-button/cast-button-component.ts index 45f55eba..71b65d84 100644 --- a/packages/core/src/core/ui/cast-button/cast-button-component.ts +++ b/packages/core/src/core/ui/cast-button/cast-button-component.ts @@ -1,6 +1,6 @@ import { defineComponent } from '../manifest'; -import type { CastButtonProps } from './cast-button-core'; import { CastButtonDataAttrs } from './cast-button-data-attrs'; +import type { CastButtonProps } from './props'; export default defineComponent()({ name: 'CastButton', diff --git a/packages/core/src/core/ui/cast-button/cast-button-core.ts b/packages/core/src/core/ui/cast-button/cast-button-core.ts index c694d5f9..5af69fc9 100644 --- a/packages/core/src/core/ui/cast-button/cast-button-core.ts +++ b/packages/core/src/core/ui/cast-button/cast-button-core.ts @@ -1,18 +1,11 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaRemotePlaybackState, RemotePlaybackConnectionState } from '../../media/state'; import type { MediaFeatureAvailability } from '../../media/types'; import type { ButtonState } from '../types'; - -export interface CastButtonProps { - /** Custom label for the button. */ - label?: string | ((state: CastButtonState) => string) | undefined; - /** Whether the button is disabled. */ - disabled?: boolean | undefined; -} +import { CAST_BUTTON_DEFAULT_PROPS, type CastButtonProps } from './props'; export interface CastButtonState extends ButtonState { castState: RemotePlaybackConnectionState; @@ -20,10 +13,7 @@ export interface CastButtonState extends ButtonState { } export class CastButtonCore { - static readonly defaultProps: NonNullableObject = { - label: '', - disabled: false, - }; + static readonly defaultProps = CAST_BUTTON_DEFAULT_PROPS; readonly state = createState({ castState: 'disconnected', @@ -97,3 +87,5 @@ export namespace CastButtonCore { export type Props = CastButtonProps; export type State = CastButtonState; } + +export type { CastButtonProps } from './props'; diff --git a/packages/core/src/core/ui/cast-button/props.ts b/packages/core/src/core/ui/cast-button/props.ts new file mode 100644 index 00000000..e18ffbc9 --- /dev/null +++ b/packages/core/src/core/ui/cast-button/props.ts @@ -0,0 +1,15 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { CastButtonState } from './cast-button-core'; + +export interface CastButtonProps { + /** Custom label for the button. */ + label?: string | ((state: CastButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; +} + +export const CAST_BUTTON_DEFAULT_PROPS: NonNullableObject = { + label: '', + disabled: false, +}; diff --git a/packages/core/src/core/ui/container/container-component.ts b/packages/core/src/core/ui/container/container-component.ts index 03ef76b1..7f11c11d 100644 --- a/packages/core/src/core/ui/container/container-component.ts +++ b/packages/core/src/core/ui/container/container-component.ts @@ -1,5 +1,5 @@ import { defineComponent } from '../manifest'; -import type { ContainerProps } from './container-core'; +import type { ContainerProps } from './props'; 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 index ce2b26c0..3fd0df3a 100644 --- a/packages/core/src/core/ui/container/container-core.ts +++ b/packages/core/src/core/ui/container/container-core.ts @@ -1,2 +1 @@ -// biome-ignore lint/suspicious/noEmptyInterface: Container currently owns no source props. -export interface ContainerProps {} +export type { ContainerProps } from './props'; diff --git a/packages/core/src/core/ui/container/props.ts b/packages/core/src/core/ui/container/props.ts new file mode 100644 index 00000000..ce2b26c0 --- /dev/null +++ b/packages/core/src/core/ui/container/props.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/controls/controls-component.ts b/packages/core/src/core/ui/controls/controls-component.ts index 3b2bc67c..1254fb0f 100644 --- a/packages/core/src/core/ui/controls/controls-component.ts +++ b/packages/core/src/core/ui/controls/controls-component.ts @@ -1,8 +1,11 @@ -import { defineComponent } from '../manifest'; +import { defineComponent, defineComponentPart } from '../manifest'; import { ControlsDataAttrs } from './controls-data-attrs'; export default defineComponent()({ name: 'Controls', - parts: ['Root', 'Group'] as const, + parts: { + Root: defineComponentPart(), + Group: defineComponentPart(), + }, dataAttrs: ControlsDataAttrs, }); diff --git a/packages/core/src/core/ui/error-dialog/error-dialog-component.ts b/packages/core/src/core/ui/error-dialog/error-dialog-component.ts index 6ae60cc1..eaee1fe8 100644 --- a/packages/core/src/core/ui/error-dialog/error-dialog-component.ts +++ b/packages/core/src/core/ui/error-dialog/error-dialog-component.ts @@ -1,9 +1,15 @@ -import type { AlertDialogProps } from '../alert-dialog/alert-dialog-core'; -import { defineComponent } from '../manifest'; +import { defineComponent, defineComponentPart } from '../manifest'; import { ErrorDialogDataAttrs } from './error-dialog-data-attrs'; +import type { ErrorDialogProps } from './props'; -export default defineComponent()({ +export default defineComponent()({ name: 'ErrorDialog', - parts: ['Root', 'Popup', 'Title', 'Description', 'Close'] as const, + parts: { + Root: defineComponentPart(), + Popup: defineComponentPart(), + Title: defineComponentPart(), + Description: defineComponentPart(), + Close: defineComponentPart(), + }, dataAttrs: ErrorDialogDataAttrs, }); diff --git a/packages/core/src/core/ui/error-dialog/error-dialog-core.ts b/packages/core/src/core/ui/error-dialog/error-dialog-core.ts index 531b40b1..5ac2555e 100644 --- a/packages/core/src/core/ui/error-dialog/error-dialog-core.ts +++ b/packages/core/src/core/ui/error-dialog/error-dialog-core.ts @@ -1,4 +1,5 @@ import { AlertDialogCore, type AlertDialogState } from '../alert-dialog/alert-dialog-core'; +import type { ErrorDialogProps } from './props'; export interface ErrorDialogState extends AlertDialogState {} @@ -8,5 +9,8 @@ export class ErrorDialogCore extends AlertDialogCore { } export namespace ErrorDialogCore { + export type Props = ErrorDialogProps; export type State = ErrorDialogState; } + +export type { ErrorDialogProps } from './props'; diff --git a/packages/core/src/core/ui/error-dialog/props.ts b/packages/core/src/core/ui/error-dialog/props.ts new file mode 100644 index 00000000..bada402e --- /dev/null +++ b/packages/core/src/core/ui/error-dialog/props.ts @@ -0,0 +1,3 @@ +import type { AlertDialogProps } from '../alert-dialog/props'; + +export type ErrorDialogProps = AlertDialogProps; diff --git a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-component.ts b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-component.ts index 3b44221c..ab976440 100644 --- a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-component.ts +++ b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-component.ts @@ -1,6 +1,6 @@ import { defineComponent } from '../manifest'; -import type { FullscreenButtonProps } from './fullscreen-button-core'; import { FullscreenButtonDataAttrs } from './fullscreen-button-data-attrs'; +import type { FullscreenButtonProps } from './props'; export default defineComponent()({ name: 'FullscreenButton', diff --git a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts index 216e9ec2..c53c0043 100644 --- a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts +++ b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts @@ -1,17 +1,10 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaFullscreenState } from '../../media/state'; import type { ButtonState } from '../types'; - -export interface FullscreenButtonProps { - /** Custom label for the button. */ - label?: string | ((state: FullscreenButtonState) => string) | undefined; - /** Whether the button is disabled. */ - disabled?: boolean | undefined; -} +import { FULLSCREEN_BUTTON_DEFAULT_PROPS, type FullscreenButtonProps } from './props'; export interface FullscreenButtonState extends Pick, ButtonState { /** Whether fullscreen can be requested on this platform. */ @@ -19,10 +12,7 @@ export interface FullscreenButtonState extends Pick = { - label: '', - disabled: false, - }; + static readonly defaultProps = FULLSCREEN_BUTTON_DEFAULT_PROPS; readonly state = createState({ fullscreen: false, @@ -93,3 +83,5 @@ export namespace FullscreenButtonCore { export type Props = FullscreenButtonProps; export type State = FullscreenButtonState; } + +export type { FullscreenButtonProps } from './props'; diff --git a/packages/core/src/core/ui/fullscreen-button/props.ts b/packages/core/src/core/ui/fullscreen-button/props.ts new file mode 100644 index 00000000..286c5c3c --- /dev/null +++ b/packages/core/src/core/ui/fullscreen-button/props.ts @@ -0,0 +1,15 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { FullscreenButtonState } from './fullscreen-button-core'; + +export interface FullscreenButtonProps { + /** Custom label for the button. */ + label?: string | ((state: FullscreenButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; +} + +export const FULLSCREEN_BUTTON_DEFAULT_PROPS: NonNullableObject = { + label: '', + disabled: false, +}; diff --git a/packages/core/src/core/ui/gesture/gesture-component.ts b/packages/core/src/core/ui/gesture/gesture-component.ts index e0b46930..e07c08da 100644 --- a/packages/core/src/core/ui/gesture/gesture-component.ts +++ b/packages/core/src/core/ui/gesture/gesture-component.ts @@ -1,5 +1,5 @@ import { defineComponent } from '../manifest'; -import type { GestureProps } from './gesture-core'; +import type { GestureProps } from './props'; 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 index 017ec288..25c2f471 100644 --- a/packages/core/src/core/ui/gesture/gesture-core.ts +++ b/packages/core/src/core/ui/gesture/gesture-core.ts @@ -1,16 +1 @@ -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; -} +export type { GesturePointerType, GestureProps, GestureRegion, GestureType } from './props'; diff --git a/packages/core/src/core/ui/gesture/props.ts b/packages/core/src/core/ui/gesture/props.ts new file mode 100644 index 00000000..017ec288 --- /dev/null +++ b/packages/core/src/core/ui/gesture/props.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 index 3a79b60a..e02dff07 100644 --- a/packages/core/src/core/ui/hotkey/hotkey-component.ts +++ b/packages/core/src/core/ui/hotkey/hotkey-component.ts @@ -1,5 +1,5 @@ import { defineComponent } from '../manifest'; -import type { HotkeyProps } from './hotkey-core'; +import type { HotkeyProps } from './props'; 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 index bda52621..c9aeaa9f 100644 --- a/packages/core/src/core/ui/hotkey/hotkey-core.ts +++ b/packages/core/src/core/ui/hotkey/hotkey-core.ts @@ -1,11 +1 @@ -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; -} +export type { HotkeyProps, HotkeyTarget } from './props'; diff --git a/packages/core/src/core/ui/hotkey/props.ts b/packages/core/src/core/ui/hotkey/props.ts new file mode 100644 index 00000000..bda52621 --- /dev/null +++ b/packages/core/src/core/ui/hotkey/props.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-feedback/seek-indicator-core.ts b/packages/core/src/core/ui/input-feedback/seek-indicator-core.ts index a4f841b6..44c8e295 100644 --- a/packages/core/src/core/ui/input-feedback/seek-indicator-core.ts +++ b/packages/core/src/core/ui/input-feedback/seek-indicator-core.ts @@ -1,6 +1,7 @@ import { createState } from '@videojs/store'; -import type { IndicatorCoreProps, IndicatorLifecycleState } from './indicator-lifecycle'; +import type { SeekIndicatorProps } from '../seek-indicator/props'; +import type { IndicatorLifecycleState } from './indicator-lifecycle'; import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle'; import { formatCurrentTime, @@ -11,8 +12,6 @@ import { type MediaSnapshot, } from './status'; -export interface SeekIndicatorProps extends IndicatorCoreProps {} - export interface SeekIndicatorState extends IndicatorLifecycleState { direction: IndicatorDirection | null; count: number; @@ -110,3 +109,5 @@ export namespace SeekIndicatorCore { export type Props = SeekIndicatorProps; export type State = SeekIndicatorState; } + +export type { SeekIndicatorProps } from '../seek-indicator/props'; diff --git a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts index e7f042b7..30af9d98 100644 --- a/packages/core/src/core/ui/input-feedback/status-announcer-core.ts +++ b/packages/core/src/core/ui/input-feedback/status-announcer-core.ts @@ -1,19 +1,14 @@ import { createState } from '@videojs/store'; -import type { IndicatorCoreProps } from './indicator-lifecycle'; +import type { StatusAnnouncerProps } from '../status-announcer/props'; import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle'; import { DEFAULT_INPUT_INDICATOR_LABELS, deriveAnnouncerLabel, type InputActionEvent, - type InputIndicatorLabels, type MediaSnapshot, } from './status'; -export interface StatusAnnouncerProps extends IndicatorCoreProps { - labels?: Partial | undefined; -} - export interface StatusAnnouncerState { label: string | null; } @@ -52,3 +47,5 @@ export namespace StatusAnnouncerCore { export type Props = StatusAnnouncerProps; export type State = StatusAnnouncerState; } + +export type { StatusAnnouncerProps } from '../status-announcer/props'; diff --git a/packages/core/src/core/ui/input-feedback/status-indicator-core.ts b/packages/core/src/core/ui/input-feedback/status-indicator-core.ts index cc3c7f08..78029e6e 100644 --- a/packages/core/src/core/ui/input-feedback/status-indicator-core.ts +++ b/packages/core/src/core/ui/input-feedback/status-indicator-core.ts @@ -1,22 +1,16 @@ import { createState } from '@videojs/store'; -import type { IndicatorCoreProps, IndicatorLifecycleState } from './indicator-lifecycle'; +import type { StatusIndicatorProps } from '../status-indicator/props'; +import type { IndicatorLifecycleState } from './indicator-lifecycle'; import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle'; import { DEFAULT_INPUT_INDICATOR_LABELS, deriveStatus, - type InputAction, type InputActionEvent, - type InputIndicatorLabels, isInputActionIncluded, type MediaSnapshot, } from './status'; -export interface StatusIndicatorProps extends IndicatorCoreProps { - actions?: readonly InputAction[] | undefined; - labels?: Partial | undefined; -} - export interface StatusIndicatorState extends IndicatorLifecycleState { status: ReturnType extends infer Details ? Details extends { status: infer Status } @@ -83,3 +77,5 @@ export namespace StatusIndicatorCore { export type Props = StatusIndicatorProps; export type State = StatusIndicatorState; } + +export type { StatusIndicatorProps } from '../status-indicator/props'; diff --git a/packages/core/src/core/ui/input-feedback/volume-indicator-core.ts b/packages/core/src/core/ui/input-feedback/volume-indicator-core.ts index e43e1ffb..844750c1 100644 --- a/packages/core/src/core/ui/input-feedback/volume-indicator-core.ts +++ b/packages/core/src/core/ui/input-feedback/volume-indicator-core.ts @@ -1,6 +1,7 @@ import { createState } from '@videojs/store'; -import type { IndicatorCoreProps, IndicatorLifecycleState } from './indicator-lifecycle'; +import type { VolumeIndicatorProps } from '../volume-indicator/props'; +import type { IndicatorLifecycleState } from './indicator-lifecycle'; import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle'; import { DEFAULT_INPUT_INDICATOR_LABELS, @@ -12,8 +13,6 @@ import { predictVolumeActionOutcome, } from './status'; -export interface VolumeIndicatorProps extends IndicatorCoreProps {} - export interface VolumeIndicatorState extends IndicatorLifecycleState { level: IndicatorVolumeLevel | null; value: string | null; @@ -135,6 +134,8 @@ export namespace VolumeIndicatorCore { export type State = VolumeIndicatorState; } +export type { VolumeIndicatorProps } from '../volume-indicator/props'; + function getVolumeBoundary(event: InputActionEvent, currentVolume: number, nextVolume: number): 'min' | 'max' | null { if (event.action !== 'volumeStep' || event.value === undefined || event.value === 0) return null; if (nextVolume !== currentVolume) return null; diff --git a/packages/core/src/core/ui/live-button/live-button-component.ts b/packages/core/src/core/ui/live-button/live-button-component.ts index d97d7de2..5f90bdaa 100644 --- a/packages/core/src/core/ui/live-button/live-button-component.ts +++ b/packages/core/src/core/ui/live-button/live-button-component.ts @@ -1,6 +1,6 @@ import { defineComponent } from '../manifest'; -import type { LiveButtonProps } from './live-button-core'; import { LiveButtonDataAttrs } from './live-button-data-attrs'; +import type { LiveButtonProps } from './props'; export default defineComponent()({ name: 'LiveButton', diff --git a/packages/core/src/core/ui/live-button/live-button-core.ts b/packages/core/src/core/ui/live-button/live-button-core.ts index af1d47f3..c2222235 100644 --- a/packages/core/src/core/ui/live-button/live-button-core.ts +++ b/packages/core/src/core/ui/live-button/live-button-core.ts @@ -1,17 +1,10 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaBufferState, MediaLiveState, MediaTimeState } from '../../media/state'; import type { ButtonState } from '../types'; - -export interface LiveButtonProps { - /** Custom label for the button. */ - label?: string | ((state: LiveButtonState) => string) | undefined; - /** Whether the button is disabled. */ - disabled?: boolean | undefined; -} +import { LIVE_BUTTON_DEFAULT_PROPS, type LiveButtonProps } from './props'; /** * Fallback offset (in seconds) from the end of the seekable window used to @@ -60,10 +53,7 @@ export class LiveButtonCore { */ static defaultText = 'Live'; - static readonly defaultProps: NonNullableObject = { - label: '', - disabled: false, - }; + static readonly defaultProps = LIVE_BUTTON_DEFAULT_PROPS; readonly state = createState({ live: false, @@ -150,6 +140,8 @@ export namespace LiveButtonCore { export type MediaState = LiveButtonMediaState; } +export type { LiveButtonProps } from './props'; + function isLiveMedia(media: LiveButtonMediaState): boolean { // `targetLiveWindow` is `0` for low-latency live, `Infinity` for DVR, and // `NaN` for on-demand or unknown — finite-or-infinite means live. diff --git a/packages/core/src/core/ui/live-button/props.ts b/packages/core/src/core/ui/live-button/props.ts new file mode 100644 index 00000000..f293f357 --- /dev/null +++ b/packages/core/src/core/ui/live-button/props.ts @@ -0,0 +1,15 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { LiveButtonState } from './live-button-core'; + +export interface LiveButtonProps { + /** Custom label for the button. */ + label?: string | ((state: LiveButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; +} + +export const LIVE_BUTTON_DEFAULT_PROPS: NonNullableObject = { + label: '', + disabled: false, +}; diff --git a/packages/core/src/core/ui/manifest.ts b/packages/core/src/core/ui/manifest.ts index f4b2d228..a7f23336 100644 --- a/packages/core/src/core/ui/manifest.ts +++ b/packages/core/src/core/ui/manifest.ts @@ -5,41 +5,48 @@ export type EmptyProps = { readonly [__EMPTY_PROPS__]?: never; }; -export interface ComponentManifest< - Props extends object = EmptyProps, - Parts extends readonly string[] = readonly string[], - PartProps extends Partial> = Partial>, -> { - name: string; - parts?: Parts; - dataAttrs?: Record; - partProps?: PartProps; +export interface ComponentPart { readonly [__PROPS_BRAND__]?: Props; } -export type InferProps = - T extends ComponentManifest>> ? Props : never; +export type ComponentPartRecord = Record>; -export type InferParts = - T extends ComponentManifest>> - ? readonly string[] extends Parts - ? never - : Parts[number] - : never; +export interface ComponentManifest { + name: string; + dataAttrs?: Record; + readonly [__PROPS_BRAND__]?: Props; +} + +export interface ComponentGroupManifest { + name: string; + parts: Parts; + dataAttrs?: Record; +} + +export type AnyComponentManifest = ComponentManifest | ComponentGroupManifest; + +type ComponentDefinition = Omit, typeof __PROPS_BRAND__>; +type ComponentGroupDefinition = ComponentGroupManifest; + +interface DefineComponentFactory { + (manifest: ComponentGroupDefinition): ComponentGroupManifest; + (manifest: ComponentDefinition): ComponentManifest; +} + +export type InferProps = + T extends ComponentManifest ? Props : T extends ComponentPart ? Props : never; + +export type InferParts = T extends ComponentGroupManifest ? keyof Parts : never; export type InferPartProps = - T extends ComponentManifest - ? K extends keyof PartProps - ? PartProps[K] - : never - : never; + T extends ComponentGroupManifest ? (K extends keyof Parts ? InferProps : never) : never; + +export function defineComponentPart(): ComponentPart { + return {} as ComponentPart; +} /** Define a component manifest. */ -export function defineComponent() { - return < - const Parts extends readonly string[] = readonly string[], - const PartProps extends Partial> = Partial>, - >( - manifest: Omit, typeof __PROPS_BRAND__> - ): ComponentManifest => manifest as ComponentManifest; +export function defineComponent(): DefineComponentFactory { + return ((manifest: ComponentDefinition | ComponentGroupDefinition) => + manifest) as DefineComponentFactory; } diff --git a/packages/core/src/core/ui/menu/menu-component.ts b/packages/core/src/core/ui/menu/menu-component.ts index 32f58ca2..3567714f 100644 --- a/packages/core/src/core/ui/menu/menu-component.ts +++ b/packages/core/src/core/ui/menu/menu-component.ts @@ -1,24 +1,33 @@ -import { defineComponent } from '../manifest'; -import type { MenuProps } from './menu-core'; +import { defineComponent, defineComponentPart } from '../manifest'; import { MenuDataAttrs } from './menu-data-attrs'; +import type { + MenuBackProps, + MenuCheckboxItemProps, + MenuItemIndicatorProps, + MenuItemProps, + MenuProps, + MenuRadioGroupProps, + MenuRadioItemProps, + MenuTriggerProps, +} from './props'; -export default defineComponent()({ +export default defineComponent()({ name: 'Menu', - parts: [ - 'Root', - 'Trigger', - 'Content', - 'View', - 'Back', - 'Group', - 'GroupLabel', - 'Item', - 'ItemIndicator', - 'ItemValue', - 'CheckboxItem', - 'RadioGroup', - 'RadioItem', - 'Separator', - ] as const, + parts: { + Root: defineComponentPart(), + Trigger: defineComponentPart(), + Content: defineComponentPart(), + View: defineComponentPart(), + Back: defineComponentPart(), + Group: defineComponentPart(), + GroupLabel: defineComponentPart(), + Item: defineComponentPart(), + ItemIndicator: defineComponentPart(), + ItemValue: defineComponentPart(), + CheckboxItem: defineComponentPart(), + RadioGroup: defineComponentPart(), + RadioItem: defineComponentPart(), + Separator: defineComponentPart(), + }, dataAttrs: MenuDataAttrs, }); diff --git a/packages/core/src/core/ui/menu/menu-core.ts b/packages/core/src/core/ui/menu/menu-core.ts index ebc6e572..03ac37d1 100644 --- a/packages/core/src/core/ui/menu/menu-core.ts +++ b/packages/core/src/core/ui/menu/menu-core.ts @@ -1,27 +1,8 @@ import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; -import type { PopoverAlign, PopoverSide } from '../popover/popover-core'; import type { TransitionFlags, TransitionState, TransitionStatus } from '../transition'; import { getTransitionFlags } from '../transition'; - -export type { PopoverAlign, PopoverSide }; - -export interface MenuProps { - /** Which side of the trigger the menu appears on. Root menus only. */ - side?: PopoverSide | undefined; - /** Alignment along the trigger's edge. Root menus only. */ - align?: PopoverAlign | undefined; - /** Controlled open state. */ - open?: boolean | undefined; - /** Initial open state (uncontrolled). */ - defaultOpen?: boolean | undefined; - /** Close the menu when Escape is pressed at root level. */ - closeOnEscape?: boolean | undefined; - /** Close the menu when clicking outside. Root menus only. */ - closeOnOutsideClick?: boolean | undefined; - /** True when this menu instance is nested inside a parent menu's content. */ - isSubmenu?: boolean | undefined; -} +import { MENU_DEFAULT_PROPS, type MenuProps, type PopoverAlign, type PopoverSide } from './props'; /** Raw transition state provided by `createTransition`. */ export interface MenuInput extends TransitionState {} @@ -37,15 +18,7 @@ export interface MenuState extends TransitionFlags { /** Base menu logic: ARIA attributes and open/close state computation. */ export class MenuCore { - static readonly defaultProps: NonNullableObject = { - side: 'bottom', - align: 'start', - open: false, - defaultOpen: false, - closeOnEscape: true, - closeOnOutsideClick: true, - isSubmenu: false, - }; + static readonly defaultProps = MENU_DEFAULT_PROPS; #props = { ...MenuCore.defaultProps }; #input: MenuInput | null = null; @@ -104,3 +77,17 @@ export namespace MenuCore { export type State = MenuState; export type Input = MenuInput; } + +export type { + MenuBackProps, + MenuCheckboxItemProps, + MenuItemIndicatorProps, + MenuItemProps, + MenuItemSettingType, + MenuProps, + MenuRadioGroupProps, + MenuRadioItemProps, + MenuTriggerProps, + PopoverAlign, + PopoverSide, +} from './props'; diff --git a/packages/core/src/core/ui/menu/props.ts b/packages/core/src/core/ui/menu/props.ts new file mode 100644 index 00000000..49186fe6 --- /dev/null +++ b/packages/core/src/core/ui/menu/props.ts @@ -0,0 +1,85 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { PopoverAlign, PopoverSide } from '../popover/props'; + +export type { PopoverAlign, PopoverSide } from '../popover/props'; + +export interface MenuProps { + /** Which side of the trigger the menu appears on. Root menus only. */ + side?: PopoverSide | undefined; + /** Alignment along the trigger's edge. Root menus only. */ + align?: PopoverAlign | undefined; + /** Controlled open state. */ + open?: boolean | undefined; + /** Initial open state (uncontrolled). */ + defaultOpen?: boolean | undefined; + /** Close the menu when Escape is pressed at root level. */ + closeOnEscape?: boolean | undefined; + /** Close the menu when clicking outside. Root menus only. */ + closeOnOutsideClick?: boolean | undefined; + /** True when this menu instance is nested inside a parent menu's content. */ + isSubmenu?: boolean | undefined; +} + +export type MenuItemSettingType = 'playback-rate' | 'quality' | 'captions'; + +export interface MenuTriggerProps { + /** Disables the trigger. */ + disabled?: boolean | undefined; + /** Setting kind for submenu triggers. */ + type?: MenuItemSettingType | undefined; +} + +export interface MenuItemProps { + /** Called when the item is selected. */ + onSelect?: (() => void) | undefined; + /** Whether the item is disabled. */ + disabled?: boolean | undefined; + /** Setting kind for submenu items. */ + type?: MenuItemSettingType | undefined; +} + +export interface MenuCheckboxItemProps { + /** Whether the item is currently checked. */ + checked: boolean; + /** Called when the checked state should change. */ + onCheckedChange: (checked: boolean) => void; + /** Whether the item is disabled. */ + disabled?: boolean | undefined; +} + +export interface MenuRadioGroupProps { + /** The currently selected value. */ + value: string; + /** Called when the user selects a radio item. */ + onValueChange: (value: string) => void; +} + +export interface MenuRadioItemProps { + /** The value this item represents. */ + value: string; + /** Whether the item is disabled. */ + disabled?: boolean | undefined; +} + +export interface MenuItemIndicatorProps { + /** Whether the indicator is currently shown. */ + checked?: boolean | undefined; + /** Render even when unchecked, useful for animating out. */ + forceMount?: boolean | undefined; +} + +export interface MenuBackProps { + /** Accessible label for the back button. */ + label?: string | undefined; +} + +export const MENU_DEFAULT_PROPS: NonNullableObject = { + side: 'bottom', + align: 'start', + open: false, + defaultOpen: false, + closeOnEscape: true, + closeOnOutsideClick: true, + isSubmenu: false, +}; diff --git a/packages/core/src/core/ui/mute-button/mute-button-component.ts b/packages/core/src/core/ui/mute-button/mute-button-component.ts index 9de8a3f8..e765b9e4 100644 --- a/packages/core/src/core/ui/mute-button/mute-button-component.ts +++ b/packages/core/src/core/ui/mute-button/mute-button-component.ts @@ -1,6 +1,6 @@ import { defineComponent } from '../manifest'; -import type { MuteButtonProps } from './mute-button-core'; import { MuteButtonDataAttrs } from './mute-button-data-attrs'; +import type { MuteButtonProps } from './props'; export default defineComponent()({ name: 'MuteButton', diff --git a/packages/core/src/core/ui/mute-button/mute-button-core.ts b/packages/core/src/core/ui/mute-button/mute-button-core.ts index 769314a6..17b9ebf0 100644 --- a/packages/core/src/core/ui/mute-button/mute-button-core.ts +++ b/packages/core/src/core/ui/mute-button/mute-button-core.ts @@ -1,20 +1,13 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaVolumeState } from '../../media/state'; import type { ButtonState } from '../types'; +import { MUTE_BUTTON_DEFAULT_PROPS, type MuteButtonProps } from './props'; export type VolumeLevel = 'off' | 'low' | 'medium' | 'high'; -export interface MuteButtonProps { - /** Custom label for the button. */ - label?: string | ((state: MuteButtonState) => string) | undefined; - /** Whether the button is disabled. */ - disabled?: boolean | undefined; -} - export interface MuteButtonState extends Pick, ButtonState { /** * Derived volume level: @@ -27,10 +20,7 @@ export interface MuteButtonState extends Pick, Button } export class MuteButtonCore { - static readonly defaultProps: NonNullableObject = { - label: '', - disabled: false, - }; + static readonly defaultProps = MUTE_BUTTON_DEFAULT_PROPS; readonly state = createState({ muted: false, @@ -92,6 +82,8 @@ export namespace MuteButtonCore { export type State = MuteButtonState; } +export type { MuteButtonProps } from './props'; + function getVolumeLevel(media: MediaVolumeState): VolumeLevel { if (media.muted || media.volume === 0) return 'off'; if (media.volume < 0.5) return 'low'; diff --git a/packages/core/src/core/ui/mute-button/props.ts b/packages/core/src/core/ui/mute-button/props.ts new file mode 100644 index 00000000..1b27a8bf --- /dev/null +++ b/packages/core/src/core/ui/mute-button/props.ts @@ -0,0 +1,15 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { MuteButtonState } from './mute-button-core'; + +export interface MuteButtonProps { + /** Custom label for the button. */ + label?: string | ((state: MuteButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; +} + +export const MUTE_BUTTON_DEFAULT_PROPS: NonNullableObject = { + label: '', + disabled: false, +}; diff --git a/packages/core/src/core/ui/pip-button/pip-button-component.ts b/packages/core/src/core/ui/pip-button/pip-button-component.ts index 25d47d4a..8cf51b0a 100644 --- a/packages/core/src/core/ui/pip-button/pip-button-component.ts +++ b/packages/core/src/core/ui/pip-button/pip-button-component.ts @@ -1,6 +1,6 @@ import { defineComponent } from '../manifest'; -import type { PiPButtonProps } from './pip-button-core'; import { PiPButtonDataAttrs } from './pip-button-data-attrs'; +import type { PiPButtonProps } from './props'; export default defineComponent()({ name: 'PiPButton', diff --git a/packages/core/src/core/ui/pip-button/pip-button-core.ts b/packages/core/src/core/ui/pip-button/pip-button-core.ts index 837bd396..fb9b16e6 100644 --- a/packages/core/src/core/ui/pip-button/pip-button-core.ts +++ b/packages/core/src/core/ui/pip-button/pip-button-core.ts @@ -1,17 +1,10 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaPictureInPictureState } from '../../media/state'; import type { ButtonState } from '../types'; - -export interface PiPButtonProps { - /** Custom label for the button. */ - label?: string | ((state: PiPButtonState) => string) | undefined; - /** Whether the button is disabled. */ - disabled?: boolean | undefined; -} +import { PIP_BUTTON_DEFAULT_PROPS, type PiPButtonProps } from './props'; export interface PiPButtonState extends Pick, ButtonState { /** Whether picture-in-picture can be requested on this platform. */ @@ -19,10 +12,7 @@ export interface PiPButtonState extends Pick, } export class PiPButtonCore { - static readonly defaultProps: NonNullableObject = { - label: '', - disabled: false, - }; + static readonly defaultProps = PIP_BUTTON_DEFAULT_PROPS; readonly state = createState({ pip: false, @@ -93,3 +83,5 @@ export namespace PiPButtonCore { export type Props = PiPButtonProps; export type State = PiPButtonState; } + +export type { PiPButtonProps } from './props'; diff --git a/packages/core/src/core/ui/pip-button/props.ts b/packages/core/src/core/ui/pip-button/props.ts new file mode 100644 index 00000000..fe95fa21 --- /dev/null +++ b/packages/core/src/core/ui/pip-button/props.ts @@ -0,0 +1,15 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { PiPButtonState } from './pip-button-core'; + +export interface PiPButtonProps { + /** Custom label for the button. */ + label?: string | ((state: PiPButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; +} + +export const PIP_BUTTON_DEFAULT_PROPS: NonNullableObject = { + label: '', + disabled: false, +}; diff --git a/packages/core/src/core/ui/play-button/play-button-component.ts b/packages/core/src/core/ui/play-button/play-button-component.ts index 704cb6a4..cae06609 100644 --- a/packages/core/src/core/ui/play-button/play-button-component.ts +++ b/packages/core/src/core/ui/play-button/play-button-component.ts @@ -1,6 +1,6 @@ import { defineComponent } from '../manifest'; -import type { PlayButtonProps } from './play-button-core'; import { PlayButtonDataAttrs } from './play-button-data-attrs'; +import type { PlayButtonProps } from './props'; export default defineComponent()({ name: 'PlayButton', diff --git a/packages/core/src/core/ui/play-button/play-button-core.ts b/packages/core/src/core/ui/play-button/play-button-core.ts index c091644a..bba65718 100644 --- a/packages/core/src/core/ui/play-button/play-button-core.ts +++ b/packages/core/src/core/ui/play-button/play-button-core.ts @@ -1,25 +1,15 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaPlaybackState } from '../../media/state'; import type { ButtonState } from '../types'; - -export interface PlayButtonProps { - /** Custom label for the button. */ - label?: string | ((state: PlayButtonState) => string) | undefined; - /** Whether the button is disabled. */ - disabled?: boolean | undefined; -} +import { PLAY_BUTTON_DEFAULT_PROPS, type PlayButtonProps } from './props'; export interface PlayButtonState extends Pick, ButtonState {} export class PlayButtonCore { - static readonly defaultProps: NonNullableObject = { - label: '', - disabled: false, - }; + static readonly defaultProps = PLAY_BUTTON_DEFAULT_PROPS; readonly state = createState({ paused: true, @@ -88,3 +78,5 @@ export namespace PlayButtonCore { export type Props = PlayButtonProps; export type State = PlayButtonState; } + +export type { PlayButtonProps } from './props'; diff --git a/packages/core/src/core/ui/play-button/props.ts b/packages/core/src/core/ui/play-button/props.ts new file mode 100644 index 00000000..486c8c39 --- /dev/null +++ b/packages/core/src/core/ui/play-button/props.ts @@ -0,0 +1,15 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { PlayButtonState } from './play-button-core'; + +export interface PlayButtonProps { + /** Custom label for the button. */ + label?: string | ((state: PlayButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; +} + +export const PLAY_BUTTON_DEFAULT_PROPS: NonNullableObject = { + label: '', + disabled: false, +}; diff --git a/packages/core/src/core/ui/playback-rate-button/playback-rate-button-component.ts b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-component.ts index 07106465..8a5a0198 100644 --- a/packages/core/src/core/ui/playback-rate-button/playback-rate-button-component.ts +++ b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-component.ts @@ -1,6 +1,6 @@ import { defineComponent } from '../manifest'; -import type { PlaybackRateButtonProps } from './playback-rate-button-core'; import { PlaybackRateButtonDataAttrs } from './playback-rate-button-data-attrs'; +import type { PlaybackRateButtonProps } from './props'; export default defineComponent()({ name: 'PlaybackRateButton', diff --git a/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts index 71cd4c35..84b5b47b 100644 --- a/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts +++ b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts @@ -1,30 +1,17 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaPlaybackRateState } from '../../media/state'; import type { ButtonState } from '../types'; - -export interface PlaybackRateButtonProps { - /** Custom label for the button. */ - label?: string | ((state: PlaybackRateButtonState) => string) | undefined; - /** Whether the button is disabled. */ - disabled?: boolean | undefined; - /** When true, pointer activation opens a menu instead of cycling. React sets this automatically inside `Menu.Trigger`. */ - menuTrigger?: boolean | undefined; -} +import { PLAYBACK_RATE_BUTTON_DEFAULT_PROPS, type PlaybackRateButtonProps } from './props'; export interface PlaybackRateButtonState extends ButtonState { rate: number; } export class PlaybackRateButtonCore { - static readonly defaultProps: NonNullableObject = { - label: '', - disabled: false, - menuTrigger: false, - }; + static readonly defaultProps = PLAYBACK_RATE_BUTTON_DEFAULT_PROPS; readonly state = createState({ rate: 1, @@ -95,3 +82,5 @@ export namespace PlaybackRateButtonCore { export type Props = PlaybackRateButtonProps; export type State = PlaybackRateButtonState; } + +export type { PlaybackRateButtonProps } from './props'; diff --git a/packages/core/src/core/ui/playback-rate-button/props.ts b/packages/core/src/core/ui/playback-rate-button/props.ts new file mode 100644 index 00000000..56f324d7 --- /dev/null +++ b/packages/core/src/core/ui/playback-rate-button/props.ts @@ -0,0 +1,18 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { PlaybackRateButtonState } from './playback-rate-button-core'; + +export interface PlaybackRateButtonProps { + /** Custom label for the button. */ + label?: string | ((state: PlaybackRateButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; + /** When true, pointer activation opens a menu instead of cycling. React sets this automatically inside `Menu.Trigger`. */ + menuTrigger?: boolean | undefined; +} + +export const PLAYBACK_RATE_BUTTON_DEFAULT_PROPS: NonNullableObject = { + label: '', + disabled: false, + menuTrigger: false, +}; diff --git a/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-component.ts b/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-component.ts index b3325068..697cb297 100644 --- a/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-component.ts +++ b/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-component.ts @@ -1,6 +1,6 @@ import { defineComponent } from '../manifest'; -import type { PlaybackRateRadioGroupProps } from './playback-rate-radio-group-core'; import { PlaybackRateRadioGroupDataAttrs } from './playback-rate-radio-group-data-attrs'; +import type { PlaybackRateRadioGroupProps } from './props'; export default defineComponent()({ name: 'PlaybackRateRadioGroup', diff --git a/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-core.ts b/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-core.ts index e582c99a..8081aaca 100644 --- a/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-core.ts +++ b/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-core.ts @@ -1,19 +1,10 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import { isFunction, isUndefined } from '@videojs/utils/predicate'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaPlaybackRateState } from '../../media/state'; import type { ButtonState } from '../types'; - -export interface PlaybackRateRadioGroupProps { - /** Custom label for the options group. */ - label?: string | ((state: PlaybackRateRadioGroupState) => string) | undefined; - /** Custom formatter for visible playback rate labels. */ - formatRate?: ((rate: number) => string) | undefined; - /** Whether playback rate selection is disabled. */ - disabled?: boolean | undefined; -} +import { PLAYBACK_RATE_RADIO_GROUP_DEFAULT_PROPS, type PlaybackRateRadioGroupProps } from './props'; export interface PlaybackRateRadioGroupState extends ButtonState { rate: number; @@ -22,16 +13,8 @@ export interface PlaybackRateRadioGroupState extends ButtonState { availability: 'available' | 'unavailable'; } -function formatPlaybackRate(rate: number): string { - return `${rate}×`; -} - export class PlaybackRateRadioGroupCore { - static readonly defaultProps: NonNullableObject = { - label: '', - formatRate: formatPlaybackRate, - disabled: false, - }; + static readonly defaultProps = PLAYBACK_RATE_RADIO_GROUP_DEFAULT_PROPS; readonly state = createState({ rate: 1, @@ -120,3 +103,5 @@ export namespace PlaybackRateRadioGroupCore { export type Props = PlaybackRateRadioGroupProps; export type State = PlaybackRateRadioGroupState; } + +export type { PlaybackRateRadioGroupProps } from './props'; diff --git a/packages/core/src/core/ui/playback-rate-radio-group/props.ts b/packages/core/src/core/ui/playback-rate-radio-group/props.ts new file mode 100644 index 00000000..26ee3ad0 --- /dev/null +++ b/packages/core/src/core/ui/playback-rate-radio-group/props.ts @@ -0,0 +1,22 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { PlaybackRateRadioGroupState } from './playback-rate-radio-group-core'; + +export interface PlaybackRateRadioGroupProps { + /** Custom label for the options group. */ + label?: string | ((state: PlaybackRateRadioGroupState) => string) | undefined; + /** Custom formatter for visible playback rate labels. */ + formatRate?: ((rate: number) => string) | undefined; + /** Whether playback rate selection is disabled. */ + disabled?: boolean | undefined; +} + +function formatPlaybackRate(rate: number): string { + return `${rate}×`; +} + +export const PLAYBACK_RATE_RADIO_GROUP_DEFAULT_PROPS: NonNullableObject = { + label: '', + formatRate: formatPlaybackRate, + disabled: false, +}; diff --git a/packages/core/src/core/ui/popover/popover-component.ts b/packages/core/src/core/ui/popover/popover-component.ts index 47c9f1d0..0ffd2133 100644 --- a/packages/core/src/core/ui/popover/popover-component.ts +++ b/packages/core/src/core/ui/popover/popover-component.ts @@ -1,9 +1,14 @@ -import { defineComponent } from '../manifest'; -import type { PopoverProps } from './popover-core'; +import { defineComponent, defineComponentPart } from '../manifest'; import { PopoverDataAttrs } from './popover-data-attrs'; +import type { PopoverProps } from './props'; -export default defineComponent()({ +export default defineComponent()({ name: 'Popover', - parts: ['Root', 'Trigger', 'Popup', 'Arrow'] as const, + parts: { + Root: defineComponentPart(), + Trigger: defineComponentPart(), + Popup: defineComponentPart(), + Arrow: defineComponentPart(), + }, dataAttrs: PopoverDataAttrs, }); diff --git a/packages/core/src/core/ui/popover/popover-core.ts b/packages/core/src/core/ui/popover/popover-core.ts index 203de30f..c5c7f3b0 100644 --- a/packages/core/src/core/ui/popover/popover-core.ts +++ b/packages/core/src/core/ui/popover/popover-core.ts @@ -1,39 +1,8 @@ import { defaults } from '@videojs/utils/object'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { TransitionFlags, TransitionState, TransitionStatus } from '../transition'; import { getTransitionFlags } from '../transition'; - -export type PopoverSide = 'top' | 'bottom' | 'left' | 'right'; - -export type PopoverAlign = 'start' | 'center' | 'end'; - -export interface PopoverProps { - /** Which side of the trigger the popup appears on. */ - side?: PopoverSide | undefined; - /** Alignment of the popup along the trigger's edge. */ - align?: PopoverAlign | undefined; - /** - * - `false` (default): non-modal; background content remains interactive. - * - `true`: modal; sets `aria-modal="true"` on the popup. - * - `'trap-focus'`: reserved for future focus-trapping behavior. - */ - modal?: boolean | 'trap-focus' | undefined; - /** Close the popup when the Escape key is pressed. */ - closeOnEscape?: boolean | undefined; - /** Close the popup when clicking outside the trigger and popup. */ - closeOnOutsideClick?: boolean | undefined; - /** Controlled open state. When set, the consumer is responsible for toggling. */ - open?: boolean | undefined; - /** Initial open state for uncontrolled usage. */ - defaultOpen?: boolean | undefined; - /** Open the popup on pointer hover instead of click. */ - openOnHover?: boolean | undefined; - /** Delay in ms before opening on hover. */ - delay?: number | undefined; - /** Delay in ms before closing after pointer leaves. */ - closeDelay?: number | undefined; -} +import { POPOVER_DEFAULT_PROPS, type PopoverAlign, type PopoverProps, type PopoverSide } from './props'; /** * The raw transition state managed by `createTransition`. Uses `active` @@ -51,18 +20,7 @@ export interface PopoverState extends TransitionFlags { } export class PopoverCore { - static readonly defaultProps: NonNullableObject = { - side: 'top', - align: 'center', - modal: false, - closeOnEscape: true, - closeOnOutsideClick: true, - open: false, - defaultOpen: false, - openOnHover: false, - delay: 300, - closeDelay: 0, - }; + static readonly defaultProps = POPOVER_DEFAULT_PROPS; #props = { ...PopoverCore.defaultProps }; @@ -114,3 +72,5 @@ export namespace PopoverCore { export type State = PopoverState; export type Input = PopoverInput; } + +export type { PopoverAlign, PopoverProps, PopoverSide } from './props'; diff --git a/packages/core/src/core/ui/popover/props.ts b/packages/core/src/core/ui/popover/props.ts new file mode 100644 index 00000000..ef7d9061 --- /dev/null +++ b/packages/core/src/core/ui/popover/props.ts @@ -0,0 +1,45 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +export type PopoverSide = 'top' | 'bottom' | 'left' | 'right'; + +export type PopoverAlign = 'start' | 'center' | 'end'; + +export interface PopoverProps { + /** Which side of the trigger the popup appears on. */ + side?: PopoverSide | undefined; + /** Alignment of the popup along the trigger's edge. */ + align?: PopoverAlign | undefined; + /** + * - `false` (default): non-modal; background content remains interactive. + * - `true`: modal; sets `aria-modal="true"` on the popup. + * - `'trap-focus'`: reserved for future focus-trapping behavior. + */ + modal?: boolean | 'trap-focus' | undefined; + /** Close the popup when the Escape key is pressed. */ + closeOnEscape?: boolean | undefined; + /** Close the popup when clicking outside the trigger and popup. */ + closeOnOutsideClick?: boolean | undefined; + /** Controlled open state. When set, the consumer is responsible for toggling. */ + open?: boolean | undefined; + /** Initial open state for uncontrolled usage. */ + defaultOpen?: boolean | undefined; + /** Open the popup on pointer hover instead of click. */ + openOnHover?: boolean | undefined; + /** Delay in ms before opening on hover. */ + delay?: number | undefined; + /** Delay in ms before closing after pointer leaves. */ + closeDelay?: number | undefined; +} + +export const POPOVER_DEFAULT_PROPS: NonNullableObject = { + side: 'top', + align: 'center', + modal: false, + closeOnEscape: true, + closeOnOutsideClick: true, + open: false, + defaultOpen: false, + openOnHover: false, + delay: 300, + closeDelay: 0, +}; diff --git a/packages/core/src/core/ui/quality-radio-group/props.ts b/packages/core/src/core/ui/quality-radio-group/props.ts new file mode 100644 index 00000000..afbaa236 --- /dev/null +++ b/packages/core/src/core/ui/quality-radio-group/props.ts @@ -0,0 +1,53 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { MediaVideoRendition } from '../../media/state'; +import type { QualityRadioGroupState } from './quality-radio-group-core'; + +export interface QualityRadioGroupProps { + /** Custom label for the options group. */ + label?: string | ((state: QualityRadioGroupState) => string) | undefined; + /** Custom formatter for visible rendition labels. */ + formatRendition?: ((rendition: MediaVideoRendition) => string) | undefined; + /** Whether quality selection is disabled. */ + disabled?: boolean | undefined; +} + +const STANDARD_RENDITION_SIZES: readonly number[] = [4320, 2160, 1440, 1080, 720, 480, 360, 240]; + +export function formatBitrate(bitrate: number): string { + return bitrate >= 1_000_000 ? `${Math.round(bitrate / 100_000) / 10} Mbps` : `${Math.round(bitrate / 1000)} kbps`; +} + +function getWidescreenSize(width: number): number | undefined { + const size = Math.round((width * 9) / 16); + return STANDARD_RENDITION_SIZES.includes(size) ? size : undefined; +} + +export function getRenditionSize(rendition: MediaVideoRendition): number | undefined { + const { width, height } = rendition; + + if (width && height) { + // 4:3 and portrait renditions use their actual vertical-ish size. For wider-than-16:9 + // cinematic encodes, snap to a known 16:9 class only when the width maps cleanly. + if (width > height && width * 9 > height * 16) return getWidescreenSize(width) ?? height; + return Math.min(width, height); + } + + if (height) return height; + if (width) return getWidescreenSize(width) ?? width; + + return undefined; +} + +export function formatRenditionLabel(rendition: MediaVideoRendition): string { + const size = getRenditionSize(rendition); + if (size) return `${size}p`; + if (rendition.bitrate) return formatBitrate(rendition.bitrate); + return 'Quality'; +} + +export const QUALITY_RADIO_GROUP_DEFAULT_PROPS: NonNullableObject = { + label: '', + formatRendition: formatRenditionLabel, + disabled: false, +}; diff --git a/packages/core/src/core/ui/quality-radio-group/quality-radio-group-component.ts b/packages/core/src/core/ui/quality-radio-group/quality-radio-group-component.ts index 9b4b0220..bdba33f3 100644 --- a/packages/core/src/core/ui/quality-radio-group/quality-radio-group-component.ts +++ b/packages/core/src/core/ui/quality-radio-group/quality-radio-group-component.ts @@ -1,5 +1,5 @@ import { defineComponent } from '../manifest'; -import type { QualityRadioGroupProps } from './quality-radio-group-core'; +import type { QualityRadioGroupProps } from './props'; import { QualityRadioGroupDataAttrs } from './quality-radio-group-data-attrs'; export default defineComponent()({ diff --git a/packages/core/src/core/ui/quality-radio-group/quality-radio-group-core.ts b/packages/core/src/core/ui/quality-radio-group/quality-radio-group-core.ts index 3e837db1..53a8edbc 100644 --- a/packages/core/src/core/ui/quality-radio-group/quality-radio-group-core.ts +++ b/packages/core/src/core/ui/quality-radio-group/quality-radio-group-core.ts @@ -1,19 +1,16 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaQualityState, MediaVideoRendition } from '../../media/state'; import type { ButtonState } from '../types'; - -export interface QualityRadioGroupProps { - /** Custom label for the options group. */ - label?: string | ((state: QualityRadioGroupState) => string) | undefined; - /** Custom formatter for visible rendition labels. */ - formatRendition?: ((rendition: MediaVideoRendition) => string) | undefined; - /** Whether quality selection is disabled. */ - disabled?: boolean | undefined; -} +import { + formatBitrate, + formatRenditionLabel, + getRenditionSize, + QUALITY_RADIO_GROUP_DEFAULT_PROPS, + type QualityRadioGroupProps, +} from './props'; export interface QualityRadioGroupRendition { value: string; @@ -32,45 +29,11 @@ export interface QualityRadioGroupState extends ButtonState { export const QUALITY_AUTO_VALUE = 'auto'; -const STANDARD_RENDITION_SIZES: readonly number[] = [4320, 2160, 1440, 1080, 720, 480, 360, 240]; - -function formatBitrate(bitrate: number): string { - return bitrate >= 1_000_000 ? `${Math.round(bitrate / 100_000) / 10} Mbps` : `${Math.round(bitrate / 1000)} kbps`; -} - -function getWidescreenSize(width: number): number | undefined { - const size = Math.round((width * 9) / 16); - return STANDARD_RENDITION_SIZES.includes(size) ? size : undefined; -} - -function getRenditionSize(rendition: MediaVideoRendition): number | undefined { - const { width, height } = rendition; - - if (width && height) { - // 4:3 and portrait renditions use their actual vertical-ish size. For wider-than-16:9 - // cinematic encodes, snap to a known 16:9 class only when the width maps cleanly. - if (width > height && width * 9 > height * 16) return getWidescreenSize(width) ?? height; - return Math.min(width, height); - } - - if (height) return height; - if (width) return getWidescreenSize(width) ?? width; - - return undefined; -} - function hasSameSize(rendition: MediaVideoRendition, renditions: readonly MediaVideoRendition[]): boolean { const size = getRenditionSize(rendition); return Boolean(size && renditions.some((other) => other !== rendition && getRenditionSize(other) === size)); } -function formatRenditionLabel(rendition: MediaVideoRendition): string { - const size = getRenditionSize(rendition); - if (size) return `${size}p`; - if (rendition.bitrate) return formatBitrate(rendition.bitrate); - return 'Quality'; -} - function formatRenditionBadge( rendition: MediaVideoRendition, renditions: readonly MediaVideoRendition[] = [] @@ -107,11 +70,7 @@ function isSameRendition(a: MediaVideoRendition, b: MediaVideoRendition): boolea } export class QualityRadioGroupCore { - static readonly defaultProps: NonNullableObject = { - label: '', - formatRendition: formatRenditionLabel, - disabled: false, - }; + static readonly defaultProps = QUALITY_RADIO_GROUP_DEFAULT_PROPS; readonly state = createState({ renditions: [], @@ -249,3 +208,5 @@ export namespace QualityRadioGroupCore { export type Props = QualityRadioGroupProps; export type State = QualityRadioGroupState; } + +export type { QualityRadioGroupProps } from './props'; diff --git a/packages/core/src/core/ui/seek-button/props.ts b/packages/core/src/core/ui/seek-button/props.ts new file mode 100644 index 00000000..7145c730 --- /dev/null +++ b/packages/core/src/core/ui/seek-button/props.ts @@ -0,0 +1,18 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { SeekButtonState } from './seek-button-core'; + +export interface SeekButtonProps { + /** Seconds to seek. Positive = forward, negative = backward. Default `30`. */ + seconds?: number | undefined; + /** Custom label for the button. */ + label?: string | ((state: SeekButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; +} + +export const SEEK_BUTTON_DEFAULT_PROPS: NonNullableObject = { + seconds: 30, + label: '', + disabled: false, +}; diff --git a/packages/core/src/core/ui/seek-button/seek-button-component.ts b/packages/core/src/core/ui/seek-button/seek-button-component.ts index 5e1cb6be..58c4d1ee 100644 --- a/packages/core/src/core/ui/seek-button/seek-button-component.ts +++ b/packages/core/src/core/ui/seek-button/seek-button-component.ts @@ -1,5 +1,5 @@ import { defineComponent } from '../manifest'; -import type { SeekButtonProps } from './seek-button-core'; +import type { SeekButtonProps } from './props'; import { SeekButtonDataAttrs } from './seek-button-data-attrs'; export default defineComponent()({ diff --git a/packages/core/src/core/ui/seek-button/seek-button-core.ts b/packages/core/src/core/ui/seek-button/seek-button-core.ts index c4062016..a1720e11 100644 --- a/packages/core/src/core/ui/seek-button/seek-button-core.ts +++ b/packages/core/src/core/ui/seek-button/seek-button-core.ts @@ -1,19 +1,10 @@ import { createState } from '@videojs/store'; import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaTimeState } from '../../media/state'; import type { ButtonState } from '../types'; - -export interface SeekButtonProps { - /** Seconds to seek. Positive = forward, negative = backward. Default `30`. */ - seconds?: number | undefined; - /** Custom label for the button. */ - label?: string | ((state: SeekButtonState) => string) | undefined; - /** Whether the button is disabled. */ - disabled?: boolean | undefined; -} +import { SEEK_BUTTON_DEFAULT_PROPS, type SeekButtonProps } from './props'; export type SeekButtonDirection = 'forward' | 'backward'; @@ -25,11 +16,7 @@ export interface SeekButtonState extends ButtonState { } export class SeekButtonCore { - static readonly defaultProps: NonNullableObject = { - seconds: 30, - label: '', - disabled: false, - }; + static readonly defaultProps = SEEK_BUTTON_DEFAULT_PROPS; readonly state = createState({ seeking: false, @@ -93,3 +80,5 @@ export namespace SeekButtonCore { export type Props = SeekButtonProps; export type State = SeekButtonState; } + +export type { SeekButtonProps } from './props'; diff --git a/packages/core/src/core/ui/seek-indicator/props.ts b/packages/core/src/core/ui/seek-indicator/props.ts new file mode 100644 index 00000000..0e2e9a25 --- /dev/null +++ b/packages/core/src/core/ui/seek-indicator/props.ts @@ -0,0 +1,3 @@ +import type { IndicatorCoreProps } from '../input-feedback/indicator-lifecycle'; + +export interface SeekIndicatorProps extends IndicatorCoreProps {} diff --git a/packages/core/src/core/ui/seek-indicator/seek-indicator-component.ts b/packages/core/src/core/ui/seek-indicator/seek-indicator-component.ts index ee3898f5..1c13434a 100644 --- a/packages/core/src/core/ui/seek-indicator/seek-indicator-component.ts +++ b/packages/core/src/core/ui/seek-indicator/seek-indicator-component.ts @@ -1,9 +1,12 @@ -import type { SeekIndicatorProps } from '../input-feedback/seek-indicator-core'; import { SeekIndicatorDataAttrs } from '../input-feedback/seek-indicator-data-attrs'; -import { defineComponent } from '../manifest'; +import { defineComponent, defineComponentPart } from '../manifest'; +import type { SeekIndicatorProps } from './props'; -export default defineComponent()({ +export default defineComponent()({ name: 'SeekIndicator', - parts: ['Root', 'Value'] as const, + parts: { + Root: defineComponentPart(), + Value: defineComponentPart(), + }, dataAttrs: SeekIndicatorDataAttrs, }); diff --git a/packages/core/src/core/ui/slider/props.ts b/packages/core/src/core/ui/slider/props.ts new file mode 100644 index 00000000..977c6615 --- /dev/null +++ b/packages/core/src/core/ui/slider/props.ts @@ -0,0 +1,44 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { SliderState } from './slider-core'; + +/** Configuration shared by all slider variants. */ +export interface SliderProps { + /** Custom label for the slider. */ + label?: string | ((state: SliderState) => string) | undefined; + /** Step increment for value changes (arrow keys). */ + step?: number | undefined; + /** Large step increment (Page Up/Down keys). */ + largeStep?: number | undefined; + /** Axis of slider movement. */ + orientation?: 'horizontal' | 'vertical' | undefined; + /** Whether the slider is non-interactive. */ + disabled?: boolean | undefined; + /** How the thumb aligns at the track edges. `edge` constrains the thumb within track bounds. */ + thumbAlignment?: 'center' | 'edge' | undefined; + /** Current slider value. */ + value?: number | undefined; + /** Minimum value of the slider range. */ + min?: number | undefined; + /** Maximum value of the slider range. */ + max?: number | undefined; +} + +export interface SliderValueProps { + /** Which slider value to display. */ + type?: 'current' | 'pointer' | undefined; + /** Custom formatter for the displayed value. */ + format?: ((value: number) => string) | undefined; +} + +export const SLIDER_DEFAULT_PROPS: NonNullableObject = { + label: '', + step: 1, + largeStep: 10, + orientation: 'horizontal', + disabled: false, + thumbAlignment: 'center', + value: 0, + min: 0, + max: 100, +}; diff --git a/packages/core/src/core/ui/slider/slider-component.ts b/packages/core/src/core/ui/slider/slider-component.ts index 6cb3898a..5cf1dea3 100644 --- a/packages/core/src/core/ui/slider/slider-component.ts +++ b/packages/core/src/core/ui/slider/slider-component.ts @@ -1,9 +1,18 @@ -import { defineComponent } from '../manifest'; -import type { SliderProps } from './slider-core'; +import { defineComponent, defineComponentPart } from '../manifest'; +import type { SliderProps, SliderValueProps } from './props'; import { SliderDataAttrs } from './slider-data-attrs'; -export default defineComponent()({ +export default defineComponent()({ name: 'Slider', - parts: ['Root', 'Track', 'Fill', 'Buffer', 'Thumb', 'Thumbnail', 'Preview', 'Value'] as const, + parts: { + Root: defineComponentPart(), + Track: defineComponentPart(), + Fill: defineComponentPart(), + Buffer: defineComponentPart(), + Thumb: defineComponentPart(), + Thumbnail: defineComponentPart(), + Preview: defineComponentPart(), + Value: defineComponentPart(), + }, dataAttrs: SliderDataAttrs, }); diff --git a/packages/core/src/core/ui/slider/slider-core.ts b/packages/core/src/core/ui/slider/slider-core.ts index cc81afbc..6ee12d08 100644 --- a/packages/core/src/core/ui/slider/slider-core.ts +++ b/packages/core/src/core/ui/slider/slider-core.ts @@ -3,27 +3,7 @@ import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; import type { NonNullableObject } from '@videojs/utils/types'; -/** Configuration shared by all slider variants. */ -export interface SliderProps { - /** Custom label for the slider. */ - label?: string | ((state: SliderState) => string) | undefined; - /** Step increment for value changes (arrow keys). */ - step?: number | undefined; - /** Large step increment (Page Up/Down keys). */ - largeStep?: number | undefined; - /** Axis of slider movement. */ - orientation?: 'horizontal' | 'vertical' | undefined; - /** Whether the slider is non-interactive. */ - disabled?: boolean | undefined; - /** How the thumb aligns at the track edges. `edge` constrains the thumb within track bounds. */ - thumbAlignment?: 'center' | 'edge' | undefined; - /** Current slider value. */ - value?: number | undefined; - /** Minimum value of the slider range. */ - min?: number | undefined; - /** Maximum value of the slider range. */ - max?: number | undefined; -} +import { SLIDER_DEFAULT_PROPS, type SliderProps } from './props'; /** Current pointer/drag input state, typically provided by a DOM controller. */ export interface SliderInput { @@ -62,17 +42,7 @@ export interface SliderState { /** Base slider logic: value mapping, ARIA attrs, and step calculations. */ export class SliderCore { - static readonly defaultProps: NonNullableObject = { - label: '', - step: 1, - largeStep: 10, - orientation: 'horizontal', - disabled: false, - thumbAlignment: 'center', - value: 0, - min: 0, - max: 100, - }; + static readonly defaultProps = SLIDER_DEFAULT_PROPS; static readonly defaultInput: SliderInput = { pointerPercent: 0, @@ -198,3 +168,5 @@ export namespace SliderCore { export type State = SliderState; export type Input = SliderInput; } + +export type { SliderProps, SliderValueProps } from './props'; diff --git a/packages/core/src/core/ui/status-announcer/props.ts b/packages/core/src/core/ui/status-announcer/props.ts new file mode 100644 index 00000000..5827dae2 --- /dev/null +++ b/packages/core/src/core/ui/status-announcer/props.ts @@ -0,0 +1,6 @@ +import type { IndicatorCoreProps } from '../input-feedback/indicator-lifecycle'; +import type { InputIndicatorLabels } from '../input-feedback/status'; + +export interface StatusAnnouncerProps extends IndicatorCoreProps { + labels?: Partial | undefined; +} diff --git a/packages/core/src/core/ui/status-announcer/status-announcer-component.ts b/packages/core/src/core/ui/status-announcer/status-announcer-component.ts index 272b0500..9a301572 100644 --- a/packages/core/src/core/ui/status-announcer/status-announcer-component.ts +++ b/packages/core/src/core/ui/status-announcer/status-announcer-component.ts @@ -1,5 +1,5 @@ -import type { StatusAnnouncerProps } from '../input-feedback/status-announcer-core'; import { defineComponent } from '../manifest'; +import type { StatusAnnouncerProps } from './props'; export default defineComponent()({ name: 'StatusAnnouncer', diff --git a/packages/core/src/core/ui/status-indicator/props.ts b/packages/core/src/core/ui/status-indicator/props.ts new file mode 100644 index 00000000..a705d473 --- /dev/null +++ b/packages/core/src/core/ui/status-indicator/props.ts @@ -0,0 +1,7 @@ +import type { IndicatorCoreProps } from '../input-feedback/indicator-lifecycle'; +import type { InputAction, InputIndicatorLabels } from '../input-feedback/status'; + +export interface StatusIndicatorProps extends IndicatorCoreProps { + actions?: readonly InputAction[] | undefined; + labels?: Partial | undefined; +} diff --git a/packages/core/src/core/ui/status-indicator/status-indicator-component.ts b/packages/core/src/core/ui/status-indicator/status-indicator-component.ts index 430c013e..97e91bee 100644 --- a/packages/core/src/core/ui/status-indicator/status-indicator-component.ts +++ b/packages/core/src/core/ui/status-indicator/status-indicator-component.ts @@ -1,9 +1,12 @@ -import type { StatusIndicatorProps } from '../input-feedback/status-indicator-core'; import { StatusIndicatorDataAttrs } from '../input-feedback/status-indicator-data-attrs'; -import { defineComponent } from '../manifest'; +import { defineComponent, defineComponentPart } from '../manifest'; +import type { StatusIndicatorProps } from './props'; -export default defineComponent()({ +export default defineComponent()({ name: 'StatusIndicator', - parts: ['Root', 'Value'] as const, + parts: { + Root: defineComponentPart(), + Value: defineComponentPart(), + }, dataAttrs: StatusIndicatorDataAttrs, }); 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 942c23f1..a86aaab3 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 @@ -5,7 +5,7 @@ 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'; +import { defineComponent, defineComponentPart } from '../manifest'; const PlayButton = createComponent( defineComponent()({ @@ -16,16 +16,34 @@ const PlayButton = createComponent( const Slider = createComponent( defineComponent<{ orientation?: 'horizontal' | 'vertical'; thumbAlignment?: 'center' | 'edge' }>()({ name: 'Slider', - parts: ['Root', 'Track', 'Fill', 'Thumb'] as const, + parts: { + Root: defineComponentPart<{ orientation?: 'horizontal' | 'vertical'; thumbAlignment?: 'center' | 'edge' }>(), + Track: defineComponentPart(), + Fill: defineComponentPart(), + Thumb: defineComponentPart(), + Value: defineComponentPart<{ type?: 'current' | 'pointer' }>(), + }, }) ); const Time = createComponent( defineComponent()({ name: 'Time', - parts: ['Value'] as const, - partProps: { - Value: {} as { type: 'current' | 'duration' }, + parts: { + Value: defineComponentPart<{ type: 'current' | 'duration' }>(), + }, + }) +); + +const Menu = createComponent( + defineComponent()({ + name: 'Menu', + parts: { + Root: defineComponentPart(), + Trigger: defineComponentPart<{ type?: 'quality' | 'playback-rate' | 'captions'; disabled?: boolean }>(), + RadioGroup: defineComponentPart<{ value: string; onValueChange: (value: string) => void }>(), + RadioItem: defineComponentPart<{ value: string; disabled?: boolean }>(), + ItemIndicator: defineComponentPart<{ checked?: boolean; forceMount?: boolean }>(), }, }) ); @@ -110,6 +128,30 @@ describe('constrained JSX', () => { void (); }); + it('accepts typed component part props', () => { + void (); + void (); + void ( + + + {}}> + + + + + + ); + }); + + it('rejects invalid typed component part props', () => { + // @ts-expect-error - invalid menu setting type + void (); + // @ts-expect-error - value is required for radio items + void (); + // @ts-expect-error - invalid time value type + void (); + }); + it('rejects platform-specific intrinsic elements', () => { // @ts-expect-error - source JSX only exposes Video.js components void (
); diff --git a/packages/core/src/core/ui/thumbnail/props.ts b/packages/core/src/core/ui/thumbnail/props.ts new file mode 100644 index 00000000..23c48ba4 --- /dev/null +++ b/packages/core/src/core/ui/thumbnail/props.ts @@ -0,0 +1,12 @@ +import type { ThumbnailCrossOrigin, ThumbnailFetchPriority, ThumbnailLoading } from './types'; + +export interface ThumbnailProps { + /** Time in seconds to display the thumbnail for. */ + time?: number | undefined; + /** CORS setting forwarded to the inner ``. */ + crossOrigin?: ThumbnailCrossOrigin | undefined; + /** Image loading strategy forwarded to the inner ``. */ + loading?: ThumbnailLoading | undefined; + /** Image fetch priority hint forwarded to the inner ``. */ + fetchPriority?: ThumbnailFetchPriority | undefined; +} diff --git a/packages/core/src/core/ui/thumbnail/thumbnail-component.ts b/packages/core/src/core/ui/thumbnail/thumbnail-component.ts index 7183cc5e..74fe8660 100644 --- a/packages/core/src/core/ui/thumbnail/thumbnail-component.ts +++ b/packages/core/src/core/ui/thumbnail/thumbnail-component.ts @@ -1,5 +1,5 @@ import { defineComponent } from '../manifest'; -import type { ThumbnailProps } from './thumbnail-core'; +import type { ThumbnailProps } from './props'; import { ThumbnailDataAttrs } from './thumbnail-data-attrs'; export default defineComponent()({ diff --git a/packages/core/src/core/ui/thumbnail/thumbnail-core.ts b/packages/core/src/core/ui/thumbnail/thumbnail-core.ts index 9bf982bf..bbf3de78 100644 --- a/packages/core/src/core/ui/thumbnail/thumbnail-core.ts +++ b/packages/core/src/core/ui/thumbnail/thumbnail-core.ts @@ -1,22 +1,5 @@ -import type { - ThumbnailConstraints, - ThumbnailCrossOrigin, - ThumbnailFetchPriority, - ThumbnailImage, - ThumbnailLoading, - ThumbnailResizeResult, -} from './types'; - -export interface ThumbnailProps { - /** Time in seconds to display the thumbnail for. */ - time?: number | undefined; - /** CORS setting forwarded to the inner ``. */ - crossOrigin?: ThumbnailCrossOrigin | undefined; - /** Image loading strategy forwarded to the inner ``. */ - loading?: ThumbnailLoading | undefined; - /** Image fetch priority hint forwarded to the inner ``. */ - fetchPriority?: ThumbnailFetchPriority | undefined; -} +import type { ThumbnailProps } from './props'; +import type { ThumbnailConstraints, ThumbnailImage, ThumbnailResizeResult } from './types'; export interface ThumbnailState { /** The thumbnail image is loading. */ @@ -158,3 +141,5 @@ export namespace ThumbnailCore { export type Props = ThumbnailProps; export type State = ThumbnailState; } + +export type { ThumbnailProps } from './props'; diff --git a/packages/core/src/core/ui/time-slider/props.ts b/packages/core/src/core/ui/time-slider/props.ts new file mode 100644 index 00000000..286c78b0 --- /dev/null +++ b/packages/core/src/core/ui/time-slider/props.ts @@ -0,0 +1,20 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import { SLIDER_DEFAULT_PROPS, type SliderProps } from '../slider/props'; + +export interface TimeSliderProps extends SliderProps { + /** @internal Derived from `currentTime` — not user-settable. */ + value?: number | undefined; + /** @internal Always 0 — not user-settable. */ + min?: number | undefined; + /** @internal Derived from `duration` — not user-settable. */ + max?: number | undefined; + /** Leading+trailing throttle (ms) for `onValueChange` during drag. */ + changeThrottle?: number | undefined; +} + +export const TIME_SLIDER_DEFAULT_PROPS: NonNullableObject = { + ...SLIDER_DEFAULT_PROPS, + label: 'Seek', + changeThrottle: 100, +}; diff --git a/packages/core/src/core/ui/time-slider/time-slider-component.ts b/packages/core/src/core/ui/time-slider/time-slider-component.ts index 5d61b419..a0b9b62d 100644 --- a/packages/core/src/core/ui/time-slider/time-slider-component.ts +++ b/packages/core/src/core/ui/time-slider/time-slider-component.ts @@ -1,9 +1,18 @@ -import { defineComponent } from '../manifest'; -import type { TimeSliderProps } from './time-slider-core'; +import { defineComponent, defineComponentPart } from '../manifest'; +import type { SliderValueProps } from '../slider/props'; +import type { TimeSliderProps } from './props'; import { TimeSliderDataAttrs } from './time-slider-data-attrs'; -export default defineComponent()({ +export default defineComponent()({ name: 'TimeSlider', - parts: ['Root', 'Track', 'Fill', 'Buffer', 'Thumb', 'Preview', 'Value'] as const, + parts: { + Root: defineComponentPart(), + Track: defineComponentPart(), + Fill: defineComponentPart(), + Buffer: defineComponentPart(), + Thumb: defineComponentPart(), + Preview: defineComponentPart(), + Value: defineComponentPart(), + }, dataAttrs: TimeSliderDataAttrs, }); diff --git a/packages/core/src/core/ui/time-slider/time-slider-core.ts b/packages/core/src/core/ui/time-slider/time-slider-core.ts index 2620180a..66dca14e 100644 --- a/packages/core/src/core/ui/time-slider/time-slider-core.ts +++ b/packages/core/src/core/ui/time-slider/time-slider-core.ts @@ -1,20 +1,9 @@ import { defaults } from '@videojs/utils/object'; import { formatTimeAsPhrase } from '@videojs/utils/time'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaBufferState, MediaTimeState } from '../../media/state'; -import { SliderCore, type SliderProps, type SliderState } from '../slider/slider-core'; - -export interface TimeSliderProps extends SliderProps { - /** @internal Derived from `currentTime` — not user-settable. */ - value?: number | undefined; - /** @internal Always 0 — not user-settable. */ - min?: number | undefined; - /** @internal Derived from `duration` — not user-settable. */ - max?: number | undefined; - /** Leading+trailing throttle (ms) for `onValueChange` during drag. */ - changeThrottle?: number | undefined; -} +import { SliderCore, type SliderState } from '../slider/slider-core'; +import { TIME_SLIDER_DEFAULT_PROPS, type TimeSliderProps } from './props'; export interface TimeSliderState extends SliderState, Pick { /** Buffered amount as a percentage of duration (0–100). */ @@ -23,11 +12,7 @@ export interface TimeSliderState extends SliderState, Pick = { - ...SliderCore.defaultProps, - label: 'Seek', - changeThrottle: 100, - }; + static override readonly defaultProps = TIME_SLIDER_DEFAULT_PROPS; #props = { ...TimeSliderCore.defaultProps }; #media: (MediaTimeState & MediaBufferState) | null = null; @@ -93,3 +78,5 @@ export namespace TimeSliderCore { export type Props = TimeSliderProps; export type State = TimeSliderState; } + +export type { TimeSliderProps } from './props'; diff --git a/packages/core/src/core/ui/time/props.ts b/packages/core/src/core/ui/time/props.ts new file mode 100644 index 00000000..271dd92c --- /dev/null +++ b/packages/core/src/core/ui/time/props.ts @@ -0,0 +1,21 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { TimeState } from './time-core'; + +/** Time display type. */ +export type TimeType = 'current' | 'duration' | 'remaining'; + +export interface TimeProps { + /** Which time value to display. */ + type?: TimeType | undefined; + /** Symbol prepended to remaining time. */ + negativeSign?: string | undefined; + /** Custom label for accessibility. */ + label?: string | ((state: TimeState) => string) | undefined; +} + +export const TIME_DEFAULT_PROPS: NonNullableObject = { + type: 'current', + negativeSign: '-', + label: '', +}; diff --git a/packages/core/src/core/ui/time/time-component.ts b/packages/core/src/core/ui/time/time-component.ts index 27b3b2c6..1913313b 100644 --- a/packages/core/src/core/ui/time/time-component.ts +++ b/packages/core/src/core/ui/time/time-component.ts @@ -1,9 +1,13 @@ -import { defineComponent } from '../manifest'; -import type { TimeProps } from './time-core'; +import { defineComponent, defineComponentPart } from '../manifest'; +import type { TimeProps } from './props'; import { TimeDataAttrs } from './time-data-attrs'; -export default defineComponent()({ +export default defineComponent()({ name: 'Time', - parts: ['Group', 'Separator', 'Value'] as const, + parts: { + Group: defineComponentPart(), + Separator: defineComponentPart(), + Value: defineComponentPart(), + }, dataAttrs: TimeDataAttrs, }); diff --git a/packages/core/src/core/ui/time/time-core.ts b/packages/core/src/core/ui/time/time-core.ts index 818c402e..5e4dd958 100644 --- a/packages/core/src/core/ui/time/time-core.ts +++ b/packages/core/src/core/ui/time/time-core.ts @@ -1,21 +1,9 @@ import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; import { formatTime, formatTimeAsPhrase, secondsToIsoDuration } from '@videojs/utils/time'; -import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaTimeState } from '../../media/state'; - -/** Time display type. */ -export type TimeType = 'current' | 'duration' | 'remaining'; - -export interface TimeProps { - /** Which time value to display. */ - type?: TimeType | undefined; - /** Symbol prepended to remaining time. */ - negativeSign?: string | undefined; - /** Custom label for accessibility. */ - label?: string | ((state: TimeState) => string) | undefined; -} +import { TIME_DEFAULT_PROPS, type TimeProps, type TimeType } from './props'; export interface TimeState { /** Time display type. */ @@ -39,11 +27,7 @@ const DEFAULT_LABELS: Record = { }; export class TimeCore { - static readonly defaultProps: NonNullableObject = { - type: 'current', - negativeSign: '-', - label: '', - }; + static readonly defaultProps = TIME_DEFAULT_PROPS; #props = { ...TimeCore.defaultProps }; #media: MediaTimeState | null = null; @@ -135,3 +119,5 @@ export namespace TimeCore { export type Props = TimeProps; export type State = TimeState; } + +export type { TimeProps, TimeType } from './props'; diff --git a/packages/core/src/core/ui/tooltip/props.ts b/packages/core/src/core/ui/tooltip/props.ts new file mode 100644 index 00000000..a0dbc279 --- /dev/null +++ b/packages/core/src/core/ui/tooltip/props.ts @@ -0,0 +1,48 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { PopoverAlign, PopoverSide } from '../popover/props'; + +export interface TooltipProps { + /** Which side of the trigger the tooltip appears on. */ + side?: PopoverSide | undefined; + /** Alignment of the tooltip along the trigger's edge. */ + align?: PopoverAlign | undefined; + /** Controlled open state. */ + open?: boolean | undefined; + /** Initial open state for uncontrolled usage. */ + defaultOpen?: boolean | undefined; + /** Delay in ms before opening on hover. */ + delay?: number | undefined; + /** Delay in ms before closing after pointer leaves. */ + closeDelay?: number | undefined; + /** When true, hovering the popup does not keep it open. */ + disableHoverablePopup?: boolean | undefined; + /** When true, the tooltip is disabled and will not open. */ + disabled?: boolean | undefined; +} + +export interface TooltipGroupProps { + /** Default open delay in ms for tooltips in this group. */ + delay?: number | undefined; + /** Default close delay in ms for tooltips in this group. */ + closeDelay?: number | undefined; + /** Duration in ms after a tooltip closes during which the next tooltip opens instantly. */ + timeout?: number | undefined; +} + +export const TOOLTIP_DEFAULT_PROPS: NonNullableObject = { + side: 'top', + align: 'center', + open: false, + defaultOpen: false, + delay: 600, + closeDelay: 0, + disableHoverablePopup: true, + disabled: false, +}; + +export const TOOLTIP_GROUP_DEFAULT_PROPS: NonNullableObject = { + delay: 600, + closeDelay: 0, + timeout: 400, +}; diff --git a/packages/core/src/core/ui/tooltip/tooltip-component.ts b/packages/core/src/core/ui/tooltip/tooltip-component.ts index c1a954dc..f0adbb6c 100644 --- a/packages/core/src/core/ui/tooltip/tooltip-component.ts +++ b/packages/core/src/core/ui/tooltip/tooltip-component.ts @@ -1,9 +1,17 @@ -import { defineComponent } from '../manifest'; -import type { TooltipProps } from './tooltip-core'; +import { defineComponent, defineComponentPart } from '../manifest'; +import type { TooltipProps } from './props'; import { TooltipDataAttrs } from './tooltip-data-attrs'; -export default defineComponent()({ +export default defineComponent()({ name: 'Tooltip', - parts: ['Provider', 'Root', 'Trigger', 'Popup', 'Arrow', 'Label', 'Shortcut'] as const, + parts: { + Provider: defineComponentPart(), + Root: defineComponentPart(), + Trigger: defineComponentPart(), + Popup: defineComponentPart(), + Arrow: defineComponentPart(), + Label: defineComponentPart(), + Shortcut: defineComponentPart(), + }, dataAttrs: TooltipDataAttrs, }); diff --git a/packages/core/src/core/ui/tooltip/tooltip-core.ts b/packages/core/src/core/ui/tooltip/tooltip-core.ts index ad0d1eaa..c26f713a 100644 --- a/packages/core/src/core/ui/tooltip/tooltip-core.ts +++ b/packages/core/src/core/ui/tooltip/tooltip-core.ts @@ -1,28 +1,9 @@ import { defaults } from '@videojs/utils/object'; -import type { NonNullableObject } from '@videojs/utils/types'; -import type { PopoverAlign, PopoverSide } from '../popover/popover-core'; +import type { PopoverAlign, PopoverSide } from '../popover/props'; import type { TransitionFlags, TransitionState, TransitionStatus } from '../transition'; import { getTransitionFlags } from '../transition'; - -export interface TooltipProps { - /** Which side of the trigger the tooltip appears on. */ - side?: PopoverSide | undefined; - /** Alignment of the tooltip along the trigger's edge. */ - align?: PopoverAlign | undefined; - /** Controlled open state. */ - open?: boolean | undefined; - /** Initial open state for uncontrolled usage. */ - defaultOpen?: boolean | undefined; - /** Delay in ms before opening on hover. */ - delay?: number | undefined; - /** Delay in ms before closing after pointer leaves. */ - closeDelay?: number | undefined; - /** When true, hovering the popup does not keep it open. */ - disableHoverablePopup?: boolean | undefined; - /** When true, the tooltip is disabled and will not open. */ - disabled?: boolean | undefined; -} +import { TOOLTIP_DEFAULT_PROPS, type TooltipProps } from './props'; export interface TooltipInput extends TransitionState {} @@ -38,16 +19,7 @@ export interface TooltipState extends TransitionFlags { } export class TooltipCore { - static readonly defaultProps: NonNullableObject = { - side: 'top', - align: 'center', - open: false, - defaultOpen: false, - delay: 600, - closeDelay: 0, - disableHoverablePopup: true, - disabled: false, - }; + static readonly defaultProps = TOOLTIP_DEFAULT_PROPS; #props = { ...TooltipCore.defaultProps }; @@ -89,3 +61,5 @@ export namespace TooltipCore { export type State = TooltipState; export type Input = TooltipInput; } + +export type { TooltipProps } from './props'; diff --git a/packages/core/src/core/ui/tooltip/tooltip-group-core.ts b/packages/core/src/core/ui/tooltip/tooltip-group-core.ts index 3eaf0d64..46e6f4a6 100644 --- a/packages/core/src/core/ui/tooltip/tooltip-group-core.ts +++ b/packages/core/src/core/ui/tooltip/tooltip-group-core.ts @@ -1,21 +1,9 @@ import { defaults } from '@videojs/utils/object'; -import type { NonNullableObject } from '@videojs/utils/types'; -export interface TooltipGroupProps { - /** Default open delay in ms for tooltips in this group. */ - delay?: number | undefined; - /** Default close delay in ms for tooltips in this group. */ - closeDelay?: number | undefined; - /** Duration in ms after a tooltip closes during which the next tooltip opens instantly. */ - timeout?: number | undefined; -} +import { TOOLTIP_GROUP_DEFAULT_PROPS, type TooltipGroupProps } from './props'; export class TooltipGroupCore { - static readonly defaultProps: NonNullableObject = { - delay: 600, - closeDelay: 0, - timeout: 400, - }; + static readonly defaultProps = TOOLTIP_GROUP_DEFAULT_PROPS; #props = { ...TooltipGroupCore.defaultProps }; #lastCloseTime = 0; @@ -55,3 +43,5 @@ export class TooltipGroupCore { export namespace TooltipGroupCore { export type Props = TooltipGroupProps; } + +export type { TooltipGroupProps } from './props'; diff --git a/packages/core/src/core/ui/volume-indicator/props.ts b/packages/core/src/core/ui/volume-indicator/props.ts new file mode 100644 index 00000000..43dd2632 --- /dev/null +++ b/packages/core/src/core/ui/volume-indicator/props.ts @@ -0,0 +1,3 @@ +import type { IndicatorCoreProps } from '../input-feedback/indicator-lifecycle'; + +export interface VolumeIndicatorProps extends IndicatorCoreProps {} diff --git a/packages/core/src/core/ui/volume-indicator/volume-indicator-component.ts b/packages/core/src/core/ui/volume-indicator/volume-indicator-component.ts index 20dc446e..acbd58d8 100644 --- a/packages/core/src/core/ui/volume-indicator/volume-indicator-component.ts +++ b/packages/core/src/core/ui/volume-indicator/volume-indicator-component.ts @@ -1,9 +1,13 @@ -import type { VolumeIndicatorProps } from '../input-feedback/volume-indicator-core'; import { VolumeIndicatorDataAttrs } from '../input-feedback/volume-indicator-data-attrs'; -import { defineComponent } from '../manifest'; +import { defineComponent, defineComponentPart } from '../manifest'; +import type { VolumeIndicatorProps } from './props'; -export default defineComponent()({ +export default defineComponent()({ name: 'VolumeIndicator', - parts: ['Root', 'Fill', 'Value'] as const, + parts: { + Root: defineComponentPart(), + Fill: defineComponentPart(), + Value: defineComponentPart(), + }, dataAttrs: VolumeIndicatorDataAttrs, }); diff --git a/packages/core/src/core/ui/volume-slider/props.ts b/packages/core/src/core/ui/volume-slider/props.ts new file mode 100644 index 00000000..2aef2594 --- /dev/null +++ b/packages/core/src/core/ui/volume-slider/props.ts @@ -0,0 +1,20 @@ +import type { NonNullableObject } from '@videojs/utils/types'; + +import { SLIDER_DEFAULT_PROPS, type SliderProps } from '../slider/props'; + +export interface VolumeSliderProps extends SliderProps { + /** Step increment for wheel scrolling. */ + wheelStep?: number | undefined; + /** @internal Derived from `volume` (0–100) — not user-settable. */ + value?: number | undefined; + /** @internal Always 0 — not user-settable. */ + min?: number | undefined; + /** @internal Always 100 — not user-settable. */ + max?: number | undefined; +} + +export const VOLUME_SLIDER_DEFAULT_PROPS: NonNullableObject = { + ...SLIDER_DEFAULT_PROPS, + label: 'Volume', + wheelStep: 5, +}; diff --git a/packages/core/src/core/ui/volume-slider/volume-slider-component.ts b/packages/core/src/core/ui/volume-slider/volume-slider-component.ts index cd27eb5e..5f17b0ef 100644 --- a/packages/core/src/core/ui/volume-slider/volume-slider-component.ts +++ b/packages/core/src/core/ui/volume-slider/volume-slider-component.ts @@ -1,9 +1,17 @@ -import { defineComponent } from '../manifest'; -import type { VolumeSliderProps } from './volume-slider-core'; +import { defineComponent, defineComponentPart } from '../manifest'; +import type { SliderValueProps } from '../slider/props'; +import type { VolumeSliderProps } from './props'; import { VolumeSliderDataAttrs } from './volume-slider-data-attrs'; -export default defineComponent()({ +export default defineComponent()({ name: 'VolumeSlider', - parts: ['Root', 'Track', 'Fill', 'Thumb', 'Preview', 'Value'] as const, + parts: { + Root: defineComponentPart(), + Track: defineComponentPart(), + Fill: defineComponentPart(), + Thumb: defineComponentPart(), + Preview: defineComponentPart(), + Value: defineComponentPart(), + }, dataAttrs: VolumeSliderDataAttrs, }); diff --git a/packages/core/src/core/ui/volume-slider/volume-slider-core.ts b/packages/core/src/core/ui/volume-slider/volume-slider-core.ts index 0e77e7b9..5a139711 100644 --- a/packages/core/src/core/ui/volume-slider/volume-slider-core.ts +++ b/packages/core/src/core/ui/volume-slider/volume-slider-core.ts @@ -2,18 +2,8 @@ import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaVolumeState } from '../../media/state'; import type { MediaFeatureAvailability } from '../../media/types'; -import { SliderCore, type SliderProps, type SliderState } from '../slider/slider-core'; - -export interface VolumeSliderProps extends SliderProps { - /** Step increment for wheel scrolling. */ - wheelStep?: number | undefined; - /** @internal Derived from `volume` (0–100) — not user-settable. */ - value?: number | undefined; - /** @internal Always 0 — not user-settable. */ - min?: number | undefined; - /** @internal Always 100 — not user-settable. */ - max?: number | undefined; -} +import { SliderCore, type SliderState } from '../slider/slider-core'; +import { VOLUME_SLIDER_DEFAULT_PROPS, type VolumeSliderProps } from './props'; export interface VolumeSliderState extends SliderState, Pick { availability: MediaFeatureAvailability; @@ -21,11 +11,7 @@ export interface VolumeSliderState extends SliderState, Pick = { - ...SliderCore.defaultProps, - label: 'Volume', - wheelStep: 5, - }; + static override readonly defaultProps = VOLUME_SLIDER_DEFAULT_PROPS; #media: MediaVolumeState | null = null; @@ -86,3 +72,5 @@ export namespace VolumeSliderCore { export type Props = VolumeSliderProps; export type State = VolumeSliderState; } + +export type { VolumeSliderProps } from './props'; diff --git a/packages/core/src/jsx-runtime.ts b/packages/core/src/jsx-runtime.ts index 305930d5..b788f5ca 100644 --- a/packages/core/src/jsx-runtime.ts +++ b/packages/core/src/jsx-runtime.ts @@ -1,10 +1,16 @@ -import type { ComponentManifest, InferPartProps, InferParts, InferProps } from './core/ui/manifest'; +import type { + AnyComponentManifest, + ComponentGroupManifest, + InferPartProps, + InferParts, + InferProps, +} from './core/ui/manifest'; export const VIDEOJS_NODE = Symbol.for('@videojs/node'); declare const EMPTY_PROPS_SYMBOL: unique symbol; -type EmptyProps = { +export type EmptyProps = { readonly [EMPTY_PROPS_SYMBOL]?: never; }; @@ -33,7 +39,7 @@ export interface Component { } type PartComponentProps = K extends 'Root' - ? InferProps + ? NonNullable> : [NonNullable>] extends [never] ? EmptyProps : NonNullable>; @@ -42,11 +48,11 @@ type CompoundComponent = { [K in InferParts & string]: Component>; }; -export type CreateComponentResult = [InferParts] extends [never] - ? Component> - : CompoundComponent; +export type CreateComponentResult = M extends ComponentGroupManifest + ? CompoundComponent + : Component>; -function createComponentPart(name: string, part: string | null): Component { +function createRuntimeComponentPart(name: string, part: string | null): Component { const fn = (_props: BaseProps & Props): ComponentNode => { throw new Error(`@videojs/core: <${name}${part ? `.${part}` : ''}> can only be evaluated by the compiler.`); }; @@ -56,21 +62,17 @@ function createComponentPart(name: string, part: string | return fn as Component; } -export const Slot = createComponentPart('Slot', null); +export const Slot = createRuntimeComponentPart('Slot', null); -export function createComponent< - M extends ComponentManifest>>, ->(manifest: M): CreateComponentResult { - const parts = manifest.parts ?? []; - - if (parts.length === 0) { - return createComponentPart(manifest.name, null) as CreateComponentResult; +export function createComponent(manifest: M): CreateComponentResult { + if (!('parts' in manifest)) { + return createRuntimeComponentPart(manifest.name, null) as CreateComponentResult; } const compound: Record> = {}; - for (const part of parts) { - compound[part] = createComponentPart(manifest.name, part); + for (const part of Object.keys(manifest.parts)) { + compound[part] = createRuntimeComponentPart(manifest.name, part); } return compound as CreateComponentResult; diff --git a/packages/skins/package.json b/packages/skins/package.json index 64343ec8..571981bf 100644 --- a/packages/skins/package.json +++ b/packages/skins/package.json @@ -35,6 +35,8 @@ "@videojs/utils": "workspace:*" }, "devDependencies": { + "@videojs/core": "workspace:*", + "@videojs/icons": "workspace:*", "tsdown": "^0.21.9" }, "keywords": [ diff --git a/packages/skins/src/default/video.skin.tsx b/packages/skins/src/default/video.skin.tsx new file mode 100644 index 00000000..92aaca8c --- /dev/null +++ b/packages/skins/src/default/video.skin.tsx @@ -0,0 +1,205 @@ +/** @jsxImportSource @videojs/core */ + +import { + AirPlayButton, + CastButton, + Container, + Controls, + FullscreenButton, + Gesture, + Hotkey, + PiPButton, + PlayButton, + SeekButton, + StatusAnnouncer, + Time, + TimeSlider, + Tooltip, +} from '@videojs/core/components'; +import { + AirPlayEnterIcon, + AirPlayExitIcon, + CastEnterIcon, + CastExitIcon, + FullscreenEnterIcon, + FullscreenExitIcon, + PauseIcon, + PipEnterIcon, + PipExitIcon, + PlayIcon, + RestartIcon, + SeekIcon, +} from '@videojs/icons/components'; +import { cn } from '@videojs/utils/style'; +import { + airplayIcon, + button, + buttonGroupEnd, + buttonGroupStart, + castIcon, + container, + controls, + fullscreenIcon, + icon, + iconContainer, + iconFlipped, + pipIcon, + playIcon, + popup, + slider, + time, +} from './tailwind/video.tailwind'; + +const SEEK_TIME = 10; + +export interface DefaultVideoSkinProps { + className?: string | undefined; + children?: unknown; +} + +export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps) { + return ( + + {children} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/skins/tsconfig.json b/packages/skins/tsconfig.json index 03760dad..26d99cfd 100644 --- a/packages/skins/tsconfig.json +++ b/packages/skins/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@videojs/core", "lib": ["ES2020"], "declarationDir": "types" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b22f9622..35e826ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,6 +425,12 @@ importers: specifier: workspace:* version: link:../utils devDependencies: + '@videojs/core': + specifier: workspace:* + version: link:../core + '@videojs/icons': + specifier: workspace:* + version: link:../icons tsdown: specifier: ^0.21.9 version: 0.21.9(@typescript/native-preview@7.0.0-dev.20260421.2)(typescript@6.0.2)