diff --git a/.changeset/7081-overlay-trigger-union.md b/.changeset/7081-overlay-trigger-union.md new file mode 100644 index 000000000..cf244ff22 --- /dev/null +++ b/.changeset/7081-overlay-trigger-union.md @@ -0,0 +1,11 @@ +--- +'@object-ui/types': minor +--- + +**The overlay family's `trigger` slot now declares the node array its Zod mirror, its runtime and its own shipped defaults already accept** (objectui#7081). + +`trigger` on `DialogSchema`, `AlertDialogSchema`, `SheetSchema`, `DrawerSchema`, `PopoverSchema`, `HoverCardSchema` and `DropdownMenuSchema` widens from `SchemaNode` to `SchemaNode | SchemaNode[]` on the TypeScript face — the spelling `ContextMenuSchema` and `TooltipSchema` already carried. Each member keeps its optionality (the first four optional, the last three required). `SchemaNode` itself is unchanged. + +This is a **widening**, not a replacement: every singular `trigger` keeps type-checking unchanged. The Zod mirror is untouched — `zod/overlay.zod.ts` already spelled every one of these keys `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` — and so is the runtime: every overlay renderer hands `schema.trigger` to `renderChildren`, whose `Array.isArray` branch has served the array form all along, and every registration's `defaultProps.trigger` ships as an array. What changes is that the TypeScript face stops under-reporting an accept set that already ships: copying a renderer's own default into a typed document is no longer a type error against the type that shipped it. The seven docs pages' `trigger` rows follow the declaration. + +Triage on the card (2026-09-03): the validator's accept set does not move, so this is a declaration catching up with what ships rather than a new capability. Per this repository's version-alignment convention, a widening of a published type surface ships as `minor` with the semantics spelled out here rather than as `major` (see AGENTS.md, "版本号策略"). diff --git a/content/docs/components/overlay/alert-dialog.mdx b/content/docs/components/overlay/alert-dialog.mdx index e6af5fe13..619abefd5 100644 --- a/content/docs/components/overlay/alert-dialog.mdx +++ b/content/docs/components/overlay/alert-dialog.mdx @@ -27,7 +27,7 @@ interface AlertDialogSchema { description?: string; // Dialog description // Trigger - trigger: SchemaNode; // Component that triggers the dialog + trigger: SchemaNode | SchemaNode[]; // Component that triggers the dialog // Body and footer content?: SchemaNode | SchemaNode[]; // Rendered between the header and the footer diff --git a/content/docs/components/overlay/dialog.mdx b/content/docs/components/overlay/dialog.mdx index ebe62fc6a..84f036803 100644 --- a/content/docs/components/overlay/dialog.mdx +++ b/content/docs/components/overlay/dialog.mdx @@ -12,7 +12,7 @@ description: "Modal dialog overlay for focused interactions" ```plaintext interface DialogSchema { type: 'dialog'; - trigger: SchemaNode; + trigger: SchemaNode | SchemaNode[]; title?: string; description?: string; children: SchemaNode[]; diff --git a/content/docs/components/overlay/drawer.mdx b/content/docs/components/overlay/drawer.mdx index 18e3367b7..f3dca42c2 100644 --- a/content/docs/components/overlay/drawer.mdx +++ b/content/docs/components/overlay/drawer.mdx @@ -12,7 +12,7 @@ description: "Sliding panel from edge of screen" ```plaintext interface DrawerSchema { type: 'drawer'; - trigger: SchemaNode; + trigger: SchemaNode | SchemaNode[]; title?: string; children: SchemaNode[]; side?: 'left' | 'right' | 'top' | 'bottom'; diff --git a/content/docs/components/overlay/dropdown-menu.mdx b/content/docs/components/overlay/dropdown-menu.mdx index 9ac4e24ef..245c5ff0c 100644 --- a/content/docs/components/overlay/dropdown-menu.mdx +++ b/content/docs/components/overlay/dropdown-menu.mdx @@ -48,7 +48,7 @@ type DropdownMenuItem = DropdownMenuCommandItem | DropdownMenuDividerItem; interface DropdownMenuSchema { type: 'dropdown-menu'; - trigger: SchemaNode; // Trigger component + trigger: SchemaNode | SchemaNode[]; // Trigger component items: DropdownMenuItem[]; // Menu items // Styling diff --git a/content/docs/components/overlay/hover-card.mdx b/content/docs/components/overlay/hover-card.mdx index c13eddd0a..302d5952e 100644 --- a/content/docs/components/overlay/hover-card.mdx +++ b/content/docs/components/overlay/hover-card.mdx @@ -14,7 +14,7 @@ The Hover Card component displays rich content when hovering over an element. ```plaintext interface HoverCardSchema { type: 'hover-card'; - trigger: SchemaNode; // Trigger element + trigger: SchemaNode | SchemaNode[]; // Trigger element content: SchemaNode | SchemaNode[]; // Card content side?: 'top' | 'right' | 'bottom' | 'left'; align?: 'start' | 'center' | 'end'; diff --git a/content/docs/components/overlay/popover.mdx b/content/docs/components/overlay/popover.mdx index 91d380abe..a365f521a 100644 --- a/content/docs/components/overlay/popover.mdx +++ b/content/docs/components/overlay/popover.mdx @@ -12,7 +12,7 @@ description: "Floating content panel" ```plaintext interface PopoverSchema { type: 'popover'; - trigger: SchemaNode; + trigger: SchemaNode | SchemaNode[]; children: SchemaNode[]; side?: 'top' | 'right' | 'bottom' | 'left'; } diff --git a/content/docs/components/overlay/sheet.mdx b/content/docs/components/overlay/sheet.mdx index ba78093b4..92bf60c26 100644 --- a/content/docs/components/overlay/sheet.mdx +++ b/content/docs/components/overlay/sheet.mdx @@ -23,7 +23,7 @@ The Sheet component displays content in a panel that slides in from the edge of ```plaintext interface SheetSchema { type: 'sheet'; - trigger: SchemaNode; // Trigger component + trigger: SchemaNode | SchemaNode[]; // Trigger component title?: string; // Sheet title description?: string; // Sheet description content: SchemaNode | SchemaNode[]; // Sheet content diff --git a/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts b/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts index 6f78d5f17..73b9762f5 100644 --- a/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts +++ b/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts @@ -312,7 +312,9 @@ describe('the docs page publishes the read dialect (objectui#7104)', () => { it('control: the rows both faces always agreed on are still there', () => { expect(rows().get('type')?.typeText).toBe("'alert-dialog'"); - expect(rows().get('trigger')?.typeText).toBe('SchemaNode'); + // `trigger` widened to the union on both faces with objectui#7081; the row + // still says what the declaration says. + expect(rows().get('trigger')?.typeText).toBe('SchemaNode | SchemaNode[]'); }); }); diff --git a/packages/types/src/__tests__/overlay-node-slot-doc-types-7082.test.ts b/packages/types/src/__tests__/overlay-node-slot-doc-types-7082.test.ts index 791fa25e7..6561449e5 100644 --- a/packages/types/src/__tests__/overlay-node-slot-doc-types-7082.test.ts +++ b/packages/types/src/__tests__/overlay-node-slot-doc-types-7082.test.ts @@ -25,22 +25,24 @@ * ## The authority here is the TS declaration, NOT the Zod mirror * * That is the one deliberate departure from the #7078 model, and it is load - * bearing. On this tree the TS interface and its mirror DISAGREE on four - * `trigger` rows: `AlertDialogSchema`, `SheetSchema`, `HoverCardSchema` and - * `DropdownMenuSchema` all declare `trigger: SchemaNode` (singular) while - * `zod/overlay.zod.ts` mirrors each as `z.union([SchemaNodeSchema, - * z.array(SchemaNodeSchema)])`. That asymmetry is objectui#7081 -- OPEN, a - * published-type widening awaiting a maintainer decision. Pinning these pages - * against the mirror would publish the array form on all four and silently - * pre-empt that ruling, so the pin follows the type an author's editor reads. + * bearing. When this file was written the TS interface and its mirror + * DISAGREED on four `trigger` rows: `AlertDialogSchema`, `SheetSchema`, + * `HoverCardSchema` and `DropdownMenuSchema` all declared `trigger: SchemaNode` + * (singular) while `zod/overlay.zod.ts` mirrored each as + * `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`. That asymmetry was + * objectui#7081, then OPEN. Pinning these pages against the mirror would have + * published the array form on all four and silently pre-empted that ruling, so + * the pin followed the type an author's editor reads, and its type-level leg + * was built to go red the day the declaration widened. * - * `DropdownMenuSchema.trigger` therefore stays SINGULAR on the page even - * though its mirror, its sibling `ContextMenuSchema` and its own shipped - * `defaultProps` (`renderers/overlay/dropdown-menu.tsx`) all use the array - * form. The incoherence is real; it IS #7081, and it is recorded below rather - * than resolved here. The type-level leg makes the boundary mechanical: widen - * the declaration and `tsc -p tsconfig.test.json` fails, so whoever lands - * #7081 is told the page owes an update. + * objectui#7081 has since LANDED (triage 2026-09-03: the validator's accept set + * does not move; the TypeScript face stops under-reporting it). All seven + * singular overlay `trigger` members now declare `SchemaNode | SchemaNode[]`, + * the four rows below were re-derived to the union in the same PR, and the + * legs that pinned the divergence now pin the agreement. The authority is + * unchanged -- the page still says what the DECLARATION says; the declaration + * simply agrees with its mirror now. `overlay-trigger-union-7081.test.ts` pins + * the widening itself, on every face it ships on. * * ## What the pages taught before, measured on `2c3cd1b` * @@ -105,19 +107,20 @@ type AdmitsArray = SchemaNode[] extends T ? true : false; // The seven corrected rows, asserted against the declarations themselves rather // than against the page text the runtime leg reads. -export type _HoverCardTrigger = Expect>; +export type _HoverCardTrigger = Expect>; export type _HoverCardContent = Expect>; // No `NonNullable` here on purpose: `SchemaNode` ALREADY admits `null | // undefined`, so stripping them would compare against a type neither side has. export type _SheetContent = Expect>; export type _ContextMenuTrigger = Expect>; -// The #7081 boundary, mechanically. `ContextMenuSchema` admits an array and its -// page says so; `DropdownMenuSchema` refuses one and its page says so too. +// The #7081 boundary, mechanically -- closed since objectui#7081 landed. +// `ContextMenuSchema` always admitted an array and its page said so; the other +// three admit one now, and their pages say so too. export type _ContextMenuAdmitsArray = Expect, true>>; -export type _DropdownAdmitsArray = Expect, false>>; -export type _AlertDialogAdmitsArray = Expect, false>>; -export type _SheetAdmitsArray = Expect, false>>; +export type _DropdownAdmitsArray = Expect, true>>; +export type _AlertDialogAdmitsArray = Expect, true>>; +export type _SheetAdmitsArray = Expect, true>>; // The premise the whole correction rests on used to be pinned here as two // type-level assertions: `ComponentSchema` is a real export, and it is NOT a @@ -219,12 +222,12 @@ const declRow = (owner: string, key: string): Member | undefined => declared.get /** The seven rows objectui#7082 corrects: owner, key, and the declared text. */ const CORRECTED: ReadonlyArray = [ - ['AlertDialogSchema', 'trigger', 'SchemaNode'], + ['AlertDialogSchema', 'trigger', 'SchemaNode | SchemaNode[]'], ['ContextMenuSchema', 'trigger', 'SchemaNode | SchemaNode[]'], - ['HoverCardSchema', 'trigger', 'SchemaNode'], + ['HoverCardSchema', 'trigger', 'SchemaNode | SchemaNode[]'], ['HoverCardSchema', 'content', 'SchemaNode | SchemaNode[]'], - ['DropdownMenuSchema', 'trigger', 'SchemaNode'], - ['SheetSchema', 'trigger', 'SchemaNode'], + ['DropdownMenuSchema', 'trigger', 'SchemaNode | SchemaNode[]'], + ['SheetSchema', 'trigger', 'SchemaNode | SchemaNode[]'], ['SheetSchema', 'content', 'SchemaNode | SchemaNode[]'], ]; @@ -259,23 +262,24 @@ describe('six overlay/feedback pages name node slots at the declared type (objec }); }); -describe('objectui#7081 is NOT pre-empted: the singular rows stay singular (objectui#7082)', () => { - it('`DropdownMenuSchema.trigger` is declared singular, so the page says singular', () => { - expect(declRow('DropdownMenuSchema', 'trigger')?.typeText).toBe('SchemaNode'); - expect(docRow('DropdownMenuSchema', 'trigger')?.typeText).toBe('SchemaNode'); +describe('objectui#7081 landed: the rows that stayed singular for it now say the union on both faces (objectui#7082)', () => { + it('`DropdownMenuSchema.trigger` is declared as the union, so the page says the union', () => { + expect(declRow('DropdownMenuSchema', 'trigger')?.typeText).toBe('SchemaNode | SchemaNode[]'); + expect(docRow('DropdownMenuSchema', 'trigger')?.typeText).toBe('SchemaNode | SchemaNode[]'); }); - it('its Zod mirror still says otherwise -- the asymmetry #7081 exists to rule on', () => { - // Recorded, not resolved. When #7081 lands, whichever side moves, one of - // these two assertions fails and the page is re-derived deliberately. + it('its Zod mirror says the union it always said -- the mirror is the side that did NOT move', () => { + // The pre-#7081 form of this pin read "still says otherwise". The + // assertion is byte-identical; only what it proves changed: the two faces + // now agree, and the declaration is the one that moved. const mirror = read('packages/types/src/zod/overlay.zod.ts'); expect(mirror).toContain( "trigger: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).describe('Menu trigger')", ); }); - it('its sibling `ContextMenuSchema` really does declare the union -- the two differ', () => { - expect(declRow('ContextMenuSchema', 'trigger')?.typeText).not.toBe( + it('its sibling `ContextMenuSchema` declares the same union -- the two no longer differ', () => { + expect(declRow('ContextMenuSchema', 'trigger')?.typeText).toBe( declRow('DropdownMenuSchema', 'trigger')?.typeText, ); }); @@ -362,9 +366,12 @@ describe('counter-probes: the readers above can still fail (objectui#7082)', () expect(regressed.get('trigger')?.typeText).not.toBe('SchemaNode'); }); - it('a blind string replace to the array form would be caught on dropdown-menu', () => { - // The exact regression the card and #7081 warn about. - const regressed = members(' trigger: SchemaNode | SchemaNode[]; // Trigger component'); + it('a blind revert to the singular form would be caught on dropdown-menu', () => { + // The mirror image of the probe this file carried while #7081 was open: the + // page then said `SchemaNode` and a blind replace to the union was the + // regression. Now the union is what the declaration says, and the singular + // spelling is the one that would drift. + const regressed = members(' trigger: SchemaNode; // Trigger component'); expect(regressed.get('trigger')?.typeText).not.toBe(declRow('DropdownMenuSchema', 'trigger')?.typeText); }); diff --git a/packages/types/src/__tests__/overlay-trigger-union-7081.test.ts b/packages/types/src/__tests__/overlay-trigger-union-7081.test.ts new file mode 100644 index 000000000..e09bc517b --- /dev/null +++ b/packages/types/src/__tests__/overlay-trigger-union-7081.test.ts @@ -0,0 +1,409 @@ +/** + * 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. + */ + +/** + * The overlay family's `trigger` slot admits a node OR a node array on every + * face it ships on (objectui#7081). + * + * ## What was wrong, measured on `origin/main` `737037f2` + * + * Seven overlay declarations spelled `trigger` as a single `SchemaNode`: + * `DialogSchema`, `AlertDialogSchema`, `SheetSchema`, `DrawerSchema` (optional) + * and `PopoverSchema`, `HoverCardSchema`, `DropdownMenuSchema` (required). + * Every one of their Zod mirrors (`zod/overlay.zod.ts`) spelled the same key + * `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`; every one of their + * renderers hands `schema.trigger` to `renderChildren`, whose `Array.isArray` + * branch (`packages/components/src/lib/utils.tsx:23`) serves the array; and + * every one of their registrations ships `defaultProps.trigger` as an ARRAY. + * The two siblings that already carried the union on the TS face + * (`TooltipSchema`, `ContextMenuSchema`) are the controls below. + * + * So the published type -- the one an author's editor reads -- refused the + * form the validator accepted, the runtime rendered and the component's own + * default shipped: copying a renderer's default into a typed document was a + * type error against the type that shipped it. + * + * ## Why no gate caught it + * + * `zod-mirror-parity.test.ts` asserts one direction only: the mirror accepts + * everything the declaration declares. A mirror WIDER than its declaration is + * exactly what that test is built to allow (objectui#5684). This file pins + * the other direction for this one slot, on this one family. + * + * ## What moved (triage 2026-09-03 on the card) + * + * The TS face only. The validator's accept set does not move -- the mirror + * already accepted both forms -- so this is a declaration catching up with + * what ships, not a capability. `SchemaNode` itself is untouched, and a bare + * `SchemaNode` slot still refuses an array (the counter-control below). + * + * ## The faces, per member + * + * - TS: `XSchema['trigger']` equals `SchemaNode | SchemaNode[]`, optionality + * kept per member (`SchemaNode` already admits `undefined`, so no + * `| undefined` on the optional ones -- the objectui#7082 note). + * - Zod: the mirror parses a single node AND an array, and refuses a non-node + * at the `trigger` path, so the parse is examining the key. + * - the registration: the shipped `defaultProps.trigger`, read off the + * renderer source, is an array, equals the typed copy in `SHIPPED`, and + * parses through the mirror. The typed copy is what compiles against the + * declaration -- the card's own complaint, made a pin. + * - the docs: each page's `trigger` row publishes the union. + * + * ## Ablation + * + * With `overlay.ts` reverted to its pre-#7081 bytes, the `_*Trigger` and + * `_*AdmitsArray` legs for the seven and the seven `shipped*` documents fail + * under `tsc -p tsconfig.test.json`; the controls, the counter-control and + * every runtime leg stay green (the mirror and the renderers never moved). + * Recorded on the PR. + */ + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { BaseSchema, SchemaNode } from '../base'; +import type { CollapsibleSchema } from '../disclosure'; +import type { + AlertDialogSchema, + ContextMenuSchema, + DialogSchema, + DrawerSchema, + DropdownMenuSchema, + HoverCardSchema, + PopoverSchema, + SheetSchema, + TooltipSchema, +} from '../overlay'; +import { + AlertDialogSchema as AlertDialogZod, + ContextMenuSchema as ContextMenuZod, + DialogSchema as DialogZod, + DrawerSchema as DrawerZod, + DropdownMenuSchema as DropdownMenuZod, + HoverCardSchema as HoverCardZod, + PopoverSchema as PopoverZod, + SheetSchema as SheetZod, + TooltipSchema as TooltipZod, +} from '../zod/overlay.zod.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +const read = (relative: string): string => readFileSync(join(REPO_ROOT, relative), 'utf8'); + +const DECLARATION = 'packages/types/src/overlay.ts'; +const MIRROR = 'packages/types/src/zod/overlay.zod.ts'; +const RENDERERS = 'packages/components/src/renderers/overlay'; +const DOCS = 'content/docs/components/overlay'; +const RENDER_CHILDREN = 'packages/components/src/lib/utils.tsx'; + +/* -- Type-level leg: compiled by `tsc -p packages/types/tsconfig.test.json` -- */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; +/** Does this slot admit the ARRAY form? The whole of objectui#7081 in one operator. */ +type AdmitsArray = SchemaNode[] extends T ? true : false; +/** Is the key spelled with a `?` -- read off the type, not off the source text. */ +type IsOptionalKey = Record extends Pick ? true : false; + +type Union = SchemaNode | SchemaNode[]; + +// The seven widened members, each equal to the union `ContextMenuSchema` spells. +export type _DialogTrigger = Expect>; +export type _AlertDialogTrigger = Expect>; +export type _SheetTrigger = Expect>; +export type _DrawerTrigger = Expect>; +export type _PopoverTrigger = Expect>; +export type _HoverCardTrigger = Expect>; +export type _DropdownMenuTrigger = Expect>; + +export type _DialogAdmitsArray = Expect, true>>; +export type _AlertDialogAdmitsArray = Expect, true>>; +export type _SheetAdmitsArray = Expect, true>>; +export type _DrawerAdmitsArray = Expect, true>>; +export type _PopoverAdmitsArray = Expect, true>>; +export type _HoverCardAdmitsArray = Expect, true>>; +export type _DropdownMenuAdmitsArray = Expect, true>>; + +// Optionality is KEPT per member: four optional, three required, as before. +export type _DialogOptional = Expect, true>>; +export type _AlertDialogOptional = Expect, true>>; +export type _SheetOptional = Expect, true>>; +export type _DrawerOptional = Expect, true>>; +export type _PopoverRequired = Expect, false>>; +export type _HoverCardRequired = Expect, false>>; +export type _DropdownMenuRequired = Expect, false>>; + +// Controls: the two members that already carried the union are unchanged. +export type _TooltipTrigger = Expect>; +export type _ContextMenuTrigger = Expect>; + +// Counter-control: `AdmitsArray` can say `false`, and `SchemaNode` itself did +// not move -- the widening is per member, not on the node type. +export type _SchemaNodeUntouched = Expect< + Equal +>; +interface SingularSlot { + trigger: SchemaNode; +} +export type _SingularRefusesArray = Expect, false>>; +export const singularStaysSingular: SingularSlot = { + // @ts-expect-error objectui#7081 -- a bare `SchemaNode` slot still refuses the array form; the widening is per member, not on `SchemaNode` + trigger: [{ type: 'button', label: 'Open' }], +}; + +// The one in-repo `trigger` OUTSIDE the family with the same asymmetry, +// recorded rather than resolved (`disclosure.ts`: `string | SchemaNode` on the +// TS face, the union in `disclosure.zod.ts`, an array in `collapsible.tsx`'s +// `defaultProps`). Fenced out of objectui#7081 and filed as objectui#7767; the +// day it widens, this leg goes red and the pin is re-derived deliberately. +export type _CollapsibleStillSingular = Expect, false>>; + +/** + * The `defaultProps.trigger` each overlay registration ships, copied VERBATIM + * from its renderer. The runtime leg below proves each copy still equals the + * source, so this table cannot drift from what ships; this table is what + * compiles against the declarations. + */ +const SHIPPED = { + dialog: [{ type: 'button', label: 'Open Dialog' }], + 'alert-dialog': [{ type: 'button', label: 'Open Alert', variant: 'destructive' }], + sheet: [{ type: 'button', label: 'Open Sheet' }], + drawer: [{ type: 'button', label: 'Open Drawer' }], + popover: [{ type: 'button', label: 'Open Popover', variant: 'outline' }], + 'hover-card': [{ type: 'button', label: 'Hover me', variant: 'link' }], + 'dropdown-menu': [{ type: 'button', label: 'Menu', variant: 'outline' }], + tooltip: [{ type: 'button', label: 'Hover me', variant: 'outline' }], + 'context-menu': [{ type: 'text', content: 'Right click here' }], +}; + +// The card's complaint, as a compile: the renderer's own shipped default, +// authored into a typed document. Red on the pre-#7081 declarations. +export const shippedDialog: DialogSchema = { type: 'dialog', trigger: SHIPPED.dialog }; +export const shippedAlertDialog: AlertDialogSchema = { type: 'alert-dialog', trigger: SHIPPED['alert-dialog'] }; +export const shippedSheet: SheetSchema = { type: 'sheet', trigger: SHIPPED.sheet }; +export const shippedDrawer: DrawerSchema = { type: 'drawer', trigger: SHIPPED.drawer }; +export const shippedPopover: PopoverSchema = { type: 'popover', trigger: SHIPPED.popover, content: [] }; +export const shippedHoverCard: HoverCardSchema = { type: 'hover-card', trigger: SHIPPED['hover-card'], content: [] }; +export const shippedDropdownMenu: DropdownMenuSchema = { + type: 'dropdown-menu', + trigger: SHIPPED['dropdown-menu'], + items: [], +}; + +// A widening, not a replacement: every singular `trigger` keeps type-checking. +const SINGLE = { type: 'button', label: 'Open' }; +export const singleDialog: DialogSchema = { type: 'dialog', trigger: SINGLE }; +export const singleAlertDialog: AlertDialogSchema = { type: 'alert-dialog', trigger: SINGLE }; +export const singleSheet: SheetSchema = { type: 'sheet', trigger: SINGLE }; +export const singleDrawer: DrawerSchema = { type: 'drawer', trigger: SINGLE }; +export const singlePopover: PopoverSchema = { type: 'popover', trigger: SINGLE, content: [] }; +export const singleHoverCard: HoverCardSchema = { type: 'hover-card', trigger: SINGLE, content: [] }; +export const singleDropdownMenu: DropdownMenuSchema = { type: 'dropdown-menu', trigger: SINGLE, items: [] }; + +/* -- Readers (the objectui#7082 shape) -- */ + +interface Member { + readonly optional: boolean; + readonly typeText: string; +} + +function schemaFence(doc: string, path: string): string { + const fences = [...doc.matchAll(/```plaintext\n([\s\S]*?)```/g)].map((match) => match[1]); + if (fences.length !== 1) throw new Error(`expected exactly one plaintext fence in ${path}, found ${fences.length}`); + return fences[0]; +} + +function interfaceBody(source: string, opener: string, path: string): string { + const start = source.indexOf(opener); + if (start === -1) throw new Error(`no \`${opener}\` block in ${path}`); + const end = source.indexOf('\n}', start); + if (end === -1) throw new Error(`unterminated \`${opener}\` block in ${path}`); + return source.slice(start + opener.length, end); +} + +function members(body: string): Map { + const bare = body.replace(/\/\*\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + const found = new Map(); + for (const match of bare.matchAll(/^ {2}(\w+)(\?)?:\s*([^;]+);/gm)) { + found.set(match[1], { optional: match[2] === '?', typeText: match[3].trim() }); + } + return found; +} + +/** + * The `trigger: [...]` literal inside a registration's `defaultProps`, as the + * VALUE it denotes. The renderers write these as plain JS object literals with + * bare keys and single-quoted strings; that grammar is converted to JSON here, + * and anything outside it THROWS rather than parsing to something else. + */ +function shippedTrigger(rendererSource: string, path: string): unknown { + const at = rendererSource.indexOf('defaultProps:'); + if (at === -1) throw new Error(`no \`defaultProps:\` in ${path}`); + const open = rendererSource.indexOf('trigger:', at); + if (open === -1) throw new Error(`no \`trigger:\` under \`defaultProps\` in ${path}`); + const start = rendererSource.indexOf('[', open); + let depth = 0; + let end = -1; + for (let i = start; i < rendererSource.length; i += 1) { + if (rendererSource[i] === '[') depth += 1; + if (rendererSource[i] === ']') { + depth -= 1; + if (depth === 0) { + end = i; + break; + } + } + } + if (start === -1 || end === -1) throw new Error(`\`defaultProps.trigger\` in ${path} is not an array literal`); + const literal = rendererSource.slice(start, end + 1); + if (!/^\[\s*\{(\s*\w+:\s*'[^'":]*'\s*,?)+\s*\}\s*\]$/.test(literal)) { + throw new Error(`\`defaultProps.trigger\` in ${path} is outside the literal grammar this reader converts: ${literal}`); + } + return JSON.parse(literal.replace(/(\w+):/g, '"$1":').replace(/'/g, '"')); +} + +/* -- The family -- */ + +interface Member7081 { + readonly name: string; + readonly type: keyof typeof SHIPPED; + readonly zod: { safeParse: (value: unknown) => { success: boolean; data?: any; error?: any } }; + readonly optional: boolean; + /** The other REQUIRED keys of the mirror, so a parse can fail only on `trigger`. */ + readonly rest: Record; +} + +const NODE = { type: 'button', label: 'Open' }; +const NODES = [NODE, { type: 'text', content: 'and another' }]; + +const WIDENED: readonly Member7081[] = [ + { name: 'DialogSchema', type: 'dialog', zod: DialogZod, optional: true, rest: {} }, + { name: 'AlertDialogSchema', type: 'alert-dialog', zod: AlertDialogZod, optional: true, rest: {} }, + { name: 'SheetSchema', type: 'sheet', zod: SheetZod, optional: true, rest: {} }, + { name: 'DrawerSchema', type: 'drawer', zod: DrawerZod, optional: true, rest: {} }, + { name: 'PopoverSchema', type: 'popover', zod: PopoverZod, optional: false, rest: { content: [] } }, + { name: 'HoverCardSchema', type: 'hover-card', zod: HoverCardZod, optional: false, rest: { content: [] } }, + { name: 'DropdownMenuSchema', type: 'dropdown-menu', zod: DropdownMenuZod, optional: false, rest: { items: [] } }, +]; + +/** The two that already declared the union: same legs, so the widened seven are held to the settled shape. */ +const CONTROLS: readonly Member7081[] = [ + { name: 'TooltipSchema', type: 'tooltip', zod: TooltipZod, optional: true, rest: {} }, + { name: 'ContextMenuSchema', type: 'context-menu', zod: ContextMenuZod, optional: true, rest: { items: [] } }, +]; + +const FAMILY = [...WIDENED, ...CONTROLS]; + +describe.each(FAMILY.map((member) => [member.name, member] as const))('%s.trigger admits a node or a node array on every face (objectui#7081)', (_title, { name, type, zod, optional, rest }) => { + it('the declaration spells the union, optionality kept', () => { + const row = members(interfaceBody(read(DECLARATION), `export interface ${name} extends BaseSchema {`, DECLARATION)).get('trigger'); + expect(row).toEqual({ optional, typeText: 'SchemaNode | SchemaNode[]' }); + }); + + it('the mirror parses a SINGLE node and hands it back', () => { + const result = zod.safeParse({ type, ...rest, trigger: NODE }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.trigger).toEqual(NODE); + }); + + it('the mirror parses a node ARRAY and hands it back', () => { + const result = zod.safeParse({ type, ...rest, trigger: NODES }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.trigger).toEqual(NODES); + }); + + it('the mirror refuses a non-node AT the `trigger` path -- the parse is examining the key', () => { + // `SchemaNodeSchema` is `BaseSchemaCore | primitive`, and `BaseSchemaCore` + // requires `type`: an object without one is not a node, in either form. + for (const wrong of [{ label: 'a node without a type' }, [{ label: 'a node without a type' }]]) { + const result = zod.safeParse({ type, ...rest, trigger: wrong }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues.map((issue: { path: unknown[] }) => String(issue.path[0]))).toContain('trigger'); + } + }); + + it('the registration ships `defaultProps.trigger` as an ARRAY, equal to the typed copy, and the mirror parses it', () => { + const path = `${RENDERERS}/${type}.tsx`; + const shipped = shippedTrigger(read(path), path); + expect(Array.isArray(shipped)).toBe(true); + expect(shipped).toEqual(SHIPPED[type]); + expect(zod.safeParse({ type, ...rest, trigger: shipped }).success).toBe(true); + }); + + it('the renderer hands `schema.trigger` to `renderChildren` -- the read site the docblock names', () => { + expect(read(`${RENDERERS}/${type}.tsx`)).toMatch(/renderChildren\(schema\.trigger/); + }); + + it('the docs page publishes the union', () => { + const path = `${DOCS}/${type}.mdx`; + const row = members(interfaceBody(schemaFence(read(path), path), `interface ${name} {`, path)).get('trigger'); + expect(row?.typeText).toBe('SchemaNode | SchemaNode[]'); + }); +}); + +describe('what did NOT move (objectui#7081)', () => { + it('`renderChildren` still has the `Array.isArray` branch every docblock points at', () => { + const utils = read(RENDER_CHILDREN); + expect(utils).toContain('export function renderChildren('); + expect(utils).toMatch(/if \(Array\.isArray\(children\)\)/); + }); + + it('the mirror spells the union on all nine overlay `trigger` members -- a census, so a tenth or a ninth cannot slip in or out unnoticed', () => { + const spelled = read(MIRROR).match(/^\s*trigger: z\.union\(\[SchemaNodeSchema, z\.array\(SchemaNodeSchema\)\]\)/gm) ?? []; + expect(spelled).toHaveLength(FAMILY.length); + }); + + it('no overlay declaration spells `trigger` as a bare `SchemaNode` any more', () => { + expect(read(DECLARATION)).not.toMatch(/^\s*trigger\??: SchemaNode;/m); + // Control: the scan can find the widened spelling, nine times. + expect(read(DECLARATION).match(/^\s*trigger\??: SchemaNode \| SchemaNode\[\];/gm)).toHaveLength(FAMILY.length); + }); + + it('every widened docblock names its read site and the array branch', () => { + const source = read(DECLARATION); + for (const { type } of WIDENED) { + expect(source, type).toContain(`packages/components/src/renderers/overlay/${type}.tsx:`); + } + expect(source.match(/`Array\.isArray` branch/g)).toHaveLength(WIDENED.length); + }); +}); + +describe('counter-probes: the readers above can still fail (objectui#7081)', () => { + it('`shippedTrigger` throws on a literal outside its grammar rather than parsing it to something else', () => { + expect(() => shippedTrigger("defaultProps: { trigger: [{ type: 'button', label: \"double\" }] }", 'x.tsx')).toThrow( + /outside the literal grammar/, + ); + expect(() => shippedTrigger("defaultProps: { trigger: { type: 'button' } }", 'x.tsx')).toThrow(/not an array literal/); + expect(() => shippedTrigger('inputs: []', 'x.tsx')).toThrow(/no `defaultProps:`/); + }); + + it('`shippedTrigger` reads a nested literal whole', () => { + expect(shippedTrigger("defaultProps: {\n trigger: [{ type: 'button', label: 'Open Alert', variant: 'destructive' }],\n}", 'x.tsx')).toEqual( + SHIPPED['alert-dialog'], + ); + }); + + it('the pre-#7081 spelling would be caught on every widened member', () => { + const regressed = members(' trigger: SchemaNode;'); + expect(regressed.get('trigger')).toEqual({ optional: false, typeText: 'SchemaNode' }); + expect(regressed.get('trigger')?.typeText).not.toBe('SchemaNode | SchemaNode[]'); + }); + + it('the extractors really parsed rows, not empty match sets', () => { + for (const { name } of FAMILY) { + expect(members(interfaceBody(read(DECLARATION), `export interface ${name} extends BaseSchema {`, DECLARATION)).size).toBeGreaterThan(1); + } + }); +}); diff --git a/packages/types/src/overlay.ts b/packages/types/src/overlay.ts index 9df1af15e..e55ec1c0d 100644 --- a/packages/types/src/overlay.ts +++ b/packages/types/src/overlay.ts @@ -46,8 +46,14 @@ export interface DialogSchema extends BaseSchema { content?: SchemaNode | SchemaNode[]; /** * Dialog trigger (button or element that opens the dialog) + * + * READ SITE: `packages/components/src/renderers/overlay/dialog.tsx:26` — + * `renderChildren(schema.trigger)` inside `DialogTrigger`, whose + * `Array.isArray` branch (`packages/components/src/lib/utils.tsx:23`) + * serves the array form; the same spelling {@link ContextMenuSchema.trigger} + * declares (objectui#7081). */ - trigger?: SchemaNode; + trigger?: SchemaNode | SchemaNode[]; /** * Default open state * @default false @@ -101,8 +107,14 @@ export interface AlertDialogSchema extends BaseSchema { content?: SchemaNode | SchemaNode[]; /** * Dialog trigger + * + * READ SITE: `packages/components/src/renderers/overlay/alert-dialog.tsx:28` — + * `renderChildren(schema.trigger)` inside `AlertDialogTrigger`, whose + * `Array.isArray` branch (`packages/components/src/lib/utils.tsx:23`) + * serves the array form; the same spelling {@link ContextMenuSchema.trigger} + * declares (objectui#7081). */ - trigger?: SchemaNode; + trigger?: SchemaNode | SchemaNode[]; /** * Default open state * @default false @@ -205,8 +217,14 @@ export interface SheetSchema extends BaseSchema { content?: SchemaNode | SchemaNode[]; /** * Sheet trigger + * + * READ SITE: `packages/components/src/renderers/overlay/sheet.tsx:26` — + * `renderChildren(schema.trigger)` inside `SheetTrigger`, whose + * `Array.isArray` branch (`packages/components/src/lib/utils.tsx:23`) + * serves the array form; the same spelling {@link ContextMenuSchema.trigger} + * declares (objectui#7081). */ - trigger?: SchemaNode; + trigger?: SchemaNode | SchemaNode[]; /** * Default open state * @default false @@ -255,8 +273,14 @@ export interface DrawerSchema extends BaseSchema { content?: SchemaNode | SchemaNode[]; /** * Drawer trigger + * + * READ SITE: `packages/components/src/renderers/overlay/drawer.tsx:27` — + * `renderChildren(schema.trigger)` inside `DrawerTrigger`, whose + * `Array.isArray` branch (`packages/components/src/lib/utils.tsx:23`) + * serves the array form; the same spelling {@link ContextMenuSchema.trigger} + * declares (objectui#7081). */ - trigger?: SchemaNode; + trigger?: SchemaNode | SchemaNode[]; /** * Default open state * @default false @@ -293,8 +317,14 @@ export interface PopoverSchema extends BaseSchema { content: SchemaNode | SchemaNode[]; /** * Popover trigger + * + * READ SITE: `packages/components/src/renderers/overlay/popover.tsx:22` — + * `renderChildren(schema.trigger)` inside `PopoverTrigger`, whose + * `Array.isArray` branch (`packages/components/src/lib/utils.tsx:23`) + * serves the array form; the same spelling {@link ContextMenuSchema.trigger} + * declares (objectui#7081). */ - trigger: SchemaNode; + trigger: SchemaNode | SchemaNode[]; /** * Default open state * @default false @@ -395,8 +425,14 @@ export interface HoverCardSchema extends BaseSchema { content: SchemaNode | SchemaNode[]; /** * Hover trigger element + * + * READ SITE: `packages/components/src/renderers/overlay/hover-card.tsx:22` — + * `renderChildren(schema.trigger)` inside `HoverCardTrigger`, whose + * `Array.isArray` branch (`packages/components/src/lib/utils.tsx:23`) + * serves the array form; the same spelling {@link ContextMenuSchema.trigger} + * declares (objectui#7081). */ - trigger: SchemaNode; + trigger: SchemaNode | SchemaNode[]; /** * Default open state * @default false @@ -543,8 +579,14 @@ export interface DropdownMenuSchema extends BaseSchema { items: MenuItem[]; /** * Menu trigger + * + * READ SITE: `packages/components/src/renderers/overlay/dropdown-menu.tsx:97` — + * `renderChildren(schema.trigger)` inside `DropdownMenuTrigger`, whose + * `Array.isArray` branch (`packages/components/src/lib/utils.tsx:23`) + * serves the array form; the same spelling {@link ContextMenuSchema.trigger} + * declares (objectui#7081). */ - trigger: SchemaNode; + trigger: SchemaNode | SchemaNode[]; /** * Default open state * @default false