Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/lukebennett88/luke-ui. ui.lukebennett.dev
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753import { resolve } from 'node:path';import { fileURLToPath } from 'node:url';import { createFileSystemGeneratorCache } from 'fumadocs-typescript';import { expect, test } from 'vite-plus/test';import type { PropProject } from './component-prop-analysis.js';import { filterGeneratedDoc, getSharedPropProject, loadExportedPropDeclaration, lukeUiReactSrcDir, typeForwardsDomProps,} from './component-prop-analysis.js';import { createComponentPropsGenerator } from './create-component-props-generator.js';
const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url));const reactSrcDir = lukeUiReactSrcDir(repoRoot);// The union fixture stands in for shapes Luke UI has only one of. `BoxProps` is the sole union among// the documented types, so the negative case — a union that forwards nothing — has no real// counterpart to assert against. Analysing the fixture against its own directory makes its two plain// branches "first-party" exactly the way a component's own interfaces are.const UNION_FIXTURE_PATH = 'apps/docs/src/lib/__fixtures__/prop-analysis-union.ts';const fixtureSrcDir = resolve(repoRoot, 'apps/docs/src/lib/__fixtures__');const generator = createComponentPropsGenerator({ cache: createFileSystemGeneratorCache(resolve(repoRoot, 'apps/docs/.source/fumadocs-typescript')),});
// Building the shared ts-morph project the first time is slow on CI; every test below reuses the// same cached project via `getSharedPropProject` instead of creating a fresh one, and gets an// explicit generous timeout so a slow first build never races vitest's 5s default.const TS_MORPH_TEST_TIMEOUT = 30_000;
async function loadDoc(path: string, name: string) { const [doc] = await generator.generateTypeTable({ path, name }, { basePath: repoRoot }); const project = await getSharedPropProject(repoRoot); const declaration = loadExportedPropDeclaration(project, repoRoot, path, name); if (doc === undefined || declaration === undefined) { throw new Error(`Missing documentation for ${name} in ${path}`); } return { declaration, doc };}
async function visiblePropNames(path: string, name: string): Promise<Array<string>> { const { declaration, doc } = await loadDoc(path, name); return filterGeneratedDoc(doc, declaration, reactSrcDir).entries.map((entry) => entry.name);}
function forwardsDomPropsForExport(project: PropProject, path: string, name: string): boolean { const declaration = loadExportedPropDeclaration(project, repoRoot, path, name); if (declaration === undefined) return false; return typeForwardsDomProps(declaration, reactSrcDir);}
test( 'keeps documented form and field props on TextInputField while hiding generic DOM props', async () => { const names = await visiblePropNames( 'packages/@luke-ui/react/src/core/text-input-field/text-input-field.tsx', 'TextInputFieldProps', ); expect(names).toContain('label'); expect(names).toContain('aria-label'); expect(names).toContain('aria-labelledby'); expect(names).toContain('value'); expect(names).toContain('onChange'); expect(names).toContain('description'); expect(names).toContain('id'); expect(names).toContain('inputId'); expect(names).not.toContain('isInvalid'); expect(names).not.toContain('onClick'); expect(names).not.toContain('onPointerMoveCapture'); }, TS_MORPH_TEST_TIMEOUT,);
test( 'keeps label, id, and ref props on CheckboxField while hiding external naming, children, isInvalid, and generic DOM props', async () => { const names = await visiblePropNames( 'packages/@luke-ui/react/src/core/checkbox-field/checkbox-field.tsx', 'CheckboxFieldProps', ); expect(names).toContain('label'); expect(names).toContain('description'); expect(names).toContain('necessityIndicator'); expect(names).toContain('id'); expect(names).toContain('inputId'); expect(names).toContain('inputRef'); expect(names).toContain('ref'); expect(names).not.toContain('aria-label'); expect(names).not.toContain('aria-labelledby'); expect(names).not.toContain('children'); expect(names).not.toContain('isInvalid'); expect(names).not.toContain('onClick'); expect(names).not.toContain('onPointerMoveCapture'); }, TS_MORPH_TEST_TIMEOUT,);
test( 'keeps accessible-name props on ComboboxField while hiding generic DOM props', async () => { const names = await visiblePropNames( 'packages/@luke-ui/react/src/core/combobox-field/combobox-field.tsx', 'ComboboxFieldProps', ); expect(names).toContain('label'); expect(names).toContain('aria-label'); expect(names).toContain('aria-labelledby'); expect(names).toContain('defaultItems'); expect(names).toContain('onOpenChange'); expect(names).not.toContain('onClick'); expect(names).not.toContain('onPointerMoveCapture'); }, TS_MORPH_TEST_TIMEOUT,);
test( 'keeps naming, selection, id, and ref props on SelectField while hiding isInvalid and generic DOM props', async () => { const names = await visiblePropNames( 'packages/@luke-ui/react/src/core/select-field/select-field.tsx', 'SelectFieldProps', ); expect(names).toContain('label'); expect(names).toContain('aria-label'); expect(names).toContain('aria-labelledby'); expect(names).toContain('aria-describedby'); expect(names).toContain('items'); expect(names).toContain('value'); expect(names).toContain('defaultValue'); expect(names).toContain('onChange'); expect(names).toContain('id'); expect(names).toContain('isPending'); expect(names).toContain('ref'); expect(names).toContain('triggerId'); expect(names).toContain('triggerRef'); expect(names).not.toContain('isInvalid'); expect(names).not.toContain('isReadOnly'); expect(names).not.toContain('onClick'); expect(names).not.toContain('onPointerMoveCapture'); }, TS_MORPH_TEST_TIMEOUT,);
test( 'keeps typography props on Heading while hiding generic DOM props', async () => { const names = await visiblePropNames( 'packages/@luke-ui/react/src/core/heading/heading.tsx', 'HeadingProps', ); expect(names).toContain('level'); expect(names).toContain('typography'); expect(names).not.toContain('onClick'); expect(names).not.toContain('onPointerMoveCapture'); expect(names).not.toContain('itemProp'); }, TS_MORPH_TEST_TIMEOUT,);
test( 'detects native DOM forwarding per exported prop type on multi-type pages', async () => { const project = await getSharedPropProject(repoRoot);
expect( forwardsDomPropsForExport( project, 'packages/@luke-ui/react/src/core/heading/heading.tsx', 'HeadingProps', ), ).toBe(true); expect( forwardsDomPropsForExport( project, 'packages/@luke-ui/react/src/core/heading/heading-context.tsx', 'HeadingLevelsProps', ), ).toBe(false); expect( forwardsDomPropsForExport( project, 'packages/@luke-ui/react/src/core/heading/heading-context.tsx', 'HeadingLevelsRenderProps', ), ).toBe(false); }, TS_MORPH_TEST_TIMEOUT,);
test( 'does not mark object-only provider props as DOM forwarding types', async () => { const { declaration } = await loadDoc( 'packages/@luke-ui/react/src/core/heading/heading-context.tsx', 'HeadingLevelsRenderProps', ); expect(typeForwardsDomProps(declaration, reactSrcDir)).toBe(false); }, TS_MORPH_TEST_TIMEOUT,);
test( 'keeps the redeclared form and state contract visible on the Combobox root primitive', async () => { const names = await visiblePropNames( 'packages/@luke-ui/react/src/core/primitives/combobox/root.tsx', 'ComboboxRootProps', ); expect(names).toContain('isDisabled'); expect(names).toContain('isReadOnly'); expect(names).toContain('isRequired'); expect(names).toContain('isInvalid'); // `form` and `name` are the same react-aria long-tail prop names hidden on AriaBaseButton // types — visible here specifically because `ComboboxRootRedeclaredRACProps` redeclares them // with useful JSDoc, which is what "redeclared in Luke UI source wins" means in practice. expect(names).toContain('name'); expect(names).toContain('form'); expect(names).toContain('validate'); expect(names).toContain('validationBehavior'); expect(names).toContain('autoFocus'); // The ~90 long-tail DOM handlers and capture-phase variants stay hidden. expect(names).not.toContain('onPointerMoveCapture'); expect(names).not.toContain('onClickCapture'); expect(names).not.toContain('onAuxClick'); }, TS_MORPH_TEST_TIMEOUT,);
/** * Representative generic DOM noise. None of it is a documented Luke UI prop, and every entry * reaches a component type only by inheriting a React element attribute bag wholesale, so no table * should ever show any of it. */const GENERIC_DOM_NOISE = ['itemProp', 'onClick', 'onPointerMoveCapture', 'tabIndex'] as const;
/** * The types whose tables were empty before this analysis existed, now audited against what each * component's guide actually teaches rather than against whatever the analysis happens to emit. * * `Kbd` stands for the pure native wrappers (`Prose`, checkbox anatomy parts, and similar): their * guides teach only that they render a native element with the component's own styling, and their * source is a bare `extends ComponentProps<'…'>`. They document no Luke UI contract beyond * pass-through DOM props, so their filtered tables are intentionally empty and rely on the * native-props note alone. * * `Code` composes `Text` and documents `lineClamp` and `textWrap` alongside its native `<code>` * DOM forwarding, so its table is no longer empty — see the `QuoteProps` entry below for the same * shape. */const AUDITED_TYPES: ReadonlyArray<{ forwardsDomProps: boolean; hidden?: ReadonlyArray<string>; name: string; path: string; visible: ReadonlyArray<string>;}> = [ { forwardsDomProps: true, name: 'CodeProps', path: 'packages/@luke-ui/react/src/core/code/code.tsx', visible: ['lineClamp', 'textWrap'], }, { forwardsDomProps: true, name: 'KbdProps', path: 'packages/@luke-ui/react/src/core/kbd/kbd.tsx', visible: [], }, { // The guide teaches `aria-label` in Accessibility for naming the loading status region. forwardsDomProps: true, name: 'LoadingSpinnerProps', path: 'packages/@luke-ui/react/src/core/loading-spinner/loading-spinner.tsx', visible: ['aria-label', 'children', 'color', 'isLoading', 'size'], }, { // The guide teaches `aria-label`, `aria-invalid`, and `inputMode` on a standalone `TextInput`. forwardsDomProps: true, name: 'TextInputProps', path: 'packages/@luke-ui/react/src/core/primitives/text-input/text-input.tsx', visible: [ 'aria-label', 'aria-invalid', 'className', 'defaultValue', 'disabled', 'form', 'id', 'inputMode', 'name', 'onHoverChange', 'onHoverEnd', 'onHoverStart', 'placeholder', 'readOnly', 'ref', 'render', 'required', 'size', 'value', ], }, { // The guide teaches `cite` for the quoted source URL. forwardsDomProps: true, name: 'QuoteProps', path: 'packages/@luke-ui/react/src/core/quote/quote.tsx', visible: ['cite', 'lineClamp', 'textWrap'], }, { // The field guide teaches connecting labels with `htmlFor`. forwardsDomProps: true, name: 'FieldLabelProps', path: 'packages/@luke-ui/react/src/core/primitives/field/label.tsx', visible: ['elementType', 'htmlFor', 'necessityIndicator', 'render'], }, { // The field guide teaches connecting helper text with `id` and `aria-describedby`. forwardsDomProps: true, name: 'FieldDescriptionProps', path: 'packages/@luke-ui/react/src/core/primitives/field/description.tsx', visible: ['elementType', 'id', 'render'], }, { // `FieldError` styles RAC's field error slot, so it documents the same element-choice contract. forwardsDomProps: true, name: 'FieldErrorProps', path: 'packages/@luke-ui/react/src/core/primitives/field/error.tsx', visible: ['elementType', 'render'], }, { // The guide teaches `elementType` for a screen-reader-only heading, `isFocusable` for focus // reveal, and `renderRoot` for a caller-owned skip link. `ref` reaches the owned root. forwardsDomProps: true, name: 'VisuallyHiddenProps', path: 'packages/@luke-ui/react/src/core/visually-hidden/visually-hidden.tsx', visible: ['elementType', 'isFocusable', 'ref', 'renderRoot'], }, { // Icon picks five SVG props by name and forwards nothing else, so its table is closed. forwardsDomProps: false, hidden: ['viewport', 'width'], name: 'IconProps', path: 'packages/@luke-ui/react/src/core/icon/icon.tsx', visible: ['aria-hidden', 'className', 'id', 'name', 'size', 'style', 'title', 'viewBox'], },];
for (const auditedType of AUDITED_TYPES) { test( `${auditedType.name} documents its own contract without generic DOM props`, async () => { const { forwardsDomProps, hidden = [], name, path, visible } = auditedType; const names = await visiblePropNames(path, name);
for (const prop of visible) { expect(names, `${name} should document ${prop}`).toContain(prop); } const hiddenProps = [ ...GENERIC_DOM_NOISE, 'key', ...(visible.includes('ref') ? [] : (['ref'] as const)), ...hidden, ]; for (const prop of hiddenProps) { expect(names, `${name} should hide ${prop}`).not.toContain(prop); }
const { declaration } = await loadDoc(path, name); expect(typeForwardsDomProps(declaration, reactSrcDir)).toBe(forwardsDomProps); }, TS_MORPH_TEST_TIMEOUT, );}
test( 'keeps both branches of a union type documented and DOM-forwarding', async () => { // `Box`'s props are a union of an element branch and a renderRoot branch. TypeScript reports // only the props common to *every* constituent, so reading the union directly hides the // element branch entirely — and with it the fact that `Box` spreads its rest props onto a // real element. const names = await visiblePropNames( 'packages/@luke-ui/react/src/core/box/box.tsx', 'BoxProps', ); // From the element branch, which the renderRoot branch does not declare. expect(names).toContain('elementType'); expect(names).toContain('ref'); // From the renderRoot branch, which the element branch types as `never`. expect(names).toContain('renderRoot'); // Shared layout props from both branches' `SprinklesProps`. expect(names).toContain('padding'); expect(names).toContain('display'); for (const prop of GENERIC_DOM_NOISE) expect(names).not.toContain(prop);
const { declaration } = await loadDoc( 'packages/@luke-ui/react/src/core/box/box.tsx', 'BoxProps', ); expect(typeForwardsDomProps(declaration, reactSrcDir)).toBe(true); }, TS_MORPH_TEST_TIMEOUT,);
test( 'hides forbidden ScrollFade props without hiding supported props', async () => { const names = await visiblePropNames( 'packages/@luke-ui/react/src/core/scroll-fade/scroll-fade.tsx', 'ScrollFadeProps', ); expect(names).toContain('axis'); for (const prop of [ 'tabIndex', 'overflow', 'overflowX', 'overflowY', 'elementType', 'role', 'renderRoot', ] as const) { expect(names, `ScrollFadeProps should hide ${prop}`).not.toContain(prop); } }, TS_MORPH_TEST_TIMEOUT,);
test( 'keeps optional undefined props while hiding direct and aliased never props', async () => { const project = await getSharedPropProject(repoRoot); const file = project.createSourceFile( resolve(fixtureSrcDir, 'prop-analysis-never.ts'), 'type Forbidden = never; export interface NeverProps { allowed?: undefined; forbidden?: never; aliased?: Forbidden; }', { overwrite: true }, ); const declaration = file.getExportedDeclarations().get('NeverProps')?.[0]; if (declaration === undefined) throw new Error('Missing NeverProps fixture declaration');
const names = filterGeneratedDoc( { description: '', id: 'NeverProps', entries: ['allowed', 'forbidden', 'aliased'].map((name) => ({ deprecated: false, description: '', name, required: false, simplifiedType: 'undefined', tags: [], type: 'undefined', })), name: 'NeverProps', }, declaration, fixtureSrcDir, ).entries.map((entry) => entry.name);
expect(names).toEqual(['allowed']); expect(typeForwardsDomProps(declaration, fixtureSrcDir)).toBe(false); }, TS_MORPH_TEST_TIMEOUT,);
test( 'does not treat every union as DOM forwarding', async () => { const project = await getSharedPropProject(repoRoot);
// A union of two plain object types forwards nothing, even though the union-aware walk visits // both constituents. expect(forwardsDomPropsForExport(project, UNION_FIXTURE_PATH, 'PlainUnionProps')).toBe(false); // One DOM-forwarding constituent is enough for the whole union to forward. expect(forwardsDomPropsForExport(project, UNION_FIXTURE_PATH, 'MixedUnionProps')).toBe(true); }, TS_MORPH_TEST_TIMEOUT,);
test( 'documents every branch of a union, not only the props common to all of them', async () => { const project = await getSharedPropProject(repoRoot); const declaration = loadExportedPropDeclaration( project, repoRoot, UNION_FIXTURE_PATH, 'PlainUnionProps', ); if (declaration === undefined) throw new Error('Missing PlainUnionProps fixture declaration');
const names = filterGeneratedDoc( { description: '', id: 'PlainUnionProps', entries: ['a', 'b'].map((name) => ({ deprecated: false, description: '', name, required: false, simplifiedType: '', tags: [], type: '', })), name: 'PlainUnionProps', }, declaration, fixtureSrcDir, ).entries.map((entry) => entry.name);
expect(names).toEqual(['a', 'b']); }, TS_MORPH_TEST_TIMEOUT,);
/** * Exact visible-prop sets for the AriaBaseButton family. Those types inherit `AriaBaseButtonProps` * (react-aria's `useButton`), which declares an undocumented long tail directly alongside genuinely * documented siblings on the same interface body. The structural analysis reads that upstream * syntax, so an upstream release that moves a prop between a curated contract and a generic element * attribute bag changes what these tables show. Pinning the whole set makes that change fail here * loudly instead of silently rewriting a published API table. * * `TextProps` is pinned for the same reason against RAC `Text` / `HTMLAttributes` churn. Types * covered by `AUDITED_TYPES` are not re-pinned here. */const PINNED_VISIBLE_PROPS: ReadonlyArray<{ exportName: string; name: string; path: string; props: ReadonlyArray<string>;}> = [ { // `Text` omits RAC's `Text` props it redeclares and adds its own typography contract. Everything // below `HTMLAttributes` must be gone. exportName: 'TextProps', name: 'TextProps', path: 'packages/@luke-ui/react/src/core/text/text.tsx', props: [ 'color', 'elementType', 'fontStyle', 'fontVariantNumeric', 'fontWeight', 'isVisuallyHidden', 'lineClamp', 'render', 'shouldDisableTrim', 'shouldInheritFont', 'slot', 'textAlign', 'textDecoration', 'textTransform', 'textWrap', 'typography', ], }, { exportName: 'ButtonProps', name: 'core ButtonProps', path: 'packages/@luke-ui/react/src/core/button/button.tsx', props: [ 'appearance', 'aria-describedby', 'aria-details', 'aria-label', 'aria-labelledby', 'autoFocus', 'children', 'endContent', 'id', 'isBlock', 'isDisabled', 'isPending', 'onBlur', 'onFocus', 'onFocusChange', 'onHoverChange', 'onHoverEnd', 'onHoverStart', 'onKeyDown', 'onKeyUp', 'onPress', 'onPressChange', 'onPressEnd', 'onPressStart', 'onPressUp', 'pressAction', 'prominence', 'ref', 'size', 'slot', 'startContent', 'tone', 'type', ], }, { exportName: 'ButtonProps', name: 'primitive ButtonProps', path: 'packages/@luke-ui/react/src/core/primitives/button/button.tsx', props: [ 'appearance', 'aria-describedby', 'aria-details', 'aria-label', 'aria-labelledby', 'autoFocus', 'children', 'id', 'isBlock', 'isDisabled', 'isPending', 'onBlur', 'onFocus', 'onFocusChange', 'onHoverChange', 'onHoverEnd', 'onHoverStart', 'onKeyDown', 'onKeyUp', 'onPress', 'onPressChange', 'onPressEnd', 'onPressStart', 'onPressUp', 'prominence', 'ref', 'render', 'size', 'slot', 'tone', 'type', ], }, { exportName: 'IconButtonProps', name: 'IconButtonProps', path: 'packages/@luke-ui/react/src/core/icon-button/icon-button.tsx', props: [ 'aria-describedby', 'aria-details', 'aria-label', 'aria-labelledby', 'autoFocus', 'icon', 'id', 'isDisabled', 'isPending', 'onBlur', 'onFocus', 'onFocusChange', 'onHoverChange', 'onHoverEnd', 'onHoverStart', 'onKeyDown', 'onKeyUp', 'onPress', 'onPressChange', 'onPressEnd', 'onPressStart', 'onPressUp', 'pressAction', 'prominence', 'ref', 'size', 'slot', 'tone', 'type', ], }, { exportName: 'ComboboxTriggerProps', name: 'ComboboxTriggerProps', path: 'packages/@luke-ui/react/src/core/primitives/combobox/trigger.tsx', props: [ 'aria-describedby', 'aria-details', 'aria-label', 'aria-labelledby', 'autoFocus', 'children', 'className', 'id', 'isDisabled', 'isPending', 'onBlur', 'onFocus', 'onFocusChange', 'onHoverChange', 'onHoverEnd', 'onHoverStart', 'onKeyDown', 'onKeyUp', 'onPress', 'onPressChange', 'onPressEnd', 'onPressStart', 'onPressUp', 'render', 'size', 'slot', 'type', ], }, { exportName: 'ComboboxClearButtonProps', name: 'ComboboxClearButtonProps', path: 'packages/@luke-ui/react/src/core/primitives/combobox/clear-button.tsx', props: [ 'aria-describedby', 'aria-details', 'aria-label', 'aria-labelledby', 'autoFocus', 'children', 'className', 'id', 'isDisabled', 'isPending', 'onBlur', 'onFocus', 'onFocusChange', 'onHoverChange', 'onHoverEnd', 'onHoverStart', 'onKeyDown', 'onKeyUp', 'onPress', 'onPressChange', 'onPressEnd', 'onPressStart', 'onPressUp', 'render', 'size', 'type', ], },];
for (const pinned of PINNED_VISIBLE_PROPS) { test( `${pinned.name} shows exactly its documented props`, async () => { const { exportName, path, props } = pinned; const names = await visiblePropNames(path, exportName); expect([...names].sort()).toEqual([...props].sort()); }, TS_MORPH_TEST_TIMEOUT, );}