diff --git a/packages/core/src/core/ui/time-slider/core.ts b/packages/core/src/core/ui/time-slider/core.ts index 304a77bf33..788e5587ab 100644 --- a/packages/core/src/core/ui/time-slider/core.ts +++ b/packages/core/src/core/ui/time-slider/core.ts @@ -16,6 +16,10 @@ import { positionText, unknownText } from '../../i18n/text/time'; import { SliderCore, type SliderProps, type SliderState } from '../slider/core'; export interface TimeSliderProps extends SliderProps { + /** Step increment in seconds for value changes (arrow keys). */ + step?: number | undefined; + /** Large step increment in seconds (Page Up/Down keys). */ + largeStep?: number | undefined; /** @internal Derived from `currentTime` — not user-settable. */ value?: number | undefined; /** @internal Always 0 — not user-settable. */ diff --git a/site/scripts/api-docs-builder/src/core-handler.ts b/site/scripts/api-docs-builder/src/core-handler.ts index f1614b0038..cf20d2c854 100644 --- a/site/scripts/api-docs-builder/src/core-handler.ts +++ b/site/scripts/api-docs-builder/src/core-handler.ts @@ -1,9 +1,11 @@ +import { omit } from 'es-toolkit/object'; import type { Expression } from 'oxc-parser'; import { formatProperties } from './formatter.js'; -import type { OxcProject, SourceFile } from './oxc-project.js'; +import type { OxcProject, ResolvedMember, SourceFile } from './oxc-project.js'; import { expressionText, + getJSDoc, getJSDocDescription, staticName, unwrapExpression, @@ -19,17 +21,18 @@ export function extractCore(filePath: string, project: OxcProject, componentName let props: ExtractedProp[] = []; let description: string | undefined; + let defaultProps = extractDefaultProps(filePath, project, componentName); const propsName = propsDeclaration && 'id' in propsDeclaration.declaration ? staticName(propsDeclaration.declaration.id) : undefined; if (propsDeclaration && propsName) { const type = referenceType(propsName, propsDeclaration.declaration.start, propsDeclaration.declaration.end); + const members = project.interfaceMembers({ file: propsDeclaration.file, type }); - props = Object.entries( - formatProperties(project, project.interfaceMembers({ file: propsDeclaration.file, type })) - ).map(([name, definition]) => ({ name, ...definition })); + props = Object.entries(formatProperties(project, members)).map(([name, definition]) => ({ name, ...definition })); description = getJSDocDescription(propsDeclaration.file, propsDeclaration.declaration); + defaultProps = omit(defaultProps, internalPropNames(members)); } let state: ExtractedProp[] = []; @@ -49,19 +52,52 @@ export function extractCore(filePath: string, project: OxcProject, componentName ...(description ? { description } : {}), props, state, - defaultProps: extractDefaultProps(filePath, project, componentName), + defaultProps, }; } -/** Extract the authored values from a component core's static defaultProps object. */ +/** Props whose most derived declaration is `@internal`: the core controls them, so inherited defaults do not apply. */ +function internalPropNames(members: readonly ResolvedMember[]): string[] { + const internal = new Map(); + + for (const { file, member } of members) { + if (member.type !== 'TSPropertySignature') continue; + + const name = staticName(member.key); + if (!name) continue; + + internal.set(name, getJSDoc(file, member)?.tags.has('internal') ?? false); + } + + return [...internal].filter(([, isInternal]) => isInternal).map(([name]) => name); +} + +/** + * Extract the authored values from a component core's static defaultProps object, following spreads of other cores' + * defaults. + */ export function extractDefaultProps( filePath: string, project: OxcProject, componentName: string ): Record { - const resolved = project.classDeclaration(filePath, `${componentName}Core`); + return classDefaultProps(filePath, project, `${componentName}Core`, new Set()); +} + +function classDefaultProps( + filePath: string, + project: OxcProject, + className: string, + visited: Set +): Record { + const resolved = project.classDeclaration(filePath, className); if (!resolved || resolved.declaration.type !== 'ClassDeclaration') return {}; + const key = `${resolved.file.filePath}#${className}`; + if (visited.has(key)) return {}; + + visited.add(key); + const defaultProps: Record = {}; for (const member of resolved.declaration.body.body) { @@ -71,24 +107,61 @@ export function extractDefaultProps( if (!object) continue; for (const property of object.properties) { - if (property.type !== 'Property' || property.kind !== 'init') continue; + if (property.type === 'SpreadElement') { + const spreadClassName = defaultPropsOwner(property.argument); + + if (spreadClassName) { + Object.assign(defaultProps, classDefaultProps(resolved.file.filePath, project, spreadClassName, visited)); + } + + continue; + } + + if (property.kind !== 'init') continue; const name = staticName(property.key); if (!name) continue; - const value = getPropertyValue(property.value, resolved.file); + const value = getPropertyValue(property.value, resolved.file, project); if (value !== undefined) defaultProps[name] = value; } } + visited.delete(key); + return defaultProps; } +/** Get `Owner` from a `...Owner.defaultProps` spread argument. */ +function defaultPropsOwner(node: Expression): string | undefined { + const expression = unwrapExpression(node); + + if ( + expression.type !== 'MemberExpression' || + expression.computed || + expression.object.type !== 'Identifier' || + staticName(expression.property) !== 'defaultProps' + ) { + return undefined; + } + + return expression.object.name; +} + /** Get the display form of an authored default value. */ -export function getPropertyValue(node: Expression, file: SourceFile): string | undefined { +export function getPropertyValue(node: Expression, file: SourceFile, project: OxcProject): string | undefined { const expression = unwrapExpression(node); + if (expression.type === 'Identifier') { + const constant = project.resolveName(file.filePath, expression.name); + + if (constant?.declaration.type === 'VariableDeclarator' && constant.declaration.init) { + const init = unwrapExpression(constant.declaration.init); + if (init.type === 'Literal') return getPropertyValue(init, constant.file, project); + } + } + if (expression.type === 'Literal') { if (typeof expression.value === 'string') return `'${expression.value.replaceAll("'", "\\'")}'`; 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 6982d2baf7..033b120332 100644 --- a/site/scripts/api-docs-builder/src/tests/e2e.test.ts +++ b/site/scripts/api-docs-builder/src/tests/e2e.test.ts @@ -568,6 +568,26 @@ describe('Component pipeline (end-to-end)', () => { expect(root.platforms.react).toEqual({}); }); + it('resolves defaults spread from the base core defaultProps', () => { + const props = findComponent('VolumeSlider')!.reference.parts!.root!.props; + + expect(props.max!.default).toBe('100'); + expect(props.orientation!.default).toBe("'horizontal'"); + }); + + it('resolves defaults that reference a constant', () => { + const props = findComponent('VolumeSlider')!.reference.parts!.root!.props; + + expect(props.step!.default).toBe('5'); + }); + + it('keeps inherited props overridden as @internal without the base default', () => { + const props = findComponent('VolumeSlider')!.reference.parts!.root!.props; + + expect(Object.keys(props)).toEqual(expect.arrayContaining(['min', 'max', 'step', 'orientation'])); + expect(props.min!.default).toBeUndefined(); + }); + it('re-exported sub-part (Thumb) resolves from slider origin', () => { const thumb = findComponent('VolumeSlider')!.reference.parts!.thumb!; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/constants.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/constants.ts new file mode 100644 index 0000000000..aae42c5db6 --- /dev/null +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/constants.ts @@ -0,0 +1,2 @@ +/** Shared default constants referenced by core defaultProps. */ +export const DEFAULT_VOLUME_STEP = 5; diff --git a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/volume-slider/core.ts b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/volume-slider/core.ts index 88f146c054..81318cf768 100644 --- a/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/volume-slider/core.ts +++ b/site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/ui/volume-slider/core.ts @@ -4,11 +4,21 @@ * Exercises: domain variant components that share base logic (slider/) * but have their own directory under core/ui/. The builder discovers * components by directory — this file must exist for volume-slider to be found. + * Also exercises defaultProps that spread the base core's defaultProps, + * defaults that reference an imported constant, and an @internal override + * whose inherited default does not apply. */ -export interface VolumeSliderProps { +import { DEFAULT_VOLUME_STEP } from '../constants'; +import { SliderCore, type SliderProps } from '../slider/core'; + +export interface VolumeSliderProps extends SliderProps { /** The orientation of the slider. */ orientation: 'horizontal' | 'vertical'; + /** Step increment for keyboard changes. */ + step: number; + /** @internal Always 0, not user-settable. */ + min: number; } export interface VolumeSliderState { @@ -18,6 +28,8 @@ export interface VolumeSliderState { export class VolumeSliderCore { static readonly defaultProps = { + ...SliderCore.defaultProps, orientation: 'horizontal', + step: DEFAULT_VOLUME_STEP, }; } diff --git a/site/src/content/docs/reference/components/slider.mdx b/site/src/content/docs/reference/components/slider.mdx index f37a6b389f..167c97d468 100644 --- a/site/src/content/docs/reference/components/slider.mdx +++ b/site/src/content/docs/reference/components/slider.mdx @@ -150,6 +150,8 @@ Renders with `role="slider"` and automatic ARIA attributes (`aria-valuemin`, `ar - Home: jump to minimum - End: jump to maximum +Before each keyboard step, the value snaps to the nearest multiple of `step`. + ## Examples ### Basic diff --git a/site/src/content/docs/reference/components/time-slider.mdx b/site/src/content/docs/reference/components/time-slider.mdx index 4000d27437..703b381c54 100644 --- a/site/src/content/docs/reference/components/time-slider.mdx +++ b/site/src/content/docs/reference/components/time-slider.mdx @@ -192,6 +192,8 @@ Renders with `role="slider"` and an automatic `aria-label` that resolves to "See - Home: seek to start - End: seek to end +Before each keyboard seek, the current time snaps to the nearest multiple of `step`. With the default `step` of `1`, pressing Arrow Right at 12.4 seconds seeks to 13 seconds. + `ChapterTitle` follows the pointer during pointer interaction. While the slider has keyboard focus, it follows the current playback position and announces chapter changes. diff --git a/site/src/content/docs/reference/components/volume-slider.mdx b/site/src/content/docs/reference/components/volume-slider.mdx index 23cd82f6b4..05fba741a5 100644 --- a/site/src/content/docs/reference/components/volume-slider.mdx +++ b/site/src/content/docs/reference/components/volume-slider.mdx @@ -99,6 +99,8 @@ Renders with `role="slider"` and an automatic `aria-label` that resolves to "Vol - Home: set volume to 0 - End: set volume to max +Before each keyboard step, the volume snaps to the nearest multiple of `step`. + Scroll wheel support: - **Mouse wheel / trackpad scroll**: adjusts volume by `wheelStep` increment (default `5`)