From a9a09a7e52264cbf1f73bc1cf76df2bb2533470a Mon Sep 17 00:00:00 2001 From: Darius Cepulis Date: Tue, 14 Jul 2026 15:34:52 -0700 Subject: [PATCH] docs(site): complete menu radio group references with demos and options hooks (#1807) Co-authored-by: Claude Fable 5 --- .../api-reference/references/mdx-structure.md | 2 + .../src/core/ui/menu/menu-item-data-attrs.ts | 6 +- .../ui/audio-track/use-audio-track-options.ts | 6 + .../use-captions-options.ts | 7 + .../use-playback-rate-options.ts | 6 + .../src/ui/quality/use-quality-options.ts | 7 + site/scripts/api-docs-builder/src/pipeline.ts | 73 ++++++- .../api-docs-builder/src/tests/e2e.test.ts | 73 ++++++- .../core/ui/gauge/gauge-label-data-attrs.ts | 21 ++ .../monorepo/packages/react/src/index.ts | 2 + .../react/src/ui/rate-options/index.ts | 8 + .../react/src/ui/rate-options/legacy/index.js | 5 + .../src/ui/rate-options/use-rate-options.ts | 39 ++++ .../packages/react/src/utils/external.ts | 1 + site/scripts/api-docs-builder/src/types.ts | 8 + .../api-docs-builder/src/util-handler.ts | 185 +++++++++++++----- site/scripts/api-docs-builder/src/utils.ts | 46 +++++ .../html/css/BasicUsage.astro | 9 + .../html/css/BasicUsage.css | 75 +++++++ .../html/css/BasicUsage.html | 26 +++ .../html/css/BasicUsage.ts | 4 + .../react/css/BasicUsage.css | 75 +++++++ .../react/css/BasicUsage.tsx | 50 +++++ .../html/css/BasicUsage.astro | 9 + .../html/css/BasicUsage.css | 75 +++++++ .../html/css/BasicUsage.html | 27 +++ .../html/css/BasicUsage.ts | 3 + .../react/css/BasicUsage.css | 75 +++++++ .../react/css/BasicUsage.tsx | 57 ++++++ .../docs/demos/menu/html/css/BasicUsage.html | 2 +- .../docs/demos/menu/react/css/BasicUsage.tsx | 2 +- .../html/css/BasicUsage.astro | 9 + .../html/css/BasicUsage.css | 75 +++++++ .../html/css/BasicUsage.html | 24 +++ .../html/css/BasicUsage.ts | 3 + .../react/css/BasicUsage.css | 75 +++++++ .../react/css/BasicUsage.tsx | 54 +++++ .../html/css/BasicUsage.astro | 9 + .../html/css/BasicUsage.css | 85 ++++++++ .../html/css/BasicUsage.html | 30 +++ .../html/css/BasicUsage.ts | 4 + .../react/css/BasicUsage.css | 85 ++++++++ .../react/css/BasicUsage.tsx | 54 +++++ .../reference/audio-track-radio-group.mdx | 67 +++++-- .../docs/reference/captions-radio-group.mdx | 122 ++++++++++++ site/src/content/docs/reference/menu.mdx | 2 +- .../reference/playback-rate-radio-group.mdx | 122 ++++++++++++ .../docs/reference/quality-radio-group.mdx | 67 +++++-- .../reference/use-audio-track-options.mdx | 35 ++++ .../docs/reference/use-captions-options.mdx | 35 ++++ .../reference/use-playback-rate-options.mdx | 35 ++++ .../docs/reference/use-quality-options.mdx | 35 ++++ .../docs/reference/write-references.mdx | 13 +- site/src/docs.config.ts | 6 + 54 files changed, 1953 insertions(+), 77 deletions(-) create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-label-data-attrs.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/rate-options/index.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/rate-options/legacy/index.js create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/rate-options/use-rate-options.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/utils/external.ts create mode 100644 site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.astro create mode 100644 site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.html create mode 100644 site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.ts create mode 100644 site/src/components/docs/demos/audio-track-radio-group/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/audio-track-radio-group/react/css/BasicUsage.tsx create mode 100644 site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.astro create mode 100644 site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.html create mode 100644 site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.ts create mode 100644 site/src/components/docs/demos/captions-radio-group/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/captions-radio-group/react/css/BasicUsage.tsx create mode 100644 site/src/components/docs/demos/playback-rate-radio-group/html/css/BasicUsage.astro create mode 100644 site/src/components/docs/demos/playback-rate-radio-group/html/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/playback-rate-radio-group/html/css/BasicUsage.html create mode 100644 site/src/components/docs/demos/playback-rate-radio-group/html/css/BasicUsage.ts create mode 100644 site/src/components/docs/demos/playback-rate-radio-group/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/playback-rate-radio-group/react/css/BasicUsage.tsx create mode 100644 site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.astro create mode 100644 site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.html create mode 100644 site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.ts create mode 100644 site/src/components/docs/demos/quality-radio-group/react/css/BasicUsage.css create mode 100644 site/src/components/docs/demos/quality-radio-group/react/css/BasicUsage.tsx create mode 100644 site/src/content/docs/reference/captions-radio-group.mdx create mode 100644 site/src/content/docs/reference/playback-rate-radio-group.mdx create mode 100644 site/src/content/docs/reference/use-audio-track-options.mdx create mode 100644 site/src/content/docs/reference/use-captions-options.mdx create mode 100644 site/src/content/docs/reference/use-playback-rate-options.mdx create mode 100644 site/src/content/docs/reference/use-quality-options.mdx diff --git a/.claude/skills/api-reference/references/mdx-structure.md b/.claude/skills/api-reference/references/mdx-structure.md index 348fddbd..074e5224 100644 --- a/.claude/skills/api-reference/references/mdx-structure.md +++ b/.claude/skills/api-reference/references/mdx-structure.md @@ -72,6 +72,8 @@ import basicUsageHtmlTs from "@/components/docs/demos/{component}/html/css/Basic ## Anatomy Section +Anatomy shows part nesting with self-closing placeholders, following the Base UI anatomy convention. No hooks, state, handlers, or option mapping — working code belongs in Examples. This holds even when a component has no React component form (e.g. the radio groups, whose React API is a hook feeding `Menu.RadioGroup`): show the part skeleton and let the Behavior prose link to the hook for wiring. + ```mdx ## Anatomy diff --git a/packages/core/src/core/ui/menu/menu-item-data-attrs.ts b/packages/core/src/core/ui/menu/menu-item-data-attrs.ts index 9fa97360..76cb4ff6 100644 --- a/packages/core/src/core/ui/menu/menu-item-data-attrs.ts +++ b/packages/core/src/core/ui/menu/menu-item-data-attrs.ts @@ -1,4 +1,8 @@ -/** Data attributes set on all navigable menu item elements. */ +/** + * Data attributes set on all navigable menu item elements. + * + * @parts item, radio-item, checkbox-item, trigger + */ export const MenuItemDataAttrs = { /** * Present on all navigable item types: Item, RadioItem, CheckboxItem, and diff --git a/packages/react/src/ui/audio-track/use-audio-track-options.ts b/packages/react/src/ui/audio-track/use-audio-track-options.ts index 8753173a..8200aaff 100644 --- a/packages/react/src/ui/audio-track/use-audio-track-options.ts +++ b/packages/react/src/ui/audio-track/use-audio-track-options.ts @@ -24,6 +24,12 @@ export interface AudioTrackOptionsResult { setValue: (value: string) => void; } +/** + * Create audio track menu options from the player audio track state. Returns + * `null` when the audio track feature is not configured. + * + * @param props - Optional `label`, `formatTrack`, and `disabled` overrides. + */ export function useAudioTrackOptions(props?: AudioTrackOptionsProps): AudioTrackOptionsResult | null { 'use no memo'; diff --git a/packages/react/src/ui/captions-radio-group/use-captions-options.ts b/packages/react/src/ui/captions-radio-group/use-captions-options.ts index 2f665f39..791b12e9 100644 --- a/packages/react/src/ui/captions-radio-group/use-captions-options.ts +++ b/packages/react/src/ui/captions-radio-group/use-captions-options.ts @@ -25,6 +25,13 @@ export interface CaptionsOptionsResult { setValue: (value: string) => void; } +/** + * Create captions menu options (including an `Off` option) from the player + * text track state. Returns `null` when the text tracks feature is not + * configured. + * + * @param props - Optional `label`, `formatTrack`, and `disabled` overrides. + */ export function useCaptionsOptions(props?: CaptionsOptionsProps): CaptionsOptionsResult | null { 'use no memo'; diff --git a/packages/react/src/ui/playback-rate/use-playback-rate-options.ts b/packages/react/src/ui/playback-rate/use-playback-rate-options.ts index e475c0c6..ae1dc644 100644 --- a/packages/react/src/ui/playback-rate/use-playback-rate-options.ts +++ b/packages/react/src/ui/playback-rate/use-playback-rate-options.ts @@ -28,6 +28,12 @@ export interface PlaybackRateOptionsResult { setValue: (value: string) => void; } +/** + * Create playback rate menu options from the player playback rate state. + * Returns `null` when the playback rate feature is not configured. + * + * @param props - Optional `label`, `formatRate`, and `disabled` overrides. + */ export function usePlaybackRateOptions(props?: PlaybackRateOptionsProps): PlaybackRateOptionsResult | null { const media = usePlayer(selectPlaybackRate); const [core] = useState(() => new PlaybackRateRadioGroupCoreClass()); diff --git a/packages/react/src/ui/quality/use-quality-options.ts b/packages/react/src/ui/quality/use-quality-options.ts index 16a93ada..52e7806f 100644 --- a/packages/react/src/ui/quality/use-quality-options.ts +++ b/packages/react/src/ui/quality/use-quality-options.ts @@ -36,6 +36,13 @@ function resolveAutoLabel(t: Translator, label: string): string { return resolveTranslation(t, label); } +/** + * Create quality menu options (including an `Auto` option) from the player + * video rendition state. Returns `null` when the quality feature is not + * configured. + * + * @param props - Optional `label`, `formatRendition`, and `disabled` overrides. + */ export function useQualityOptions(props?: QualityOptionsProps): QualityOptionsResult | null { 'use no memo'; diff --git a/site/scripts/api-docs-builder/src/pipeline.ts b/site/scripts/api-docs-builder/src/pipeline.ts index 2628211e..ae56b749 100644 --- a/site/scripts/api-docs-builder/src/pipeline.ts +++ b/site/scripts/api-docs-builder/src/pipeline.ts @@ -23,12 +23,13 @@ import type { CSSVarsExtraction, DataAttrDef, DataAttrsExtraction, + ExtraDataAttrsSource, PartReference, PartSource, PropDef, StateDef, } from './types.js'; -import { kebabToPascal, partKebabFromSource, sortProps } from './utils.js'; +import { getJSDocTagValue, kebabToPascal, log, partKebabFromSource, sortProps } from './utils.js'; // ─── Overrides ───────────────────────────────────────────────────── @@ -105,6 +106,48 @@ export function buildCSSVars(cssVarsData: CSSVarsExtraction): Record { + if (!ts.isVariableStatement(node)) return; + const declaresExport = node.declarationList.declarations.some( + (decl) => ts.isIdentifier(decl.name) && decl.name.text === exportName + ); + if (declaresExport) tagValue = getJSDocTagValue(node, 'parts'); + }); + if (!tagValue) continue; + + const parts = tagValue + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + if (parts.length === 0) continue; + + extras.push({ path: filePath, parts }); + } + + return extras; +} + export function discoverComponents(monorepoRoot: string): ComponentSource[] { const coreUiPath = path.join(monorepoRoot, 'packages/core/src/core/ui'); const htmlUiPath = path.join(monorepoRoot, 'packages/html/src/ui'); @@ -142,6 +185,9 @@ export function discoverComponents(monorepoRoot: string): ComponentSource[] { const partsIndexFile = path.join(reactUiPath, dir.name, 'index.parts.ts'); if (fs.existsSync(partsIndexFile)) source.partsIndexPath = partsIndexFile; + const extraDataAttrs = discoverExtraDataAttrs(componentDir, dir.name); + if (extraDataAttrs.length > 0) source.extraDataAttrs = extraDataAttrs; + if (source.corePath) { components.push(source); } @@ -154,7 +200,6 @@ export function discoverComponents(monorepoRoot: string): ComponentSource[] { export function createComponentProgram(sources: ComponentSource[], monorepoRoot: string): ts.Program { const htmlUiPath = path.join(monorepoRoot, 'packages/html/src/ui'); - const coreUiPath = path.join(monorepoRoot, 'packages/core/src/core/ui'); const files: string[] = []; for (const source of sources) { @@ -163,6 +208,7 @@ export function createComponentProgram(sources: ComponentSource[], monorepoRoot: if (source.cssVarsPath) files.push(source.cssVarsPath); if (source.htmlPath) files.push(source.htmlPath); if (source.partsIndexPath) files.push(source.partsIndexPath); + if (source.extraDataAttrs) files.push(...source.extraDataAttrs.map((extra) => extra.path)); if (source.partsIndexPath) { const htmlDir = path.join(htmlUiPath, source.kebab); @@ -458,6 +504,25 @@ function buildMultiPartReference( } } + for (const extra of source.extraDataAttrs ?? []) { + const componentName = dataAttrsComponentName(path.basename(extra.path)); + const extraData = extractDataAttrs(extra.path, program, componentName); + if (!extraData) { + log.warn(`No ${componentName}DataAttrs export found in ${extra.path}; skipping @parts merge`); + continue; + } + + const extraAttrs = buildDataAttrs(extraData); + for (const partKebab of extra.parts) { + const partRef = partsRecord[partKebab]; + if (!partRef) { + log.warn(`@parts in ${extra.path} references unknown part "${partKebab}" on ${source.name}`); + continue; + } + partRef.dataAttributes = { ...partRef.dataAttributes, ...extraAttrs }; + } + } + return { name: source.name, props: {}, @@ -481,6 +546,10 @@ export function buildComponentReference( } } + if (source.extraDataAttrs?.length) { + log.warn(`Ignoring @parts data-attrs in ${source.kebab}: ${source.name} is not a multi-part component`); + } + return buildSingleComponentReference(source, program); } diff --git a/site/scripts/api-docs-builder/src/tests/e2e.test.ts b/site/scripts/api-docs-builder/src/tests/e2e.test.ts index d6b322ed..2b1121e4 100644 --- a/site/scripts/api-docs-builder/src/tests/e2e.test.ts +++ b/site/scripts/api-docs-builder/src/tests/e2e.test.ts @@ -23,7 +23,9 @@ * Core instantiation, sub-parts with/without HTML elements, * React-only parts (no platforms.html), sub-part data-attr * inheritance (stateAttrMap heuristic), non-boolean type - * inference (number, string literal union via type alias). + * inference (number, string literal union via type alias), + * extra @parts-tagged data-attrs files attaching to the + * listed parts (gauge-label-data-attrs.ts). * slider/ — Base multi-part component. Exercises: base component whose * parts are re-exported by domain variants. * volume-slider/ — Domain variant. Exercises: re-exported parts from slider, @@ -35,6 +37,13 @@ * create* factory, mixin display name stripping, selector discovery, * @label overloads, slug collision (react vs html create-player), * framework assignment. + * ui/rate-options/ — Hook re-exported through a directory index + * (entry index → ./ui/rate-options → ./use-rate-options), with a + * namespace merged onto the function (Props/Result pattern). + * Exercises: recursive re-export resolution in util discovery, + * entry-visibility filtering (useRateInternals is scanned but never + * re-exported to the entry), and skipping re-exports that resolve to + * a directory with no index.ts (./legacy holds only compiled JS). * * Features (packages/core/src/dom/store/features/): * playback.ts — Simple feature. Exercises: boolean state properties, @@ -343,6 +352,28 @@ describe('Component pipeline (end-to-end)', () => { expect(label.platforms.react).toEqual({}); expect(label.platforms.html).toBeUndefined(); }); + + // Extra data-attrs files ({component}-{x}-data-attrs.ts, next to the + // main {component}-data-attrs.ts) declare their target parts with a + // @parts JSDoc tag. This covers attrs that a DOM layer applies to + // parts directly, invisible to the per-part stateAttrMap heuristic + // (e.g. menu-item-data-attrs.ts applied by create-menu.ts). + it('extra @parts-tagged data-attrs file attaches to listed parts', () => { + const parts = findComponent('Gauge')!.reference.parts!; + + // label: no other attrs — gets the extra file's attrs + expect(parts.label!.dataAttributes['data-emphasized']).toMatchObject({ + description: 'Present when the value is emphasized.', + }); + + // fill: extra attrs merge with attrs inherited via stateAttrMap + expect(parts.fill!.dataAttributes['data-emphasized']).toBeDefined(); + expect(parts.fill!.dataAttributes['data-percentage']).toBeDefined(); + + // parts not listed in @parts are untouched + expect(parts.track!.dataAttributes).toEqual({}); + expect(parts.indicator!.dataAttributes['data-emphasized']).toBeUndefined(); + }); }); // ───────────────────────────────────────────────────────────────── @@ -539,6 +570,39 @@ describe('Util pipeline (end-to-end)', () => { expect(findByName('useFormat', 'react')).toBeDefined(); }); + // Entry indexes often re-export hooks through a directory index + // (entry index → ./ui/rate-options → ./use-rate-options). Discovery + // must follow re-export hops to the declaring module so JSDoc and + // overload extraction read the real source file. The fixture also + // merges a namespace onto the hook (the repo's Props/Result pattern), + // which must not break FunctionNode detection. + it('discovers hooks re-exported through a directory index', () => { + const entry = findByName('useRateOptions', 'react'); + expect(entry).toBeDefined(); + expect(entry!.slug).toBe('use-rate-options'); + expect(entry!.data.description).toContain('Create rate menu options'); + + const overload = entry!.data.overloads[0]!; + expect(overload.parameters.props).toBeDefined(); + expect(overload.parameters.props!.description).toContain('formatRate'); + }); + + // Whole modules are scanned, but only names visible from the entry + // point (through named re-exports and local `export *` chains) are + // public API. useRateInternals matches the use* convention and lives + // in a scanned file, but is never re-exported up to the entry. + it('excludes exports that are not visible from the entry point', () => { + expect(findByName('useRateInternals', 'react')).toBeUndefined(); + }); + + // rate-options/index.ts re-exports './legacy', which resolves to a + // directory with no index.ts (only compiled index.js). Discovery must + // skip it — not crash reading a directory — and the unreachable export + // stays undocumented. + it('skips re-exports that resolve to a directory without index.ts', () => { + expect(findByName('useLegacyRate', 'react')).toBeUndefined(); + }); + it('discovers controllers from HTML entry points', () => { expect(findByName('PlayerController', 'html')).toBeDefined(); expect(findByName('SnapshotController', 'html')).toBeDefined(); @@ -562,6 +626,13 @@ describe('Util pipeline (end-to-end)', () => { expect(findByName('createPlayer', 'html')).toBeDefined(); expect(findByName('createSelector', null)).toBeDefined(); }); + + it('leaves external re-exports with their canonical entry point', () => { + const matches = entries.filter((entry) => entry.data.name === 'createSelector'); + + expect(matches).toHaveLength(1); + expect(matches[0]).toMatchObject({ slug: 'create-selector', framework: null }); + }); }); // ───────────────────────────────────────────────────────────────── diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-label-data-attrs.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-label-data-attrs.ts new file mode 100644 index 00000000..35f0a51a --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/gauge/gauge-label-data-attrs.ts @@ -0,0 +1,21 @@ +/** + * Data attributes fixture for part-scoped attrs files. + * + * Exercises: extra data-attrs files ({component}-{x}-data-attrs.ts) declare + * their target parts with a @parts JSDoc tag on the exported const. Listed + * parts get the attrs merged into whatever they already have — plain attach + * (label) and merge with attrs inherited via the stateAttrMap heuristic + * (fill). This header's own raw "@parts" mention is a deliberate hazard: + * the builder must bind to the tag in the JSDoc block closest to the export, + * not the first match anywhere in the file. + */ + +/** + * Data attributes set on caption-like parts. + * + * @parts label, fill + */ +export const GaugeLabelDataAttrs = { + /** Present when the value is emphasized. */ + emphasized: 'data-emphasized', +} as const; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/index.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/index.ts index 5f88ace1..1a14fc5f 100644 --- a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/index.ts +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/index.ts @@ -1,4 +1,6 @@ export { usePlayer } from './player/context'; export { createPlayer } from './player/create-player'; +export { useRateOptions } from './ui/rate-options'; +export { createSelector } from './utils/external'; export { mergeProps } from './utils/merge-props'; export { useFormat } from './utils/use-format'; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/rate-options/index.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/rate-options/index.ts new file mode 100644 index 00000000..30470250 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/rate-options/index.ts @@ -0,0 +1,8 @@ +// Resolves to a directory with no index.ts — discovery must skip it, not crash. +export { useLegacyRate } from './legacy'; +export { + type RateOption, + type RateOptionsProps, + type RateOptionsResult, + useRateOptions, +} from './use-rate-options'; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/rate-options/legacy/index.js b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/rate-options/legacy/index.js new file mode 100644 index 00000000..7846ad9c --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/rate-options/legacy/index.js @@ -0,0 +1,5 @@ +// Compiled-only module (no TypeScript source): exercises discovery skipping +// a re-export specifier that resolves to a directory without index.ts/.tsx. +export function useLegacyRate() { + return 1; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/rate-options/use-rate-options.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/rate-options/use-rate-options.ts new file mode 100644 index 00000000..9c243a1b --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/rate-options/use-rate-options.ts @@ -0,0 +1,39 @@ +export interface RateOptionsProps { + /** Custom formatter for visible rate labels. */ + formatRate?: ((rate: number) => string) | undefined; + /** Whether rate selection is disabled. */ + disabled?: boolean | undefined; +} + +export interface RateOption { + rate: number; + label: string; + disabled: boolean; +} + +export interface RateOptionsResult { + rate: number; + options: RateOption[]; + setRate: (rate: number) => void; +} + +/** + * Create rate menu options from the player rate state. Returns `null` when + * the rate feature is not configured. + * + * @param props - Optional `formatRate` and `disabled` overrides. + */ +export function useRateOptions(props?: RateOptionsProps): RateOptionsResult | null { + return props?.disabled ? null : { rate: 1, options: [], setRate: () => {} }; +} + +export namespace useRateOptions { + export type Props = RateOptionsProps; + export type Result = RateOptionsResult; + export type Option = RateOption; +} + +/** Internal helper — matches the use* convention but is never re-exported to the entry point. */ +export function useRateInternals(): number { + return 1; +} diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/utils/external.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/utils/external.ts new file mode 100644 index 00000000..99d658a5 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/utils/external.ts @@ -0,0 +1 @@ +export { createContext as createSelector } from 'react'; diff --git a/site/scripts/api-docs-builder/src/types.ts b/site/scripts/api-docs-builder/src/types.ts index 41c51a1b..e446af14 100644 --- a/site/scripts/api-docs-builder/src/types.ts +++ b/site/scripts/api-docs-builder/src/types.ts @@ -59,6 +59,14 @@ export interface ComponentSource { htmlPath?: string; /** Path to index.parts.ts (if multi-part) */ partsIndexPath?: string; + /** Extra part-scoped data-attrs files ({kebab}-{x}-data-attrs.ts with a `@parts` tag) */ + extraDataAttrs?: ExtraDataAttrsSource[]; +} + +export interface ExtraDataAttrsSource { + path: string; + /** Part kebabs listed in the `@parts` JSDoc tag on the file's export */ + parts: string[]; } /** diff --git a/site/scripts/api-docs-builder/src/util-handler.ts b/site/scripts/api-docs-builder/src/util-handler.ts index 3e6fe68d..7dea6884 100644 --- a/site/scripts/api-docs-builder/src/util-handler.ts +++ b/site/scripts/api-docs-builder/src/util-handler.ts @@ -45,6 +45,7 @@ import { } from '../../../src/types/util-reference.js'; import { utilReferenceSlug } from '../../../src/utils/utilReferenceSlug.js'; import { abbreviateType, formatDetailedType, formatType } from './formatter.js'; +import { getJSDocTagValue, hasJSDocTag } from './utils.js'; const PREFIX = '\x1b[35m[api-docs-builder]\x1b[0m'; @@ -90,11 +91,12 @@ function resolveModulePath(fromFile: string, specifier: string): string { const dir = path.dirname(fromFile); const resolved = path.resolve(dir, specifier); - // Try exact match, then with extensions + // Try exact match, then with extensions. Require a file — a bare + // directory specifier must fall through to index resolution below. const extensions = ['', '.ts', '.tsx']; for (const ext of extensions) { const full = resolved + ext; - if (fs.existsSync(full)) return full; + if (fs.existsSync(full) && fs.statSync(full).isFile()) return full; } // Try index files @@ -106,21 +108,138 @@ function resolveModulePath(fromFile: string, specifier: string): string { return resolved; } -function resolveLocalModules(indexPath: string): string[] { - const sourceFile = ts.createSourceFile(indexPath, fs.readFileSync(indexPath, 'utf-8'), ts.ScriptTarget.Latest, true); +function isFile(filePath: string): boolean { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); +} +// Memoized: getUtilEntries resolves the same entry point twice (program +// creation + discovery), and each pass re-reads every module in the graph. +const localModulesCache = new Map(); + +function resolveLocalModules(indexPath: string): string[] { + const cached = localModulesCache.get(indexPath); + if (cached) return cached; + + const visited = new Set([indexPath]); const localPaths: string[] = []; - ts.forEachChild(sourceFile, (node) => { - if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { - const specifier = node.moduleSpecifier.text; - if (specifier.startsWith('.')) { - localPaths.push(resolveModulePath(indexPath, specifier)); + // Post-order: a module's own re-exports are pushed before the module + // itself, so declaring files are scanned (and win seenKeys dedup) before + // the directory indexes that re-export them. + function collect(filePath: string): void { + const sourceFile = ts.createSourceFile(filePath, fs.readFileSync(filePath, 'utf-8'), ts.ScriptTarget.Latest, true); + + ts.forEachChild(sourceFile, (node) => { + if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { + const specifier = node.moduleSpecifier.text; + if (!specifier.startsWith('.')) return; + + const resolved = resolveModulePath(filePath, specifier); + if (visited.has(resolved)) return; + visited.add(resolved); + + // resolveModulePath falls back to the raw path when nothing matches; + // skip anything that isn't a readable file (e.g. a directory with no + // index.ts) instead of crashing on the read. + if (!isFile(resolved)) return; + + collect(resolved); + localPaths.push(resolved); } + }); + } + + collect(indexPath); + localModulesCache.set(indexPath, localPaths); + + return localPaths; +} + +// Names actually exported from an entry point, resolving local `export *` +// chains. External star re-exports (`@videojs/*`) are skipped — they can't +// make a locally-declared symbol visible. Discovery scans whole modules, so +// without this filter a deep-scanned file's internal exports (never +// re-exported up to the entry) would be documented as public API. +function collectVisibleExportNames(indexPath: string, visited = new Set()): Set { + const names = new Set(); + if (visited.has(indexPath) || !isFile(indexPath)) return names; + visited.add(indexPath); + + const sourceFile = ts.createSourceFile(indexPath, fs.readFileSync(indexPath, 'utf-8'), ts.ScriptTarget.Latest, true); + + ts.forEachChild(sourceFile, (node) => { + if (ts.isExportDeclaration(node)) { + if (node.exportClause && ts.isNamedExports(node.exportClause)) { + for (const spec of node.exportClause.elements) { + names.add(spec.name.text); + if (spec.propertyName) names.add(spec.propertyName.text); + } + } else if (node.exportClause && ts.isNamespaceExport(node.exportClause)) { + names.add(node.exportClause.name.text); + } else if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { + const specifier = node.moduleSpecifier.text; + if (!specifier.startsWith('.')) return; + for (const name of collectVisibleExportNames(resolveModulePath(indexPath, specifier), visited)) { + names.add(name); + } + } + return; + } + + const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined; + const isExported = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + if (!isExported) return; + + if (ts.isVariableStatement(node)) { + for (const decl of node.declarationList.declarations) { + if (ts.isIdentifier(decl.name)) names.add(decl.name.text); + } + } else if ( + (ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isEnumDeclaration(node)) && + node.name + ) { + names.add(node.name.text); } }); - return localPaths; + return names; +} + +function collectDeclaredExportNames(modulePath: string): Set { + const names = new Set(); + const sourceFile = ts.createSourceFile( + modulePath, + fs.readFileSync(modulePath, 'utf-8'), + ts.ScriptTarget.Latest, + true + ); + + ts.forEachChild(sourceFile, (node) => { + const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined; + const isExported = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + if (!isExported) return; + + if (ts.isVariableStatement(node)) { + for (const declaration of node.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text); + } + } else if ( + (ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isEnumDeclaration(node)) && + node.name + ) { + names.add(node.name.text); + } + }); + + return names; } // ─── Phase 2: Convention Matching ────────────────────────────────── @@ -550,39 +669,6 @@ function getJSDocParamDescription(node: ts.Node, paramName: string): string | un return undefined; } -function hasJSDocTag(node: ts.Node, tagName: string): boolean { - const jsDocNodes = (node as any).jsDoc as ts.JSDoc[] | undefined; - if (!jsDocNodes?.length) return false; - - for (const doc of jsDocNodes) { - if (!doc.tags) continue; - for (const tag of doc.tags) { - if (tag.tagName.text === tagName) return true; - } - } - return false; -} - -function getJSDocTagValue(node: ts.Node, tagName: string): string | undefined { - const jsDocNodes = (node as any).jsDoc as ts.JSDoc[] | undefined; - if (!jsDocNodes?.length) return undefined; - - for (const doc of jsDocNodes) { - if (!doc.tags) continue; - for (const tag of doc.tags) { - if (tag.tagName.text === tagName) { - if (!tag.comment) return undefined; - if (typeof tag.comment === 'string') return tag.comment.trim(); - return tag.comment - .map((c: ts.JSDocComment) => ('text' in c ? c.text : '')) - .join('') - .trim(); - } - } - } - return undefined; -} - // ─── Shared AST Helpers ───────────────────────────────────────────── function buildParamEntry( @@ -961,6 +1047,7 @@ function discoverUtilExports(monorepoRoot: string, program: ts.Program): UtilEnt const localModules = resolveLocalModules(indexPath); // When the entry point is a leaf module (no re-exports), scan it directly const modulesToScan = localModules.length > 0 ? localModules : [indexPath]; + const visibleNames = collectVisibleExportNames(indexPath); const failedModules: string[] = []; // Collect all TAE exports for type resolution (formatDetailedType) @@ -970,6 +1057,8 @@ function discoverUtilExports(monorepoRoot: string, program: ts.Program): UtilEnt // utilities, contexts, and selectors (e.g., usePlayer, createPlayer, selectPlayback) for (const modulePath of modulesToScan) { if (!fs.existsSync(modulePath)) continue; + const declaredNames = collectDeclaredExportNames(modulePath); + if (declaredNames.size === 0) continue; let ast: tae.ModuleNode; try { @@ -982,6 +1071,13 @@ function discoverUtilExports(monorepoRoot: string, program: ts.Program): UtilEnt allExports.push(...ast.exports); for (const exportNode of ast.exports) { + // Re-exported APIs are owned by their declaring module. Local + // declarations are scanned post-order, while external package + // re-exports are documented by that package's canonical entry point. + if (!declaredNames.has(exportNode.name)) continue; + // Whole modules are scanned, but only exports that are actually + // visible from the entry point are public API. + if (!visibleNames.has(exportNode.name)) continue; processExport(exportNode, modulePath, entryPoint, program, seenKeys, seenSlugs, entries, allExports); } } @@ -1010,6 +1106,7 @@ function discoverUtilExports(monorepoRoot: string, program: ts.Program): UtilEnt for (const modulePath of failedModules) { const rawExports = discoverExportsFromRawAST(modulePath, program); for (const info of rawExports) { + if (!visibleNames.has(info.name)) continue; processRawExport(info, entryPoint, program, seenKeys, seenSlugs, entries); } } @@ -1021,7 +1118,7 @@ function discoverUtilExports(monorepoRoot: string, program: ts.Program): UtilEnt const rawExports = discoverExportsFromRawAST(modulePath, program); for (const info of rawExports) { - if (!info.isClass) continue; + if (!info.isClass || !visibleNames.has(info.name)) continue; processRawExport(info, entryPoint, program, seenKeys, seenSlugs, entries); } } diff --git a/site/scripts/api-docs-builder/src/utils.ts b/site/scripts/api-docs-builder/src/utils.ts index c08c447d..8b4daf5e 100644 --- a/site/scripts/api-docs-builder/src/utils.ts +++ b/site/scripts/api-docs-builder/src/utils.ts @@ -1,5 +1,51 @@ +import type * as ts from 'typescript'; import type { PropDef } from './types.js'; +const PREFIX = '\x1b[35m[api-docs-builder]\x1b[0m'; + +export const log = { + info: (...args: unknown[]) => console.log(PREFIX, ...args), + warn: (...args: unknown[]) => console.warn(PREFIX, '\x1b[33mwarn:\x1b[0m', ...args), + error: (...args: unknown[]) => console.error(PREFIX, '\x1b[31merror:\x1b[0m', ...args), +}; + +export function hasJSDocTag(node: ts.Node, tagName: string): boolean { + const jsDocNodes = (node as any).jsDoc as ts.JSDoc[] | undefined; + if (!jsDocNodes?.length) return false; + + for (const doc of jsDocNodes) { + if (!doc.tags) continue; + for (const tag of doc.tags) { + if (tag.tagName.text === tagName) return true; + } + } + return false; +} + +export function getJSDocTagValue(node: ts.Node, tagName: string): string | undefined { + const jsDocNodes = (node as any).jsDoc as ts.JSDoc[] | undefined; + if (!jsDocNodes?.length) return undefined; + + // TS attaches every leading JSDoc block to the node (file headers included) + // and parses @tags even mid-sentence — the block closest to the declaration + // is the binding one, so scan in reverse. + for (const doc of [...jsDocNodes].reverse()) { + if (!doc.tags) continue; + for (const tag of doc.tags) { + if (tag.tagName.text === tagName) { + if (!tag.comment) return undefined; + if (typeof tag.comment === 'string') return tag.comment.trim(); + return tag.comment + .map((c: ts.JSDocComment) => ('text' in c ? c.text : '')) + .join('') + .trim(); + } + } + } + + return undefined; +} + export function kebabToPascal(str: string): string { return str .split('-') diff --git a/site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.astro b/site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.astro new file mode 100644 index 00000000..c515bacd --- /dev/null +++ b/site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.astro @@ -0,0 +1,9 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +--- + + + diff --git a/site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.css b/site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.css new file mode 100644 index 00000000..05c186ad --- /dev/null +++ b/site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.css @@ -0,0 +1,75 @@ +.video-player, +.video-player media-container { + position: relative; + display: block; +} + +.video-media { + width: 100%; +} + +.menu-bar { + position: absolute; + right: 10px; + bottom: 10px; +} + +.settings-trigger { + padding: 6px 16px; + color: black; + cursor: pointer; + background: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 9999px; + backdrop-filter: blur(10px); +} + +.menu { + --media-menu-side-offset: 8px; + box-sizing: border-box; + display: grid; + gap: 2px; + min-width: 180px; + max-width: var(--media-menu-available-width, var(--media-popover-available-width, none)); + max-height: var(--media-menu-available-height, var(--media-popover-available-height, none)); + padding: 6px; + overflow: auto; + overscroll-behavior: none; + font-size: 14px; + color: white; + background: rgba(0, 0, 0, 0.88); + border-radius: 8px; + backdrop-filter: blur(10px); +} + +.menu-group { + display: grid; + gap: 2px; +} + +.menu-item { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + min-height: 32px; + padding: 0 10px; + font: inherit; + color: inherit; + cursor: pointer; + background: none; + border: 0; + border-radius: 6px; +} + +.menu-item[data-highlighted] { + background: rgba(255, 255, 255, 0.16); +} + +.menu-indicator { + opacity: 0; +} + +media-menu-radio-item[aria-checked="true"] .menu-indicator { + opacity: 1; +} diff --git a/site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.html b/site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.html new file mode 100644 index 00000000..ff36c184 --- /dev/null +++ b/site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.html @@ -0,0 +1,26 @@ + + + + + + diff --git a/site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.ts b/site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.ts new file mode 100644 index 00000000..c3fe1b8e --- /dev/null +++ b/site/src/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.ts @@ -0,0 +1,4 @@ +import '@videojs/html/video/player'; +import '@videojs/html/media/hlsjs-video'; +import '@videojs/html/ui/menu'; +import '@videojs/html/ui/audio-track-radio-group'; diff --git a/site/src/components/docs/demos/audio-track-radio-group/react/css/BasicUsage.css b/site/src/components/docs/demos/audio-track-radio-group/react/css/BasicUsage.css new file mode 100644 index 00000000..b33a75b0 --- /dev/null +++ b/site/src/components/docs/demos/audio-track-radio-group/react/css/BasicUsage.css @@ -0,0 +1,75 @@ +.media-container { + position: relative; +} + +.media-container video { + width: 100%; +} + +.menu-bar { + position: absolute; + right: 10px; + bottom: 10px; +} + +.settings-trigger { + padding: 6px 16px; + color: black; + cursor: pointer; + background: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 9999px; + backdrop-filter: blur(10px); +} + +.menu { + --media-menu-side-offset: 8px; + box-sizing: border-box; + display: grid; + gap: 2px; + min-width: 180px; + max-width: var(--media-menu-available-width, var(--media-popover-available-width, none)); + max-height: var(--media-menu-available-height, var(--media-popover-available-height, none)); + padding: 6px; + margin: 0; + overflow: auto; + overscroll-behavior: none; + font-size: 14px; + color: white; + background: rgba(0, 0, 0, 0.88); + border: 0; + border-radius: 8px; + backdrop-filter: blur(10px); +} + +.menu-group { + display: grid; + gap: 2px; +} + +.menu-item { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + min-height: 32px; + padding: 0 10px; + font: inherit; + color: inherit; + cursor: pointer; + background: none; + border: 0; + border-radius: 6px; +} + +.menu-item[data-highlighted] { + background: rgba(255, 255, 255, 0.16); +} + +.menu-indicator { + opacity: 0; +} + +[role="menuitemradio"][aria-checked="true"] .menu-indicator { + opacity: 1; +} diff --git a/site/src/components/docs/demos/audio-track-radio-group/react/css/BasicUsage.tsx b/site/src/components/docs/demos/audio-track-radio-group/react/css/BasicUsage.tsx new file mode 100644 index 00000000..d40a61be --- /dev/null +++ b/site/src/components/docs/demos/audio-track-radio-group/react/css/BasicUsage.tsx @@ -0,0 +1,50 @@ +import { createPlayer, Menu, useAudioTrackOptions } from '@videojs/react'; +import { HlsJsVideo } from '@videojs/react/media/hlsjs-video'; +import { videoFeatures } from '@videojs/react/video'; +import type { ReactNode } from 'react'; + +const Player = createPlayer({ features: videoFeatures }); +const src = 'https://stream.mux.com/s41JYeqIpBMBzE4OzxDyGR2yrp2hD1CQ6gJN9SlVGDQ.m3u8'; + +function AudioMenu(): ReactNode { + const audioTrack = useAudioTrackOptions(); + if (audioTrack?.state.availability !== 'available') return null; + + return ( + + }> + Audio + + + + {audioTrack.options.map((option) => ( + + {option.label} + + ✓ + + + ))} + + + + ); +} + +export default function BasicUsage() { + return ( + + + +
+ +
+
+
+ ); +} diff --git a/site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.astro b/site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.astro new file mode 100644 index 00000000..c515bacd --- /dev/null +++ b/site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.astro @@ -0,0 +1,9 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +--- + + + diff --git a/site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.css b/site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.css new file mode 100644 index 00000000..0260379d --- /dev/null +++ b/site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.css @@ -0,0 +1,75 @@ +.video-player, +.video-player media-container { + position: relative; + display: block; +} + +.video-player video { + width: 100%; +} + +.menu-bar { + position: absolute; + right: 10px; + bottom: 10px; +} + +.settings-trigger { + padding: 6px 16px; + color: black; + cursor: pointer; + background: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 9999px; + backdrop-filter: blur(10px); +} + +.menu { + --media-menu-side-offset: 8px; + box-sizing: border-box; + display: grid; + gap: 2px; + min-width: 180px; + max-width: var(--media-menu-available-width, var(--media-popover-available-width, none)); + max-height: var(--media-menu-available-height, var(--media-popover-available-height, none)); + padding: 6px; + overflow: auto; + overscroll-behavior: none; + font-size: 14px; + color: white; + background: rgba(0, 0, 0, 0.88); + border-radius: 8px; + backdrop-filter: blur(10px); +} + +.menu-group { + display: grid; + gap: 2px; +} + +.menu-item { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + min-height: 32px; + padding: 0 10px; + font: inherit; + color: inherit; + cursor: pointer; + background: none; + border: 0; + border-radius: 6px; +} + +.menu-item[data-highlighted] { + background: rgba(255, 255, 255, 0.16); +} + +.menu-indicator { + opacity: 0; +} + +media-menu-radio-item[aria-checked="true"] .menu-indicator { + opacity: 1; +} diff --git a/site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.html b/site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.html new file mode 100644 index 00000000..0fa18623 --- /dev/null +++ b/site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.html @@ -0,0 +1,27 @@ + + + + + + diff --git a/site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.ts b/site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.ts new file mode 100644 index 00000000..26c97bd7 --- /dev/null +++ b/site/src/components/docs/demos/captions-radio-group/html/css/BasicUsage.ts @@ -0,0 +1,3 @@ +import '@videojs/html/video/player'; +import '@videojs/html/ui/menu'; +import '@videojs/html/ui/captions-radio-group'; diff --git a/site/src/components/docs/demos/captions-radio-group/react/css/BasicUsage.css b/site/src/components/docs/demos/captions-radio-group/react/css/BasicUsage.css new file mode 100644 index 00000000..b33a75b0 --- /dev/null +++ b/site/src/components/docs/demos/captions-radio-group/react/css/BasicUsage.css @@ -0,0 +1,75 @@ +.media-container { + position: relative; +} + +.media-container video { + width: 100%; +} + +.menu-bar { + position: absolute; + right: 10px; + bottom: 10px; +} + +.settings-trigger { + padding: 6px 16px; + color: black; + cursor: pointer; + background: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 9999px; + backdrop-filter: blur(10px); +} + +.menu { + --media-menu-side-offset: 8px; + box-sizing: border-box; + display: grid; + gap: 2px; + min-width: 180px; + max-width: var(--media-menu-available-width, var(--media-popover-available-width, none)); + max-height: var(--media-menu-available-height, var(--media-popover-available-height, none)); + padding: 6px; + margin: 0; + overflow: auto; + overscroll-behavior: none; + font-size: 14px; + color: white; + background: rgba(0, 0, 0, 0.88); + border: 0; + border-radius: 8px; + backdrop-filter: blur(10px); +} + +.menu-group { + display: grid; + gap: 2px; +} + +.menu-item { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + min-height: 32px; + padding: 0 10px; + font: inherit; + color: inherit; + cursor: pointer; + background: none; + border: 0; + border-radius: 6px; +} + +.menu-item[data-highlighted] { + background: rgba(255, 255, 255, 0.16); +} + +.menu-indicator { + opacity: 0; +} + +[role="menuitemradio"][aria-checked="true"] .menu-indicator { + opacity: 1; +} diff --git a/site/src/components/docs/demos/captions-radio-group/react/css/BasicUsage.tsx b/site/src/components/docs/demos/captions-radio-group/react/css/BasicUsage.tsx new file mode 100644 index 00000000..0c76fb75 --- /dev/null +++ b/site/src/components/docs/demos/captions-radio-group/react/css/BasicUsage.tsx @@ -0,0 +1,57 @@ +import { createPlayer, Menu, useCaptionsOptions } from '@videojs/react'; +import { Video, videoFeatures } from '@videojs/react/video'; +import type { ReactNode } from 'react'; + +const Player = createPlayer({ features: videoFeatures }); + +function CaptionsMenu(): ReactNode { + const captions = useCaptionsOptions(); + if (captions?.state.availability !== 'available') return null; + + return ( + + }> + Captions + + + + {captions.options.map((option) => ( + + {option.label} + + ✓ + + + ))} + + + + ); +} + +export default function BasicUsage() { + return ( + + + +
+ +
+
+
+ ); +} diff --git a/site/src/components/docs/demos/menu/html/css/BasicUsage.html b/site/src/components/docs/demos/menu/html/css/BasicUsage.html index 5cf0955d..de421cf6 100644 --- a/site/src/components/docs/demos/menu/html/css/BasicUsage.html +++ b/site/src/components/docs/demos/menu/html/css/BasicUsage.html @@ -2,7 +2,7 @@ + diff --git a/site/src/components/docs/demos/playback-rate-radio-group/html/css/BasicUsage.css b/site/src/components/docs/demos/playback-rate-radio-group/html/css/BasicUsage.css new file mode 100644 index 00000000..0260379d --- /dev/null +++ b/site/src/components/docs/demos/playback-rate-radio-group/html/css/BasicUsage.css @@ -0,0 +1,75 @@ +.video-player, +.video-player media-container { + position: relative; + display: block; +} + +.video-player video { + width: 100%; +} + +.menu-bar { + position: absolute; + right: 10px; + bottom: 10px; +} + +.settings-trigger { + padding: 6px 16px; + color: black; + cursor: pointer; + background: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 9999px; + backdrop-filter: blur(10px); +} + +.menu { + --media-menu-side-offset: 8px; + box-sizing: border-box; + display: grid; + gap: 2px; + min-width: 180px; + max-width: var(--media-menu-available-width, var(--media-popover-available-width, none)); + max-height: var(--media-menu-available-height, var(--media-popover-available-height, none)); + padding: 6px; + overflow: auto; + overscroll-behavior: none; + font-size: 14px; + color: white; + background: rgba(0, 0, 0, 0.88); + border-radius: 8px; + backdrop-filter: blur(10px); +} + +.menu-group { + display: grid; + gap: 2px; +} + +.menu-item { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + min-height: 32px; + padding: 0 10px; + font: inherit; + color: inherit; + cursor: pointer; + background: none; + border: 0; + border-radius: 6px; +} + +.menu-item[data-highlighted] { + background: rgba(255, 255, 255, 0.16); +} + +.menu-indicator { + opacity: 0; +} + +media-menu-radio-item[aria-checked="true"] .menu-indicator { + opacity: 1; +} diff --git a/site/src/components/docs/demos/playback-rate-radio-group/html/css/BasicUsage.html b/site/src/components/docs/demos/playback-rate-radio-group/html/css/BasicUsage.html new file mode 100644 index 00000000..c08984bb --- /dev/null +++ b/site/src/components/docs/demos/playback-rate-radio-group/html/css/BasicUsage.html @@ -0,0 +1,24 @@ + + + + + + diff --git a/site/src/components/docs/demos/playback-rate-radio-group/html/css/BasicUsage.ts b/site/src/components/docs/demos/playback-rate-radio-group/html/css/BasicUsage.ts new file mode 100644 index 00000000..3d7b6dd6 --- /dev/null +++ b/site/src/components/docs/demos/playback-rate-radio-group/html/css/BasicUsage.ts @@ -0,0 +1,3 @@ +import '@videojs/html/video/player'; +import '@videojs/html/ui/menu'; +import '@videojs/html/ui/playback-rate-radio-group'; diff --git a/site/src/components/docs/demos/playback-rate-radio-group/react/css/BasicUsage.css b/site/src/components/docs/demos/playback-rate-radio-group/react/css/BasicUsage.css new file mode 100644 index 00000000..b33a75b0 --- /dev/null +++ b/site/src/components/docs/demos/playback-rate-radio-group/react/css/BasicUsage.css @@ -0,0 +1,75 @@ +.media-container { + position: relative; +} + +.media-container video { + width: 100%; +} + +.menu-bar { + position: absolute; + right: 10px; + bottom: 10px; +} + +.settings-trigger { + padding: 6px 16px; + color: black; + cursor: pointer; + background: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 9999px; + backdrop-filter: blur(10px); +} + +.menu { + --media-menu-side-offset: 8px; + box-sizing: border-box; + display: grid; + gap: 2px; + min-width: 180px; + max-width: var(--media-menu-available-width, var(--media-popover-available-width, none)); + max-height: var(--media-menu-available-height, var(--media-popover-available-height, none)); + padding: 6px; + margin: 0; + overflow: auto; + overscroll-behavior: none; + font-size: 14px; + color: white; + background: rgba(0, 0, 0, 0.88); + border: 0; + border-radius: 8px; + backdrop-filter: blur(10px); +} + +.menu-group { + display: grid; + gap: 2px; +} + +.menu-item { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + min-height: 32px; + padding: 0 10px; + font: inherit; + color: inherit; + cursor: pointer; + background: none; + border: 0; + border-radius: 6px; +} + +.menu-item[data-highlighted] { + background: rgba(255, 255, 255, 0.16); +} + +.menu-indicator { + opacity: 0; +} + +[role="menuitemradio"][aria-checked="true"] .menu-indicator { + opacity: 1; +} diff --git a/site/src/components/docs/demos/playback-rate-radio-group/react/css/BasicUsage.tsx b/site/src/components/docs/demos/playback-rate-radio-group/react/css/BasicUsage.tsx new file mode 100644 index 00000000..81653e3a --- /dev/null +++ b/site/src/components/docs/demos/playback-rate-radio-group/react/css/BasicUsage.tsx @@ -0,0 +1,54 @@ +import { createPlayer, Menu, usePlaybackRateOptions } from '@videojs/react'; +import { Video, videoFeatures } from '@videojs/react/video'; +import type { ReactNode } from 'react'; + +const Player = createPlayer({ features: videoFeatures }); + +function SpeedMenu(): ReactNode { + const playbackRate = usePlaybackRateOptions(); + if (playbackRate?.state.availability !== 'available') return null; + + return ( + + }> + Speed + + + + {playbackRate.options.map((option) => ( + + {option.label} + + ✓ + + + ))} + + + + ); +} + +export default function BasicUsage() { + return ( + + + + + ); +} diff --git a/site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.astro b/site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.astro new file mode 100644 index 00000000..c515bacd --- /dev/null +++ b/site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.astro @@ -0,0 +1,9 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +--- + + + diff --git a/site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.css b/site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.css new file mode 100644 index 00000000..6f45be23 --- /dev/null +++ b/site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.css @@ -0,0 +1,85 @@ +.video-player, +.video-player media-container { + position: relative; + display: block; +} + +.video-media { + width: 100%; +} + +.menu-bar { + position: absolute; + right: 10px; + bottom: 10px; +} + +.settings-trigger { + padding: 6px 16px; + color: black; + cursor: pointer; + background: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 9999px; + backdrop-filter: blur(10px); +} + +.menu { + --media-menu-side-offset: 8px; + box-sizing: border-box; + display: grid; + gap: 2px; + min-width: 180px; + max-width: var(--media-menu-available-width, var(--media-popover-available-width, none)); + max-height: var(--media-menu-available-height, var(--media-popover-available-height, none)); + padding: 6px; + overflow: auto; + overscroll-behavior: none; + font-size: 14px; + color: white; + background: rgba(0, 0, 0, 0.88); + border-radius: 8px; + backdrop-filter: blur(10px); +} + +.menu-group { + display: grid; + gap: 2px; +} + +.menu-item { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + min-height: 32px; + padding: 0 10px; + font: inherit; + color: inherit; + cursor: pointer; + background: none; + border: 0; + border-radius: 6px; +} + +.menu-item[data-highlighted] { + background: rgba(255, 255, 255, 0.16); +} + +.menu-tier { + margin-left: 2px; + font-size: 10px; +} + +.menu-badge { + margin-left: auto; + color: rgba(255, 255, 255, 0.72); +} + +.menu-indicator { + opacity: 0; +} + +media-menu-radio-item[aria-checked="true"] .menu-indicator { + opacity: 1; +} diff --git a/site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.html b/site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.html new file mode 100644 index 00000000..4b6e1e08 --- /dev/null +++ b/site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.html @@ -0,0 +1,30 @@ + + + + + + diff --git a/site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.ts b/site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.ts new file mode 100644 index 00000000..fe67c115 --- /dev/null +++ b/site/src/components/docs/demos/quality-radio-group/html/css/BasicUsage.ts @@ -0,0 +1,4 @@ +import '@videojs/html/video/player'; +import '@videojs/html/media/hlsjs-video'; +import '@videojs/html/ui/menu'; +import '@videojs/html/ui/quality-radio-group'; diff --git a/site/src/components/docs/demos/quality-radio-group/react/css/BasicUsage.css b/site/src/components/docs/demos/quality-radio-group/react/css/BasicUsage.css new file mode 100644 index 00000000..c9a4ad59 --- /dev/null +++ b/site/src/components/docs/demos/quality-radio-group/react/css/BasicUsage.css @@ -0,0 +1,85 @@ +.media-container { + position: relative; +} + +.media-container video { + width: 100%; +} + +.menu-bar { + position: absolute; + right: 10px; + bottom: 10px; +} + +.settings-trigger { + padding: 6px 16px; + color: black; + cursor: pointer; + background: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 9999px; + backdrop-filter: blur(10px); +} + +.menu { + --media-menu-side-offset: 8px; + box-sizing: border-box; + display: grid; + gap: 2px; + min-width: 180px; + max-width: var(--media-menu-available-width, var(--media-popover-available-width, none)); + max-height: var(--media-menu-available-height, var(--media-popover-available-height, none)); + padding: 6px; + margin: 0; + overflow: auto; + overscroll-behavior: none; + font-size: 14px; + color: white; + background: rgba(0, 0, 0, 0.88); + border: 0; + border-radius: 8px; + backdrop-filter: blur(10px); +} + +.menu-group { + display: grid; + gap: 2px; +} + +.menu-item { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + min-height: 32px; + padding: 0 10px; + font: inherit; + color: inherit; + cursor: pointer; + background: none; + border: 0; + border-radius: 6px; +} + +.menu-item[data-highlighted] { + background: rgba(255, 255, 255, 0.16); +} + +.menu-tier { + margin-left: 2px; + font-size: 10px; +} + +.menu-badge { + margin-left: auto; + color: rgba(255, 255, 255, 0.72); +} + +.menu-indicator { + opacity: 0; +} + +[role="menuitemradio"][aria-checked="true"] .menu-indicator { + opacity: 1; +} diff --git a/site/src/components/docs/demos/quality-radio-group/react/css/BasicUsage.tsx b/site/src/components/docs/demos/quality-radio-group/react/css/BasicUsage.tsx new file mode 100644 index 00000000..7aab8550 --- /dev/null +++ b/site/src/components/docs/demos/quality-radio-group/react/css/BasicUsage.tsx @@ -0,0 +1,54 @@ +import { createPlayer, Menu, useQualityOptions } from '@videojs/react'; +import { HlsJsVideo } from '@videojs/react/media/hlsjs-video'; +import { videoFeatures } from '@videojs/react/video'; +import type { ReactNode } from 'react'; + +const Player = createPlayer({ features: videoFeatures }); +const src = 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4.m3u8'; + +function QualityMenu(): ReactNode { + const quality = useQualityOptions(); + if (quality?.state.availability !== 'available') return null; + + return ( + + }> + Quality + + + + {quality.options.map((option) => ( + + + {option.label} + {option.tier ? {option.tier} : null} + + {option.badge ? {option.badge} : null} + + ✓ + + + ))} + + + + ); +} + +export default function BasicUsage() { + return ( + + + +
+ +
+
+
+ ); +} diff --git a/site/src/content/docs/reference/audio-track-radio-group.mdx b/site/src/content/docs/reference/audio-track-radio-group.mdx index 749ce4a3..51c1eb2e 100644 --- a/site/src/content/docs/reference/audio-track-radio-group.mdx +++ b/site/src/content/docs/reference/audio-track-radio-group.mdx @@ -8,6 +8,19 @@ description: A menu radio group for selecting an audio track import ComponentReference from "@/components/docs/api-reference/ComponentReference.astro"; import DocsLink from "@/components/docs/DocsLink.astro"; import FrameworkCase from "@/components/docs/FrameworkCase.astro"; +import StyleCase from "@/components/docs/StyleCase.astro"; +import Demo from "@/components/docs/demos/Demo.astro"; + +{/* React demos */} +import BasicUsageDemoReact from "@/components/docs/demos/audio-track-radio-group/react/css/BasicUsage"; +import basicUsageReactTsx from "@/components/docs/demos/audio-track-radio-group/react/css/BasicUsage.tsx?raw"; +import basicUsageReactCss from "@/components/docs/demos/audio-track-radio-group/react/css/BasicUsage.css?raw"; + +{/* HTML demos */} +import BasicUsageDemoHtml from "@/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.astro"; +import basicUsageHtml from "@/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.html?raw"; +import basicUsageHtmlCss from "@/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.css?raw"; +import basicUsageHtmlTs from "@/components/docs/demos/audio-track-radio-group/html/css/BasicUsage.ts?raw"; Creates radio items from the player audio track state and selects the enabled track. @@ -15,16 +28,11 @@ Creates radio items from the player audio track state and selects the enabled tr ```tsx - const audioTrack = useAudioTrackOptions(); - - - Audio - {audioTrack.options.map((option) => ( - - {option.label} - - - ))} + + + + + ``` @@ -47,7 +55,7 @@ Creates radio items from the player audio track state and selects the enabled tr The group is available when the configured media exposes more than one audio track. Labels use the track label, then language, then kind. Pass `formatTrack` to customize the visible labels. -`useAudioTrackOptions` returns `null` when the audio track feature is not configured. Use the returned options with `Menu.RadioGroup`. +`useAudioTrackOptions` returns `null` when the audio track feature is not configured. Use the returned options with `Menu.RadioGroup`. @@ -72,7 +80,42 @@ The group is available when the configured media exposes more than one audio tra ## Accessibility -The group uses the menu radio group pattern. It receives an accessible label from the `label` prop or defaults to `Audio`. +The group uses the menu radio group pattern. + + +Give `Menu.RadioGroup` an accessible name with `aria-label` or a nested `Menu.GroupLabel`. + + + +The element receives an accessible label from the `label` property or defaults to `Audio`. + + +## Examples + +### Basic usage + + + + + + + + + + + + + + + + diff --git a/site/src/content/docs/reference/captions-radio-group.mdx b/site/src/content/docs/reference/captions-radio-group.mdx new file mode 100644 index 00000000..d333838a --- /dev/null +++ b/site/src/content/docs/reference/captions-radio-group.mdx @@ -0,0 +1,122 @@ +--- +title: CaptionsRadioGroup +frameworkTitle: + html: media-captions-radio-group +description: A menu radio group for selecting caption and subtitle tracks +--- + +import ComponentReference from "@/components/docs/api-reference/ComponentReference.astro"; +import DocsLink from "@/components/docs/DocsLink.astro"; +import FrameworkCase from "@/components/docs/FrameworkCase.astro"; +import StyleCase from "@/components/docs/StyleCase.astro"; +import Demo from "@/components/docs/demos/Demo.astro"; + +{/* React demos */} +import BasicUsageDemoReact from "@/components/docs/demos/captions-radio-group/react/css/BasicUsage"; +import basicUsageReactTsx from "@/components/docs/demos/captions-radio-group/react/css/BasicUsage.tsx?raw"; +import basicUsageReactCss from "@/components/docs/demos/captions-radio-group/react/css/BasicUsage.css?raw"; + +{/* HTML demos */} +import BasicUsageDemoHtml from "@/components/docs/demos/captions-radio-group/html/css/BasicUsage.astro"; +import basicUsageHtml from "@/components/docs/demos/captions-radio-group/html/css/BasicUsage.html?raw"; +import basicUsageHtmlCss from "@/components/docs/demos/captions-radio-group/html/css/BasicUsage.css?raw"; +import basicUsageHtmlTs from "@/components/docs/demos/captions-radio-group/html/css/BasicUsage.ts?raw"; + +Creates radio items from the player text track state and selects the showing caption or subtitle track. + +## Anatomy + + + ```tsx + + + + + + + ``` + + + + ```html + + + + ``` + + +## Behavior + +The group is available when the configured media exposes at least one caption or subtitle track. The first generated option is `Off`; selecting it hides captions. Track labels use the track label, then language, then kind. Pass `formatTrack` to customize the visible labels. + + +`useCaptionsOptions` returns `null` when the text tracks feature is not configured. Use the returned options with `Menu.RadioGroup`. + + + +`` generates `` children, including the `Off` item. Add an optional `