Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/core/src/core/ui/time-slider/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
93 changes: 83 additions & 10 deletions site/scripts/api-docs-builder/src/core-handler.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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[] = [];
Expand All @@ -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<string, boolean>();

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<string, string> {
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<string>
): Record<string, string> {
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<string, string> = {};

for (const member of resolved.declaration.body.body) {
Expand All @@ -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("'", "\\'")}'`;

Expand Down
20 changes: 20 additions & 0 deletions site/scripts/api-docs-builder/src/tests/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Shared default constants referenced by core defaultProps. */
export const DEFAULT_VOLUME_STEP = 5;
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,6 +28,8 @@ export interface VolumeSliderState {

export class VolumeSliderCore {
static readonly defaultProps = {
...SliderCore.defaultProps,
orientation: 'horizontal',
step: DEFAULT_VOLUME_STEP,
};
}
2 changes: 2 additions & 0 deletions site/src/content/docs/reference/components/slider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ Renders with `role="slider"` and automatic ARIA attributes (`aria-valuemin`, `ar
- <kbd>Home</kbd>: jump to minimum
- <kbd>End</kbd>: jump to maximum

Before each keyboard step, the value snaps to the nearest multiple of `step`.

## Examples

### Basic
Expand Down
2 changes: 2 additions & 0 deletions site/src/content/docs/reference/components/time-slider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ Renders with `role="slider"` and an automatic `aria-label` that resolves to "See
- <kbd>Home</kbd>: seek to start
- <kbd>End</kbd>: seek to end

Before each keyboard seek, the current time snaps to the nearest multiple of `step`. With the default `step` of `1`, pressing <kbd>Arrow Right</kbd> at 12.4 seconds seeks to 13 seconds.

<FrameworkCase frameworks={["react"]}>
`ChapterTitle` follows the pointer during pointer interaction. While the slider has keyboard focus, it follows the current playback position and announces chapter changes.
</FrameworkCase>
Expand Down
2 changes: 2 additions & 0 deletions site/src/content/docs/reference/components/volume-slider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ Renders with `role="slider"` and an automatic `aria-label` that resolves to "Vol
- <kbd>Home</kbd>: set volume to 0
- <kbd>End</kbd>: 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`)
Expand Down
Loading