diff --git a/.claude/skills/api-reference/references/builder-conventions.md b/.claude/skills/api-reference/references/builder-conventions.md index 1729d7a2a..de90c6ace 100644 --- a/.claude/skills/api-reference/references/builder-conventions.md +++ b/.claude/skills/api-reference/references/builder-conventions.md @@ -8,6 +8,7 @@ Naming and file placement conventions required by the api-docs-builder at `site/ |------|------|---------| | Core | `packages/core/src/core/ui/{name}/{name}-core.ts` | Props, State, defaultProps | | Data attrs | `packages/core/src/core/ui/{name}/{name}-data-attrs.ts` | Data attribute definitions | +| CSS vars | `packages/core/src/core/ui/{name}/{name}-css-vars.ts` | CSS custom property definitions (optional) | | HTML element | `packages/html/src/ui/{name}/{name}-element.ts` | Custom element with `static tagName` | | React parts | `packages/react/src/ui/{name}/index.parts.ts` | Multi-part detection (optional) | @@ -21,6 +22,7 @@ The builder derives PascalCase from kebab-case using `kebabCase` from es-toolkit | State interface | `PlayButtonState` | | Core class | `PlayButtonCore` | | Data attrs export | `PlayButtonDataAttrs` | +| CSS vars export | `PlayButtonCSSVars` | | HTML element class | `PlayButtonElement` | | HTML tag name | `static tagName = 'media-play-button'` | @@ -40,10 +42,16 @@ Use overrides only when the standard conversion fails (e.g., acronyms like PiP). **Detection**: Presence of `packages/react/src/ui/{name}/index.parts.ts`. -**Primary part identification**: The part whose HTML element file is `{name}-element.ts` (not `{name}-{part}-element.ts`). The primary part receives the shared core props/state/data-attrs. +**Non-local re-export filtering**: Only exports with source paths starting with `./` are treated as parts. Re-exports from other directories (e.g., `../slider/index.parts`) are filtered out. This prevents domain variant components (TimeSlider, VolumeSlider) from inheriting base component parts. + +**Single-part fallback**: When filtering leaves only one part (typically Root), the component uses single-part mode — the remaining part's props/state/data-attrs are promoted to the top level, not nested under `parts`. + +**Primary part identification**: The part whose React source file instantiates the component's Core class (matches `new \w+Core\(`). The primary part receives the shared core props/state/data-attrs/css-vars. **Non-primary parts**: Each gets its own element file at `{name}-{part}-element.ts`. Element class must be `{Name}{Part}Element` (e.g., `TimeGroupElement`). +**Framework-divergent parts**: All parts get `platforms.react`. Parts with a matching HTML element file also get `platforms.html`. The renderer filters parts by framework — React-only parts are hidden in HTML docs. + **Part descriptions**: Extracted from JSDoc on the React component export: ```tsx /** Displays a formatted time value. */ @@ -70,6 +78,7 @@ The builder fails silently for many issues — data just won't appear in the JSO | Empty props | Interface not named `{PascalCase}Props` | | Empty state | Interface not named `{PascalCase}State` | | No data attributes | File missing or export not named `{PascalCase}DataAttrs` | +| No CSS vars | File missing or export not named `{PascalCase}CSSVars` | | No HTML tag | Element file missing or no `static tagName` | | No part descriptions | Missing JSDoc on React component exports | | Wrong PascalCase | Need a `NAME_OVERRIDES` entry | diff --git a/.claude/skills/api-reference/references/mdx-structure.md b/.claude/skills/api-reference/references/mdx-structure.md index 74456f0dc..da74f2d65 100644 --- a/.claude/skills/api-reference/references/mdx-structure.md +++ b/.claude/skills/api-reference/references/mdx-structure.md @@ -195,7 +195,7 @@ Always the last element in the file: ``` -The component auto-renders Props, State, Data Attributes for single-part and all Parts for multi-part. +The component auto-renders Props, State, Data Attributes, and CSS Custom Properties for single-part and all Parts for multi-part. For multi-part components, React-only parts are hidden in HTML docs via framework filtering. ### Required Astro Component Imports diff --git a/internal/design/site/api-docs-builder.md b/internal/design/site/api-docs-builder.md index b348dbe3f..01593e41c 100644 --- a/internal/design/site/api-docs-builder.md +++ b/internal/design/site/api-docs-builder.md @@ -6,8 +6,8 @@ rendered documentation tables. When implementation diverges from this spec, this Inspired by [Base UI](https://github.com/mui/base-ui)'s API reference system. Base UI generates one JSON file per component part, and each part gets its own props and data-attributes tables. Our system aspires to the same philosophy but has a known architectural limitation: a single Props/State -interface per component at the core level means only one part (the "primary") can own props and -state. Non-primary parts are documented with a tag name and description only. +interface per component at the core level means only one part (the "primary") can own core-level props +and state. Non-primary parts get shared data attributes, custom React-specific props, and a description. **Principles:** @@ -208,6 +208,7 @@ kebab-name `{name}`: |------|----------|----------| | Core | `packages/core/src/core/ui/{name}/{name}-core.ts` | Yes | | Data attrs | `packages/core/src/core/ui/{name}/{name}-data-attrs.ts` | No | +| CSS vars | `packages/core/src/core/ui/{name}/{name}-css-vars.ts` | No | | HTML element | `packages/html/src/ui/{name}/{name}-element.ts` | No | | React parts index | `packages/react/src/ui/{name}/index.parts.ts` | No | @@ -217,6 +218,11 @@ PascalCase name. **Multi-part detection:** A component is multi-part if and only if `index.parts.ts` exists. +**Domain variant components:** Components like TimeSlider and VolumeSlider that share base +logic (e.g., from `slider/`) must still have their own directories under `core/ui/`. The +builder discovers components by directory — files nested inside a shared directory (like +`slider/time-slider-core.ts`) won't be found. + ### 2b. Component extraction #### Single-part components @@ -227,6 +233,7 @@ Extract from the three source files and merge into one reference object. |--------|----------| | Core file | Props interface members (if present), State interface members (if present), `defaultProps` values (if present) | | Data attrs file | Data attribute names, JSDoc descriptions, and inferred types (if file exists) | +| CSS vars file | CSS custom property names and JSDoc descriptions (if file exists) | | HTML element file | `static tagName` value (if file exists) | **Naming conventions the builder depends on:** @@ -237,6 +244,7 @@ Extract from the three source files and merge into one reference object. | State interface | `{PascalCase}State` | | Core class | `{PascalCase}Core` | | Data attrs export | `{PascalCase}DataAttrs` | +| CSS vars export | `{PascalCase}CSSVars` | | HTML element class | `{PascalCase}Element` | **All symbols are optional.** Only the Core class is required. If a component has no Props @@ -258,6 +266,7 @@ component is skipped with a warning. | Both Props and State | Warn, skip component | Component omitted | | `defaultProps` static | Silent | Props have no `default` field | | Data-attrs file/export | Silent | `dataAttributes: {}` | +| CSS-vars file/export | Silent | `cssCustomProperties: {}` | | JSDoc on a data attribute | Silent | `description: ""` (empty string) | | HTML element file | Silent | No `platforms.html` section | @@ -298,40 +307,71 @@ state type, the builder falls back to the existing `@type` JSDoc tag extraction. The builder discovers parts from `index.parts.ts` and matches them to HTML element files. +**Re-exported parts:** When `index.parts.ts` re-exports parts from another component (source +path doesn't start with `./`), the builder resolves the re-export back to its origin. It +parses the origin's `index.parts.ts`, matches each re-exported name to the original local +export, then derives the kebab segment and HTML element file from the **origin component** — +not the current one. Re-exported parts are never primary. For example, TimeSlider re-exports +Buffer, Fill, Thumb, Track, and Value from `../slider/index.parts`; each resolves to the +Slider component's HTML element files (`slider-buffer-element.ts`, etc.). + +**Single-part fallback:** When all exports are local and filtering leaves only one part, the +component uses single-part mode. The remaining part (typically Root) becomes the top-level +component — its props/state/data-attrs/CSS-vars are promoted to the component level, not +nested under `parts`. Components with re-exported parts (like TimeSlider and VolumeSlider) +always produce multi-part output since the re-exports are resolved rather than filtered. + **Primary vs. sub-part convention:** -Every multi-part component has one **primary part** and one or more **sub-parts**. The -convention is file naming: +Every multi-part component has one **primary part** and one or more **sub-parts**. -- The **root element** file is `{component}-element.ts` (e.g., `time-element.ts`) -- **Sub-part element** files are `{component}-{part}-element.ts` (e.g., `time-group-element.ts`) -- The primary part is whichever part maps to the root element — i.e., the part that does NOT - have a `{component}-{part}-element.ts` file, because its element IS the root element. +**Primary part:** The part whose React source file instantiates the component's Core class +(matches `new \w+Core\(`). This captures the architectural relationship — the primary part +owns the Core — and is immune to import ordering and framework-divergent element structures. -This is a naming convention, not configuration. The root element file always exists for the -primary part; sub-parts always have their own element files. +Sub-part element files use the naming convention `{component}-{part}-element.ts` (e.g., +`time-group-element.ts`) for HTML tag resolution. **Part-to-element matching:** -For each named export in `index.parts.ts`: +For each local named export in `index.parts.ts`: 1. Derive kebab segment from the export's source path (e.g., `./time-value` → `value`) 2. Look for `{component}-{part}-element.ts` in the HTML directory 3. If found → sub-part (gets its own tag name) -4. If not found → primary part (gets the root element's tag name from `{component}-element.ts`) +4. If not found AND `{component}-element.ts` exists → check via Core-instantiation for primary + +For re-exported parts: use the origin component's kebab and HTML directory for element file +lookup. The element class name is derived from the filename convention (`kebabToPascal` of the +basename, e.g., `slider-buffer-element.ts` → `SliderBufferElement`), not from the current +component's PascalCase name. This same convention-based derivation is also used for local +non-primary parts. -**What the primary part gets:** The shared core Props, State, data attributes, and the root -element's tag name. +**What the primary part gets:** The shared core Props, State, data attributes, CSS custom +properties, and the root element's tag name. -**What sub-parts get:** Their own tag name, a description (from React component JSDoc), and -empty props/state/dataAttributes. +**What sub-parts get:** Their own tag name, a description (from React JSDoc), shared data +attributes from the component's `*-data-attrs.ts` file (when the sub-part's React source +references `stateAttrMap`), and custom React-specific props (own members on the +`{LocalName}Props` interface, excluding inherited `UIComponentProps` members and `children`). +State and CSS custom properties remain empty. -**What the top-level component gets:** Empty props, state, dataAttributes, and empty platforms. -All meaningful data lives in the `parts` record. +For re-exported sub-parts, data attributes come from the **origin** component's data-attrs file +(e.g., TimeSlider.Fill uses Slider's data-attrs, not TimeSlider's), because the builder can't +resolve spread entries and the origin file has the complete set that sub-parts inherit. + +**What the top-level component gets:** Empty props, state, dataAttributes, cssCustomProperties, +and empty platforms. All meaningful data lives in the `parts` record. + +**Framework-divergent parts:** Parts discovered from `index.parts.ts` always get +`platforms.react`. Parts with a matching HTML element file also get `platforms.html`. The +renderer filters parts by framework — only parts with the current framework's platform +entry are shown. This handles cases like Popover where Arrow, Popup, and Trigger are +React-only compound parts with no HTML element counterparts. > **Known limitation:** Our architecture has a single Props/State interface per component at the -> core level, so only the primary part can own them. In Base UI, each part has its own props -> independently. If a sub-part needs its own props in the future, the core architecture would -> need per-part interfaces. +> core level, so only the primary part can own core-level props and state. Sub-parts can declare +> custom React-specific props (e.g., `SliderValueProps.type`), which the builder extracts from +> the React source. In Base UI, each part has its own props independently. ### 2c. Util discovery @@ -470,9 +510,11 @@ ComponentReference ├── props: Record — Empty {} for multi-part top-level ├── state: Record — Empty {} for multi-part top-level ├── dataAttributes: Record +├── cssCustomProperties: Record ├── platforms -│ └── html? -│ └── tagName: string — e.g., "media-toggle-button" +│ ├── html? +│ │ └── tagName: string — e.g., "media-toggle-button" +│ └── react? — Present for React-discovered parts (object, no fields) └── parts?: Record — Only for multi-part components └── [partId] ├── name: string — PascalCase part name (e.g., "Track") @@ -480,9 +522,17 @@ ComponentReference ├── props: Record ├── state: Record ├── dataAttributes: Record + ├── cssCustomProperties: Record └── platforms - └── html? - └── tagName: string + ├── html? + │ └── tagName: string + └── react? — Always present (parts come from index.parts.ts) +``` + +**CSSVarDef:** + +``` +└── description: string — JSDoc description of the CSS custom property ``` **PropDef:** @@ -596,6 +646,7 @@ omitted (absence means not required). This keeps JSON files small. "description": "Present when the button is disabled." } }, + "cssCustomProperties": {}, "platforms": { "html": { "tagName": "media-toggle-button" @@ -612,6 +663,7 @@ omitted (absence means not required). This keeps JSON files small. "props": {}, "state": {}, "dataAttributes": {}, + "cssCustomProperties": {}, "platforms": {}, "parts": { "indicator": { @@ -651,10 +703,12 @@ omitted (absence means not required). This keeps JSON files small. "type": "'empty' | 'partial' | 'full'" } }, + "cssCustomProperties": {}, "platforms": { "html": { "tagName": "media-meter" - } + }, + "react": {} } }, "track": { @@ -663,10 +717,12 @@ omitted (absence means not required). This keeps JSON files small. "props": {}, "state": {}, "dataAttributes": {}, + "cssCustomProperties": {}, "platforms": { "html": { "tagName": "media-meter-track" - } + }, + "react": {} } }, "fill": { @@ -675,10 +731,12 @@ omitted (absence means not required). This keeps JSON files small. "props": {}, "state": {}, "dataAttributes": {}, + "cssCustomProperties": {}, "platforms": { "html": { "tagName": "media-meter-fill" - } + }, + "react": {} } } } @@ -826,7 +884,8 @@ Both consume the same model, which prevents anchor drift (TOC links matching ren H2 "API Reference" id="api-reference" ├─ H3 "Props" id="props" (if props non-empty) ├─ H3 "State" id="state" (if state non-empty) - └─ H3 "Data attributes" id="data-attributes" (if dataAttributes non-empty) + ├─ H3 "Data attributes" id="data-attributes" (if dataAttributes non-empty) + └─ H3 "CSS custom properties" id="css-custom-properties" (if cssCustomProperties non-empty) ``` **Multi-part** heading structure: @@ -837,7 +896,8 @@ H2 "API Reference" id="api-reference" │ or "{part.tagName}" (HTML) │ ├─ H4 "Props" id="{partId}-props" (if props non-empty) │ ├─ H4 "State" id="{partId}-state" (if state non-empty) - │ └─ H4 "Data attributes" id="{partId}-data-attributes" (if dataAttributes non-empty) + │ ├─ H4 "Data attributes" id="{partId}-data-attributes" (if dataAttributes non-empty) + │ └─ H4 "CSS custom properties" id="{partId}-css-custom-properties" (if cssCustomProperties non-empty) ├─ H3 next part... └─ ... ``` @@ -846,6 +906,10 @@ Multi-part H3 headings are framework-aware: React sees the PascalCase part name HTML sees the tag name (e.g., "media-meter-track"). The TOC emits both variants with `frameworks` metadata so the correct one displays per framework. +**Framework filtering:** The TOC and rendered output only emit headings for parts the current +framework supports (derived from `platforms` keys). React-only parts (those with +`platforms.react` but no `platforms.html`) are hidden when viewing HTML docs. + ### 5b. Util reference model **Single-overload** heading structure: @@ -1051,7 +1115,25 @@ renders inline: > `ReturnType` — Description text here. -### 6f. Multi-part component rendering +### 6f. CSS custom properties table (components) + +Rendered by `ApiCSSVarsTable` → `CSSVarRow` → `DetailRow`. Uses the same disclosure +pattern as data attributes tables but with no type column. + +**Columns:** + +| Variable | | +|----------|-| + +- **Variable** — CSS custom property name in monospace (e.g., `--media-slider-fill`). +- **(toggle)** — Disclosure triangle. Present if the row has a description. + +**Disclosure panel** (when expanded): + +Contains a description list (`
`): +- **Description** — Markdown-rendered description. Only shown if `description` is present. + +### 6g. Multi-part component rendering For multi-part components, the top-level has no tables (all empty). Each part renders as: @@ -1061,6 +1143,7 @@ H3: Part name (framework-specific label) H4: Props (if non-empty) → Props table H4: State (if non-empty) → State table H4: Data attributes (if non-empty) → Data attributes table + H4: CSS custom properties (if non-empty) → CSS custom properties table ``` **State section preamble** (framework-specific): @@ -1068,7 +1151,7 @@ H3: Part name (framework-specific label) - **React:** "State is accessible via the `render`, `className`, and `style` props." - **HTML:** "State is reflected as data attributes for CSS styling." -### 6g. Multi-overload util rendering +### 6h. Multi-overload util rendering Each overload renders as: @@ -1085,7 +1168,7 @@ heading text. Otherwise fall back to "Overload {N}". **Heading ID:** Labeled overloads use the kebab-case slug of the label (e.g., `"Video"` → `id="video"`). Unlabeled overloads use `id="overload-{n}"`. -### 6h. Disclosure panel interaction +### 6i. Disclosure panel interaction The `DetailRow` component implements an expandable disclosure pattern: diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 0585d11e0..d2ab5c72f 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -24,14 +24,17 @@ export * from './ui/seek-button/seek-button-data-attrs'; export * from './ui/slider/slider-core'; export * from './ui/slider/slider-css-vars'; export * from './ui/slider/slider-data-attrs'; -export * from './ui/slider/time-slider-core'; -export * from './ui/slider/time-slider-data-attrs'; -export * from './ui/slider/volume-slider-core'; export * from './ui/thumbnail/thumbnail-core'; export * from './ui/thumbnail/thumbnail-data-attrs'; export * from './ui/thumbnail/thumbnail-media-fragment'; export * from './ui/thumbnail/types'; export * from './ui/time/time-core'; export * from './ui/time/time-data-attrs'; +export * from './ui/time-slider/time-slider-core'; +export * from './ui/time-slider/time-slider-css-vars'; +export * from './ui/time-slider/time-slider-data-attrs'; export * from './ui/transition'; export * from './ui/types'; +export * from './ui/volume-slider/volume-slider-core'; +export * from './ui/volume-slider/volume-slider-css-vars'; +export * from './ui/volume-slider/volume-slider-data-attrs'; diff --git a/packages/core/src/core/ui/slider/tests/time-slider-core.test.ts b/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts similarity index 99% rename from packages/core/src/core/ui/slider/tests/time-slider-core.test.ts rename to packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts index f0f4a3216..af22ea714 100644 --- a/packages/core/src/core/ui/slider/tests/time-slider-core.test.ts +++ b/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { MediaBufferState, MediaTimeState } from '../../../media/state'; -import type { SliderInteraction } from '../slider-core'; +import type { SliderInteraction } from '../../slider/slider-core'; import { TimeSliderCore } from '../time-slider-core'; type TimeSliderMedia = MediaTimeState & MediaBufferState; diff --git a/packages/core/src/core/ui/slider/time-slider-core.ts b/packages/core/src/core/ui/time-slider/time-slider-core.ts similarity index 98% rename from packages/core/src/core/ui/slider/time-slider-core.ts rename to packages/core/src/core/ui/time-slider/time-slider-core.ts index 1197fb3f2..303a8d841 100644 --- a/packages/core/src/core/ui/slider/time-slider-core.ts +++ b/packages/core/src/core/ui/time-slider/time-slider-core.ts @@ -4,7 +4,7 @@ import { formatTimeAsPhrase } from '@videojs/utils/time'; import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaBufferState, MediaTimeState } from '../../media/state'; -import { type SliderBaseProps, SliderCore, type SliderInteraction, type SliderState } from './slider-core'; +import { type SliderBaseProps, SliderCore, type SliderInteraction, type SliderState } from '../slider/slider-core'; export interface TimeSliderProps extends SliderBaseProps { /** Trailing-edge throttle (ms) for seek requests during drag. */ diff --git a/packages/core/src/core/ui/time-slider/time-slider-css-vars.ts b/packages/core/src/core/ui/time-slider/time-slider-css-vars.ts new file mode 100644 index 000000000..28e7b8d2e --- /dev/null +++ b/packages/core/src/core/ui/time-slider/time-slider-css-vars.ts @@ -0,0 +1,11 @@ +// Values from SliderCSSVars — duplicated because the api-docs-builder extracts +// JSDoc from the object literal, so we need component-specific descriptions here. +/** CSS custom property names for time slider visual state. */ +export const TimeSliderCSSVars = { + /** Fill level percentage (0–100), representing current playback position. */ + fill: '--media-slider-fill', + /** Pointer position percentage (0–100), tracking the cursor along the slider. */ + pointer: '--media-slider-pointer', + /** Buffer level percentage (0–100), indicating how much media has been buffered. */ + buffer: '--media-slider-buffer', +} as const; diff --git a/packages/core/src/core/ui/slider/time-slider-data-attrs.ts b/packages/core/src/core/ui/time-slider/time-slider-data-attrs.ts similarity index 82% rename from packages/core/src/core/ui/slider/time-slider-data-attrs.ts rename to packages/core/src/core/ui/time-slider/time-slider-data-attrs.ts index 06f817ec8..7391544d9 100644 --- a/packages/core/src/core/ui/slider/time-slider-data-attrs.ts +++ b/packages/core/src/core/ui/time-slider/time-slider-data-attrs.ts @@ -1,5 +1,5 @@ +import { SliderDataAttrs } from '../slider/slider-data-attrs'; import type { StateAttrMap } from '../types'; -import { SliderDataAttrs } from './slider-data-attrs'; import type { TimeSliderState } from './time-slider-core'; export const TimeSliderDataAttrs = { diff --git a/packages/core/src/core/ui/slider/tests/volume-slider-core.test.ts b/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts similarity index 98% rename from packages/core/src/core/ui/slider/tests/volume-slider-core.test.ts rename to packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts index 3239cce63..3ccbd9693 100644 --- a/packages/core/src/core/ui/slider/tests/volume-slider-core.test.ts +++ b/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { MediaVolumeState } from '../../../media/state'; -import type { SliderInteraction } from '../slider-core'; +import type { SliderInteraction } from '../../slider/slider-core'; import { VolumeSliderCore } from '../volume-slider-core'; function createInteraction(overrides: Partial = {}): SliderInteraction { diff --git a/packages/core/src/core/ui/slider/volume-slider-core.ts b/packages/core/src/core/ui/volume-slider/volume-slider-core.ts similarity index 97% rename from packages/core/src/core/ui/slider/volume-slider-core.ts rename to packages/core/src/core/ui/volume-slider/volume-slider-core.ts index d9fc6f6a9..61eacb632 100644 --- a/packages/core/src/core/ui/slider/volume-slider-core.ts +++ b/packages/core/src/core/ui/volume-slider/volume-slider-core.ts @@ -2,7 +2,7 @@ import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaVolumeState } from '../../media/state'; -import { type SliderBaseProps, SliderCore, type SliderInteraction, type SliderState } from './slider-core'; +import { type SliderBaseProps, SliderCore, type SliderInteraction, type SliderState } from '../slider/slider-core'; export interface VolumeSliderProps extends SliderBaseProps {} diff --git a/packages/core/src/core/ui/volume-slider/volume-slider-css-vars.ts b/packages/core/src/core/ui/volume-slider/volume-slider-css-vars.ts new file mode 100644 index 000000000..e70537152 --- /dev/null +++ b/packages/core/src/core/ui/volume-slider/volume-slider-css-vars.ts @@ -0,0 +1,9 @@ +// Values from SliderCSSVars — duplicated because the api-docs-builder extracts +// JSDoc from the object literal, so we need component-specific descriptions here. +/** CSS custom property names for volume slider visual state. */ +export const VolumeSliderCSSVars = { + /** Fill level percentage (0–100), representing the current volume level. */ + fill: '--media-slider-fill', + /** Pointer position percentage (0–100), tracking the cursor along the slider. */ + pointer: '--media-slider-pointer', +} as const; diff --git a/packages/core/src/core/ui/volume-slider/volume-slider-data-attrs.ts b/packages/core/src/core/ui/volume-slider/volume-slider-data-attrs.ts new file mode 100644 index 000000000..99aea06de --- /dev/null +++ b/packages/core/src/core/ui/volume-slider/volume-slider-data-attrs.ts @@ -0,0 +1,7 @@ +import { SliderDataAttrs } from '../slider/slider-data-attrs'; +import type { StateAttrMap } from '../types'; +import type { VolumeSliderState } from './volume-slider-core'; + +export const VolumeSliderDataAttrs = { + ...SliderDataAttrs, +} as const satisfies StateAttrMap; diff --git a/packages/core/src/dom/tests/test-helpers.ts b/packages/core/src/dom/tests/test-helpers.ts index f5c497a86..84d849560 100644 --- a/packages/core/src/dom/tests/test-helpers.ts +++ b/packages/core/src/dom/tests/test-helpers.ts @@ -1,5 +1,5 @@ import type { SliderState } from '../../core/ui/slider/slider-core'; -import type { TimeSliderState } from '../../core/ui/slider/time-slider-core'; +import type { TimeSliderState } from '../../core/ui/time-slider/time-slider-core'; // --------------------------------------------------------------------------- // Mock Video diff --git a/packages/core/src/dom/ui/slider-css-vars.ts b/packages/core/src/dom/ui/slider-css-vars.ts index d483e5de0..3a22526c5 100644 --- a/packages/core/src/dom/ui/slider-css-vars.ts +++ b/packages/core/src/dom/ui/slider-css-vars.ts @@ -1,6 +1,6 @@ import type { SliderState } from '../../core/ui/slider/slider-core'; import { SliderCSSVars } from '../../core/ui/slider/slider-css-vars'; -import type { TimeSliderState } from '../../core/ui/slider/time-slider-core'; +import type { TimeSliderState } from '../../core/ui/time-slider/time-slider-core'; export function getSliderCSSVars(state: SliderState): Record { return { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6d753048f..de9059426 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -39,7 +39,7 @@ export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button'; export { PiPButton, type PiPButtonProps } from './ui/pip-button/pip-button'; export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button'; export { PlaybackRateButton, type PlaybackRateButtonProps } from './ui/playback-rate-button/playback-rate-button'; -export { Popover } from './ui/popover'; +export { Popover, type PopoverContextValue, usePopoverContext } from './ui/popover'; export { Poster, type PosterProps } from './ui/poster/poster'; export { SeekButton, type SeekButtonProps } from './ui/seek-button/seek-button'; export { Slider } from './ui/slider'; diff --git a/packages/react/src/ui/popover/index.parts.ts b/packages/react/src/ui/popover/index.parts.ts index 139fa7e1d..9d7ac7fbf 100644 --- a/packages/react/src/ui/popover/index.parts.ts +++ b/packages/react/src/ui/popover/index.parts.ts @@ -1,5 +1,4 @@ export { PopoverArrow as Arrow, type PopoverArrowProps as ArrowProps } from './popover-arrow'; -export { type PopoverContextValue, usePopoverContext } from './popover-context'; export { PopoverPopup as Popup, type PopoverPopupProps as PopupProps } from './popover-popup'; export { PopoverRoot as Root, type PopoverRootProps as RootProps } from './popover-root'; export { PopoverTrigger as Trigger, type PopoverTriggerProps as TriggerProps } from './popover-trigger'; diff --git a/packages/react/src/ui/popover/index.ts b/packages/react/src/ui/popover/index.ts index 9d2d8d70b..f4f698df9 100644 --- a/packages/react/src/ui/popover/index.ts +++ b/packages/react/src/ui/popover/index.ts @@ -1 +1,2 @@ export * as Popover from './index.parts'; +export { type PopoverContextValue, usePopoverContext } from './popover-context'; diff --git a/packages/react/src/ui/popover/popover-arrow.tsx b/packages/react/src/ui/popover/popover-arrow.tsx index 411fb0545..efb145e6f 100644 --- a/packages/react/src/ui/popover/popover-arrow.tsx +++ b/packages/react/src/ui/popover/popover-arrow.tsx @@ -9,6 +9,7 @@ import { usePopoverContext } from './popover-context'; export interface PopoverArrowProps extends UIComponentProps<'div', PopoverState> {} +/** Decorative arrow pointing from the popup toward the trigger. Hidden from assistive technology. */ export const PopoverArrow = forwardRef(function PopoverArrow( { render, className, style, ...elementProps }, forwardedRef diff --git a/packages/react/src/ui/popover/popover-popup.tsx b/packages/react/src/ui/popover/popover-popup.tsx index b23d9fe6b..dd2b85bfa 100644 --- a/packages/react/src/ui/popover/popover-popup.tsx +++ b/packages/react/src/ui/popover/popover-popup.tsx @@ -15,6 +15,7 @@ export interface PopoverPopupProps extends UIComponentProps<'div', PopoverState> const POPOVER_RESET: CSSProperties = { position: 'fixed', inset: 'auto', margin: 0 }; +/** Container for the popover content. Positioned relative to the trigger using CSS anchor positioning with a JavaScript fallback. */ export const PopoverPopup = forwardRef(function PopoverPopup( { render, className, style, ...elementProps }, forwardedRef diff --git a/packages/react/src/ui/popover/popover-trigger.tsx b/packages/react/src/ui/popover/popover-trigger.tsx index d8a1924f6..315070cb4 100644 --- a/packages/react/src/ui/popover/popover-trigger.tsx +++ b/packages/react/src/ui/popover/popover-trigger.tsx @@ -10,6 +10,7 @@ import { usePopoverContext } from './popover-context'; export interface PopoverTriggerProps extends UIComponentProps<'button', PopoverState> {} +/** Button that toggles the popover visibility. Renders a ` + +
+ Popover content +
+
+ + + diff --git a/site/src/components/docs/demos/popover/html/css/BasicUsage.ts b/site/src/components/docs/demos/popover/html/css/BasicUsage.ts new file mode 100644 index 000000000..5f95baa64 --- /dev/null +++ b/site/src/components/docs/demos/popover/html/css/BasicUsage.ts @@ -0,0 +1,2 @@ +import '@videojs/html/video/player'; +import '@videojs/html/ui/popover'; diff --git a/site/src/components/docs/demos/popover/react/css/BasicUsage.css b/site/src/components/docs/demos/popover/react/css/BasicUsage.css new file mode 100644 index 000000000..786413ff3 --- /dev/null +++ b/site/src/components/docs/demos/popover/react/css/BasicUsage.css @@ -0,0 +1,45 @@ +.react-popover-basic { + position: relative; +} + +.react-popover-basic video { + width: 100%; +} + +.react-popover-basic__bar { + position: absolute; + bottom: 10px; + left: 10px; +} + +.react-popover-basic__trigger { + padding: 6px 16px; + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + color: black; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 9999px; + cursor: pointer; +} + +.react-popover-basic__popup { + /* Reset UA [popover] defaults */ + margin: 0; + border: 0; + --media-popover-side-offset: 8px; + + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(10px); + color: white; + border-radius: 8px; + padding: 12px 16px; + font-size: 14px; +} + +.react-popover-basic__arrow { + fill: rgba(0, 0, 0, 0.85); +} + +.react-popover-basic__content { + white-space: nowrap; +} diff --git a/site/src/components/docs/demos/popover/react/css/BasicUsage.tsx b/site/src/components/docs/demos/popover/react/css/BasicUsage.tsx new file mode 100644 index 000000000..3e914f41e --- /dev/null +++ b/site/src/components/docs/demos/popover/react/css/BasicUsage.tsx @@ -0,0 +1,31 @@ +import { createPlayer, Popover } from '@videojs/react'; +import { Video, videoFeatures } from '@videojs/react/video'; + +import './BasicUsage.css'; + +const Player = createPlayer({ features: videoFeatures }); + +export default function BasicUsage() { + return ( + + + + + ); +} diff --git a/site/src/components/docs/demos/time-slider/html/css/BasicUsage.astro b/site/src/components/docs/demos/time-slider/html/css/BasicUsage.astro new file mode 100644 index 000000000..e7e95f498 --- /dev/null +++ b/site/src/components/docs/demos/time-slider/html/css/BasicUsage.astro @@ -0,0 +1,10 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +import './BasicUsage.css'; +--- + + + diff --git a/site/src/components/docs/demos/time-slider/html/css/BasicUsage.css b/site/src/components/docs/demos/time-slider/html/css/BasicUsage.css new file mode 100644 index 000000000..d7dd8684b --- /dev/null +++ b/site/src/components/docs/demos/time-slider/html/css/BasicUsage.css @@ -0,0 +1,30 @@ +.html-time-slider-basic, +.html-time-slider-basic media-container { + display: block; + position: relative; +} + +.html-time-slider-basic video { + width: 100%; +} + +.html-time-slider-basic__slider { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 20px; + cursor: pointer; + background: rgba(255, 255, 255, 0.3); +} + +.html-time-slider-basic__slider::before { + content: ""; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: var(--media-slider-fill); + background: white; + pointer-events: none; +} diff --git a/site/src/components/docs/demos/time-slider/html/css/BasicUsage.html b/site/src/components/docs/demos/time-slider/html/css/BasicUsage.html new file mode 100644 index 000000000..2e6330111 --- /dev/null +++ b/site/src/components/docs/demos/time-slider/html/css/BasicUsage.html @@ -0,0 +1,12 @@ + + + + + + diff --git a/site/src/components/docs/demos/time-slider/html/css/BasicUsage.ts b/site/src/components/docs/demos/time-slider/html/css/BasicUsage.ts new file mode 100644 index 000000000..d40811aab --- /dev/null +++ b/site/src/components/docs/demos/time-slider/html/css/BasicUsage.ts @@ -0,0 +1,2 @@ +import '@videojs/html/video/player'; +import '@videojs/html/ui/time-slider'; diff --git a/site/src/components/docs/demos/time-slider/html/css/WithParts.astro b/site/src/components/docs/demos/time-slider/html/css/WithParts.astro new file mode 100644 index 000000000..acb39eea2 --- /dev/null +++ b/site/src/components/docs/demos/time-slider/html/css/WithParts.astro @@ -0,0 +1,10 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './WithParts.html?raw'; +import './WithParts.css'; +--- + + + diff --git a/site/src/components/docs/demos/time-slider/html/css/WithParts.css b/site/src/components/docs/demos/time-slider/html/css/WithParts.css new file mode 100644 index 000000000..9924fddb2 --- /dev/null +++ b/site/src/components/docs/demos/time-slider/html/css/WithParts.css @@ -0,0 +1,95 @@ +.html-time-slider-parts, +.html-time-slider-parts media-container { + display: block; + position: relative; +} + +.html-time-slider-parts video { + width: 100%; +} + +.html-time-slider-parts__slider { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + height: 20px; + cursor: pointer; +} + +.html-time-slider-parts__track { + position: absolute; + left: 0; + right: 0; + height: 4px; + background: rgba(255, 255, 255, 0.3); + border-radius: 9999px; + transition: height 150ms ease; +} + +.html-time-slider-parts__slider[data-interactive] .html-time-slider-parts__track { + height: 6px; +} + +.html-time-slider-parts__buffer { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: var(--media-slider-buffer); + background: rgba(255, 255, 255, 0.4); + border-radius: 9999px; +} + +.html-time-slider-parts__fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: var(--media-slider-fill); + background: white; + border-radius: 9999px; +} + +.html-time-slider-parts__thumb { + position: absolute; + left: var(--media-slider-fill); + width: 14px; + height: 14px; + background: white; + border-radius: 50%; + transform: translateX(-50%) scale(0); + transition: transform 150ms ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} + +.html-time-slider-parts__slider[data-interactive] .html-time-slider-parts__thumb { + transform: translateX(-50%) scale(1); +} + +.html-time-slider-parts__slider[data-dragging] .html-time-slider-parts__thumb { + transform: translateX(-50%) scale(1.1); +} + +.html-time-slider-parts__value { + position: absolute; + left: var(--media-slider-pointer); + bottom: 100%; + transform: translateX(-50%); + margin-bottom: 6px; + background: rgba(0, 0, 0, 0.8); + color: white; + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; + pointer-events: none; + white-space: nowrap; + opacity: 0; + transition: opacity 150ms ease; +} + +.html-time-slider-parts__slider[data-pointing] .html-time-slider-parts__value { + opacity: 1; +} diff --git a/site/src/components/docs/demos/time-slider/html/css/WithParts.html b/site/src/components/docs/demos/time-slider/html/css/WithParts.html new file mode 100644 index 000000000..15d0fdd05 --- /dev/null +++ b/site/src/components/docs/demos/time-slider/html/css/WithParts.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/site/src/components/docs/demos/time-slider/html/css/WithParts.ts b/site/src/components/docs/demos/time-slider/html/css/WithParts.ts new file mode 100644 index 000000000..d40811aab --- /dev/null +++ b/site/src/components/docs/demos/time-slider/html/css/WithParts.ts @@ -0,0 +1,2 @@ +import '@videojs/html/video/player'; +import '@videojs/html/ui/time-slider'; diff --git a/site/src/components/docs/demos/time-slider/react/css/BasicUsage.css b/site/src/components/docs/demos/time-slider/react/css/BasicUsage.css new file mode 100644 index 000000000..9f11b9bbe --- /dev/null +++ b/site/src/components/docs/demos/time-slider/react/css/BasicUsage.css @@ -0,0 +1,28 @@ +.react-time-slider-basic { + position: relative; +} + +.react-time-slider-basic video { + width: 100%; +} + +.react-time-slider-basic__slider { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 20px; + cursor: pointer; + background: rgba(255, 255, 255, 0.3); +} + +.react-time-slider-basic__slider::before { + content: ""; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: var(--media-slider-fill); + background: white; + pointer-events: none; +} diff --git a/site/src/components/docs/demos/time-slider/react/css/BasicUsage.tsx b/site/src/components/docs/demos/time-slider/react/css/BasicUsage.tsx new file mode 100644 index 000000000..dea159145 --- /dev/null +++ b/site/src/components/docs/demos/time-slider/react/css/BasicUsage.tsx @@ -0,0 +1,23 @@ +import { createPlayer, TimeSlider } from '@videojs/react'; +import { Video, videoFeatures } from '@videojs/react/video'; + +import './BasicUsage.css'; + +const Player = createPlayer({ features: videoFeatures }); + +export default function BasicUsage() { + return ( + + + + + ); +} diff --git a/site/src/components/docs/demos/time-slider/react/css/WithParts.css b/site/src/components/docs/demos/time-slider/react/css/WithParts.css new file mode 100644 index 000000000..465fbbbf0 --- /dev/null +++ b/site/src/components/docs/demos/time-slider/react/css/WithParts.css @@ -0,0 +1,93 @@ +.react-time-slider-parts { + position: relative; +} + +.react-time-slider-parts video { + width: 100%; +} + +.react-time-slider-parts__slider { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + height: 20px; + cursor: pointer; +} + +.react-time-slider-parts__track { + position: absolute; + left: 0; + right: 0; + height: 4px; + background: rgba(255, 255, 255, 0.3); + border-radius: 9999px; + transition: height 150ms ease; +} + +.react-time-slider-parts__slider[data-interactive] .react-time-slider-parts__track { + height: 6px; +} + +.react-time-slider-parts__buffer { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: var(--media-slider-buffer); + background: rgba(255, 255, 255, 0.4); + border-radius: 9999px; +} + +.react-time-slider-parts__fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: var(--media-slider-fill); + background: white; + border-radius: 9999px; +} + +.react-time-slider-parts__thumb { + position: absolute; + left: var(--media-slider-fill); + width: 14px; + height: 14px; + background: white; + border-radius: 50%; + transform: translateX(-50%) scale(0); + transition: transform 150ms ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} + +.react-time-slider-parts__slider[data-interactive] .react-time-slider-parts__thumb { + transform: translateX(-50%) scale(1); +} + +.react-time-slider-parts__slider[data-dragging] .react-time-slider-parts__thumb { + transform: translateX(-50%) scale(1.1); +} + +.react-time-slider-parts__value { + position: absolute; + left: var(--media-slider-pointer); + bottom: 100%; + transform: translateX(-50%); + margin-bottom: 6px; + background: rgba(0, 0, 0, 0.8); + color: white; + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; + pointer-events: none; + white-space: nowrap; + opacity: 0; + transition: opacity 150ms ease; +} + +.react-time-slider-parts__slider[data-pointing] .react-time-slider-parts__value { + opacity: 1; +} diff --git a/site/src/components/docs/demos/time-slider/react/css/WithParts.tsx b/site/src/components/docs/demos/time-slider/react/css/WithParts.tsx new file mode 100644 index 000000000..e0c06cb95 --- /dev/null +++ b/site/src/components/docs/demos/time-slider/react/css/WithParts.tsx @@ -0,0 +1,30 @@ +import { createPlayer, TimeSlider } from '@videojs/react'; +import { Video, videoFeatures } from '@videojs/react/video'; + +import './WithParts.css'; + +const Player = createPlayer({ features: videoFeatures }); + +export default function WithParts() { + return ( + + + + + ); +} diff --git a/site/src/components/docs/demos/volume-slider/html/css/BasicUsage.astro b/site/src/components/docs/demos/volume-slider/html/css/BasicUsage.astro new file mode 100644 index 000000000..e7e95f498 --- /dev/null +++ b/site/src/components/docs/demos/volume-slider/html/css/BasicUsage.astro @@ -0,0 +1,10 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +import './BasicUsage.css'; +--- + + + diff --git a/site/src/components/docs/demos/volume-slider/html/css/BasicUsage.css b/site/src/components/docs/demos/volume-slider/html/css/BasicUsage.css new file mode 100644 index 000000000..29febaca2 --- /dev/null +++ b/site/src/components/docs/demos/volume-slider/html/css/BasicUsage.css @@ -0,0 +1,60 @@ +.html-volume-slider-basic, +.html-volume-slider-basic media-container { + display: block; + position: relative; +} + +.html-volume-slider-basic video { + width: 100%; +} + +.html-volume-slider-basic__mute-button { + position: absolute; + bottom: 10px; + left: 10px; + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + color: black; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 9999px; + padding-block: 8px; + padding-inline: 20px; + cursor: pointer; +} + +.html-volume-slider-basic__mute-button .show-when-muted { + display: none; +} +.html-volume-slider-basic__mute-button .show-when-unmuted { + display: none; +} +.html-volume-slider-basic__mute-button[data-muted] .show-when-muted { + display: inline; +} +.html-volume-slider-basic__mute-button:not([data-muted]) .show-when-unmuted { + display: inline; +} + +.html-volume-slider-basic__slider { + position: absolute; + bottom: 10px; + right: 10px; + width: 100px; + height: 20px; + cursor: pointer; + background: rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); + border-radius: 9999px; +} + +.html-volume-slider-basic__slider::before { + content: ""; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: var(--media-slider-fill); + background: white; + border-radius: 9999px; + pointer-events: none; +} diff --git a/site/src/components/docs/demos/volume-slider/html/css/BasicUsage.html b/site/src/components/docs/demos/volume-slider/html/css/BasicUsage.html new file mode 100644 index 000000000..6bd411cab --- /dev/null +++ b/site/src/components/docs/demos/volume-slider/html/css/BasicUsage.html @@ -0,0 +1,16 @@ + + + + + Unmute + Mute + + + + diff --git a/site/src/components/docs/demos/volume-slider/html/css/BasicUsage.ts b/site/src/components/docs/demos/volume-slider/html/css/BasicUsage.ts new file mode 100644 index 000000000..bc4876879 --- /dev/null +++ b/site/src/components/docs/demos/volume-slider/html/css/BasicUsage.ts @@ -0,0 +1,3 @@ +import '@videojs/html/video/player'; +import '@videojs/html/ui/mute-button'; +import '@videojs/html/ui/volume-slider'; diff --git a/site/src/components/docs/demos/volume-slider/html/css/WithParts.astro b/site/src/components/docs/demos/volume-slider/html/css/WithParts.astro new file mode 100644 index 000000000..acb39eea2 --- /dev/null +++ b/site/src/components/docs/demos/volume-slider/html/css/WithParts.astro @@ -0,0 +1,10 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './WithParts.html?raw'; +import './WithParts.css'; +--- + + + diff --git a/site/src/components/docs/demos/volume-slider/html/css/WithParts.css b/site/src/components/docs/demos/volume-slider/html/css/WithParts.css new file mode 100644 index 000000000..c90ecfe5e --- /dev/null +++ b/site/src/components/docs/demos/volume-slider/html/css/WithParts.css @@ -0,0 +1,113 @@ +.html-volume-slider-parts, +.html-volume-slider-parts media-container { + display: block; + position: relative; +} + +.html-volume-slider-parts video { + width: 100%; +} + +.html-volume-slider-parts__mute-button { + position: absolute; + bottom: 10px; + left: 10px; + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + color: black; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 9999px; + padding-block: 8px; + padding-inline: 20px; + cursor: pointer; +} + +.html-volume-slider-parts__mute-button .show-when-muted { + display: none; +} +.html-volume-slider-parts__mute-button .show-when-unmuted { + display: none; +} +.html-volume-slider-parts__mute-button[data-muted] .show-when-muted { + display: inline; +} +.html-volume-slider-parts__mute-button:not([data-muted]) .show-when-unmuted { + display: inline; +} + +.html-volume-slider-parts__slider { + position: absolute; + bottom: 10px; + right: 10px; + width: 100px; + display: flex; + align-items: center; + height: 20px; + cursor: pointer; +} + +.html-volume-slider-parts__track { + position: absolute; + left: 0; + right: 0; + height: 4px; + background: rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); + border-radius: 9999px; + transition: height 150ms ease; +} + +.html-volume-slider-parts__slider[data-interactive] .html-volume-slider-parts__track { + height: 6px; +} + +.html-volume-slider-parts__fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: var(--media-slider-fill); + background: white; + border-radius: 9999px; +} + +.html-volume-slider-parts__thumb { + position: absolute; + left: var(--media-slider-fill); + width: 14px; + height: 14px; + background: white; + border-radius: 50%; + transform: translateX(-50%) scale(0); + transition: transform 150ms ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} + +.html-volume-slider-parts__slider[data-interactive] .html-volume-slider-parts__thumb { + transform: translateX(-50%) scale(1); +} + +.html-volume-slider-parts__slider[data-dragging] .html-volume-slider-parts__thumb { + transform: translateX(-50%) scale(1.1); +} + +.html-volume-slider-parts__value { + position: absolute; + left: var(--media-slider-pointer); + bottom: 100%; + transform: translateX(-50%); + margin-bottom: 6px; + background: rgba(0, 0, 0, 0.8); + color: white; + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; + pointer-events: none; + white-space: nowrap; + opacity: 0; + transition: opacity 150ms ease; +} + +.html-volume-slider-parts__slider[data-pointing] .html-volume-slider-parts__value { + opacity: 1; +} diff --git a/site/src/components/docs/demos/volume-slider/html/css/WithParts.html b/site/src/components/docs/demos/volume-slider/html/css/WithParts.html new file mode 100644 index 000000000..63f3810a2 --- /dev/null +++ b/site/src/components/docs/demos/volume-slider/html/css/WithParts.html @@ -0,0 +1,22 @@ + + + + + Unmute + Mute + + + + + + + + + + diff --git a/site/src/components/docs/demos/volume-slider/html/css/WithParts.ts b/site/src/components/docs/demos/volume-slider/html/css/WithParts.ts new file mode 100644 index 000000000..bc4876879 --- /dev/null +++ b/site/src/components/docs/demos/volume-slider/html/css/WithParts.ts @@ -0,0 +1,3 @@ +import '@videojs/html/video/player'; +import '@videojs/html/ui/mute-button'; +import '@videojs/html/ui/volume-slider'; diff --git a/site/src/components/docs/demos/volume-slider/react/css/BasicUsage.css b/site/src/components/docs/demos/volume-slider/react/css/BasicUsage.css new file mode 100644 index 000000000..4d7ead448 --- /dev/null +++ b/site/src/components/docs/demos/volume-slider/react/css/BasicUsage.css @@ -0,0 +1,45 @@ +.react-volume-slider-basic { + position: relative; +} + +.react-volume-slider-basic video { + width: 100%; +} + +.react-volume-slider-basic__mute-button { + position: absolute; + bottom: 10px; + left: 10px; + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + color: black; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 9999px; + padding-block: 8px; + padding-inline: 20px; + cursor: pointer; +} + +.react-volume-slider-basic__slider { + position: absolute; + bottom: 10px; + right: 10px; + width: 100px; + height: 20px; + cursor: pointer; + background: rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); + border-radius: 9999px; +} + +.react-volume-slider-basic__slider::before { + content: ""; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: var(--media-slider-fill); + background: white; + border-radius: 9999px; + pointer-events: none; +} diff --git a/site/src/components/docs/demos/volume-slider/react/css/BasicUsage.tsx b/site/src/components/docs/demos/volume-slider/react/css/BasicUsage.tsx new file mode 100644 index 000000000..9e180dca7 --- /dev/null +++ b/site/src/components/docs/demos/volume-slider/react/css/BasicUsage.tsx @@ -0,0 +1,27 @@ +import { createPlayer, MuteButton, VolumeSlider } from '@videojs/react'; +import { Video, videoFeatures } from '@videojs/react/video'; + +import './BasicUsage.css'; + +const Player = createPlayer({ features: videoFeatures }); + +export default function BasicUsage() { + return ( + + + + + ); +} diff --git a/site/src/components/docs/demos/volume-slider/react/css/WithParts.css b/site/src/components/docs/demos/volume-slider/react/css/WithParts.css new file mode 100644 index 000000000..d451de73c --- /dev/null +++ b/site/src/components/docs/demos/volume-slider/react/css/WithParts.css @@ -0,0 +1,98 @@ +.react-volume-slider-parts { + position: relative; +} + +.react-volume-slider-parts video { + width: 100%; +} + +.react-volume-slider-parts__mute-button { + position: absolute; + bottom: 10px; + left: 10px; + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + color: black; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 9999px; + padding-block: 8px; + padding-inline: 20px; + cursor: pointer; +} + +.react-volume-slider-parts__slider { + position: absolute; + bottom: 10px; + right: 10px; + width: 100px; + display: flex; + align-items: center; + height: 20px; + cursor: pointer; +} + +.react-volume-slider-parts__track { + position: absolute; + left: 0; + right: 0; + height: 4px; + background: rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); + border-radius: 9999px; + transition: height 150ms ease; +} + +.react-volume-slider-parts__slider[data-interactive] .react-volume-slider-parts__track { + height: 6px; +} + +.react-volume-slider-parts__fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: var(--media-slider-fill); + background: white; + border-radius: 9999px; +} + +.react-volume-slider-parts__thumb { + position: absolute; + left: var(--media-slider-fill); + width: 14px; + height: 14px; + background: white; + border-radius: 50%; + transform: translateX(-50%) scale(0); + transition: transform 150ms ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} + +.react-volume-slider-parts__slider[data-interactive] .react-volume-slider-parts__thumb { + transform: translateX(-50%) scale(1); +} + +.react-volume-slider-parts__slider[data-dragging] .react-volume-slider-parts__thumb { + transform: translateX(-50%) scale(1.1); +} + +.react-volume-slider-parts__value { + position: absolute; + left: var(--media-slider-pointer); + bottom: 100%; + transform: translateX(-50%); + margin-bottom: 6px; + background: rgba(0, 0, 0, 0.8); + color: white; + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; + pointer-events: none; + white-space: nowrap; + opacity: 0; + transition: opacity 150ms ease; +} + +.react-volume-slider-parts__slider[data-pointing] .react-volume-slider-parts__value { + opacity: 1; +} diff --git a/site/src/components/docs/demos/volume-slider/react/css/WithParts.tsx b/site/src/components/docs/demos/volume-slider/react/css/WithParts.tsx new file mode 100644 index 000000000..5c133bc3b --- /dev/null +++ b/site/src/components/docs/demos/volume-slider/react/css/WithParts.tsx @@ -0,0 +1,33 @@ +import { createPlayer, MuteButton, VolumeSlider } from '@videojs/react'; +import { Video, videoFeatures } from '@videojs/react/video'; + +import './WithParts.css'; + +const Player = createPlayer({ features: videoFeatures }); + +export default function WithParts() { + return ( + + + + + ); +} diff --git a/site/src/content/docs/reference/popover.mdx b/site/src/content/docs/reference/popover.mdx new file mode 100644 index 000000000..24dc510dd --- /dev/null +++ b/site/src/content/docs/reference/popover.mdx @@ -0,0 +1,127 @@ +--- +title: Popover +frameworkTitle: + html: media-popover +description: A popover component for displaying contextual content anchored to a trigger +--- + +import ComponentReference from "@/components/docs/api-reference/ComponentReference.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/popover/react/css/BasicUsage"; +import basicUsageReactTsx from "@/components/docs/demos/popover/react/css/BasicUsage.tsx?raw"; +import basicUsageReactCss from "@/components/docs/demos/popover/react/css/BasicUsage.css?raw"; + +{/* HTML demos */} +import BasicUsageDemoHtml from "@/components/docs/demos/popover/html/css/BasicUsage.astro"; +import basicUsageHtml from "@/components/docs/demos/popover/html/css/BasicUsage.html?raw"; +import basicUsageHtmlCss from "@/components/docs/demos/popover/html/css/BasicUsage.css?raw"; +import basicUsageHtmlTs from "@/components/docs/demos/popover/html/css/BasicUsage.ts?raw"; + +## Anatomy + + + ```tsx + + Open + + + Content + + + ``` + + + + ```html + + +
Content
+
+ ``` +
+ +## Behavior + +Displays contextual content anchored to a trigger element. By default, opens on click and closes when clicking outside, pressing Escape, or when the trigger loses focus. + +Set `openOnHover` to open on pointer hover instead of click. Use `delay` and `closeDelay` to control timing for hover interactions. + +The `side` and `align` props control popup placement relative to the trigger. The popup repositions automatically to stay within viewport bounds. + + + In React, the component is composed from four parts: `Root` manages state, + `Trigger` toggles the popover, `Popup` contains the content, and `Arrow` + renders a directional arrow. + + + + In HTML, the `media-popover` element wraps a trigger (first child button) and + popup content (second child). The element manages open/close state and + positioning automatically. + + +## Styling + +Use [CSS custom properties](#css-custom-properties) for positioning offsets: + +```css +media-popover { + --media-popover-side-offset: 8px; + --media-popover-align-offset: 0px; +} +``` + +Style based on open state and transition phases: + +```css +media-popover[data-open] .popup { + display: block; +} +media-popover[data-starting-style] .popup { + opacity: 0; +} +media-popover[data-ending-style] .popup { + opacity: 0; +} +``` + +## Accessibility + +The trigger receives `aria-expanded` reflecting the open state. When `modal` is set, the popup receives `aria-modal="true"`. Closing via Escape is enabled by default and can be disabled with `closeOnEscape={false}`. + +## Examples + +### Basic Usage + + + + + + + + + + + + + + + + + + diff --git a/site/src/content/docs/reference/time-slider.mdx b/site/src/content/docs/reference/time-slider.mdx new file mode 100644 index 000000000..93fa76fde --- /dev/null +++ b/site/src/content/docs/reference/time-slider.mdx @@ -0,0 +1,102 @@ +--- +title: TimeSlider +frameworkTitle: + html: media-time-slider +description: A slider component for seeking through media playback time +--- + +import ComponentReference from "@/components/docs/api-reference/ComponentReference.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 WithPartsDemoReact from "@/components/docs/demos/time-slider/react/css/WithParts"; +import withPartsReactTsx from "@/components/docs/demos/time-slider/react/css/WithParts.tsx?raw"; +import withPartsReactCss from "@/components/docs/demos/time-slider/react/css/WithParts.css?raw"; + +{/* HTML demos */} +import WithPartsDemoHtml from "@/components/docs/demos/time-slider/html/css/WithParts.astro"; +import withPartsHtml from "@/components/docs/demos/time-slider/html/css/WithParts.html?raw"; +import withPartsHtmlCss from "@/components/docs/demos/time-slider/html/css/WithParts.css?raw"; +import withPartsHtmlTs from "@/components/docs/demos/time-slider/html/css/WithParts.ts?raw"; + +## Anatomy + + + ```tsx + + ``` + + + + ```html + + ``` + + +## Behavior + +Displays and controls the current playback position. Dragging the slider seeks the media. The fill level reflects `currentTime / duration` as a percentage, and the buffer level shows how much media has been buffered. + +Seeking is throttled via the `commitThrottle` prop (default 100ms) to avoid overwhelming the media element during drag operations. + +## Styling + +Use [CSS custom properties](#css-custom-properties) to style the fill, pointer, and buffer levels: + +```css +media-time-slider::before { + width: calc(var(--media-slider-fill) * 1%); +} +``` + +Use `data-seeking` to style during active seek operations: + +```css +media-time-slider[data-seeking] { + opacity: 0.8; +} +``` + +## Accessibility + +Renders with `role="slider"` and automatic `aria-label` of "Seek". Override with the `label` prop. Keyboard controls: + +- Arrow Left / Arrow Right: step by `step` increment +- Page Up / Page Down: step by `largeStep` increment +- Home: seek to start +- End: seek to end + +## Examples + +Nest sub-components for full control over the slider's DOM structure. This example includes a track, fill bar, buffer indicator, draggable thumb, and a tooltip that shows the pointed-at time. + + + + + + + + + + + + + + + + + + diff --git a/site/src/content/docs/reference/volume-slider.mdx b/site/src/content/docs/reference/volume-slider.mdx new file mode 100644 index 000000000..06d07519c --- /dev/null +++ b/site/src/content/docs/reference/volume-slider.mdx @@ -0,0 +1,92 @@ +--- +title: VolumeSlider +frameworkTitle: + html: media-volume-slider +description: A slider component for controlling media playback volume +--- + +import ComponentReference from "@/components/docs/api-reference/ComponentReference.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 WithPartsDemoReact from "@/components/docs/demos/volume-slider/react/css/WithParts"; +import withPartsReactTsx from "@/components/docs/demos/volume-slider/react/css/WithParts.tsx?raw"; +import withPartsReactCss from "@/components/docs/demos/volume-slider/react/css/WithParts.css?raw"; + +{/* HTML demos */} +import WithPartsDemoHtml from "@/components/docs/demos/volume-slider/html/css/WithParts.astro"; +import withPartsHtml from "@/components/docs/demos/volume-slider/html/css/WithParts.html?raw"; +import withPartsHtmlCss from "@/components/docs/demos/volume-slider/html/css/WithParts.css?raw"; +import withPartsHtmlTs from "@/components/docs/demos/volume-slider/html/css/WithParts.ts?raw"; + +## Anatomy + + + ```tsx + + ``` + + + + ```html + + ``` + + +## Behavior + +Controls the media volume level. The slider maps its 0–100 internal range to the media's 0–1 volume scale. When the media is muted, the fill level drops to 0 regardless of the stored volume value. + +## Styling + +Use [CSS custom properties](#css-custom-properties) to style the fill and pointer levels: + +```css +media-volume-slider::before { + width: calc(var(--media-slider-fill) * 1%); +} +``` + +## Accessibility + +Renders with `role="slider"` and automatic `aria-label` of "Volume". Override with the `label` prop. Keyboard controls: + +- Arrow Left / Arrow Right: step by `step` increment +- Page Up / Page Down: step by `largeStep` increment +- Home: set volume to 0 +- End: set volume to max + +## Examples + +Nest sub-components for full control over the slider's DOM structure. This example includes a track, fill bar, draggable thumb, and a tooltip that shows the volume percentage on hover. + + + + + + + + + + + + + + + + + + diff --git a/site/src/docs.config.ts b/site/src/docs.config.ts index b5be9811e..235fe77b2 100644 --- a/site/src/docs.config.ts +++ b/site/src/docs.config.ts @@ -37,10 +37,13 @@ export const sidebar: Sidebar = [ { slug: 'reference/pip-button' }, { slug: 'reference/play-button' }, { slug: 'reference/playback-rate-button' }, + { slug: 'reference/popover' }, { slug: 'reference/poster' }, { slug: 'reference/seek-button' }, { slug: 'reference/thumbnail' }, { slug: 'reference/time' }, + { slug: 'reference/time-slider' }, + { slug: 'reference/volume-slider' }, ], }, { diff --git a/site/src/types/component-reference.ts b/site/src/types/component-reference.ts index 8ab5819ac..f7b898e0b 100644 --- a/site/src/types/component-reference.ts +++ b/site/src/types/component-reference.ts @@ -26,18 +26,24 @@ export const DataAttrDefSchema = z.object({ detailedType: z.string().optional(), }); +export const CSSVarDefSchema = z.object({ + description: z.string(), +}); + export const PartReferenceSchema = z.object({ name: z.string(), description: z.string().optional(), props: z.record(z.string(), PropDefSchema), state: z.record(z.string(), StateDefSchema), dataAttributes: z.record(z.string(), DataAttrDefSchema), + cssCustomProperties: z.record(z.string(), CSSVarDefSchema), platforms: z.object({ html: z .object({ tagName: z.string(), }) .optional(), + react: z.object({}).optional(), }), }); @@ -48,5 +54,6 @@ export const ComponentReferenceSchema = PartReferenceSchema.extend({ export type PropDef = z.infer; export type StateDef = z.infer; export type DataAttrDef = z.infer; +export type CSSVarDef = z.infer; export type PartReference = z.infer; export type ComponentReference = z.infer; diff --git a/site/src/utils/componentReferenceModel.js b/site/src/utils/componentReferenceModel.js index 8c34a8a95..761d19071 100644 --- a/site/src/utils/componentReferenceModel.js +++ b/site/src/utils/componentReferenceModel.js @@ -29,6 +29,12 @@ const API_REFERENCE_SUBSECTIONS = Object.freeze([ singleId: 'data-attributes', suffix: 'data-attributes', }, + { + key: 'cssCustomProperties', + title: 'CSS custom properties', + singleId: 'css-custom-properties', + suffix: 'css-custom-properties', + }, ]); export const API_REFERENCE_SUBSECTION_TITLES = Object.freeze(API_REFERENCE_SUBSECTIONS.map((section) => section.title)); @@ -93,6 +99,7 @@ export function createComponentReferenceModel(componentName, apiReference) { react: part.name, html: part.platforms?.html?.tagName ?? part.name, }, + frameworks: [...(part.platforms?.html ? ['html'] : []), ...(part.platforms?.react ? ['react'] : [])], sections: createSections(part, { forPart: true, partId }), data: part, })); @@ -146,18 +153,23 @@ export function buildComponentReferenceTocHeadings(apiReferenceModel) { if (apiReferenceModel.hasParts) { for (const part of apiReferenceModel.parts) { - headings.push({ - depth: 3, - text: part.labelByFramework.react, - slug: part.id, - frameworks: ['react'], - }); - headings.push({ - depth: 3, - text: part.labelByFramework.html, - slug: part.id, - frameworks: ['html'], - }); + // Only emit headings for frameworks the part supports + if (part.frameworks.includes('react')) { + headings.push({ + depth: 3, + text: part.labelByFramework.react, + slug: part.id, + frameworks: ['react'], + }); + } + if (part.frameworks.includes('html')) { + headings.push({ + depth: 3, + text: part.labelByFramework.html, + slug: part.id, + frameworks: ['html'], + }); + } for (const section of part.sections) { headings.push({ @@ -165,6 +177,7 @@ export function buildComponentReferenceTocHeadings(apiReferenceModel) { text: section.title, slug: section.id, tocKind: section.tocKind, + ...(part.frameworks.length < 2 ? { frameworks: part.frameworks } : {}), }); } } diff --git a/site/src/utils/tests/componentReferenceModel.test.ts b/site/src/utils/tests/componentReferenceModel.test.ts index 910013f55..d1b9e47c8 100644 --- a/site/src/utils/tests/componentReferenceModel.test.ts +++ b/site/src/utils/tests/componentReferenceModel.test.ts @@ -51,6 +51,7 @@ describe('createComponentReferenceModel', () => { props: {}, state: {}, dataAttributes: {}, + cssCustomProperties: {}, platforms: {}, parts: { root: { @@ -67,10 +68,12 @@ describe('createComponentReferenceModel', () => { description: 'Visible', }, }, + cssCustomProperties: {}, platforms: { html: { tagName: 'media-controls', }, + react: {}, }, }, group: { @@ -78,7 +81,10 @@ describe('createComponentReferenceModel', () => { props: {}, state: {}, dataAttributes: {}, - platforms: {}, + cssCustomProperties: {}, + platforms: { + react: {}, + }, }, }, }; @@ -99,6 +105,7 @@ describe('createComponentReferenceModel', () => { react: 'Root', html: 'media-controls', }, + frameworks: ['html', 'react'], componentName: 'Controls.Root', sections: [ { @@ -123,6 +130,7 @@ describe('createComponentReferenceModel', () => { react: 'Group', html: 'Group', }, + frameworks: ['react'], componentName: 'Controls.Group', sections: [], }, @@ -138,6 +146,7 @@ describe('buildComponentReferenceTocHeadings', () => { props: {}, state: {}, dataAttributes: {}, + cssCustomProperties: {}, platforms: {}, parts: { root: { @@ -153,10 +162,12 @@ describe('buildComponentReferenceTocHeadings', () => { description: 'Visible', }, }, + cssCustomProperties: {}, platforms: { html: { tagName: 'media-controls', }, + react: {}, }, }, }, @@ -197,4 +208,53 @@ describe('buildComponentReferenceTocHeadings', () => { }, ]); }); + + it('filters part headings by framework and adds frameworks to single-platform subsections', () => { + const apiReference = { + name: 'Popover', + props: {}, + state: {}, + dataAttributes: {}, + cssCustomProperties: {}, + platforms: {}, + parts: { + trigger: { + name: 'Trigger', + props: { + onClick: { type: '() => void' }, + }, + state: {}, + dataAttributes: {}, + cssCustomProperties: {}, + platforms: { + react: {}, + }, + }, + }, + }; + + const model = createComponentReferenceModel('Popover', apiReference); + const headings = buildComponentReferenceTocHeadings(model); + + expect(headings).toEqual([ + { + depth: 2, + text: 'API Reference', + slug: 'api-reference', + }, + { + depth: 3, + text: 'Trigger', + slug: 'trigger', + frameworks: ['react'], + }, + { + depth: 4, + text: 'Props', + slug: 'trigger-props', + tocKind: 'api-reference-subsection', + frameworks: ['react'], + }, + ]); + }); });