Skip to content

Commit 0ee619a

Browse files
committed
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 <noreply@anthropic.com>
1 parent 5988b6b commit 0ee619a

6 files changed

Lines changed: 227 additions & 11 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/components': minor
3+
'@object-ui/types': minor
4+
---
5+
6+
`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.
7+
8+
**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.
9+
10+
**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`.
11+
12+
**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.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#11365 — `input-otp` draws the separator its docs page and two
11+
* catalog entries author.
12+
*
13+
* The docs page's "With Separator" section shows
14+
* `components-form-input-otp/with-visual-separator`, and
15+
* `components-form-input-otp/verification-form` authors the same key on an
16+
* `input-otp` nested in a `flex` node's `properties` bag. Before this card no
17+
* type declared `separator` on `InputOTPSchema` and the renderer read nothing
18+
* for it, so both entries rendered ZERO `role="separator"` elements through the
19+
* real `SchemaRenderer`. Triage ruled the enforce arm (ADR-0049): declare the
20+
* key on both faces and draw the primitive's `InputOTPSeparator`.
21+
*
22+
* ## What each block pins
23+
*
24+
* - The render block counts the `role="separator"` elements the renderer
25+
* actually emitted, on the two authoring entries and on the entry that does
26+
* NOT author the key (the control: without `separator` nothing changes).
27+
* - The parse block asks both faces. `safeValidateSchema` is `.passthrough()`,
28+
* so a green there says nothing about an UNDECLARED key; the strict authoring
29+
* face closes unknown keys, so a green there is the declaration's evidence.
30+
* The value probe (a non-boolean `separator`) is what proves the declared
31+
* member judges its value rather than merely being tolerated.
32+
*/
33+
import { describe, it, expect } from 'vitest';
34+
import { render } from '@testing-library/react';
35+
// Module scope, not a hook: registering the renderers is an unbounded module
36+
// load (AGENTS.md, test discipline — flaky tests: find the race).
37+
import '@object-ui/components';
38+
import { SchemaRenderer, toRenderableSchema } from '@object-ui/react';
39+
import { safeValidateSchema, StrictAnyComponentSchema } from '@object-ui/types/zod';
40+
import { getExample } from '../src/index.js';
41+
42+
const AUTHORING = [
43+
'components-form-input-otp/with-visual-separator',
44+
'components-form-input-otp/verification-form',
45+
] as const;
46+
47+
/** Every issue as `code path: message`, so a red run says what broke. */
48+
function issuesOf(result: { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[]; message: string }> } }): string[] {
49+
return result.success || !result.error
50+
? []
51+
: result.error.issues.map((i) => `${i.code} ${i.path.map(String).join('.')}: ${i.message}`);
52+
}
53+
54+
function renderNode(schema: unknown): HTMLElement {
55+
const { container } = render(<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />);
56+
return container;
57+
}
58+
59+
/**
60+
* The slot count of each `InputOTPGroup`, in document order. The container's
61+
* other children are the separator and the library's own wrapper around the
62+
* hidden `<input>`, which is not a group.
63+
*/
64+
function groupSizes(container: HTMLElement): number[] {
65+
const host = container.querySelector('[data-input-otp-container]');
66+
expect(host, 'no input-otp container rendered: nothing below would be a reading').not.toBeNull();
67+
return Array.from((host as Element).children)
68+
.filter((el) => el.getAttribute('role') !== 'separator' && !el.querySelector('input'))
69+
.map((group) => group.children.length);
70+
}
71+
72+
describe('objectui#11365 — the authored `separator` draws one InputOTPSeparator', () => {
73+
it.each(AUTHORING)('%s renders exactly one role="separator"', (id) => {
74+
const container = renderNode(getExample(id).schema);
75+
expect(container.querySelector('[data-input-otp-container]'), 'no input-otp rendered').not.toBeNull();
76+
expect(container.querySelectorAll('[role="separator"]')).toHaveLength(1);
77+
});
78+
79+
it('the separator splits the six slots at the midpoint, between two groups', () => {
80+
const container = renderNode(getExample('components-form-input-otp/with-visual-separator').schema);
81+
expect(groupSizes(container)).toEqual([3, 3]);
82+
});
83+
84+
it('the control: the entry without `separator` draws none, in one group', () => {
85+
const container = renderNode(getExample('components-form-input-otp/6-digit-otp').schema);
86+
expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
87+
expect(groupSizes(container)).toEqual([6]);
88+
});
89+
90+
it('`separator: false` renders the same markup as no `separator` at all', () => {
91+
const absent = renderNode({ type: 'input-otp', length: 6 }).innerHTML;
92+
const off = renderNode({ type: 'input-otp', length: 6, separator: false }).innerHTML;
93+
expect(off).toBe(absent);
94+
});
95+
});
96+
97+
describe('objectui#11365 — both authoring entries validate, on both faces', () => {
98+
it.each(AUTHORING)('%s validates under safeValidateSchema', (id) => {
99+
expect(issuesOf(safeValidateSchema(getExample(id).schema))).toEqual([]);
100+
});
101+
102+
it.each(AUTHORING)('%s validates under the strict authoring face (no unrecognized `separator`)', (id) => {
103+
expect(issuesOf(StrictAnyComponentSchema.safeParse(getExample(id).schema))).toEqual([]);
104+
});
105+
106+
it('a non-boolean `separator` is refused by value, at its path, in a bag child too', () => {
107+
// Counter-probe: a passthrough hole would accept this; a declared boolean
108+
// member refuses it with `invalid_type` at the key.
109+
const flat = safeValidateSchema({ type: 'input-otp', separator: 'yes' });
110+
expect(issuesOf(flat).map((s) => s.split(':')[0])).toEqual(['invalid_type separator']);
111+
112+
const nested = safeValidateSchema({
113+
type: 'flex',
114+
properties: { children: [{ type: 'input-otp', separator: 'yes' }] },
115+
});
116+
expect(nested.success).toBe(false);
117+
expect(issuesOf(nested).map((s) => s.split(':')[0])).toEqual(['invalid_type properties.children.0.separator']);
118+
});
119+
});

‎packages/components/src/renderers/form/input-otp.tsx‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import { ComponentRegistry } from '@object-ui/core';
1010
import type { InputOTPSchema } from '@object-ui/types';
11-
import { InputOTP, InputOTPGroup, InputOTPSlot } from '../../ui';
11+
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '../../ui';
1212
import { toFormControlDomProps } from '../../lib/form-control-dom-props';
1313

1414
ComponentRegistry.register('input-otp',
@@ -22,7 +22,15 @@ ComponentRegistry.register('input-otp',
2222
// published 4- and 8-digit examples drew six slots (objectui#11347). One
2323
// spelling, no alias.
2424
const length = schema.length || 6;
25-
const slots = Array.from({ length });
25+
// `separator` (`InputOTPSchema.separator`, objectui#11365) splits the slots
26+
// into two groups at the midpoint and draws the primitive's separator
27+
// between them, the shadcn "with separator" layout. An odd `length` puts
28+
// the extra slot in the first group; a single slot has nothing to separate.
29+
// Absent or `false`, `split === length` and the markup is the one group
30+
// it always was.
31+
const split = schema.separator === true && length > 1 ? Math.ceil(length / 2) : length;
32+
const slotsFrom = (from: number, to: number) =>
33+
Array.from({ length: to - from }, (_, k) => <InputOTPSlot key={from + k} index={from + k} />);
2634

2735
const handleChange = (val: string) => {
2836
if (onChange) {
@@ -39,11 +47,13 @@ ComponentRegistry.register('input-otp',
3947
{...toFormControlDomProps(otpProps)}
4048
style={style}
4149
>
42-
<InputOTPGroup>
43-
{slots.map((_, i) => (
44-
<InputOTPSlot key={i} index={i} />
45-
))}
46-
</InputOTPGroup>
50+
<InputOTPGroup>{slotsFrom(0, split)}</InputOTPGroup>
51+
{split < length && (
52+
<>
53+
<InputOTPSeparator />
54+
<InputOTPGroup>{slotsFrom(split, length)}</InputOTPGroup>
55+
</>
56+
)}
4757
</InputOTP>
4858
);
4959
},
@@ -52,6 +62,7 @@ ComponentRegistry.register('input-otp',
5262
label: 'Input OTP',
5363
inputs: [
5464
{ name: 'length', type: 'number' },
65+
{ name: 'separator', type: 'boolean' },
5566
{ name: 'className', type: 'string' }
5667
],
5768
defaultProps: {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#11365 — `InputOTPSchema.separator` is declared on BOTH faces, as a
11+
* boolean (triage's enforce arm under ADR-0049).
12+
*
13+
* The TypeScript face carries `BaseSchema`'s `[key: string]: any`, so an
14+
* undeclared `separator` already compiled whatever its value; the declared
15+
* member is what makes a non-boolean refused. The compile-time rows below are
16+
* checked by `tsc -p tsconfig.test.json`; the `describe` block reads the zod
17+
* mirror at runtime, value probe and control included. The rendering half
18+
* (one `role="separator"`, and none without the key) is pinned beside the
19+
* catalog entries in `examples/schema-catalog/test/input-otp-separator-11365.test.tsx`.
20+
*/
21+
22+
import { describe, it, expect } from 'vitest';
23+
import { InputOTPSchema } from '../zod/form.zod';
24+
import type { InputOTPSchema as TsInputOTPSchema } from '../form';
25+
26+
/** Compile-time truth assertion, erased at runtime — only `tsc` checks these. */
27+
type Expect<T extends true> = T;
28+
/** Compile-time equality, exact in both directions; `any` equals nothing but `any`. */
29+
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
30+
31+
export type _SeparatorIsABoolean = Expect<Equal<TsInputOTPSchema['separator'], boolean | undefined>>;
32+
33+
/** CONTROL — the declared spelling compiles. */
34+
export const _separatorAccepted: TsInputOTPSchema = { type: 'input-otp', length: 6, separator: true };
35+
// @ts-expect-error -- `separator` is a declared boolean, so the index signature no longer admits a string
36+
export const _separatorRefused: TsInputOTPSchema = { type: 'input-otp', length: 6, separator: 'yes' };
37+
38+
describe('InputOTPSchema.separator on the zod mirror (objectui#11365)', () => {
39+
it('a boolean `separator` parses and survives', () => {
40+
const result = InputOTPSchema.safeParse({ type: 'input-otp', separator: true });
41+
expect(result.success).toBe(true);
42+
expect(result.success && result.data.separator).toBe(true);
43+
});
44+
45+
it('a non-boolean `separator` is refused with `invalid_type` at the key', () => {
46+
const result = InputOTPSchema.safeParse({ type: 'input-otp', separator: 'yes' });
47+
expect(result.success).toBe(false);
48+
const found = result.success ? [] : result.error.issues.map((i) => [i.code, i.path.join('.')]);
49+
expect(found).toEqual([['invalid_type', 'separator']]);
50+
});
51+
52+
it('CONTROL — the same probe on an undeclared key passes through, so the refusal above is the declaration', () => {
53+
expect(InputOTPSchema.safeParse({ type: 'input-otp', notAKey11365: 'yes' }).success).toBe(true);
54+
});
55+
});

‎packages/types/src/form.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1453,6 +1453,19 @@ export interface InputOTPSchema extends BaseSchema {
14531453
* @default 6
14541454
*/
14551455
length?: number;
1456+
/**
1457+
* Draw a visual separator between two halves of the slots
1458+
*
1459+
* READ SITE: `packages/components/src/renderers/form/input-otp.tsx`. `true`
1460+
* splits the {@link InputOTPSchema.length} slots into two groups at the
1461+
* midpoint and draws one separator (`role="separator"`) between them; an odd
1462+
* `length` puts the extra slot in the first group, and a single slot has
1463+
* nothing to separate. Absent or `false` draws one group. Declared by
1464+
* objectui#11365: the docs page and two catalog entries authored it while no
1465+
* type declared it and the renderer read nothing for it.
1466+
* @default false
1467+
*/
1468+
separator?: boolean;
14561469
/**
14571470
* Default value
14581471
*/
@@ -1501,7 +1514,8 @@ export interface InputOTPSchema extends BaseSchema {
15011514
* `maxLength`, `value` (in
15021515
* `packages/components/src/renderers/form/input-otp.tsx`). The undeclared
15031516
* `maxLength` read has since moved to the declared {@link InputOTPSchema.length}
1504-
* (objectui#11347).
1517+
* (objectui#11347), and objectui#11365 added a read of the declared
1518+
* {@link InputOTPSchema.separator}.
15051519
*
15061520
* Before objectui#9256 tombstoned them here, `body` and `children` were both
15071521
* inherited-and-optional from {@link BaseSchema} — so authoring either here
@@ -1530,7 +1544,8 @@ export interface InputOTPSchema extends BaseSchema {
15301544
* `maxLength`, `value` (in
15311545
* `packages/components/src/renderers/form/input-otp.tsx`). The undeclared
15321546
* `maxLength` read has since moved to the declared {@link InputOTPSchema.length}
1533-
* (objectui#11347).
1547+
* (objectui#11347), and objectui#11365 added a read of the declared
1548+
* {@link InputOTPSchema.separator}.
15341549
*
15351550
* Before objectui#9256 tombstoned them here, `body` and `children` were both
15361551
* inherited-and-optional from {@link BaseSchema} — so authoring either here

‎packages/types/src/zod/form.zod.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,10 @@ export const InputOTPSchema = BaseSchema.extend({
748748
name: z.string().optional().describe('Field name for form submission'),
749749
label: z.string().optional().describe('OTP input label'),
750750
length: z.number().optional().describe('Number of OTP digits'),
751+
separator: z
752+
.boolean()
753+
.optional()
754+
.describe('Draw a visual separator between two halves of the slots (objectui#11365)'),
751755
defaultValue: z.string().optional().describe('Default value'),
752756
value: z.string().optional().describe('Controlled value'),
753757
description: z.string().optional().describe('Help text'),
@@ -759,14 +763,14 @@ export const InputOTPSchema = BaseSchema.extend({
759763
+ 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or '
760764
+ '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An '
761765
+ '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. '
762-
+ 'What it renders instead: `length`, `value`.',
766+
+ 'What it renders instead: `length`, `separator`, `value`.',
763767
),
764768
children: retirementTombstone(
765769
'REFUSED (objectui#9256, ADR-0049) — `input-otp` reads NEITHER content channel: measured with the '
766770
+ 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or '
767771
+ '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An '
768772
+ '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. '
769-
+ 'What it renders instead: `length`, `value`.',
773+
+ 'What it renders instead: `length`, `separator`, `value`.',
770774
),
771775
});
772776

0 commit comments

Comments
 (0)