Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/11365-input-otp-separator.md
Original file line number Diff line number Diff line change
@@ -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.
140 changes: 140 additions & 0 deletions examples/schema-catalog/test/input-otp-separator-11365.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* 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 { 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';

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(<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />);
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 `<input>`, 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('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);
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;
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']);
});
});
25 changes: 18 additions & 7 deletions packages/components/src/renderers/form/input-otp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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) => <InputOTPSlot key={from + k} index={from + k} />);

const handleChange = (val: string) => {
if (onChange) {
Expand All @@ -39,11 +47,13 @@ ComponentRegistry.register('input-otp',
{...toFormControlDomProps(otpProps)}
style={style}
>
<InputOTPGroup>
{slots.map((_, i) => (
<InputOTPSlot key={i} index={i} />
))}
</InputOTPGroup>
<InputOTPGroup>{slotsFrom(0, split)}</InputOTPGroup>
{split < length && (
<>
<InputOTPSeparator />
<InputOTPGroup>{slotsFrom(split, length)}</InputOTPGroup>
</>
)}
</InputOTP>
);
},
Expand All @@ -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: {
Expand Down
55 changes: 55 additions & 0 deletions packages/types/src/__tests__/input-otp-separator-11365.test.ts
Original file line number Diff line number Diff line change
@@ -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 extends true> = T;
/** Compile-time equality, exact in both directions; `any` equals nothing but `any`. */
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;

export type _SeparatorIsABoolean = Expect<Equal<TsInputOTPSchema['separator'], boolean | undefined>>;

/** 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);
});
});
19 changes: 17 additions & 2 deletions packages/types/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions packages/types/src/zod/form.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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`.',
),
});

Expand Down
Loading