From f356164bbbb2977050b85e971853b1d3299174bf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:19:43 +0000 Subject: [PATCH 1/9] fix(components,types): list/loading/input-otp stop reading undeclared keys; loading and input-otp read their declared spellings (objectui#11347) The display, feedback and form units of objectui#11347, the components preparation card for objectui#8347's BaseSchema index-signature removal. Each key was measured on its own: the installed @objectstack/spec (17.5.0) has no row for `list`, `loading` or `input-otp`, and no catalog entry, example, docs page, package README or objectstack-shipped document authors any of the three read keys. - list: the `title` heading read is retired (rule 3: no spec row, no producer). Its registration input goes with it. - loading: the undeclared `text` read moves to the declared `LoadingSchema.label` ("Loading text/message" on both faces), which was inert. One spelling, no alias. - input-otp: the undeclared `maxLength` read moves to the declared `InputOTPSchema.length`, the spelling all five catalog entries and the docs page author. Measured through the real SchemaRenderer, `length: 4` and `length: 8` drew six slots before and draw four and eight now. The body/children tombstone texts on the zod mirror and the matching docblocks are brought in line with what the renderers now read. Claude-Session: https://claude.ai/code/session_01VhxTqosz7wn54ahqyxgERT Co-authored-by: Claude --- .../src/renderers/data-display/list.tsx | 13 +++++-------- .../components/src/renderers/feedback/loading.tsx | 13 +++++++++---- .../components/src/renderers/form/input-otp.tsx | 11 ++++++++--- packages/types/src/data-display.ts | 8 ++++++-- packages/types/src/feedback.ts | 15 ++++++++++++--- packages/types/src/form.ts | 14 ++++++++++++-- packages/types/src/zod/data-display.zod.ts | 6 +++--- packages/types/src/zod/feedback.zod.ts | 4 ++-- packages/types/src/zod/form.zod.ts | 4 ++-- 9 files changed, 59 insertions(+), 29 deletions(-) diff --git a/packages/components/src/renderers/data-display/list.tsx b/packages/components/src/renderers/data-display/list.tsx index 21ff7dc82a..70858615c5 100644 --- a/packages/components/src/renderers/data-display/list.tsx +++ b/packages/components/src/renderers/data-display/list.tsx @@ -23,15 +23,13 @@ ComponentRegistry.register('list', // Default styles for ordered/unordered const listStyle = schema.ordered ? "list-decimal" : "list-disc"; + // ⛔ No `title` heading (objectui#11347). `ListSchema` declares no `title`, + // the installed `@objectstack/spec` has no row for `list`, and no catalog + // entry, example, doc page or README authors one, so the read that drew an + // `h3` from it rode `BaseSchema`'s index signature alone and was retired. return (
- {schema.title && ( -

- {schema.title} -

- )} - - li]:mt-2", listStyle, @@ -52,7 +50,6 @@ ComponentRegistry.register('list', namespace: 'ui', label: 'List', inputs: [ - { name: 'title', type: 'string' }, { name: 'ordered', type: 'boolean' }, { name: 'items', diff --git a/packages/components/src/renderers/feedback/loading.tsx b/packages/components/src/renderers/feedback/loading.tsx index 5318aad515..11bf6eb5c2 100644 --- a/packages/components/src/renderers/feedback/loading.tsx +++ b/packages/components/src/renderers/feedback/loading.tsx @@ -26,8 +26,13 @@ ComponentRegistry.register('loading', size === ('xl' as any) && 'h-16 w-16' )} /> - {schema.text && ( -

{schema.text}

+ {/* The message is the DECLARED `label` (`LoadingSchema.label`, "Loading + text/message", on both faces). The renderer used to read an + undeclared `text` here and leave `label` inert; no producer authors + either spelling, so the read moved to the declared one and `text` + is retired, with no alias (objectui#11347). */} + {schema.label && ( +

{schema.label}

)}
); @@ -53,7 +58,7 @@ ComponentRegistry.register('loading', namespace: 'ui', label: 'Loading', inputs: [ - { name: 'text', type: 'string' }, + { name: 'label', type: 'string' }, { name: 'size', type: 'enum', @@ -64,7 +69,7 @@ ComponentRegistry.register('loading', { name: 'className', type: 'string' } ], defaultProps: { - text: 'Loading...', + label: 'Loading...', size: 'md', fullscreen: false } diff --git a/packages/components/src/renderers/form/input-otp.tsx b/packages/components/src/renderers/form/input-otp.tsx index b24d8c02e8..96851d019f 100644 --- a/packages/components/src/renderers/form/input-otp.tsx +++ b/packages/components/src/renderers/form/input-otp.tsx @@ -16,7 +16,12 @@ ComponentRegistry.register('input-otp', // `style` forwarded by name; the rest through the form-control // declaration (objectui#5632). const { style, ...otpProps } = props; - const length = schema.maxLength || 6; + // The slot count is the DECLARED `length` (`InputOTPSchema.length`, + // `@default 6`), the spelling every catalog entry and the docs page author. + // This used to read an undeclared `maxLength` and ignore `length`, so the + // published 4- and 8-digit examples drew six slots (objectui#11347). One + // spelling, no alias. + const length = schema.length || 6; const slots = Array.from({ length }); const handleChange = (val: string) => { @@ -46,11 +51,11 @@ ComponentRegistry.register('input-otp', namespace: 'ui', label: 'Input OTP', inputs: [ - { name: 'maxLength', type: 'number' }, + { name: 'length', type: 'number' }, { name: 'className', type: 'string' } ], defaultProps: { - maxLength: 6 + length: 6 } } ); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 6326ec8e93..e1544cdc5e 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -305,9 +305,9 @@ export interface ListSchema extends BaseSchema { */ dense?: boolean; /** - * Classes on the wrapper `div` around the title and the list. + * Classes on the wrapper `div` around the list. * - * READ SITE: `packages/components/src/renderers/data-display/list.tsx:27` — + * READ SITE: `packages/components/src/renderers/data-display/list.tsx` — * `cn("space-y-2", schema.wrapperClass)`. Undeclared until * objectui#7722, surviving only on `BaseSchema`'s index signature: the same * key, on the same class of read, that `CheckboxSchema` (`b74a8598d`), @@ -328,6 +328,8 @@ export interface ListSchema extends BaseSchema { * from; this declaration carries none. What the renderer DOES read off this * node, from the same instrument: `bind`, `items`, `ordered`, `title`, * `wrapperClass` (in `packages/components/src/renderers/data-display/list.tsx`). + * `title` has since been retired there (objectui#11347): it was never a + * member here and no producer authored it. * * ⭐ THE ITEM CHANNEL IS A DIFFERENT KEY AND STAYS LIVE. That renderer draws * each entry from its `content` (`renderChildren(item.content)`) — a read filed @@ -375,6 +377,8 @@ export interface ListSchema extends BaseSchema { * from; this declaration carries none. What the renderer DOES read off this * node, from the same instrument: `bind`, `items`, `ordered`, `title`, * `wrapperClass` (in `packages/components/src/renderers/data-display/list.tsx`). + * `title` has since been retired there (objectui#11347): it was never a + * member here and no producer authored it. * * ⭐ THE ITEM CHANNEL IS A DIFFERENT KEY AND STAYS LIVE. That renderer draws * each entry from its `content` (`renderChildren(item.content)`) — a read filed diff --git a/packages/types/src/feedback.ts b/packages/types/src/feedback.ts index e80a83f600..8486958dd4 100644 --- a/packages/types/src/feedback.ts +++ b/packages/types/src/feedback.ts @@ -23,7 +23,12 @@ import type { BaseSchema, SchemaNode } from './base.js'; export interface LoadingSchema extends BaseSchema { type: 'loading'; /** - * Loading text/message + * Loading text/message, drawn under the spinner. + * + * READ SITE: `packages/components/src/renderers/feedback/loading.tsx`, which + * draws `schema.label` in a `p` under the spinner. Inert until objectui#11347: + * the renderer read an undeclared `text` instead, which no producer + * authored, so the read moved to this declared spelling with no alias. */ label?: string; /** @@ -52,7 +57,9 @@ export interface LoadingSchema extends BaseSchema { * scores. Every read is filed under the TYPE of the object it is read from; * this declaration carries none. What the renderer DOES read off this node: * `fullscreen`, `size`, `text` (in - * `packages/components/src/renderers/feedback/loading.tsx`). + * `packages/components/src/renderers/feedback/loading.tsx`). The undeclared + * `text` read has since moved to the declared {@link LoadingSchema.label} + * (objectui#11347). * * Before objectui#9256 tombstoned them here, `body` and `children` were both * inherited-and-optional from {@link BaseSchema} — so authoring either here @@ -78,7 +85,9 @@ export interface LoadingSchema extends BaseSchema { * scores. Every read is filed under the TYPE of the object it is read from; * this declaration carries none. What the renderer DOES read off this node: * `fullscreen`, `size`, `text` (in - * `packages/components/src/renderers/feedback/loading.tsx`). + * `packages/components/src/renderers/feedback/loading.tsx`). The undeclared + * `text` read has since moved to the declared {@link LoadingSchema.label} + * (objectui#11347). * * Before objectui#9256 tombstoned them here, `body` and `children` were both * inherited-and-optional from {@link BaseSchema} — so authoring either here diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 2fce3dbb30..1738781cc8 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -1443,6 +1443,12 @@ export interface InputOTPSchema extends BaseSchema { label?: string; /** * Number of OTP digits + * + * READ SITE: `packages/components/src/renderers/form/input-otp.tsx`, as the + * slot count. Inert until objectui#11347: the renderer read an undeclared + * `maxLength` and drew six slots whatever `length` said, while every + * catalog entry and the docs page author `length`. The read moved to this + * spelling with no alias, which also makes the `@default` below true. * @default 6 */ length?: number; @@ -1492,7 +1498,9 @@ export interface InputOTPSchema extends BaseSchema { * scores. Every read is filed under the TYPE of the object it is read from; * this declaration carries none. What the renderer DOES read off this node: * `maxLength`, `value` (in - * `packages/components/src/renderers/form/input-otp.tsx`). + * `packages/components/src/renderers/form/input-otp.tsx`). The undeclared + * `maxLength` read has since moved to the declared {@link InputOTPSchema.length} + * (objectui#11347). * * Before objectui#9256 tombstoned them here, `body` and `children` were both * inherited-and-optional from {@link BaseSchema} — so authoring either here @@ -1519,7 +1527,9 @@ export interface InputOTPSchema extends BaseSchema { * scores. Every read is filed under the TYPE of the object it is read from; * this declaration carries none. What the renderer DOES read off this node: * `maxLength`, `value` (in - * `packages/components/src/renderers/form/input-otp.tsx`). + * `packages/components/src/renderers/form/input-otp.tsx`). The undeclared + * `maxLength` read has since moved to the declared {@link InputOTPSchema.length} + * (objectui#11347). * * Before objectui#9256 tombstoned them here, `body` and `children` were both * inherited-and-optional from {@link BaseSchema} — so authoring either here diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 271026475a..219d22f77b 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -190,14 +190,14 @@ export const ListSchema = BaseSchema.extend({ dividers: z.boolean().optional().describe('Show dividers between items'), dense: z.boolean().optional().describe('Dense spacing'), wrapperClass: z.string().optional() - .describe('Classes on the wrapper div around the title and the list — merged with the base `space-y-2` (objectui#7722)'), + .describe('Classes on the wrapper div around the list — merged with the base `space-y-2` (objectui#7722)'), body: retirementTombstone( 'REFUSED (objectui#9256, ADR-0049) — `list` reads NEITHER content channel: measured with the ' + 'TypeScript type checker over one program built from the repo-root tsconfig on a BUILT tree, no ' + 'renderer read consumes `body` or `children` for this node, and `SchemaRenderer` strips both out ' + 'of the props bag it spreads. An authored value therefore rendered NOTHING — no render-time error or ' + 'warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `bind`, `items`, `ordered`, `title`, `wrapperClass`. ' + + 'What it renders instead: `bind`, `items`, `ordered`, `wrapperClass`. ' + 'The ITEM channel is a different key and stays live: each entry is drawn from its `content` ' + '(`renderChildren(item.content)`), a read filed under ListItem and not under this node. ' + '`ui:list` is the measured SOLE owner of the bare `list` key (`view:list` passes `skipFallback: true`); ' @@ -209,7 +209,7 @@ export const ListSchema = BaseSchema.extend({ + 'renderer read consumes `body` or `children` for this node, and `SchemaRenderer` strips both out ' + 'of the props bag it spreads. An authored value therefore rendered NOTHING — no render-time error or ' + 'warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `bind`, `items`, `ordered`, `title`, `wrapperClass`. ' + + 'What it renders instead: `bind`, `items`, `ordered`, `wrapperClass`. ' + 'The ITEM channel is a different key and stays live: each entry is drawn from its `content` ' + '(`renderChildren(item.content)`), a read filed under ListItem and not under this node. ' + '`ui:list` is the measured SOLE owner of the bare `list` key (`view:list` passes `skipFallback: true`); ' diff --git a/packages/types/src/zod/feedback.zod.ts b/packages/types/src/zod/feedback.zod.ts index a8b64ae05b..e237e49dd7 100644 --- a/packages/types/src/zod/feedback.zod.ts +++ b/packages/types/src/zod/feedback.zod.ts @@ -34,14 +34,14 @@ export const LoadingSchema = BaseSchema.extend({ + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `fullscreen`, `size`, `text`.', + + 'What it renders instead: `fullscreen`, `label`, `size`.', ), children: retirementTombstone( 'REFUSED (objectui#9256, ADR-0049) — `loading` reads NEITHER content channel: measured with the ' + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `fullscreen`, `size`, `text`.', + + 'What it renders instead: `fullscreen`, `label`, `size`.', ), }); diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 0912ec2e9a..9eed89603e 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -759,14 +759,14 @@ export const InputOTPSchema = BaseSchema.extend({ + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `maxLength`, `value`.', + + 'What it renders instead: `length`, `value`.', ), children: retirementTombstone( 'REFUSED (objectui#9256, ADR-0049) — `input-otp` reads NEITHER content channel: measured with the ' + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `maxLength`, `value`.', + + 'What it renders instead: `length`, `value`.', ), }); From d32acf1042a54c7e1df9dc84b2cca744125b15a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:19:54 +0000 Subject: [PATCH 2/9] fix(components): sidebar-menu-button stops reading an undeclared `active` (objectui#11347) The navigation unit of objectui#11347. `active` was read off a `schema` typed `BaseSchema` itself: no type declares it for `sidebar-menu-button`, the installed @objectstack/spec has no row for the node, and no catalog entry, example, docs page or README authors it (the five catalog sidebars author none). With no spec row and no producer, the read is retired along with its registration input; `isActive` is no longer driven by the node. Claude-Session: https://claude.ai/code/session_01VhxTqosz7wn54ahqyxgERT Co-authored-by: Claude --- packages/components/src/renderers/navigation/sidebar.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/components/src/renderers/navigation/sidebar.tsx b/packages/components/src/renderers/navigation/sidebar.tsx index 3a6ebe4df5..bb186aed6c 100644 --- a/packages/components/src/renderers/navigation/sidebar.tsx +++ b/packages/components/src/renderers/navigation/sidebar.tsx @@ -176,9 +176,13 @@ ComponentRegistry.register('sidebar-menu-button', // different mechanism group and a different card. const { style, ...buttonProps } = props; + // ⛔ No `isActive` from the node (objectui#11347). `active` was read off a + // `schema` typed `BaseSchema` itself: no type declares it for this node, + // the installed `@objectstack/spec` has no row for `sidebar-menu-button`, + // and no catalog entry, example, doc page or README authors it, so the read + // rode the index signature alone and was retired. return ( @@ -190,7 +194,6 @@ ComponentRegistry.register('sidebar-menu-button', namespace: 'ui', label: 'Sidebar Menu Button', inputs: [ - { name: 'active', type: 'boolean' }, { name: 'size', type: 'enum', enum: ['default', 'sm', 'lg'] }, { name: 'tooltip', type: 'string' }, CHILDREN_SLOT From 748f3b86aec7a181ec54a7ce224c24e15fd96194 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:19:55 +0000 Subject: [PATCH 3/9] fix(components,types): drawer, dropdown-menu, popover and sheet stop reading undeclared keys (objectui#11347) The overlay unit of objectui#11347. The installed @objectstack/spec has no row for any of these four nodes, and no catalog entry, example, docs page, README or objectstack-shipped document authors any of the keys, so each named read is retired (rule 3): - drawer: `shouldScaleBackground`, `footer` and `showClose`. The drawer draws no footer and no Close button from the node any more. - dropdown-menu, popover, sheet: `modal`. Their registration inputs (and sheet's `modal` seed) go with them. Measured through the real SchemaRenderer: an authored `modal` or `shouldScaleBackground` still reaches the Radix / vaul root through the prop spread, which the strict authoring face owns; the named reads were redundant on that path. Collateral, each forced by a verdict here: - `node-slot-numeric-falsy.test.tsx` loses its `drawer footer` row (the slot no longer exists). - `CRUDDialogSchema.showClose`'s docblock and its pin in `layout-default-jsdoc-7361.test.ts` named the drawer's look-alike read as live; both now record it retired. - the pending objectui#8318 changeset gets a dated correction for the same sentence; its frontmatter is unchanged. Claude-Session: https://claude.ai/code/session_01VhxTqosz7wn54ahqyxgERT Co-authored-by: Claude --- .../8318-jsdoc-default-tag-corrections.md | 2 + .../node-slot-numeric-falsy.test.tsx | 5 ++- .../src/renderers/overlay/drawer.tsx | 37 +++++++------------ .../src/renderers/overlay/dropdown-menu.tsx | 6 ++- .../src/renderers/overlay/popover.tsx | 7 +++- .../src/renderers/overlay/sheet.tsx | 8 ++-- .../layout-default-jsdoc-7361.test.ts | 24 +++++++----- packages/types/src/crud.ts | 15 +++----- packages/types/src/overlay.ts | 18 +++++++++ packages/types/src/zod/overlay.zod.ts | 18 ++++----- 10 files changed, 80 insertions(+), 60 deletions(-) diff --git a/.changeset/8318-jsdoc-default-tag-corrections.md b/.changeset/8318-jsdoc-default-tag-corrections.md index af850f6c88..05274368e9 100644 --- a/.changeset/8318-jsdoc-default-tag-corrections.md +++ b/.changeset/8318-jsdoc-default-tag-corrections.md @@ -49,3 +49,5 @@ described nothing that runs. The pin `packages/types/src/__tests__/layout-default-jsdoc-7361.test.ts` grows from 15 rows to 24, both sides derived off disk as before. + +**Correction, 2026-10-01 (objectui#11347).** The `CRUDDialogSchema` bullet above says `showClose`'s one other occurrence belongs to `DrawerSchema`. That was true when this change was written, and it no longer is: objectui#11347 retired the `drawer` renderer's `showClose` read, which `DrawerSchema` never declared and no producer authored, so `showClose` is now read under no node type at all. The `CRUDDialogSchema.showClose` docblock and its pin in `layout-default-jsdoc-7361.test.ts` say so. The verdict this change records is unchanged: `showClose` still publishes no `@default`. diff --git a/packages/components/src/renderers/__tests__/node-slot-numeric-falsy.test.tsx b/packages/components/src/renderers/__tests__/node-slot-numeric-falsy.test.tsx index 0c6c1fa0ea..16d2b57762 100644 --- a/packages/components/src/renderers/__tests__/node-slot-numeric-falsy.test.tsx +++ b/packages/components/src/renderers/__tests__/node-slot-numeric-falsy.test.tsx @@ -102,6 +102,10 @@ interface Site { * `body` STAYS — that key is `PageCardProps.body`, a read-only back-compat path * for stored documents under a different retirement (objectstack#5775, ADR-0087 * D2), and it is still a live node slot. + * + * ⚠️ `drawer footer` LEFT this table the same way: objectui#11347 retired the + * drawer's `footer` read, so the slot ceased to exist rather than stopped + * leaking. */ const SITES: readonly Site[] = [ { name: 'container children', type: 'container', namespace: 'ui', slot: 'children' }, @@ -115,7 +119,6 @@ const SITES: readonly Site[] = [ { name: 'page:card footer', type: 'card', namespace: 'page', slot: 'footer' }, { name: 'dialog footer', type: 'dialog', namespace: 'ui', slot: 'footer', base: { defaultOpen: true } }, { name: 'sheet footer', type: 'sheet', namespace: 'ui', slot: 'footer', base: { defaultOpen: true } }, - { name: 'drawer footer', type: 'drawer', namespace: 'ui', slot: 'footer', base: { defaultOpen: true } }, { name: 'table footer', type: 'table', diff --git a/packages/components/src/renderers/overlay/drawer.tsx b/packages/components/src/renderers/overlay/drawer.tsx index b34c8815a2..5e8abbe192 100644 --- a/packages/components/src/renderers/overlay/drawer.tsx +++ b/packages/components/src/renderers/overlay/drawer.tsx @@ -13,16 +13,24 @@ import { DrawerTrigger, DrawerContent, DrawerHeader, - DrawerFooter, DrawerTitle, - DrawerDescription, - DrawerClose + DrawerDescription } from '../../ui'; -import { renderChildren, renderNodeSlot, renderTriggerSlot } from '../../lib/utils'; +import { renderChildren, renderTriggerSlot } from '../../lib/utils'; ComponentRegistry.register('drawer', ({ schema, className, ...props }: { schema: DrawerSchema; className?: string; [key: string]: any }) => ( - + // ⛔ Three reads retired here (objectui#11347): `shouldScaleBackground`, + // `footer` and `showClose`. `DrawerSchema` declares none of them, the + // installed `@objectstack/spec` has no row for `drawer`, and no catalog + // entry, example, doc page or README authors any of them, so each read + // rode `BaseSchema`'s index signature alone. The drawer therefore draws no + // footer and no Close button from the node. `shouldScaleBackground` is the + // primitive's own prop and still defaults inside the `Drawer` wrapper; the + // node no longer names it, and what an authored one does through + // `SchemaRenderer`'s prop spread is the strict face's question, not this + // read's. + {renderTriggerSlot(DrawerTrigger, schema.trigger)} @@ -30,20 +38,6 @@ ComponentRegistry.register('drawer', {schema.description && {schema.description}} {renderChildren(schema.content)} -{/* ⛔ No `&&` guard on a node slot (objectui#9162): `&&` evaluates to the - slot, so a legal authored `footer: 0` painted the character "0" — - and it short-circuits, so `renderChildren`'s own falsy leg never - ran. `renderNodeSlot` runs the wrapper only when the slot has - content, so the chrome disappears with it. */} - {renderNodeSlot(schema.footer, (footer) => ( - - {renderChildren(footer)} - {/* `showClose` is a declared BOOLEAN, not a node slot — its `&&` - is not the objectui#9162 construct and stays. Its coupling to - `footer` is pre-existing behaviour and is unchanged here. */} - {schema.showClose && } - - ))} ), @@ -53,18 +47,13 @@ ComponentRegistry.register('drawer', inputs: [ { name: 'title', type: 'string' }, { name: 'description', type: 'string' }, - { name: 'shouldScaleBackground', type: 'boolean' }, { name: 'defaultOpen', type: 'boolean' }, - { name: 'showClose', type: 'boolean' }, { name: 'trigger', type: 'slot' }, { name: 'content', type: 'slot' }, - { - name: 'footer', - type: 'slot' }, { name: 'className', type: 'string' } ], defaultProps: { diff --git a/packages/components/src/renderers/overlay/dropdown-menu.tsx b/packages/components/src/renderers/overlay/dropdown-menu.tsx index 692738b467..7476558664 100644 --- a/packages/components/src/renderers/overlay/dropdown-menu.tsx +++ b/packages/components/src/renderers/overlay/dropdown-menu.tsx @@ -83,6 +83,10 @@ const renderMenuItems = (items: MenuItem[] | undefined) => { }); }; +// ⛔ No `modal` read (objectui#11347). `DropdownMenuSchema` declares no `modal`, the +// installed `@objectstack/spec` has no row for `dropdown-menu`, and no catalog entry, +// example, doc page or README authors one, so `modal={schema.modal}` rode +// `BaseSchema`'s index signature alone and was retired. ComponentRegistry.register('dropdown-menu', ({ schema, className, ...props }: { schema: DropdownMenuSchema; className?: string; [key: string]: any }) => { // Read-time resolution against the display locale (objectui#4580 revised @@ -92,7 +96,7 @@ ComponentRegistry.register('dropdown-menu', // `DropdownMenuContent` lazily. The body became a block only to host this hook. const locale = useDisplayLocale(); return ( - + {renderTriggerSlot(DropdownMenuTrigger, schema.trigger)} {schema.label && ( diff --git a/packages/components/src/renderers/overlay/popover.tsx b/packages/components/src/renderers/overlay/popover.tsx index 5e453619ee..9ab2ae4495 100644 --- a/packages/components/src/renderers/overlay/popover.tsx +++ b/packages/components/src/renderers/overlay/popover.tsx @@ -15,9 +15,13 @@ import { } from '../../ui'; import { renderChildren, renderTriggerSlot } from '../../lib/utils'; +// ⛔ No `modal` read (objectui#11347). `PopoverSchema` declares no `modal`, the +// installed `@objectstack/spec` has no row for `popover`, and no catalog entry, +// example, doc page or README authors one, so `modal={schema.modal}` rode +// `BaseSchema`'s index signature alone and was retired. ComponentRegistry.register('popover', ({ schema, className, ...props }: { schema: PopoverSchema; className?: string; [key: string]: any }) => ( - + {renderTriggerSlot(PopoverTrigger, schema.trigger)} {renderChildren(schema.content)} @@ -28,7 +32,6 @@ ComponentRegistry.register('popover', namespace: 'ui', label: 'Popover', inputs: [ - { name: 'modal', type: 'boolean' }, { name: 'defaultOpen', type: 'boolean' }, { name: 'align', type: 'enum', enum: ['start', 'center', 'end'] }, { name: 'side', type: 'enum', enum: ['top', 'right', 'bottom', 'left'] }, diff --git a/packages/components/src/renderers/overlay/sheet.tsx b/packages/components/src/renderers/overlay/sheet.tsx index dc027f7909..b4b95baef6 100644 --- a/packages/components/src/renderers/overlay/sheet.tsx +++ b/packages/components/src/renderers/overlay/sheet.tsx @@ -19,9 +19,13 @@ import { } from '../../ui'; import { renderChildren, renderNodeSlot, renderTriggerSlot } from '../../lib/utils'; +// ⛔ No `modal` read (objectui#11347). `SheetSchema` declares no `modal`, the +// installed `@objectstack/spec` has no row for `sheet`, and no catalog entry, +// example, doc page or README authors one, so `modal={schema.modal}` rode +// `BaseSchema`'s index signature alone and was retired. ComponentRegistry.register('sheet', ({ schema, className, ...props }: { schema: SheetSchema; className?: string; [key: string]: any }) => ( - + {renderTriggerSlot(SheetTrigger, schema.trigger)} @@ -49,7 +53,6 @@ ComponentRegistry.register('sheet', { name: 'title', type: 'string' }, { name: 'description', type: 'string' }, { name: 'side', type: 'enum', enum: ['top', 'right', 'bottom', 'left'] }, - { name: 'modal', type: 'boolean' }, { name: 'defaultOpen', type: 'boolean' }, { name: 'trigger', @@ -68,7 +71,6 @@ ComponentRegistry.register('sheet', title: 'Sheet Title', description: 'Sheet description', side: 'right', - modal: true, trigger: [{ type: 'button', label: 'Open Sheet' }], content: [{ type: 'text', content: 'Sheet content goes here' }] } diff --git a/packages/types/src/__tests__/layout-default-jsdoc-7361.test.ts b/packages/types/src/__tests__/layout-default-jsdoc-7361.test.ts index 15e5a86db8..187bcfe873 100644 --- a/packages/types/src/__tests__/layout-default-jsdoc-7361.test.ts +++ b/packages/types/src/__tests__/layout-default-jsdoc-7361.test.ts @@ -706,8 +706,8 @@ describe('objectui#8318 — the `@default`s the "no renderer reads it" list got * | `CRUDDialogSchema.size` | no `crud-dialog` registration at all; | * | `.closeOnOutsideClick` | plus, per key, its own name census: | * | `.closeOnEscape` | zero occurrences for the middle two, and | - * | `.showClose` | for `showClose` one occurrence that | - * | | belongs to `DrawerSchema` | + * | `.showClose` | for `showClose` a look-alike `drawer` | + * | | read, retired since objectui#11347 | * * ## Derivation — the two `loading` rows * @@ -746,8 +746,9 @@ describe('objectui#8318 — the `@default`s the "no renderer reads it" list got * header of `handler-keys-string-any-mirrors-7344.test.ts`. What IS derived here * is everything reachable from a named file: that the `card` primitive does not * consume `variant`, that `page`'s allow-list does not carry `isDefault`, that - * `ActionRunner` reads `method` but not `level`, and that the one `showClose` - * read in the tree belongs to `DrawerSchema`. + * `ActionRunner` reads `method` but not `level`, and that the `drawer` + * renderer, whose look-alike `showClose` read this row once had to rule out, + * no longer reads the key at all (objectui#11347 retired it). */ const UI_CARD = 'packages/components/src/ui/card.tsx'; const UI_ALERT = 'packages/components/src/ui/alert.tsx'; @@ -965,21 +966,24 @@ describe('objectui#8318 ruling — nine `@default`s corrected against a measured }); /** - * The one of the four whose census is NOT empty, which is why its wording - * differs: crediting this declaration with `DrawerSchema`'s reader is the - * exact mistake a name-only census makes. + * The one of the four whose census was NOT empty, which is why its wording + * differs: crediting this declaration with the `drawer` renderer's reader + * was the exact mistake a name-only census makes. objectui#11347 retired + * that look-alike read, and the docblock says so. */ - it('row 24 — `showClose` publishes no tag and names the look-alike reader', () => { + it('row 24 — `showClose` publishes no tag and names the retired look-alike reader', () => { expectAbsentTagWithEvidence(interfaceBody(crud, 'CRUDDialogSchema'), 'showClose', [ 'drawer.tsx', 'DrawerSchema', + 'objectui#11347', ]); }); - it('the look-alike really exists, and really is another declaration', () => { + it('the look-alike read is retired: the drawer renderer no longer reads `schema.showClose`', () => { const drawer = read(DRAWER); - expect(drawer).toMatch(/schema\.showClose/); + // FIRING CONTROL: the file was read, and it is still the drawer renderer. expect(drawer).toContain('DrawerSchema'); + expect(drawer).not.toMatch(/schema\.showClose/); }); }); }); diff --git a/packages/types/src/crud.ts b/packages/types/src/crud.ts index a43874558d..03fb811ff9 100644 --- a/packages/types/src/crud.ts +++ b/packages/types/src/crud.ts @@ -481,15 +481,12 @@ export interface CRUDDialogSchema extends BaseSchema { * Show close button. * * NO `@default`, deliberately (objectui#8318): the tag published `true` with - * no reader to apply it. This key is the one on this interface whose census - * is NOT empty, and that is exactly why it needs saying: `showClose` has one - * occurrence outside this declaration and its zod twin -- - * `packages/components/src/renderers/overlay/drawer.tsx:38`, - * `{schema.showClose && ...}` -- and that read belongs to - * `DrawerSchema.showClose`, a different member on a different, registered - * node type. Counting it here would be crediting this declaration with - * another one's reader. The missing registration is in the interface docblock - * above. + * no reader to apply it. When that was written this key's census was NOT + * empty: `packages/components/src/renderers/overlay/drawer.tsx` read a + * `showClose` of its own, which was never this declaration's reader (and was + * never declared on `DrawerSchema` either). objectui#11347 retired that + * read, so no renderer reads `showClose` under any node type now. The + * missing registration is in the interface docblock above. */ showClose?: boolean; } diff --git a/packages/types/src/overlay.ts b/packages/types/src/overlay.ts index ff5fe4f04a..065e71cca6 100644 --- a/packages/types/src/overlay.ts +++ b/packages/types/src/overlay.ts @@ -462,6 +462,8 @@ export interface SheetSchema extends BaseSchema { * `content`, `defaultOpen`, `description`, `footer`, `modal`, `side`, * `title`, `trigger` (in * `packages/components/src/renderers/overlay/sheet.tsx`). + * The `modal` read has since been retired (objectui#11347): it was never a + * member here and no producer authored it. * * Before objectui#9256 tombstoned them here, `body` and `children` were both * inherited-and-optional from {@link BaseSchema} — so authoring either here @@ -489,6 +491,8 @@ export interface SheetSchema extends BaseSchema { * `content`, `defaultOpen`, `description`, `footer`, `modal`, `side`, * `title`, `trigger` (in * `packages/components/src/renderers/overlay/sheet.tsx`). + * The `modal` read has since been retired (objectui#11347): it was never a + * member here and no producer authored it. * * Before objectui#9256 tombstoned them here, `body` and `children` were both * inherited-and-optional from {@link BaseSchema} — so authoring either here @@ -569,6 +573,9 @@ export interface DrawerSchema extends BaseSchema { * `content`, `defaultOpen`, `description`, `footer`, `shouldScaleBackground`, * `showClose`, `title`, `trigger` (in * `packages/components/src/renderers/overlay/drawer.tsx`). + * The `footer`, `shouldScaleBackground` and `showClose` reads have since + * been retired (objectui#11347): none was ever a member here and no producer + * authored any of them. * * Before objectui#9256 tombstoned them here, `body` and `children` were both * inherited-and-optional from {@link BaseSchema} — so authoring either here @@ -596,6 +603,9 @@ export interface DrawerSchema extends BaseSchema { * `content`, `defaultOpen`, `description`, `footer`, `shouldScaleBackground`, * `showClose`, `title`, `trigger` (in * `packages/components/src/renderers/overlay/drawer.tsx`). + * The `footer`, `shouldScaleBackground` and `showClose` reads have since + * been retired (objectui#11347): none was ever a member here and no producer + * authored any of them. * * Before objectui#9256 tombstoned them here, `body` and `children` were both * inherited-and-optional from {@link BaseSchema} — so authoring either here @@ -672,6 +682,8 @@ export interface PopoverSchema extends BaseSchema { * this declaration carries none. What the renderer DOES read off this node: * `align`, `content`, `defaultOpen`, `modal`, `side`, `trigger` (in * `packages/components/src/renderers/overlay/popover.tsx`). + * The `modal` read has since been retired (objectui#11347): it was never a + * member here and no producer authored it. * * Before objectui#9256 tombstoned them here, `body` and `children` were both * inherited-and-optional from {@link BaseSchema} — so authoring either here @@ -698,6 +710,8 @@ export interface PopoverSchema extends BaseSchema { * this declaration carries none. What the renderer DOES read off this node: * `align`, `content`, `defaultOpen`, `modal`, `side`, `trigger` (in * `packages/components/src/renderers/overlay/popover.tsx`). + * The `modal` read has since been retired (objectui#11347): it was never a + * member here and no producer authored it. * * Before objectui#9256 tombstoned them here, `body` and `children` were both * inherited-and-optional from {@link BaseSchema} — so authoring either here @@ -1083,6 +1097,8 @@ export interface DropdownMenuSchema extends BaseSchema { * this declaration carries none. What the renderer DOES read off this node: * `align`, `defaultOpen`, `items`, `label`, `modal`, `side`, `trigger` (in * `packages/components/src/renderers/overlay/dropdown-menu.tsx`). + * The `modal` read has since been retired (objectui#11347): it was never a + * member here and no producer authored it. * * Before objectui#9256 tombstoned them here, `body` and `children` were both * inherited-and-optional from {@link BaseSchema} — so authoring either here @@ -1110,6 +1126,8 @@ export interface DropdownMenuSchema extends BaseSchema { * this declaration carries none. What the renderer DOES read off this node: * `align`, `defaultOpen`, `items`, `label`, `modal`, `side`, `trigger` (in * `packages/components/src/renderers/overlay/dropdown-menu.tsx`). + * The `modal` read has since been retired (objectui#11347): it was never a + * member here and no producer authored it. * * Before objectui#9256 tombstoned them here, `body` and `children` were both * inherited-and-optional from {@link BaseSchema} — so authoring either here diff --git a/packages/types/src/zod/overlay.zod.ts b/packages/types/src/zod/overlay.zod.ts index 64a1d4b2d7..13f3abe0d4 100644 --- a/packages/types/src/zod/overlay.zod.ts +++ b/packages/types/src/zod/overlay.zod.ts @@ -232,7 +232,7 @@ export const SheetSchema = BaseSchema.extend({ + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `content`, `defaultOpen`, `description`, `footer`, `modal`, `side`, ' + + 'What it renders instead: `content`, `defaultOpen`, `description`, `footer`, `side`, ' + '`title`, `trigger`.', ), children: retirementTombstone( @@ -240,7 +240,7 @@ export const SheetSchema = BaseSchema.extend({ + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `content`, `defaultOpen`, `description`, `footer`, `modal`, `side`, ' + + 'What it renders instead: `content`, `defaultOpen`, `description`, `footer`, `side`, ' + '`title`, `trigger`.', ), }); @@ -263,16 +263,14 @@ export const DrawerSchema = BaseSchema.extend({ + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `content`, `defaultOpen`, `description`, `footer`, ' - + '`shouldScaleBackground`, `showClose`, `title`, `trigger`.', + + 'What it renders instead: `content`, `defaultOpen`, `description`, `title`, `trigger`.', ), children: retirementTombstone( 'REFUSED (objectui#9256, ADR-0049) — `drawer` reads NEITHER content channel: measured with the ' + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `content`, `defaultOpen`, `description`, `footer`, ' - + '`shouldScaleBackground`, `showClose`, `title`, `trigger`.', + + 'What it renders instead: `content`, `defaultOpen`, `description`, `title`, `trigger`.', ), }); @@ -293,14 +291,14 @@ export const PopoverSchema = BaseSchema.extend({ + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `align`, `content`, `defaultOpen`, `modal`, `side`, `trigger`.', + + 'What it renders instead: `align`, `content`, `defaultOpen`, `side`, `trigger`.', ), children: retirementTombstone( 'REFUSED (objectui#9256, ADR-0049) — `popover` reads NEITHER content channel: measured with the ' + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `align`, `content`, `defaultOpen`, `modal`, `side`, `trigger`.', + + 'What it renders instead: `align`, `content`, `defaultOpen`, `side`, `trigger`.', ), }); @@ -477,14 +475,14 @@ export const DropdownMenuSchema = BaseSchema.extend({ + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `align`, `defaultOpen`, `items`, `label`, `modal`, `side`, `trigger`.', + + 'What it renders instead: `align`, `defaultOpen`, `items`, `label`, `side`, `trigger`.', ), children: retirementTombstone( 'REFUSED (objectui#9256, ADR-0049) — `dropdown-menu` reads NEITHER content channel: measured with the ' + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + 'authored value therefore rendered NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `align`, `defaultOpen`, `items`, `label`, `modal`, `side`, `trigger`.', + + 'What it renders instead: `align`, `defaultOpen`, `items`, `label`, `side`, `trigger`.', ), }); From 29544069ddc6436e49d32f5cd0ad44866fade54f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:26:21 +0000 Subject: [PATCH 4/9] test(components): type the authoring literals as the nodes they are (objectui#11347) 64 of the 94 test errors objectui#8347's P1 mutation raises in this package are literals checked against `BaseSchema` (the `renderComponent` parameter, `SchemaRenderer`'s `schema`, or a `SchemaNode` child slot) that author keys their concrete node type declares. Each is now typed as that node through a small typed identity per file (`actionBar`, `textNode`, `selectNode`, ...), which keeps excess-property checking against the concrete type rather than switching it off. The button variant / size and input type tables are typed with the declared unions, which the concrete type now demands. Left as they are, on purpose: - 29 literals author a node no `@object-ui/types` declaration covers: the public blocks' `properties` bag (`element:text`, `element:text_input`, `element:record_picker`), `page:tabs`' `items`, `action:button`'s executor keys, the page kinds' `kind`, the legacy `props` alias bag and a test-registered probe type. Handed back on objectui#11347 rather than cast or widened. - `label`'s retired `content` spelling is a deliberate probe; it is objectui#8347's to flip. Claude-Session: https://claude.ai/code/session_01VhxTqosz7wn54ahqyxgERT Co-authored-by: Claude --- .../src/__tests__/action-bar.test.tsx | 99 ++++++++++--------- .../src/__tests__/basic-renderers.test.tsx | 90 ++++++++++------- .../__tests__/data-table-row-click.test.tsx | 13 ++- .../empty-action-declared-7105.test.tsx | 16 +-- .../src/__tests__/form-renderers.test.tsx | 77 ++++++++++----- .../__tests__/required-marker-markup.test.tsx | 22 ++++- .../span-children-rendering.test.tsx | 14 ++- .../__tests__/span-value-fallback.test.tsx | 14 ++- .../select-label-association.test.tsx | 23 +++-- 9 files changed, 238 insertions(+), 130 deletions(-) diff --git a/packages/components/src/__tests__/action-bar.test.tsx b/packages/components/src/__tests__/action-bar.test.tsx index 749aaa4b03..801634b588 100644 --- a/packages/components/src/__tests__/action-bar.test.tsx +++ b/packages/components/src/__tests__/action-bar.test.tsx @@ -7,6 +7,15 @@ import { render } from '@testing-library/react'; import { ComponentRegistry } from '@object-ui/core'; import { ActionProvider } from '@object-ui/react'; import { renderComponent, validateComponentRegistration } from './test-utils'; +import type { ActionBarSchema } from '../renderers/action/action-bar'; + +/** + * Types each literal below as the `action:bar` node it is rather than as the + * `BaseSchema` `renderComponent` accepts (objectui#11347): once `BaseSchema`'s + * index signature is gone (objectui#8347), a literal checked against + * `BaseSchema` may author only `BaseSchema`'s own keys. + */ +const actionBar = (schema: ActionBarSchema): ActionBarSchema => schema; // Ensure action renderers are loaded (side-effect imports via vitest.setup.tsx) @@ -24,39 +33,39 @@ describe('ActionBar (action:bar)', () => { describe('rendering', () => { it('renders nothing when actions array is empty', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', actions: [], - }); + })); expect(container.innerHTML).toBe(''); }); it('renders action buttons for provided actions', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', actions: [ { name: 'save', label: 'Save', type: 'script', component: 'action:button' }, { name: 'cancel', label: 'Cancel', type: 'script', component: 'action:button' }, ], - }); + })); expect(container.textContent).toContain('Save'); expect(container.textContent).toContain('Cancel'); }); it('renders with role="toolbar" and aria-label', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', actions: [ { name: 'test', label: 'Test', type: 'script' }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); expect(toolbar).toBeTruthy(); expect(toolbar?.getAttribute('aria-label')).toBe('Actions'); }); it('filters actions by location', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', location: 'list_toolbar', actions: [ @@ -64,7 +73,7 @@ describe('ActionBar (action:bar)', () => { { name: 'header_action', label: 'Header Action', type: 'script', locations: ['record_header'] }, { name: 'both_action', label: 'Both Action', type: 'script', locations: ['list_toolbar', 'record_header'] }, ], - }); + })); expect(container.textContent).toContain('Toolbar Action'); expect(container.textContent).not.toContain('Header Action'); expect(container.textContent).toContain('Both Action'); @@ -78,7 +87,7 @@ describe('ActionBar (action:bar)', () => { // is the declaration; no declaration, no located placement (ADR-0078 reads // an action with no `locations` as inert for exactly this reason). it('hides an action that declares no locations when filtering by location', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', location: 'record_header', actions: [ @@ -87,7 +96,7 @@ describe('ActionBar (action:bar)', () => { { name: 'other_loc', label: 'Other Location', type: 'script', locations: ['list_toolbar'] }, { name: 'here_loc', label: 'Here Location', type: 'script', locations: ['record_header'] }, ], - }); + })); expect(container.textContent).not.toContain('No Location'); // An empty array reads the same as an absent one — the third dialect // (`action:group` hid `[]` but showed `undefined`) is gone too. @@ -97,26 +106,26 @@ describe('ActionBar (action:bar)', () => { }); it('renders all actions when no location filter is set', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', actions: [ { name: 'a1', label: 'Action 1', type: 'script', locations: ['list_toolbar'] }, { name: 'a2', label: 'Action 2', type: 'script', locations: ['record_header'] }, ], - }); + })); expect(container.textContent).toContain('Action 1'); expect(container.textContent).toContain('Action 2'); }); it('deduplicates actions by name', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', actions: [ { name: 'change_status', label: 'Change Status', type: 'script', component: 'action:button' }, { name: 'assign_user', label: 'Assign User', type: 'script', component: 'action:button' }, { name: 'change_status', label: 'Change Status', type: 'script', component: 'action:button' }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); expect(toolbar).toBeTruthy(); // Should only render 2 actions (duplicates removed) @@ -126,7 +135,7 @@ describe('ActionBar (action:bar)', () => { }); it('deduplicates actions after location filtering', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', location: 'record_header', actions: [ @@ -135,7 +144,7 @@ describe('ActionBar (action:bar)', () => { { name: 'change_status', label: 'Change Status', type: 'script', locations: ['record_header', 'record_more'] }, { name: 'assign_user', label: 'Assign User', type: 'script', locations: ['record_header'] }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); expect(toolbar).toBeTruthy(); // Should only render 2 unique actions @@ -145,7 +154,7 @@ describe('ActionBar (action:bar)', () => { describe('overflow', () => { it('groups excess actions into overflow menu when maxVisible is exceeded', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', maxVisible: 2, actions: [ @@ -154,7 +163,7 @@ describe('ActionBar (action:bar)', () => { { name: 'a3', label: 'Action 3', type: 'script' }, { name: 'a4', label: 'Action 4', type: 'script' }, ], - }); + })); // First 2 should be visible as buttons expect(container.textContent).toContain('Action 1'); expect(container.textContent).toContain('Action 2'); @@ -167,14 +176,14 @@ describe('ActionBar (action:bar)', () => { }); it('does not show overflow when actions fit within maxVisible', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', maxVisible: 5, actions: [ { name: 'a1', label: 'Action 1', type: 'script' }, { name: 'a2', label: 'Action 2', type: 'script' }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); expect(toolbar!.children.length).toBe(2); }); @@ -185,7 +194,7 @@ describe('ActionBar (action:bar)', () => { ':scope > button:not([aria-haspopup]), :scope > [role="button"]:not([aria-haspopup])'; it('orders inline actions by `order` (lower = earlier / primary slot)', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', location: 'record_header', maxVisible: 5, @@ -195,7 +204,7 @@ describe('ActionBar (action:bar)', () => { { name: 'print', label: 'Print', type: 'script', locations: ['record_header'], order: 5 }, { name: 'approve', label: 'Approve', type: 'script', locations: ['record_header'], order: -10 }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); const inlineText = Array.from(toolbar!.querySelectorAll(inlineButtonsSelector)) .map(b => b.textContent) @@ -206,7 +215,7 @@ describe('ActionBar (action:bar)', () => { }); it('promotes a low-`order` action into the primary slot even when registered last', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', location: 'record_header', maxVisible: 1, @@ -214,7 +223,7 @@ describe('ActionBar (action:bar)', () => { { name: 'app_action', label: 'App Action', type: 'script', locations: ['record_header'] }, { name: 'approve', label: 'Approve', type: 'script', locations: ['record_header'], order: -100 }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); // 1 inline primary button + 1 overflow menu trigger expect(toolbar!.children.length).toBe(2); @@ -226,7 +235,7 @@ describe('ActionBar (action:bar)', () => { }); it('is stable — actions without `order` keep their registration order', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', location: 'record_header', maxVisible: 5, @@ -235,7 +244,7 @@ describe('ActionBar (action:bar)', () => { { name: 'b', label: 'Bravo', type: 'script', locations: ['record_header'] }, { name: 'c', label: 'Charlie', type: 'script', locations: ['record_header'] }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); const inlineText = Array.from(toolbar!.querySelectorAll(inlineButtonsSelector)) .map(b => b.textContent) @@ -245,7 +254,7 @@ describe('ActionBar (action:bar)', () => { }); it('tie-break: a `primary` action wins the primary slot when nobody sets `order`', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', location: 'record_header', maxVisible: 1, @@ -255,7 +264,7 @@ describe('ActionBar (action:bar)', () => { { name: 'export', label: 'Export', type: 'script', locations: ['record_header'] }, { name: 'submit', label: 'Submit', type: 'script', variant: 'primary', locations: ['record_header'] }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); // 1 inline primary button + 1 overflow menu trigger expect(toolbar!.children.length).toBe(2); @@ -267,7 +276,7 @@ describe('ActionBar (action:bar)', () => { }); it('`order` outranks the `primary` tie-break', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', location: 'record_header', maxVisible: 5, @@ -277,7 +286,7 @@ describe('ActionBar (action:bar)', () => { { name: 'submit', label: 'Submit', type: 'script', variant: 'primary', locations: ['record_header'] }, { name: 'approve', label: 'Approve', type: 'script', order: -100, locations: ['record_header'] }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); const inlineText = Array.from(toolbar!.querySelectorAll(inlineButtonsSelector)) .map(b => b.textContent) @@ -288,13 +297,13 @@ describe('ActionBar (action:bar)', () => { describe('systemActions', () => { it('renders a single overflow menu when only systemActions are provided', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', systemActions: [ { name: 'sys_duplicate', label: 'Duplicate', type: 'script' }, { name: 'sys_export', label: 'Export', type: 'script' }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); expect(toolbar).toBeTruthy(); // 0 inline buttons + 1 overflow menu trigger @@ -302,7 +311,7 @@ describe('ActionBar (action:bar)', () => { }); it('merges business overflow and systemActions into ONE overflow menu', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', maxVisible: 2, actions: [ @@ -315,7 +324,7 @@ describe('ActionBar (action:bar)', () => { { name: 'sys_duplicate', label: 'Duplicate', type: 'script' }, { name: 'sys_delete', label: 'Delete', type: 'script' }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); // 2 inline buttons + exactly 1 overflow menu trigger — never two expect(toolbar!.children.length).toBe(3); @@ -325,7 +334,7 @@ describe('ActionBar (action:bar)', () => { }); it('systemActions never appear inline regardless of maxVisible', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', maxVisible: 10, actions: [ @@ -334,7 +343,7 @@ describe('ActionBar (action:bar)', () => { systemActions: [ { name: 'sys_duplicate', label: 'Duplicate', type: 'script' }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); // 1 inline business button + 1 overflow menu for the system action expect(toolbar!.children.length).toBe(2); @@ -345,13 +354,13 @@ describe('ActionBar (action:bar)', () => { }); it('renders overflow menu when only systemActions exist even with empty actions', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', actions: [], systemActions: [ { name: 'sys_history', label: 'History', type: 'script' }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); expect(toolbar).toBeTruthy(); expect(toolbar!.children.length).toBe(1); @@ -360,36 +369,36 @@ describe('ActionBar (action:bar)', () => { describe('styling', () => { it('applies custom className', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', className: 'my-custom-bar', actions: [ { name: 'test', label: 'Test', type: 'script' }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); expect(toolbar?.className).toContain('my-custom-bar'); }); it('supports vertical direction', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', direction: 'vertical', actions: [ { name: 'test', label: 'Test', type: 'script' }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); expect(toolbar?.className).toContain('flex-col'); }); it('defaults to horizontal direction', () => { - const { container } = renderComponent({ + const { container } = renderComponent(actionBar({ type: 'action:bar', actions: [ { name: 'test', label: 'Test', type: 'script' }, ], - }); + })); const toolbar = container.querySelector('[role="toolbar"]'); expect(toolbar?.className).toContain('flex-row'); }); @@ -406,7 +415,7 @@ describe('ActionBar (action:bar)', () => { describe('requiredPermissions capability gate', () => { const Bar = ({ actions, systemActions }: { actions?: any[]; systemActions?: any[] }) => { const Component = ComponentRegistry.get('action:bar')!; - return ; + return ; }; const withUser = (user: unknown, props: { actions?: any[]; systemActions?: any[] }) => render(); diff --git a/packages/components/src/__tests__/basic-renderers.test.tsx b/packages/components/src/__tests__/basic-renderers.test.tsx index 62b30852b0..704553caf3 100644 --- a/packages/components/src/__tests__/basic-renderers.test.tsx +++ b/packages/components/src/__tests__/basic-renderers.test.tsx @@ -17,6 +17,30 @@ import { // cold transform is billed to `hookTimeout`, which is why this carried a raised // timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021). import '../renderers'; +import type { + DivSchema, + HtmlSchema, + IconSchema, + ImageSchema, + SeparatorSchema, + TextSchema, + TextSpanSchema, +} from '@object-ui/types'; + +/** + * Each literal below is typed as the node it is rather than as the + * `BaseSchema` `renderComponent` accepts, children included (objectui#11347): + * once `BaseSchema`'s index signature is gone (objectui#8347), a literal + * checked against `BaseSchema` or `SchemaNode` may author only `BaseSchema`'s + * own keys. + */ +const textNode = (schema: TextSchema): TextSchema => schema; +const divNode = (schema: DivSchema): DivSchema => schema; +const spanNode = (schema: TextSpanSchema): TextSpanSchema => schema; +const imageNode = (schema: ImageSchema): ImageSchema => schema; +const iconNode = (schema: IconSchema): IconSchema => schema; +const separatorNode = (schema: SeparatorSchema): SeparatorSchema => schema; +const htmlNode = (schema: HtmlSchema): HtmlSchema => schema; /** * Comprehensive tests for basic renderer components @@ -32,10 +56,10 @@ describe('Basic Renderers - Display Issue Detection', () => { }); it('should render text content without issues', () => { - const { container } = renderComponent({ + const { container } = renderComponent(textNode({ type: 'text', content: 'Hello World', - }); + })); expect(container.textContent).toContain('Hello World'); const issues = getAllDisplayIssues(container); @@ -43,10 +67,10 @@ describe('Basic Renderers - Display Issue Detection', () => { }); it('should handle empty content gracefully', () => { - const { container } = renderComponent({ + const { container } = renderComponent(textNode({ type: 'text', content: '', - }); + })); const domCheck = checkDOMStructure(container); // Empty text is acceptable, just verify it doesn't crash @@ -70,7 +94,7 @@ describe('Basic Renderers - Display Issue Detection', () => { it('should render with designer props correctly', () => { const { container } = renderComponent( - { type: 'text', content: 'Designer Test' }, + textNode({ type: 'text', content: 'Designer Test' }), ); // Verify it renders without errors @@ -86,10 +110,10 @@ describe('Basic Renderers - Display Issue Detection', () => { }); it('should render container without issues', () => { - const { container } = renderComponent({ + const { container } = renderComponent(divNode({ type: 'div', className: 'test-class', - }); + })); const div = container.querySelector('div'); expect(div).toBeTruthy(); @@ -98,23 +122,23 @@ describe('Basic Renderers - Display Issue Detection', () => { }); it('should render children correctly', () => { - const { container } = renderComponent({ + const { container } = renderComponent(divNode({ type: 'div', children: [ - { type: 'text', content: 'Child 1' }, - { type: 'text', content: 'Child 2' }, + textNode({ type: 'text', content: 'Child 1' }), + textNode({ type: 'text', content: 'Child 2' }), ], - }); + })); expect(container.textContent).toContain('Child 1'); expect(container.textContent).toContain('Child 2'); }); it('should not have display issues', () => { - const { container } = renderComponent({ + const { container } = renderComponent(divNode({ type: 'div', - children: [{ type: 'text', content: 'Content' }], - }); + children: [textNode({ type: 'text', content: 'Content' })], + })); const issues = getAllDisplayIssues(container); // Should have no critical issues @@ -129,13 +153,13 @@ describe('Basic Renderers - Display Issue Detection', () => { }); it('should render inline content', () => { - const { container } = renderComponent({ + const { container } = renderComponent(spanNode({ type: 'span', // Canonical child key — what `TextSpanSchema` declares and what the // renderer reads (objectui#5027). This case used to spell `body`, the // one key nothing produced, which is what kept the defect invisible. - children: [{ type: 'text', content: 'Inline text' }], - }); + children: [textNode({ type: 'text', content: 'Inline text' })], + })); const span = container.querySelector('span'); expect(span).toBeTruthy(); @@ -150,11 +174,11 @@ describe('Basic Renderers - Display Issue Detection', () => { }); it('should render with required alt attribute', () => { - const { container } = renderComponent({ + const { container } = renderComponent(imageNode({ type: 'image', src: 'https://example.com/image.jpg', alt: 'Test image', - }); + })); const img = container.querySelector('img'); expect(img).toBeTruthy(); @@ -163,10 +187,10 @@ describe('Basic Renderers - Display Issue Detection', () => { }); it('should detect missing alt attribute', () => { - const { container } = renderComponent({ + const { container } = renderComponent(imageNode({ type: 'image', src: 'https://example.com/image.jpg', - }); + })); const img = container.querySelector('img'); // If img has alt, it's good; if not, our check should detect it @@ -196,10 +220,10 @@ describe('Basic Renderers - Display Issue Detection', () => { // the placeholder marker is what separates "resolved the requested glyph" // from "drew the dashed box that means it did not". it('should render icon without issues', () => { - const { container } = renderComponent({ + const { container } = renderComponent(iconNode({ type: 'icon', icon: 'star', - }); + })); // Icon should render an SVG const svg = container.querySelector('svg'); @@ -208,11 +232,11 @@ describe('Basic Renderers - Display Issue Detection', () => { }); it('should apply size classes correctly', () => { - const { container } = renderComponent({ + const { container } = renderComponent(iconNode({ type: 'icon', icon: 'heart', size: 24, - }); + })); const svg = container.querySelector('svg'); expect(svg).toBeTruthy(); @@ -227,24 +251,24 @@ describe('Basic Renderers - Display Issue Detection', () => { }); it('should render separator with proper role', () => { - const { container } = renderComponent({ + const { container } = renderComponent(separatorNode({ type: 'separator', - }); + })); const separator = container.querySelector('[role="separator"], hr, [data-orientation]'); expect(separator).toBeTruthy(); }); it('should support both orientations', () => { - const { container: horizontal } = renderComponent({ + const { container: horizontal } = renderComponent(separatorNode({ type: 'separator', orientation: 'horizontal', - }); + })); - const { container: vertical } = renderComponent({ + const { container: vertical } = renderComponent(separatorNode({ type: 'separator', orientation: 'vertical', - }); + })); expect(horizontal.querySelector('*')).toBeTruthy(); expect(vertical.querySelector('*')).toBeTruthy(); @@ -258,10 +282,10 @@ describe('Basic Renderers - Display Issue Detection', () => { }); it('should render HTML content safely', () => { - const { container } = renderComponent({ + const { container } = renderComponent(htmlNode({ type: 'html', html: '

HTML Content

', - }); + })); // HTML renderer uses dangerouslySetInnerHTML const hasContent = container.querySelector('p') || container.textContent?.includes('HTML Content'); diff --git a/packages/components/src/__tests__/data-table-row-click.test.tsx b/packages/components/src/__tests__/data-table-row-click.test.tsx index 85b603b582..32cbcc031c 100644 --- a/packages/components/src/__tests__/data-table-row-click.test.tsx +++ b/packages/components/src/__tests__/data-table-row-click.test.tsx @@ -25,6 +25,15 @@ import { renderComponent } from './test-utils'; // cold transform is billed to `hookTimeout`, which is why this carried a raised // timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021). import '../renderers'; +import type { DataTableSchema } from '@object-ui/types'; + +/** + * Types a literal below as the node it is rather than as the `BaseSchema` + * `renderComponent` accepts (objectui#11347): once `BaseSchema`'s index + * signature is gone (objectui#8347), a literal checked against `BaseSchema` + * may author only `BaseSchema`'s own keys. + */ +const dataTableNode = (schema: DataTableSchema): DataTableSchema => schema; describe('data-table — row click heuristic ignores overlay items', () => { const baseSchema = { @@ -85,12 +94,12 @@ describe('data-table — row click heuristic ignores overlay items', () => { it('clicking a plain text cell still calls onRowClick (sanity)', () => { const onRowClick = vi.fn(); - const { container } = renderComponent({ + const { container } = renderComponent(dataTableNode({ type: 'data-table', columns: [{ header: 'Name', accessorKey: 'name' }], data: [{ id: '1', name: 'Alice' }], onRowClick, - }); + })); // Click on the name cell text (not a button/link/menu). const cell = Array.from(container.querySelectorAll('td')).find((td) => diff --git a/packages/components/src/__tests__/empty-action-declared-7105.test.tsx b/packages/components/src/__tests__/empty-action-declared-7105.test.tsx index 85e5e58b14..be9d2a61de 100644 --- a/packages/components/src/__tests__/empty-action-declared-7105.test.tsx +++ b/packages/components/src/__tests__/empty-action-declared-7105.test.tsx @@ -72,7 +72,7 @@ import { render } from '@testing-library/react'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '@object-ui/react'; import { EmptySchema as EmptyMirror } from '@object-ui/types/zod'; -import type { EmptySchema, SchemaNode } from '@object-ui/types'; +import type { ButtonSchema, EmptySchema, SchemaNode } from '@object-ui/types'; import '../renderers'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -101,16 +101,20 @@ type _ActionIsSchemaNode = Expect< Equal< EmptySchema['action'], SchemaNode > >; * The shipped demo, as a literal the compiler checks against the declared type. * The first test asserts it is byte-identical to the file the docs page renders. */ +const DEMO_ACTION: ButtonSchema = { + type: 'button', + label: 'Create Project', + variant: 'default', +}; +// `DEMO_ACTION` is typed as the `button` node it is (objectui#11347): the slot is +// `SchemaNode`, and once `BaseSchema`'s index signature is gone (objectui#8347) +// a literal checked against it may author only `BaseSchema`'s own keys. const DEMO = { type: 'empty', icon: 'folder-plus', title: 'No projects yet', description: 'Get started by creating your first project', - action: { - type: 'button', - label: 'Create Project', - variant: 'default', - }, + action: DEMO_ACTION, } satisfies EmptySchema; const readDemo = (): unknown => JSON.parse(readFileSync(DEMO_PATH, 'utf8')); diff --git a/packages/components/src/__tests__/form-renderers.test.tsx b/packages/components/src/__tests__/form-renderers.test.tsx index 58353e83c6..5371a8e0c1 100644 --- a/packages/components/src/__tests__/form-renderers.test.tsx +++ b/packages/components/src/__tests__/form-renderers.test.tsx @@ -18,6 +18,31 @@ import { // cold transform is billed to `hookTimeout`, which is why this carried a raised // timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021). import '../renderers'; +import type { + ButtonSchema, + InputSchema, + LabelSchema, + RadioGroupSchema, + SelectSchema, + SliderSchema, + TextSchema, +} from '@object-ui/types'; + +/** + * Types a literal below as the node it is rather than as the `BaseSchema` + * `renderComponent` accepts, children included (objectui#11347): once + * `BaseSchema`'s index signature is gone (objectui#8347), a literal checked + * against `BaseSchema` or `SchemaNode` may author only `BaseSchema`'s own keys. + * The literals that deliberately author a retired or undeclared key stay + * untyped, on purpose. + */ +const buttonNode = (schema: ButtonSchema): ButtonSchema => schema; +const inputNode = (schema: InputSchema): InputSchema => schema; +const labelNode = (schema: LabelSchema): LabelSchema => schema; +const radioGroupNode = (schema: RadioGroupSchema): RadioGroupSchema => schema; +const selectNode = (schema: SelectSchema): SelectSchema => schema; +const sliderNode = (schema: SliderSchema): SliderSchema => schema; +const textNode = (schema: TextSchema): TextSchema => schema; /** * Comprehensive tests for form renderer components @@ -47,14 +72,14 @@ describe('Form Renderers - Display Issue Detection', () => { }); it('should support different variants', () => { - const variants = ['default', 'secondary', 'destructive', 'outline', 'ghost', 'link']; + const variants: NonNullable[] = ['default', 'secondary', 'destructive', 'outline', 'ghost', 'link']; variants.forEach(variant => { - const { container } = renderComponent({ + const { container } = renderComponent(buttonNode({ type: 'button', label: 'Test', variant, - }); + })); const button = container.querySelector('button'); expect(button).toBeTruthy(); @@ -62,14 +87,14 @@ describe('Form Renderers - Display Issue Detection', () => { }); it('should support different sizes', () => { - const sizes = ['default', 'sm', 'lg', 'icon']; + const sizes: NonNullable[] = ['default', 'sm', 'lg', 'icon']; sizes.forEach(size => { - const { container } = renderComponent({ + const { container } = renderComponent(buttonNode({ type: 'button', label: 'Test', size, - }); + })); const button = container.querySelector('button'); expect(button).toBeTruthy(); @@ -77,10 +102,10 @@ describe('Form Renderers - Display Issue Detection', () => { }); it('should render children when no label provided', () => { - const { container } = renderComponent({ + const { container } = renderComponent(buttonNode({ type: 'button', - children: [{ type: 'text', content: 'Child Content' }], - }); + children: [textNode({ type: 'text', content: 'Child Content' })], + })); expect(container.textContent).toContain('Child Content'); }); @@ -110,12 +135,12 @@ describe('Form Renderers - Display Issue Detection', () => { }); it('should show required indicator when required', () => { - const { container } = renderComponent({ + const { container } = renderComponent(inputNode({ type: 'input', label: 'Required Field', name: 'required', required: true, - }); + })); const label = container.querySelector('label'); // The label should have a required indicator (*) @@ -123,14 +148,14 @@ describe('Form Renderers - Display Issue Detection', () => { }); it('should support different input types', () => { - const types = ['text', 'email', 'password', 'number', 'tel', 'url', 'date']; + const types: NonNullable[] = ['text', 'email', 'password', 'number', 'tel', 'url', 'date']; types.forEach(inputType => { - const { container } = renderComponent({ + const { container } = renderComponent(inputNode({ type: 'input', label: 'Test', inputType, - }); + })); const input = container.querySelector('input'); expect(input?.getAttribute('type')).toBe(inputType); @@ -148,11 +173,11 @@ describe('Form Renderers - Display Issue Detection', () => { }); it('should display error message when provided', () => { - const { container } = renderComponent({ + const { container } = renderComponent(inputNode({ type: 'input', label: 'Field', error: 'This field has an error', - }); + })); expect(container.textContent).toContain('This field has an error'); const error = container.querySelector('.text-destructive'); @@ -177,11 +202,11 @@ describe('Form Renderers - Display Issue Detection', () => { }); it('should handle readonly state', () => { - const { container } = renderComponent({ + const { container } = renderComponent(inputNode({ type: 'input', label: 'Field', readOnly: true, - }); + })); const input = container.querySelector('input'); expect(input?.hasAttribute('readonly')).toBe(true); @@ -261,14 +286,14 @@ describe('Form Renderers - Display Issue Detection', () => { }); it('should render select with options', () => { - const { container } = renderComponent({ + const { container } = renderComponent(selectNode({ type: 'select', label: 'Choose option', options: [ { value: '1', label: 'Option 1' }, { value: '2', label: 'Option 2' }, ], - }); + })); // Select component should be present expect(container.querySelector('[role="combobox"]') || container.querySelector('select')).toBeTruthy(); @@ -317,14 +342,14 @@ describe('Form Renderers - Display Issue Detection', () => { }); it('should render radio group with options', () => { - const { container } = renderComponent({ + const { container } = renderComponent(radioGroupNode({ type: 'radio-group', label: 'Choose one', options: [ { value: 'a', label: 'Option A' }, { value: 'b', label: 'Option B' }, ], - }); + })); const radioGroup = container.querySelector('[role="radiogroup"]'); expect(radioGroup).toBeTruthy(); @@ -338,12 +363,12 @@ describe('Form Renderers - Display Issue Detection', () => { }); it('should render slider component', () => { - const { container } = renderComponent({ + const { container } = renderComponent(sliderNode({ type: 'slider', label: 'Volume', min: 0, max: 100, - }); + })); const slider = container.querySelector('[role="slider"], input[type="range"]'); expect(slider).toBeTruthy(); @@ -357,10 +382,10 @@ describe('Form Renderers - Display Issue Detection', () => { }); it('should render label element', () => { - const { container } = renderComponent({ + const { container } = renderComponent(labelNode({ type: 'label', text: 'Form Label', - }); + })); const label = container.querySelector('label'); expect(label).toBeTruthy(); diff --git a/packages/components/src/__tests__/required-marker-markup.test.tsx b/packages/components/src/__tests__/required-marker-markup.test.tsx index 893f1db926..b9ff081338 100644 --- a/packages/components/src/__tests__/required-marker-markup.test.tsx +++ b/packages/components/src/__tests__/required-marker-markup.test.tsx @@ -54,6 +54,18 @@ import { renderComponent } from './test-utils'; // Registers the renderers at module scope, not in a hook // (object-ui/no-dynamic-import-in-test-hook, objectui#3010). import '../renderers'; +import type { CheckboxSchema, InputSchema, SelectSchema, TextareaSchema } from '@object-ui/types'; + +/** + * Types a literal below as the node it is rather than as the `BaseSchema` + * `renderComponent` accepts (objectui#11347): once `BaseSchema`'s index + * signature is gone (objectui#8347), a literal checked against `BaseSchema` + * may author only `BaseSchema`'s own keys. + */ +const inputNode = (schema: InputSchema): InputSchema => schema; +const textareaNode = (schema: TextareaSchema): TextareaSchema => schema; +const checkboxNode = (schema: CheckboxSchema): CheckboxSchema => schema; +const selectNode = (schema: SelectSchema): SelectSchema => schema; afterEach(cleanup); @@ -106,19 +118,19 @@ const SITES: Site[] = [ }, { site: 'input (renderers/form/input.tsx)', - render: (required) => renderComponent({ type: 'input', id: 'in-ctl', label: 'Title', required }), + render: (required) => renderComponent(inputNode({ type: 'input', id: 'in-ctl', label: 'Title', required })), label: () => document.querySelector('label[for="in-ctl"]'), expectRequiredState: () => expect(document.getElementById('in-ctl')).toHaveAttribute('required'), }, { site: 'textarea (renderers/form/textarea.tsx)', - render: (required) => renderComponent({ type: 'textarea', id: 'ta-ctl', label: 'Title', required }), + render: (required) => renderComponent(textareaNode({ type: 'textarea', id: 'ta-ctl', label: 'Title', required })), label: () => document.querySelector('label[for="ta-ctl"]'), expectRequiredState: () => expect(document.getElementById('ta-ctl')).toHaveAttribute('required'), }, { site: 'checkbox (renderers/form/checkbox.tsx)', - render: (required) => renderComponent({ type: 'checkbox', id: 'cb-ctl', label: 'Title', required }), + render: (required) => renderComponent(checkboxNode({ type: 'checkbox', id: 'cb-ctl', label: 'Title', required })), label: () => document.querySelector('label[for="cb-ctl"]'), // Radix writes `aria-required` on the role=checkbox button from `required`. expectRequiredState: () => expect(screen.getByRole('checkbox')).toHaveAttribute('aria-required', 'true'), @@ -126,13 +138,13 @@ const SITES: Site[] = [ { site: 'select (renderers/form/select.tsx)', render: (required) => - renderComponent({ + renderComponent(selectNode({ type: 'select', id: 'sel-ctl', label: 'Title', required, options: [{ label: 'A', value: 'a' }], - }), + })), // The label names the trigger through `for` since objectui#10435, whose // own pin (`select-label-association.test.tsx`) reads the resulting name. label: () => document.querySelector('label[for="sel-ctl"]'), diff --git a/packages/components/src/__tests__/span-children-rendering.test.tsx b/packages/components/src/__tests__/span-children-rendering.test.tsx index 7372d93211..2345834e4f 100644 --- a/packages/components/src/__tests__/span-children-rendering.test.tsx +++ b/packages/components/src/__tests__/span-children-rendering.test.tsx @@ -36,12 +36,20 @@ import { describe, it, expect } from 'vitest'; import { render } from '@testing-library/react'; import { SchemaRenderer } from '@object-ui/react'; -import type { TextSpanSchema } from '@object-ui/types'; +import type { TextSchema, TextSpanSchema } from '@object-ui/types'; // Registers the renderers at module scope, NOT inside a `beforeAll` — there the // cold transform is billed to `hookTimeout`. See // object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021). import '../renderers'; +/** + * Types a child literal below as the `text` node it is (objectui#11347): a + * `children` entry is checked against `SchemaNode`, and once `BaseSchema`'s + * index signature is gone (objectui#8347) a literal checked against it may + * author only `BaseSchema`'s own keys. + */ +const textNode = (schema: TextSchema): TextSchema => schema; + /** Renders a `kind:'html'` page — source compiled by the parser, then rendered. */ function renderHtmlPage(source: string) { return render(); @@ -69,7 +77,7 @@ describe('span renders its canonical child key (#5027)', () => { const schema: TextSpanSchema = { type: 'span', className: 'json-authored', - children: [{ type: 'text', content: 'inline from children' }], + children: [textNode({ type: 'text', content: 'inline from children' })], }; const { container } = render(); @@ -83,7 +91,7 @@ describe('span renders its canonical child key (#5027)', () => { const schema: TextSpanSchema = { type: 'span', className: 'single-child', - children: { type: 'text', content: 'lone child' }, + children: textNode({ type: 'text', content: 'lone child' }), }; const { container } = render(); diff --git a/packages/components/src/__tests__/span-value-fallback.test.tsx b/packages/components/src/__tests__/span-value-fallback.test.tsx index c576729567..e621594a81 100644 --- a/packages/components/src/__tests__/span-value-fallback.test.tsx +++ b/packages/components/src/__tests__/span-value-fallback.test.tsx @@ -43,12 +43,20 @@ import { describe, it, expect } from 'vitest'; import { render } from '@testing-library/react'; import { SchemaRenderer } from '@object-ui/react'; -import type { TextSpanSchema } from '@object-ui/types'; +import type { TextSchema, TextSpanSchema } from '@object-ui/types'; // Registers the renderers at module scope, NOT inside a `beforeAll` — there the // cold transform is billed to `hookTimeout`. See // object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021). import '../renderers'; +/** + * Types a child literal below as the `text` node it is (objectui#11347): a + * `children` entry is checked against `SchemaNode`, and once `BaseSchema`'s + * index signature is gone (objectui#8347) a literal checked against it may + * author only `BaseSchema`'s own keys. + */ +const textNode = (schema: TextSchema): TextSchema => schema; + describe('span reads its declared `value` key (#5050)', () => { it('renders the plain `{ type: "span", value }` the card reports rendering empty', () => { // The reproduction from the card, written exactly as `TextSpanSchema` and @@ -76,7 +84,7 @@ describe('span reads its declared `value` key (#5050)', () => { type: 'span', className: 'both-keys', value: 'value must not render', - children: [{ type: 'text', content: 'children win' }], + children: [textNode({ type: 'text', content: 'children win' })], }; const { container } = render(); @@ -95,7 +103,7 @@ describe('span reads its declared `value` key (#5050)', () => { type: 'span', className: 'single-child-wins', value: 'value must not render', - children: { type: 'text', content: 'lone child wins' }, + children: textNode({ type: 'text', content: 'lone child wins' }), }; const { container } = render(); diff --git a/packages/components/src/renderers/form/__tests__/select-label-association.test.tsx b/packages/components/src/renderers/form/__tests__/select-label-association.test.tsx index d5941deae0..7e5bbd85d8 100644 --- a/packages/components/src/renderers/form/__tests__/select-label-association.test.tsx +++ b/packages/components/src/renderers/form/__tests__/select-label-association.test.tsx @@ -35,6 +35,15 @@ import { renderComponent } from '../../../__tests__/test-utils'; // Registers `select` at module scope, not in a hook // (object-ui/no-dynamic-import-in-test-hook, objectui#3010). import '../../../renderers'; +import type { SelectSchema } from '@object-ui/types'; + +/** + * Types a literal below as the node it is rather than as the `BaseSchema` + * `renderComponent` and `SchemaRenderer` accept (objectui#11347): once `BaseSchema`'s index + * signature is gone (objectui#8347), a literal checked against `BaseSchema` + * may author only `BaseSchema`'s own keys. + */ +const selectNode = (schema: SelectSchema): SelectSchema => schema; afterEach(cleanup); @@ -45,7 +54,7 @@ const OPTIONS = [ describe('ui:select — label names the combobox (objectui#10435)', () => { it('is found by getByRole combobox with the label as its name', () => { - renderComponent({ type: 'select', id: 'title', label: 'Title', options: OPTIONS }); + renderComponent(selectNode({ type: 'select', id: 'title', label: 'Title', options: OPTIONS })); const combobox = screen.getByRole('combobox', { name: 'Title' }); expect(combobox).toHaveAccessibleName('Title'); @@ -53,7 +62,7 @@ describe('ui:select — label names the combobox (objectui#10435)', () => { }); it('keeps the name `Title` when required: the aria-hidden marker stays out of it', () => { - renderComponent({ type: 'select', id: 'title', label: 'Title', required: true, options: OPTIONS }); + renderComponent(selectNode({ type: 'select', id: 'title', label: 'Title', required: true, options: OPTIONS })); // The marker is still drawn inside the label (objectui#10368) ... const label = document.querySelector('label[for="title"]'); @@ -67,7 +76,7 @@ describe('ui:select — label names the combobox (objectui#10435)', () => { }); it('points `for` at an id that really exists on the trigger', () => { - renderComponent({ type: 'select', id: 'title', label: 'Title', options: OPTIONS }); + renderComponent(selectNode({ type: 'select', id: 'title', label: 'Title', options: OPTIONS })); const label = document.querySelector('label[for]'); expect(label).not.toBeNull(); @@ -81,7 +90,7 @@ describe('ui:select — label names the combobox (objectui#10435)', () => { }); it('names the combobox with the label, not the placeholder text inside it', () => { - renderComponent({ type: 'select', id: 'title', label: 'Title', placeholder: 'Choose…', options: OPTIONS }); + renderComponent(selectNode({ type: 'select', id: 'title', label: 'Title', placeholder: 'Choose…', options: OPTIONS })); const combobox = screen.getByRole('combobox'); expect(combobox).toHaveTextContent('Choose…'); @@ -89,7 +98,7 @@ describe('ui:select — label names the combobox (objectui#10435)', () => { }); it('names the combobox with the label, not the selected value text inside it', () => { - renderComponent({ type: 'select', id: 'title', label: 'Title', value: 'ms', options: OPTIONS }); + renderComponent(selectNode({ type: 'select', id: 'title', label: 'Title', value: 'ms', options: OPTIONS })); const combobox = screen.getByRole('combobox'); expect(combobox).toHaveTextContent('Ms'); @@ -101,7 +110,7 @@ describe('ui:select — label names the combobox (objectui#10435)', () => { // `schema.id`, so without one the renderer neither writes `for` nor mints // an `id`, and the combobox stays unnamed, as before this change. Pinned so // a later change to that answer is a visible decision, not a drift. - renderComponent({ type: 'select', label: 'Title', placeholder: 'Choose…', options: OPTIONS }); + renderComponent(selectNode({ type: 'select', label: 'Title', placeholder: 'Choose…', options: OPTIONS })); const combobox = screen.getByRole('combobox'); expect(combobox).toHaveTextContent('Choose…'); @@ -115,7 +124,7 @@ describe('ui:select — label names the combobox (objectui#10435)', () => { it('survives the real render path through SchemaRenderer', () => { render( , ); From bba957503a98d1725e038a83ae6772a37125e4f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:26:39 +0000 Subject: [PATCH 5/9] docs(components): input-otp and loading pages name the keys their renderers read (objectui#11347) The input-otp page's schema block shows `length` as the optional slot count it is (default 6) and says the renderer now reads it; the loading page gains the declared `label`. Plus the changeset for objectui#11347. Claude-Session: https://claude.ai/code/session_01VhxTqosz7wn54ahqyxgERT Co-authored-by: Claude --- .changeset/11347-components-uncast-reads.md | 22 ++++++++++++++++++++ content/docs/components/feedback/loading.mdx | 7 +++++++ content/docs/components/form/input-otp.mdx | 9 +++++++- 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .changeset/11347-components-uncast-reads.md diff --git a/.changeset/11347-components-uncast-reads.md b/.changeset/11347-components-uncast-reads.md new file mode 100644 index 0000000000..acfadd6f50 --- /dev/null +++ b/.changeset/11347-components-uncast-reads.md @@ -0,0 +1,22 @@ +--- +'@object-ui/components': minor +'@object-ui/types': patch +--- + +Eight renderers stop reading node keys that no declaration carries (objectui#11347, the `@object-ui/components` preparation for objectui#8347's removal of `BaseSchema`'s index signature). The installed `@objectstack/spec` has no row for any of these eight node types, so each key was decided by who authors it. No catalog entry, example, docs page, package README or objectstack-shipped document authors any of them. + +**Two reads move to the spelling the type already declares.** Neither move adds an alias. + +- `input-otp` draws `length` slots. It used to read an undeclared `maxLength` and ignore the declared `length`, so the published 4-digit and 8-digit examples drew six slots each. `maxLength` is no longer read. Write `length`, which defaults to 6. +- `loading` draws its message from the declared `label`. It used to read an undeclared `text` and leave `label` inert. `text` is no longer read. Write `label`. + +**Eight reads are retired, because no type declares the key and nothing authors it:** + +- `list`: `title`. The list draws no heading. +- `sidebar-menu-button`: `active`. The node no longer sets the button's active state. +- `drawer`: `footer`, `showClose` and `shouldScaleBackground`. The drawer draws no footer and no Close button. +- `dropdown-menu`, `popover` and `sheet`: `modal`. + +Each retired key also leaves its registration's `inputs`, and `modal` leaves the seed values in `sheet`'s `defaultProps`. `SchemaRenderer` still spreads unread node keys onto the component as props. So an authored `modal` or `shouldScaleBackground` still reaches the Radix or vaul root, as it already did beside the named read. Refusing those keys is the strict authoring face's job, not a renderer read's. + +**Types.** In `@object-ui/types`, the "What it renders instead" lists in the `body` / `children` refusal messages of these node types now name what the renderers read. The docblocks of `LoadingSchema.label`, `InputOTPSchema.length`, `ListSchema` and the four overlay schemas record these changes. No accept set changes, and no member is added or removed. diff --git a/content/docs/components/feedback/loading.mdx b/content/docs/components/feedback/loading.mdx index e20ee05a7e..ea630374ae 100644 --- a/content/docs/components/feedback/loading.mdx +++ b/content/docs/components/feedback/loading.mdx @@ -14,7 +14,14 @@ description: "Loading spinner to indicate processing" ```plaintext interface LoadingSchema { type: 'loading'; + label?: string; // Text drawn under the spinner size?: 'sm' | 'md' | 'lg'; className?: string; } ``` + + + The message under the spinner is `label`, the key `LoadingSchema` declares. + Until objectui#11347 the renderer read an undeclared `text` instead and left + `label` inert. `text` is not a key of this node: write `label`. + diff --git a/content/docs/components/form/input-otp.mdx b/content/docs/components/form/input-otp.mdx index 20cd117136..de59b4ce9e 100644 --- a/content/docs/components/form/input-otp.mdx +++ b/content/docs/components/form/input-otp.mdx @@ -27,7 +27,7 @@ The Input OTP component provides a secure way to enter one-time passwords or ver ```plaintext interface InputOTPSchema { type: 'input-otp'; - length: number; // Number of digits + length?: number; // Number of digits (default 6) value?: string; // Current value separator?: boolean; // Show visual separator @@ -44,6 +44,13 @@ interface InputOTPSchema { } ``` + + `length` is the slot count, and the renderer now reads it. Until objectui#11347 + it read an undeclared `maxLength` instead and ignored `length`, so the 4- and + 8-digit examples above drew six slots. `maxLength` is not a key of this node: + write `length`. + + ## Examples ### In a Form From 5cd2c588488ddd387da433af4868fb7037cbbc71 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 08:51:23 +0000 Subject: [PATCH 6/9] fix(types,components): keep list's `title` read and declare ListSchema.title (objectui#11347, round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The at-tier record on PR #11360 found a producer the census missed: the published `skills/objectui` expressions guide authors `{ "type": "list", "title": "Team", ... }` and says `title` is read off the node. The seat ruled under the card's rule 2, by 未声明键随实现, 文档随之: `list` has no row in the installed @objectstack/spec, the renderer reads `title` and the registration publishes it, so the key is declared rather than retired. - `list` draws `schema.title` as its heading again, and the registration's `title` input returns. - `ListSchema.title?: string` is declared on the TypeScript face and on the zod mirror. A non-string `title` is now refused by name where `.passthrough()` used to keep it unjudged. Both faces gain the key together, so no zod-mirror-parity row opens. - The list docs page documents `title`. The refusal messages and docblocks no longer call it retired. - `list-title-declared-11347.test.tsx` pins it on three readers: the guide's literal drawn through the real SchemaRenderer (with a no-title control), the zod mirror, and the compiler (`@ts-expect-error` on a numeric title). - The changeset names the guide as `list.title`'s producer. It also stops claiming that `dropdown-menu` lost a `modal` input it never published. Claude-Session: https://claude.ai/code/session_01VhxTqosz7wn54ahqyxgERT Co-authored-by: Claude --- .changeset/11347-components-uncast-reads.md | 17 ++-- content/docs/components/data-display/list.mdx | 1 + .../list-title-declared-11347.test.tsx | 77 +++++++++++++++++++ .../src/renderers/data-display/list.tsx | 16 ++-- packages/types/src/data-display.ts | 19 +++-- packages/types/src/zod/data-display.zod.ts | 8 +- 6 files changed, 120 insertions(+), 18 deletions(-) create mode 100644 packages/components/src/__tests__/list-title-declared-11347.test.tsx diff --git a/.changeset/11347-components-uncast-reads.md b/.changeset/11347-components-uncast-reads.md index acfadd6f50..4515985d55 100644 --- a/.changeset/11347-components-uncast-reads.md +++ b/.changeset/11347-components-uncast-reads.md @@ -3,20 +3,27 @@ '@object-ui/types': patch --- -Eight renderers stop reading node keys that no declaration carries (objectui#11347, the `@object-ui/components` preparation for objectui#8347's removal of `BaseSchema`'s index signature). The installed `@objectstack/spec` has no row for any of these eight node types, so each key was decided by who authors it. No catalog entry, example, docs page, package README or objectstack-shipped document authors any of them. +Eight renderers stop riding `BaseSchema`'s index signature for node keys their types did not declare (objectui#11347, the `@object-ui/components` preparation for objectui#8347's removal of that signature). The installed `@objectstack/spec` has no row for any of these eight node types, so each key was decided by who authors it. Only one of them has a producer: the published `skills/objectui` expressions guide authors `title` on a `list` node. No catalog entry, example, docs page, package README or objectstack-shipped document authors any of the others. + +**One key is kept and declared.** `ListSchema.title` is now declared on both faces, as an optional string. The `list` renderer keeps drawing it as a heading above the list, and the `list` registration keeps publishing it as an input. The zod mirror now refuses a `title` that is not a string, where `.passthrough()` used to keep it unjudged. **Two reads move to the spelling the type already declares.** Neither move adds an alias. - `input-otp` draws `length` slots. It used to read an undeclared `maxLength` and ignore the declared `length`, so the published 4-digit and 8-digit examples drew six slots each. `maxLength` is no longer read. Write `length`, which defaults to 6. - `loading` draws its message from the declared `label`. It used to read an undeclared `text` and leave `label` inert. `text` is no longer read. Write `label`. -**Eight reads are retired, because no type declares the key and nothing authors it:** +**Seven reads are retired, because no type declares the key and nothing authors it:** -- `list`: `title`. The list draws no heading. - `sidebar-menu-button`: `active`. The node no longer sets the button's active state. - `drawer`: `footer`, `showClose` and `shouldScaleBackground`. The drawer draws no footer and no Close button. - `dropdown-menu`, `popover` and `sheet`: `modal`. -Each retired key also leaves its registration's `inputs`, and `modal` leaves the seed values in `sheet`'s `defaultProps`. `SchemaRenderer` still spreads unread node keys onto the component as props. So an authored `modal` or `shouldScaleBackground` still reaches the Radix or vaul root, as it already did beside the named read. Refusing those keys is the strict authoring face's job, not a renderer read's. +Where a registration published a retired key as an input, the input goes too: `active` on `sidebar-menu-button`, the three `drawer` keys, and `modal` on `popover` and `sheet`. `dropdown-menu` never published `modal`. `sheet`'s `defaultProps` also loses its `modal` seed. `SchemaRenderer` still spreads unread node keys onto the component as props, so an authored `modal` or `shouldScaleBackground` still reaches the Radix or vaul root, as it already did beside the named read. Refusing those keys is the strict authoring face's job, not a renderer read's. + +**Types.** In `@object-ui/types`: + +- `ListSchema` gains `title?: string` on both faces. +- The "What it renders instead" lists in the `body` / `children` refusal messages of these node types now name what the renderers read. +- The docblocks of `LoadingSchema.label`, `InputOTPSchema.length`, `ListSchema.title` and the four overlay schemas record these changes. -**Types.** In `@object-ui/types`, the "What it renders instead" lists in the `body` / `children` refusal messages of these node types now name what the renderers read. The docblocks of `LoadingSchema.label`, `InputOTPSchema.length`, `ListSchema` and the four overlay schemas record these changes. No accept set changes, and no member is added or removed. +Apart from `ListSchema.title`, no member is added or removed and no accept set changes. diff --git a/content/docs/components/data-display/list.mdx b/content/docs/components/data-display/list.mdx index 536cb54ef1..55b7d57183 100644 --- a/content/docs/components/data-display/list.mdx +++ b/content/docs/components/data-display/list.mdx @@ -12,6 +12,7 @@ description: "Display a list of items" ```plaintext interface ListSchema { type: 'list'; + title?: string; // heading drawn above the list items: SchemaNode[]; ordered?: boolean; className?: string; diff --git a/packages/components/src/__tests__/list-title-declared-11347.test.tsx b/packages/components/src/__tests__/list-title-declared-11347.test.tsx new file mode 100644 index 0000000000..c4e55c109f --- /dev/null +++ b/packages/components/src/__tests__/list-title-declared-11347.test.tsx @@ -0,0 +1,77 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `list` draws an authored `title`, and `ListSchema` declares it (objectui#11347). + * + * The renderer has read `schema.title` all along, drawing it as a heading above + * the list, and the `list` registration publishes it as an input. Until + * objectui#11347 no declaration carried it, so it survived only on + * `BaseSchema`'s index signature, which objectui#8347 removes. The installed + * `@objectstack/spec` has no row for `list`, and a published producer authors + * the key: the `skills/objectui` expressions guide teaches + * `{ "type": "list", "title": "Team", "ordered": true, ... }`. So the key is + * declared where the renderer and the registry already honour it, rather than + * retired. + * + * Three readers pin it: + * - the runtime: the guide's literal, rendered through the real + * `SchemaRenderer`, draws its title, and a node without one draws none; + * - the zod mirror: a string `title` parses, and a number is refused at `title`; + * - the compiler: `tsc -p tsconfig.test.json` reads the typed literal and + * the `@ts-expect-error` line below. Vitest cannot see either. + */ + +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { SchemaRenderer } from '@object-ui/react'; +import { ListSchema as ListMirror } from '@object-ui/types/zod'; +import type { ListSchema } from '@object-ui/types'; +// Registers the renderers at module scope, NOT inside a `beforeAll`: there the +// cold transform is billed to `hookTimeout` (object-ui/no-dynamic-import-in-test-hook, +// objectui#3010). +import '../renderers'; + +/** The literal the expressions guide teaches, typed against the declaration. */ +const GUIDE_LIST: ListSchema = { + type: 'list', + title: 'Team', + ordered: true, + items: [{ content: 'Ada' }, { content: 'Linus' }], +}; + +// The declaration is a STRING: a number is refused on the TypeScript face, with +// or without `BaseSchema`'s index signature, because a declared member wins. +// @ts-expect-error -- `title` is declared `string` +const _NUMERIC_TITLE: ListSchema = { type: 'list', items: [], title: 42 }; +void _NUMERIC_TITLE; + +describe('list draws its declared `title` (objectui#11347)', () => { + it("draws the expressions guide's title as the heading above the list", () => { + const { container } = render(); + const heading = container.querySelector('h3'); + expect(heading?.textContent).toBe('Team'); + // The list itself still renders, under the heading. + expect(container.querySelectorAll('ol > li')).toHaveLength(2); + }); + + it('LIVE CONTROL: a list without a title draws no heading', () => { + const { container } = render( + , + ); + expect(container.querySelector('h3')).toBeNull(); + expect(container.querySelectorAll('ol > li')).toHaveLength(2); + }); + + it('the zod mirror declares it: a string parses, and a number is refused at `title`', () => { + expect(ListMirror.safeParse(GUIDE_LIST).success).toBe(true); + const refused = ListMirror.safeParse({ type: 'list', items: [], title: 42 }); + expect(refused.success).toBe(false); + expect(refused.error?.issues.map((i) => i.path.join('.'))).toContain('title'); + }); +}); diff --git a/packages/components/src/renderers/data-display/list.tsx b/packages/components/src/renderers/data-display/list.tsx index 70858615c5..f88567b468 100644 --- a/packages/components/src/renderers/data-display/list.tsx +++ b/packages/components/src/renderers/data-display/list.tsx @@ -23,13 +23,18 @@ ComponentRegistry.register('list', // Default styles for ordered/unordered const listStyle = schema.ordered ? "list-decimal" : "list-disc"; - // ⛔ No `title` heading (objectui#11347). `ListSchema` declares no `title`, - // the installed `@objectstack/spec` has no row for `list`, and no catalog - // entry, example, doc page or README authors one, so the read that drew an - // `h3` from it rode `BaseSchema`'s index signature alone and was retired. return (
- + {schema.title} + + )} + + li]:mt-2", listStyle, @@ -50,6 +55,7 @@ ComponentRegistry.register('list', namespace: 'ui', label: 'List', inputs: [ + { name: 'title', type: 'string' }, { name: 'ordered', type: 'boolean' }, { name: 'items', diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index e1544cdc5e..9c07110390 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -285,6 +285,19 @@ export interface AvatarSchema extends BaseSchema { */ export interface ListSchema extends BaseSchema { type: 'list'; + /** + * Heading drawn above the list. + * + * READ SITE: `packages/components/src/renderers/data-display/list.tsx`, which + * draws `schema.title` in an `h3` above the list when it is set; the `list` + * registration publishes it as an input. Undeclared until objectui#11347, + * surviving only on `BaseSchema`'s index signature. The installed + * `@objectstack/spec` has no row for `list`, and the published + * `skills/objectui` expressions guide authors `title` on a `list` node, so + * the key is declared where the renderer and the registry already honour it + * rather than retired. + */ + title?: string; /** * List items */ @@ -305,7 +318,7 @@ export interface ListSchema extends BaseSchema { */ dense?: boolean; /** - * Classes on the wrapper `div` around the list. + * Classes on the wrapper `div` around the title and the list. * * READ SITE: `packages/components/src/renderers/data-display/list.tsx` — * `cn("space-y-2", schema.wrapperClass)`. Undeclared until @@ -328,8 +341,6 @@ export interface ListSchema extends BaseSchema { * from; this declaration carries none. What the renderer DOES read off this * node, from the same instrument: `bind`, `items`, `ordered`, `title`, * `wrapperClass` (in `packages/components/src/renderers/data-display/list.tsx`). - * `title` has since been retired there (objectui#11347): it was never a - * member here and no producer authored it. * * ⭐ THE ITEM CHANNEL IS A DIFFERENT KEY AND STAYS LIVE. That renderer draws * each entry from its `content` (`renderChildren(item.content)`) — a read filed @@ -377,8 +388,6 @@ export interface ListSchema extends BaseSchema { * from; this declaration carries none. What the renderer DOES read off this * node, from the same instrument: `bind`, `items`, `ordered`, `title`, * `wrapperClass` (in `packages/components/src/renderers/data-display/list.tsx`). - * `title` has since been retired there (objectui#11347): it was never a - * member here and no producer authored it. * * ⭐ THE ITEM CHANNEL IS A DIFFERENT KEY AND STAYS LIVE. That renderer draws * each entry from its `content` (`renderChildren(item.content)`) — a read filed diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 219d22f77b..7f1775359d 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -185,19 +185,21 @@ export const ListItemSchema = z.object({ */ export const ListSchema = BaseSchema.extend({ type: z.literal('list'), + title: z.string().optional() + .describe('Heading drawn above the list (objectui#11347)'), items: z.array(ListItemSchema).describe('List items'), ordered: z.boolean().optional().describe('Whether list is ordered'), dividers: z.boolean().optional().describe('Show dividers between items'), dense: z.boolean().optional().describe('Dense spacing'), wrapperClass: z.string().optional() - .describe('Classes on the wrapper div around the list — merged with the base `space-y-2` (objectui#7722)'), + .describe('Classes on the wrapper div around the title and the list — merged with the base `space-y-2` (objectui#7722)'), body: retirementTombstone( 'REFUSED (objectui#9256, ADR-0049) — `list` reads NEITHER content channel: measured with the ' + 'TypeScript type checker over one program built from the repo-root tsconfig on a BUILT tree, no ' + 'renderer read consumes `body` or `children` for this node, and `SchemaRenderer` strips both out ' + 'of the props bag it spreads. An authored value therefore rendered NOTHING — no render-time error or ' + 'warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `bind`, `items`, `ordered`, `wrapperClass`. ' + + 'What it renders instead: `bind`, `items`, `ordered`, `title`, `wrapperClass`. ' + 'The ITEM channel is a different key and stays live: each entry is drawn from its `content` ' + '(`renderChildren(item.content)`), a read filed under ListItem and not under this node. ' + '`ui:list` is the measured SOLE owner of the bare `list` key (`view:list` passes `skipFallback: true`); ' @@ -209,7 +211,7 @@ export const ListSchema = BaseSchema.extend({ + 'renderer read consumes `body` or `children` for this node, and `SchemaRenderer` strips both out ' + 'of the props bag it spreads. An authored value therefore rendered NOTHING — no render-time error or ' + 'warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it. ' - + 'What it renders instead: `bind`, `items`, `ordered`, `wrapperClass`. ' + + 'What it renders instead: `bind`, `items`, `ordered`, `title`, `wrapperClass`. ' + 'The ITEM channel is a different key and stays live: each entry is drawn from its `content` ' + '(`renderChildren(item.content)`), a read filed under ListItem and not under this node. ' + '`ui:list` is the measured SOLE owner of the bare `list` key (`view:list` passes `skipFallback: true`); ' From 61be4073022a7f00af99f04247be4ffcbf8ee2ba Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 08:55:36 +0000 Subject: [PATCH 7/9] test(components): type the untitled-list control as a ListSchema, not a fresh literal (objectui#11347) `satisfies` keeps a literal fresh, so under objectui#8347's P1 mutation the control's literal was still checked against `SchemaRenderer`'s `BaseSchema` and `title` read as an excess key. A typed const carries the declaration. Claude-Session: https://claude.ai/code/session_01VhxTqosz7wn54ahqyxgERT Co-authored-by: Claude --- .../src/__tests__/list-title-declared-11347.test.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/components/src/__tests__/list-title-declared-11347.test.tsx b/packages/components/src/__tests__/list-title-declared-11347.test.tsx index c4e55c109f..5ebd3b09f2 100644 --- a/packages/components/src/__tests__/list-title-declared-11347.test.tsx +++ b/packages/components/src/__tests__/list-title-declared-11347.test.tsx @@ -61,9 +61,8 @@ describe('list draws its declared `title` (objectui#11347)', () => { }); it('LIVE CONTROL: a list without a title draws no heading', () => { - const { container } = render( - , - ); + const untitled: ListSchema = { ...GUIDE_LIST, title: undefined }; + const { container } = render(); expect(container.querySelector('h3')).toBeNull(); expect(container.querySelectorAll('ol > li')).toHaveLength(2); }); From 57c757ffaf03258cfa980d55ea58690c911dbacc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:10:58 +0000 Subject: [PATCH 8/9] docs(changeset): bind the drawer line address in the objectui#8318 correction to its tree (objectui#11347) The `changeset-claim-re-read` comment flagged `renderers/overlay/drawer.tsx:38` in `.changeset/8318-jsdoc-default-tag-corrections.md`, because this branch rewrites that line. The flagged address is in the original bullet, inside the append-only prefix, so it stays byte-identical. The appended correction now binds the same address, in its own sentence, to `33f4a1980` (the commit that added this changeset), where line 38 is the `drawer` registration's `showClose` read. It also names that read by what it is, so the published CHANGELOG line is anchored. The first 2879 bytes still `cmp` equal to origin/main. Claude-Session: https://claude.ai/code/session_01VhxTqosz7wn54ahqyxgERT Co-authored-by: Claude --- .changeset/8318-jsdoc-default-tag-corrections.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/8318-jsdoc-default-tag-corrections.md b/.changeset/8318-jsdoc-default-tag-corrections.md index 05274368e9..0e3f988d3e 100644 --- a/.changeset/8318-jsdoc-default-tag-corrections.md +++ b/.changeset/8318-jsdoc-default-tag-corrections.md @@ -50,4 +50,4 @@ described nothing that runs. The pin `packages/types/src/__tests__/layout-default-jsdoc-7361.test.ts` grows from 15 rows to 24, both sides derived off disk as before. -**Correction, 2026-10-01 (objectui#11347).** The `CRUDDialogSchema` bullet above says `showClose`'s one other occurrence belongs to `DrawerSchema`. That was true when this change was written, and it no longer is: objectui#11347 retired the `drawer` renderer's `showClose` read, which `DrawerSchema` never declared and no producer authored, so `showClose` is now read under no node type at all. The `CRUDDialogSchema.showClose` docblock and its pin in `layout-default-jsdoc-7361.test.ts` say so. The verdict this change records is unchanged: `showClose` still publishes no `@default`. +**Correction, 2026-10-01 (objectui#11347).** The `CRUDDialogSchema` bullet above says `showClose`'s one other occurrence belongs to `DrawerSchema`. That bullet's `renderers/overlay/drawer.tsx:38` is an address into the tree at `33f4a1980`, where that line is the `drawer` registration's `showClose` read. The sentence was true when this change was written, and it no longer is: objectui#11347 retired that read, which `DrawerSchema` never declared and no producer authored, so `showClose` is now read under no node type at all. The `CRUDDialogSchema.showClose` docblock and its pin in `layout-default-jsdoc-7361.test.ts` say so. The verdict this change records is unchanged: `showClose` still publishes no `@default`. From 172751bab4931d6b501af30ed074fe0d7526da7d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:51:31 +0000 Subject: [PATCH 9/9] docs(changeset): @object-ui/types is minor, because ListSchema gains a member (objectui#11347, round 3) Round 2 declared `ListSchema.title` on the shipped `.d.ts` and on the mirror's `.shape`, and the mirror now refuses a non-string `title` by name. objectui#7722 graded that class on this same schema `minor` ("the published face gains ... members"), so `patch` was wrong. The Types section now says so, in the same words 7722 uses. Claude-Session: https://claude.ai/code/session_01VhxTqosz7wn54ahqyxgERT Co-authored-by: Claude --- .changeset/11347-components-uncast-reads.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/11347-components-uncast-reads.md b/.changeset/11347-components-uncast-reads.md index 4515985d55..6d6390b512 100644 --- a/.changeset/11347-components-uncast-reads.md +++ b/.changeset/11347-components-uncast-reads.md @@ -1,6 +1,6 @@ --- '@object-ui/components': minor -'@object-ui/types': patch +'@object-ui/types': minor --- Eight renderers stop riding `BaseSchema`'s index signature for node keys their types did not declare (objectui#11347, the `@object-ui/components` preparation for objectui#8347's removal of that signature). The installed `@objectstack/spec` has no row for any of these eight node types, so each key was decided by who authors it. Only one of them has a producer: the published `skills/objectui` expressions guide authors `title` on a `list` node. No catalog entry, example, docs page, package README or objectstack-shipped document authors any of the others. @@ -27,3 +27,5 @@ Where a registration published a retired key as an input, the input goes too: `a - The docblocks of `LoadingSchema.label`, `InputOTPSchema.length`, `ListSchema.title` and the four overlay schemas record these changes. Apart from `ListSchema.title`, no member is added or removed and no accept set changes. + +**minor, not patch — the published face gains a member.** `ListSchema.title` is a new member of the shipped `.d.ts` and of the mirror's `.shape`, and the mirror refuses a non-string `title` by name, the class objectui#7722 graded `minor` on this same schema.