From 0ee619a8231ce80e623cfda20bfdb117ea141206 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:04:38 +0000 Subject: [PATCH 1/3] feat(types,components): declare `separator` on InputOTPSchema and draw it (objectui#11365) The docs page's "With Separator" example and two catalog entries author `separator: true` on `input-otp`, but no type declared it and the renderer read nothing for it: both entries rendered zero `role="separator"` elements through the real SchemaRenderer. Triage ruled the enforce arm (ADR-0049). - `InputOTPSchema.separator?: boolean` on the TS face and the zod mirror. - The renderer splits the slots at the midpoint (odd length: the extra slot goes first) and draws one InputOTPSeparator between two groups. Absent or false keeps the one-group markup. - The registration publishes `separator` as a boolean input. - Pins: the catalog render/parse pin and the types face pin. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .changeset/11365-input-otp-separator.md | 12 ++ .../test/input-otp-separator-11365.test.tsx | 119 ++++++++++++++++++ .../src/renderers/form/input-otp.tsx | 25 ++-- .../input-otp-separator-11365.test.ts | 55 ++++++++ packages/types/src/form.ts | 19 ++- packages/types/src/zod/form.zod.ts | 8 +- 6 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 .changeset/11365-input-otp-separator.md create mode 100644 examples/schema-catalog/test/input-otp-separator-11365.test.tsx create mode 100644 packages/types/src/__tests__/input-otp-separator-11365.test.ts diff --git a/.changeset/11365-input-otp-separator.md b/.changeset/11365-input-otp-separator.md new file mode 100644 index 0000000000..0f2ede8930 --- /dev/null +++ b/.changeset/11365-input-otp-separator.md @@ -0,0 +1,12 @@ +--- +'@object-ui/components': minor +'@object-ui/types': minor +--- + +`input-otp` draws the separator its docs page teaches (objectui#11365). The "With Separator" example on the `input-otp` docs page and two catalog entries (`with-visual-separator`, and the `input-otp` inside `verification-form`) author `separator: true`, but no type declared the key and the renderer read nothing for it, so they drew no separator at all. Triage ruled the enforce arm of ADR-0049: the shipped primitive already exports `InputOTPSeparator`, so the key is declared and drawn, and the docs page and catalog entries stay as they are. + +**Components.** With `separator: true`, the `input-otp` renderer splits its `length` slots into two groups at the midpoint and draws one `InputOTPSeparator` (`role="separator"`) between them, the shadcn "with separator" layout. An odd `length` puts the extra slot in the first group (5 slots draw as 3 and 2). One slot has nothing to separate and draws no separator. Without `separator`, or with `separator: false`, the markup is unchanged: one group holding every slot. The `input-otp` registration publishes `separator` as a boolean input. + +**Types.** `InputOTPSchema` gains `separator?: boolean` on both faces. The TypeScript face now refuses a non-boolean `separator`; before, `BaseSchema`'s index signature admitted any value. The zod mirror refuses a non-boolean `separator` with `invalid_type` at the key; before, `.passthrough()` kept it unjudged. The strict authoring face now accepts `separator`, where it used to report it as an unrecognized key. The "What it renders instead" list in the node's `body` / `children` refusal messages now names `separator`. + +**minor, not patch — the published face gains a member.** `separator` is a new member of the shipped `.d.ts` and of the mirror's `.shape`, and the mirror refuses a non-boolean value by name. diff --git a/examples/schema-catalog/test/input-otp-separator-11365.test.tsx b/examples/schema-catalog/test/input-otp-separator-11365.test.tsx new file mode 100644 index 0000000000..23f72bf87d --- /dev/null +++ b/examples/schema-catalog/test/input-otp-separator-11365.test.tsx @@ -0,0 +1,119 @@ +/** + * 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. + */ + +/** + * objectui#11365 — `input-otp` draws the separator its docs page and two + * catalog entries author. + * + * The docs page's "With Separator" section shows + * `components-form-input-otp/with-visual-separator`, and + * `components-form-input-otp/verification-form` authors the same key on an + * `input-otp` nested in a `flex` node's `properties` bag. Before this card no + * type declared `separator` on `InputOTPSchema` and the renderer read nothing + * for it, so both entries rendered ZERO `role="separator"` elements through the + * real `SchemaRenderer`. Triage ruled the enforce arm (ADR-0049): declare the + * key on both faces and draw the primitive's `InputOTPSeparator`. + * + * ## What each block pins + * + * - The render block counts the `role="separator"` elements the renderer + * actually emitted, on the two authoring entries and on the entry that does + * NOT author the key (the control: without `separator` nothing changes). + * - The parse block asks both faces. `safeValidateSchema` is `.passthrough()`, + * so a green there says nothing about an UNDECLARED key; the strict authoring + * face closes unknown keys, so a green there is the declaration's evidence. + * The value probe (a non-boolean `separator`) is what proves the declared + * member judges its value rather than merely being tolerated. + */ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +// Module scope, not a hook: registering the renderers is an unbounded module +// load (AGENTS.md, test discipline — flaky tests: find the race). +import '@object-ui/components'; +import { SchemaRenderer, toRenderableSchema } from '@object-ui/react'; +import { safeValidateSchema, StrictAnyComponentSchema } from '@object-ui/types/zod'; +import { getExample } from '../src/index.js'; + +const AUTHORING = [ + 'components-form-input-otp/with-visual-separator', + 'components-form-input-otp/verification-form', +] as const; + +/** Every issue as `code path: message`, so a red run says what broke. */ +function issuesOf(result: { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[]; message: string }> } }): string[] { + return result.success || !result.error + ? [] + : result.error.issues.map((i) => `${i.code} ${i.path.map(String).join('.')}: ${i.message}`); +} + +function renderNode(schema: unknown): HTMLElement { + const { container } = render(); + return container; +} + +/** + * The slot count of each `InputOTPGroup`, in document order. The container's + * other children are the separator and the library's own wrapper around the + * hidden ``, which is not a group. + */ +function groupSizes(container: HTMLElement): number[] { + const host = container.querySelector('[data-input-otp-container]'); + expect(host, 'no input-otp container rendered: nothing below would be a reading').not.toBeNull(); + return Array.from((host as Element).children) + .filter((el) => el.getAttribute('role') !== 'separator' && !el.querySelector('input')) + .map((group) => group.children.length); +} + +describe('objectui#11365 — the authored `separator` draws one InputOTPSeparator', () => { + it.each(AUTHORING)('%s renders exactly one role="separator"', (id) => { + const container = renderNode(getExample(id).schema); + expect(container.querySelector('[data-input-otp-container]'), 'no input-otp rendered').not.toBeNull(); + expect(container.querySelectorAll('[role="separator"]')).toHaveLength(1); + }); + + it('the separator splits the six slots at the midpoint, between two groups', () => { + const container = renderNode(getExample('components-form-input-otp/with-visual-separator').schema); + expect(groupSizes(container)).toEqual([3, 3]); + }); + + it('the control: the entry without `separator` draws none, in one group', () => { + const container = renderNode(getExample('components-form-input-otp/6-digit-otp').schema); + expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0); + expect(groupSizes(container)).toEqual([6]); + }); + + it('`separator: false` renders the same markup as no `separator` at all', () => { + const absent = renderNode({ type: 'input-otp', length: 6 }).innerHTML; + const off = renderNode({ type: 'input-otp', length: 6, separator: false }).innerHTML; + expect(off).toBe(absent); + }); +}); + +describe('objectui#11365 — both authoring entries validate, on both faces', () => { + it.each(AUTHORING)('%s validates under safeValidateSchema', (id) => { + expect(issuesOf(safeValidateSchema(getExample(id).schema))).toEqual([]); + }); + + it.each(AUTHORING)('%s validates under the strict authoring face (no unrecognized `separator`)', (id) => { + expect(issuesOf(StrictAnyComponentSchema.safeParse(getExample(id).schema))).toEqual([]); + }); + + it('a non-boolean `separator` is refused by value, at its path, in a bag child too', () => { + // Counter-probe: a passthrough hole would accept this; a declared boolean + // member refuses it with `invalid_type` at the key. + const flat = safeValidateSchema({ type: 'input-otp', separator: 'yes' }); + expect(issuesOf(flat).map((s) => s.split(':')[0])).toEqual(['invalid_type separator']); + + const nested = safeValidateSchema({ + type: 'flex', + properties: { children: [{ type: 'input-otp', separator: 'yes' }] }, + }); + expect(nested.success).toBe(false); + expect(issuesOf(nested).map((s) => s.split(':')[0])).toEqual(['invalid_type properties.children.0.separator']); + }); +}); diff --git a/packages/components/src/renderers/form/input-otp.tsx b/packages/components/src/renderers/form/input-otp.tsx index 96851d019f..34bcd41c22 100644 --- a/packages/components/src/renderers/form/input-otp.tsx +++ b/packages/components/src/renderers/form/input-otp.tsx @@ -8,7 +8,7 @@ import { ComponentRegistry } from '@object-ui/core'; import type { InputOTPSchema } from '@object-ui/types'; -import { InputOTP, InputOTPGroup, InputOTPSlot } from '../../ui'; +import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '../../ui'; import { toFormControlDomProps } from '../../lib/form-control-dom-props'; ComponentRegistry.register('input-otp', @@ -22,7 +22,15 @@ ComponentRegistry.register('input-otp', // 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 }); + // `separator` (`InputOTPSchema.separator`, objectui#11365) splits the slots + // into two groups at the midpoint and draws the primitive's separator + // between them, the shadcn "with separator" layout. An odd `length` puts + // the extra slot in the first group; a single slot has nothing to separate. + // Absent or `false`, `split === length` and the markup is the one group + // it always was. + const split = schema.separator === true && length > 1 ? Math.ceil(length / 2) : length; + const slotsFrom = (from: number, to: number) => + Array.from({ length: to - from }, (_, k) => ); const handleChange = (val: string) => { if (onChange) { @@ -39,11 +47,13 @@ ComponentRegistry.register('input-otp', {...toFormControlDomProps(otpProps)} style={style} > - - {slots.map((_, i) => ( - - ))} - + {slotsFrom(0, split)} + {split < length && ( + <> + + {slotsFrom(split, length)} + + )} ); }, @@ -52,6 +62,7 @@ ComponentRegistry.register('input-otp', label: 'Input OTP', inputs: [ { name: 'length', type: 'number' }, + { name: 'separator', type: 'boolean' }, { name: 'className', type: 'string' } ], defaultProps: { diff --git a/packages/types/src/__tests__/input-otp-separator-11365.test.ts b/packages/types/src/__tests__/input-otp-separator-11365.test.ts new file mode 100644 index 0000000000..9d63642647 --- /dev/null +++ b/packages/types/src/__tests__/input-otp-separator-11365.test.ts @@ -0,0 +1,55 @@ +/** + * 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. + */ + +/** + * objectui#11365 — `InputOTPSchema.separator` is declared on BOTH faces, as a + * boolean (triage's enforce arm under ADR-0049). + * + * The TypeScript face carries `BaseSchema`'s `[key: string]: any`, so an + * undeclared `separator` already compiled whatever its value; the declared + * member is what makes a non-boolean refused. The compile-time rows below are + * checked by `tsc -p tsconfig.test.json`; the `describe` block reads the zod + * mirror at runtime, value probe and control included. The rendering half + * (one `role="separator"`, and none without the key) is pinned beside the + * catalog entries in `examples/schema-catalog/test/input-otp-separator-11365.test.tsx`. + */ + +import { describe, it, expect } from 'vitest'; +import { InputOTPSchema } from '../zod/form.zod'; +import type { InputOTPSchema as TsInputOTPSchema } from '../form'; + +/** Compile-time truth assertion, erased at runtime — only `tsc` checks these. */ +type Expect = T; +/** Compile-time equality, exact in both directions; `any` equals nothing but `any`. */ +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; + +export type _SeparatorIsABoolean = Expect>; + +/** CONTROL — the declared spelling compiles. */ +export const _separatorAccepted: TsInputOTPSchema = { type: 'input-otp', length: 6, separator: true }; +// @ts-expect-error -- `separator` is a declared boolean, so the index signature no longer admits a string +export const _separatorRefused: TsInputOTPSchema = { type: 'input-otp', length: 6, separator: 'yes' }; + +describe('InputOTPSchema.separator on the zod mirror (objectui#11365)', () => { + it('a boolean `separator` parses and survives', () => { + const result = InputOTPSchema.safeParse({ type: 'input-otp', separator: true }); + expect(result.success).toBe(true); + expect(result.success && result.data.separator).toBe(true); + }); + + it('a non-boolean `separator` is refused with `invalid_type` at the key', () => { + const result = InputOTPSchema.safeParse({ type: 'input-otp', separator: 'yes' }); + expect(result.success).toBe(false); + const found = result.success ? [] : result.error.issues.map((i) => [i.code, i.path.join('.')]); + expect(found).toEqual([['invalid_type', 'separator']]); + }); + + it('CONTROL — the same probe on an undeclared key passes through, so the refusal above is the declaration', () => { + expect(InputOTPSchema.safeParse({ type: 'input-otp', notAKey11365: 'yes' }).success).toBe(true); + }); +}); diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 0108933915..0191a45581 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -1453,6 +1453,19 @@ export interface InputOTPSchema extends BaseSchema { * @default 6 */ length?: number; + /** + * Draw a visual separator between two halves of the slots + * + * READ SITE: `packages/components/src/renderers/form/input-otp.tsx`. `true` + * splits the {@link InputOTPSchema.length} slots into two groups at the + * midpoint and draws one separator (`role="separator"`) between them; an odd + * `length` puts the extra slot in the first group, and a single slot has + * nothing to separate. Absent or `false` draws one group. Declared by + * objectui#11365: the docs page and two catalog entries authored it while no + * type declared it and the renderer read nothing for it. + * @default false + */ + separator?: boolean; /** * Default value */ @@ -1501,7 +1514,8 @@ export interface InputOTPSchema extends BaseSchema { * `maxLength`, `value` (in * `packages/components/src/renderers/form/input-otp.tsx`). The undeclared * `maxLength` read has since moved to the declared {@link InputOTPSchema.length} - * (objectui#11347). + * (objectui#11347), and objectui#11365 added a read of the declared + * {@link InputOTPSchema.separator}. * * Before objectui#9256 tombstoned them here, `body` and `children` were both * inherited-and-optional from {@link BaseSchema} — so authoring either here @@ -1530,7 +1544,8 @@ export interface InputOTPSchema extends BaseSchema { * `maxLength`, `value` (in * `packages/components/src/renderers/form/input-otp.tsx`). The undeclared * `maxLength` read has since moved to the declared {@link InputOTPSchema.length} - * (objectui#11347). + * (objectui#11347), and objectui#11365 added a read of the declared + * {@link InputOTPSchema.separator}. * * 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/form.zod.ts b/packages/types/src/zod/form.zod.ts index 51309b7176..3e1b429d79 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -748,6 +748,10 @@ export const InputOTPSchema = BaseSchema.extend({ name: z.string().optional().describe('Field name for form submission'), label: z.string().optional().describe('OTP input label'), length: z.number().optional().describe('Number of OTP digits'), + separator: z + .boolean() + .optional() + .describe('Draw a visual separator between two halves of the slots (objectui#11365)'), defaultValue: z.string().optional().describe('Default value'), value: z.string().optional().describe('Controlled value'), description: z.string().optional().describe('Help text'), @@ -759,14 +763,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: `length`, `value`.', + + 'What it renders instead: `length`, `separator`, `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: `length`, `value`.', + + 'What it renders instead: `length`, `separator`, `value`.', ), }); From a5a64dd4a334d09499cdae8fa152529164e12b09 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:48:33 +0000 Subject: [PATCH 2/3] test(schema-catalog): pin the input-otp registration's `separator` input (objectui#11365) Without the boolean `separator` row in the `input-otp` registration's `inputs`, the html/jsx page tier's whitelist answers `unknown-prop` for a key the renderer honours (measured once with the row removed). Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../test/input-otp-separator-11365.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/examples/schema-catalog/test/input-otp-separator-11365.test.tsx b/examples/schema-catalog/test/input-otp-separator-11365.test.tsx index 23f72bf87d..4ce53adea0 100644 --- a/examples/schema-catalog/test/input-otp-separator-11365.test.tsx +++ b/examples/schema-catalog/test/input-otp-separator-11365.test.tsx @@ -35,6 +35,7 @@ import { render } from '@testing-library/react'; // Module scope, not a hook: registering the renderers is an unbounded module // load (AGENTS.md, test discipline — flaky tests: find the race). import '@object-ui/components'; +import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer, toRenderableSchema } from '@object-ui/react'; import { safeValidateSchema, StrictAnyComponentSchema } from '@object-ui/types/zod'; import { getExample } from '../src/index.js'; @@ -87,6 +88,16 @@ describe('objectui#11365 — the authored `separator` draws one InputOTPSeparato expect(groupSizes(container)).toEqual([6]); }); + it('the registration publishes `separator` as a boolean input', () => { + // `inputs` is the html/jsx page tier's prop whitelist: without this row, + // `separator` on an `input-otp` tag draws an `unknown-prop` warning on a + // key the renderer honours (measured once on objectui#11365's PR). + const inputs = ComponentRegistry.getMeta('input-otp')?.inputs ?? []; + expect(inputs.filter((i) => i.name === 'separator')).toEqual([ + expect.objectContaining({ name: 'separator', type: 'boolean' }), + ]); + }); + it('`separator: false` renders the same markup as no `separator` at all', () => { const absent = renderNode({ type: 'input-otp', length: 6 }).innerHTML; const off = renderNode({ type: 'input-otp', length: 6, separator: false }).innerHTML; From da4d38ef042f771adc918f817aeae62986945c27 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:50:21 +0000 Subject: [PATCH 3/3] test(schema-catalog): pin input-otp's odd-length and one-slot separator layout (objectui#11365) Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../test/input-otp-separator-11365.test.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/examples/schema-catalog/test/input-otp-separator-11365.test.tsx b/examples/schema-catalog/test/input-otp-separator-11365.test.tsx index 4ce53adea0..1ed1a3d218 100644 --- a/examples/schema-catalog/test/input-otp-separator-11365.test.tsx +++ b/examples/schema-catalog/test/input-otp-separator-11365.test.tsx @@ -82,6 +82,16 @@ describe('objectui#11365 — the authored `separator` draws one InputOTPSeparato expect(groupSizes(container)).toEqual([3, 3]); }); + it('an odd `length` puts the extra slot in the first group; one slot draws no separator', () => { + const five = renderNode({ type: 'input-otp', length: 5, separator: true }); + expect(groupSizes(five)).toEqual([3, 2]); + expect(five.querySelectorAll('[role="separator"]')).toHaveLength(1); + + const one = renderNode({ type: 'input-otp', length: 1, separator: true }); + expect(groupSizes(one)).toEqual([1]); + expect(one.querySelectorAll('[role="separator"]')).toHaveLength(0); + }); + it('the control: the entry without `separator` draws none, in one group', () => { const container = renderNode(getExample('components-form-input-otp/6-digit-otp').schema); expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);