From 7a8a589e4b8eecbb080d2c10b2790d36a7da9c02 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 02:53:48 +0000 Subject: [PATCH 1/3] test(components): pin that the two radio-group orientations must diverge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red on the current renderer, for the reason the card measured: the two rendered radiogroup roots come back character-for-character equal, because `RadioGroupSchema.orientation` is declared by the TS type and the zod mirror and read by nothing. The pin asserts the INEQUALITY rather than "horizontal renders something" — the latter is true before and after any fix and would pin nothing. Fixtures are authored here rather than reused from the schema catalog, whose radio-group fixtures still spell the key `direction` (objectui#6157). Part of #6158 --- .../radio-group-orientation.test.tsx | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 packages/components/src/renderers/form/__tests__/radio-group-orientation.test.tsx diff --git a/packages/components/src/renderers/form/__tests__/radio-group-orientation.test.tsx b/packages/components/src/renderers/form/__tests__/radio-group-orientation.test.tsx new file mode 100644 index 0000000000..490ec3f8e3 --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/radio-group-orientation.test.tsx @@ -0,0 +1,148 @@ +/** + * 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. + */ + +/** + * `RadioGroupSchema.orientation` must reach the DOM (objectui#6158). + * + * The key was DECLARED in two layers and read by none: + * + * - `packages/types/src/form.ts:383` — `orientation?: 'horizontal' | 'vertical'` + * with `@default 'vertical'`; + * - `packages/types/src/zod/form.zod.ts:282` — + * `z.enum(['horizontal', 'vertical']).optional()`; + * - `packages/components/src/renderers/form/radio-group.tsx` — contained + * neither the string `orientation` nor `direction`, and forwarded only + * `defaultValue`, `className`, the form-control DOM whitelist and the + * designer props. + * + * The measurable consequence: EVERY radiogroup root the library rendered was + * byte-identical on that axis — no `data-orientation`, no `aria-orientation` — + * so the docs page's `## Layout Options` section demonstrated a distinction the + * product could not make. The horizontal demo rendered vertically. + * + * ## Why these assertions are shaped the way they are + * + * A pin that asserts `orientation: 'horizontal'` renders SOMETHING is a phantom: + * it passes on the unfixed renderer too, because the unfixed renderer renders + * something for every input. The defect is an EQUALITY — two authored values + * producing one output — so the pin has to assert the INEQUALITY. `renders + * different markup for the two orientations` below is that assertion, and it is + * red on the unfixed renderer for the right reason: the two roots come back + * character-for-character equal. + * + * Both fixtures are authored HERE rather than reusing + * `examples/schema-catalog/src/schemas/components-form-radio-group/*`. Those + * catalog fixtures currently spell the key `direction`, which nothing declares — + * that divergence is objectui#6157's scope and is deliberately not touched by + * this branch. Until it lands, the two shipped docs demos still render + * identically to each other even with this renderer fix in place. + * + * The two fixtures differ in EXACTLY one key. Same `id`, same options, same + * order — so a difference in the rendered roots cannot come from anywhere but + * `orientation`, and the byte comparison stays a real measurement. + */ + +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import type { RadioGroupSchema } from '@object-ui/types'; +// Module scope, not `beforeAll` — the cold transform must not be billed to +// `hookTimeout`. See object-ui/no-dynamic-import-in-test-hook (objectui#3010). +import '../../../renderers'; + +const BASE = { + type: 'radio-group', + id: 'os6158-size', + options: [ + { value: 'sm', label: 'Small' }, + { value: 'md', label: 'Medium' }, + { value: 'lg', label: 'Large' }, + ], +} as const; + +const HORIZONTAL: RadioGroupSchema = { ...BASE, orientation: 'horizontal' }; +const VERTICAL: RadioGroupSchema = { ...BASE, orientation: 'vertical' }; +/** `orientation` omitted entirely — the `@default 'vertical'` case. */ +const DEFAULTED: RadioGroupSchema = { ...BASE }; + +function renderRoot(schema: RadioGroupSchema): HTMLElement { + const Component = ComponentRegistry.get(schema.type); + if (!Component) throw new Error('radio-group is not registered'); + const { container } = render(); + const root = container.querySelector('[role="radiogroup"]'); + if (!root) throw new Error('no [role="radiogroup"] root was rendered'); + return root; +} + +describe('radio-group renderer — orientation (objectui#6158)', () => { + it('renders DIFFERENT markup for the two orientations', () => { + const horizontal = renderRoot(HORIZONTAL).outerHTML; + const vertical = renderRoot(VERTICAL).outerHTML; + + // The whole defect in one line. On the unfixed renderer these two strings + // are equal, which is precisely the bug the card measured off the built + // site. Anything weaker than an inequality passes before AND after the fix. + expect(horizontal).not.toBe(vertical); + }); + + it('carries the authored orientation onto the radiogroup root', () => { + const horizontal = renderRoot(HORIZONTAL); + const vertical = renderRoot(VERTICAL); + + // Asserted on the DOM, not on a spy over the props reaching Radix: a Radix + // version that accepted the prop and ignored it would satisfy a spy and + // still ship the byte-identical markup this card is about. + expect(horizontal.getAttribute('data-orientation')).toBe('horizontal'); + expect(horizontal.getAttribute('aria-orientation')).toBe('horizontal'); + expect(vertical.getAttribute('data-orientation')).toBe('vertical'); + expect(vertical.getAttribute('aria-orientation')).toBe('vertical'); + }); + + it('lays the horizontal group out as a row and the vertical group as a stack', () => { + const horizontal = renderRoot(HORIZONTAL); + const vertical = renderRoot(VERTICAL); + + // The attributes above are the a11y/keyboard half. This is the half a + // reader of the docs page actually sees: `## Layout Options` promises a + // visual difference, so the layout utilities have to diverge too. + expect(horizontal.className).toContain('flex'); + expect(horizontal.className).not.toContain('grid'); + expect(vertical.className).toContain('grid'); + expect(vertical.className).not.toContain('flex'); + }); + + it('enforces the declared `@default \'vertical\'` when orientation is omitted', () => { + const defaulted = renderRoot(DEFAULTED); + + // Red before the fix: the unfixed renderer emitted no orientation + // attribute at all, so the declared default was as unenforced as the + // explicit values were. + expect(defaulted.getAttribute('data-orientation')).toBe('vertical'); + expect(defaulted.getAttribute('aria-orientation')).toBe('vertical'); + + // ⚠️ This half passes on a revert as well — before the fix the two roots + // were equal because BOTH were orientation-less. It is kept because it is + // what makes "the default is vertical" (rather than merely "some default") + // fall out of the assertion above, and it fails loudly if a later change + // gives the omitted case its own branch. + expect(defaulted.outerHTML).toBe(renderRoot(VERTICAL).outerHTML); + }); + + it('lets an author className still win over the orientation layout classes', () => { + const Component = ComponentRegistry.get('radio-group')!; + const { container } = render( + , + ); + const root = container.querySelector('[role="radiogroup"]'); + + // tailwind-merge resolves the conflict in the author's favour; the + // orientation classes are a default, not an override. + expect(root?.className).toContain('gap-8'); + expect(root?.className).not.toContain('gap-4'); + }); +}); From db2783170a6affabea579bb43e3b4c19175e9a56 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 02:55:42 +0000 Subject: [PATCH 2/3] fix(components): forward RadioGroupSchema.orientation to the rendered group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `orientation` was declared in two layers and read by none: the shipped TS type (`@default 'vertical'`, enum) and the zod mirror both carry it, while the renderer contained neither the string `orientation` nor `direction`. Every radiogroup root the library rendered was therefore byte-identical on that axis — no `data-orientation`, no `aria-orientation` — so the docs page's `## Layout Options` section demonstrated a distinction the product could not make, and the horizontal demo rendered vertically. Radix's `RadioGroup` accepts `orientation` natively with the same two-value vocabulary (verified against the installed 1.4.7: `RovingFocusGroupProps ['orientation']` = `AriaAttributes['aria-orientation']`), and puts it on the root as `aria-orientation` plus, via RovingFocusGroup, `data-orientation`. It is forwarded BY NAME rather than by reopening the DOM spread — `toFormControlDomProps` is a closed whitelist and this is the objectui#4435 route that file documents. The declared `@default 'vertical'` is applied here rather than left to Radix's own `undefined`: a default the type documents and nothing applies is the same declared-but-unenforced defect as the key itself. Vertical is also what the group has always looked like, so this makes the announced orientation agree with the rendered one instead of being absent. The layout utilities follow the key, since `## Layout Options` promises a visible difference; author `className` composes last so tailwind-merge resolves conflicts in the author's favour. Registry meta `inputs` gains `orientation` in the same change — the third surface that omitted it. Part of #6158 --- .../src/renderers/form/radio-group.tsx | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/components/src/renderers/form/radio-group.tsx b/packages/components/src/renderers/form/radio-group.tsx index 593d666fdf..c865fac09e 100644 --- a/packages/components/src/renderers/form/radio-group.tsx +++ b/packages/components/src/renderers/form/radio-group.tsx @@ -9,9 +9,32 @@ import { ComponentRegistry } from '@object-ui/core'; import type { RadioGroupSchema } from '@object-ui/types'; import { RadioGroup, RadioGroupItem, Label } from '../../ui'; +import { cn } from '../../lib/utils'; import { toControlValue } from './option-value'; import { toFormControlDomProps } from '../../lib/form-control-dom-props'; +/** + * The declared default (`packages/types/src/form.ts` — `@default 'vertical'`). + * Applied here rather than left to Radix's own `undefined`, because a default + * the type documents and nothing applies is the same declared-but-unenforced + * defect as the key itself was (objectui#6158). Vertical is also what the + * group has always LOOKED like — the `grid gap-2` stack — so this makes the + * announced orientation agree with the rendered one instead of being absent. + */ +const DEFAULT_ORIENTATION = 'vertical' as const; + +/** + * Layout utilities per orientation. `vertical` deliberately names no class: + * the `ui/radio-group` wrapper already applies `grid gap-2`, and that stack IS + * the vertical layout. Author `className` is composed LAST so tailwind-merge + * resolves every conflict in the author's favour — these are a default, not an + * override. + */ +const ORIENTATION_CLASS: Record<'horizontal' | 'vertical', string | undefined> = { + horizontal: 'flex flex-row flex-wrap items-center gap-4', + vertical: undefined, +}; + ComponentRegistry.register('radio-group', ({ schema, className, ...props }: { schema: RadioGroupSchema; className?: string; [key: string]: any }) => { // Extract designer-related props @@ -22,12 +45,21 @@ ComponentRegistry.register('radio-group', ...radioProps } = props; + // Forwarded BY NAME, not by reopening the spread: `toFormControlDomProps` + // is a closed whitelist and `orientation` is not on it, which is exactly + // the objectui#4435 route that file documents for a key like this. Radix's + // `RadioGroup` takes `orientation` natively with the same two-value + // vocabulary, and puts it on the root as both `aria-orientation` and (via + // RovingFocusGroup) `data-orientation`. + const orientation = schema.orientation ?? DEFAULT_ORIENTATION; + return ( // Radix speaks strings — stringify authored (possibly numeric) values for // the control; ids stay stable via the same stringification (#3090). Date: Tue, 25 Aug 2026 03:43:31 +0000 Subject: [PATCH 3/3] test(components): type the orientation fixtures through one factory + changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `as const` made `options` a readonly tuple, which `RadioGroupSchema.options: RadioOption[]` rejects, and a `satisfies Omit<…>` widened `type` back to `string`. A factory with a return-type annotation contextually types both, and carries the "differ in EXACTLY one key" property in the code rather than in a comment beside it. Part of #6158 --- .changeset/6158-radio-group-orientation.md | 41 +++++++++++++++++++ .../radio-group-orientation.test.tsx | 39 ++++++++++-------- 2 files changed, 63 insertions(+), 17 deletions(-) create mode 100644 .changeset/6158-radio-group-orientation.md diff --git a/.changeset/6158-radio-group-orientation.md b/.changeset/6158-radio-group-orientation.md new file mode 100644 index 0000000000..51472aa062 --- /dev/null +++ b/.changeset/6158-radio-group-orientation.md @@ -0,0 +1,41 @@ +--- +'@object-ui/components': minor +--- + +`radio-group` now renders the `orientation` its own type has always declared (objectui#6158). + +`RadioGroupSchema.orientation` was declared in two layers and read by none. The shipped TS +type carries `orientation?: 'horizontal' | 'vertical'` with `@default 'vertical'` +(`packages/types/src/form.ts:383`) and the zod mirror carries the matching +`z.enum(['horizontal', 'vertical'])` (`packages/types/src/zod/form.zod.ts:282`), while +`packages/components/src/renderers/form/radio-group.tsx` contained neither the string +`orientation` nor `direction` and forwarded only `defaultValue`, `className`, the +form-control DOM whitelist and the designer props. + +The consequence was measurable rather than cosmetic: every radiogroup root the library +rendered came back byte-identical on that axis — no `data-orientation`, no +`aria-orientation` — so the docs page's `## Layout Options` section demonstrated a +distinction the product could not make, and its horizontal demo rendered vertically. An +author reading the shipped type had every reason to write `orientation: 'horizontal'` and +no way to discover it was inert. + +The key is now forwarded to the underlying Radix `RadioGroup`, which accepts it natively +with the same two-value vocabulary and puts it on the root as `aria-orientation` and +`data-orientation`; the layout utilities follow it so the visible difference the docs +promise is real. This restores declared = enforced **without widening the acceptance +set** — no new key is accepted, and no spelling outside the declared enum becomes legal. + +Two behaviour notes for anyone already shipping radio groups: + +- The declared `@default 'vertical'` is now actually applied instead of being left to + Radix's own `undefined`. A group that never authored the key keeps the vertical stack it + already rendered, and additionally announces `aria-orientation="vertical"` — the + announced orientation now agrees with the rendered one rather than being absent. Arrow + key roving focus narrows to Up/Down for those groups, which is the correct pairing for a + vertical stack. +- Author `className` still wins: the orientation layout utilities compose first and the + authored class last, so tailwind-merge resolves every conflict in the author's favour. + +Registry meta `inputs` for `radio-group` gains `orientation` in the same change — it was +the third surface that omitted the key, and leaving it out would have kept the designer +palette disagreeing with the type. diff --git a/packages/components/src/renderers/form/__tests__/radio-group-orientation.test.tsx b/packages/components/src/renderers/form/__tests__/radio-group-orientation.test.tsx index 490ec3f8e3..29777b91c1 100644 --- a/packages/components/src/renderers/form/__tests__/radio-group-orientation.test.tsx +++ b/packages/components/src/renderers/form/__tests__/radio-group-orientation.test.tsx @@ -41,10 +41,6 @@ * that divergence is objectui#6157's scope and is deliberately not touched by * this branch. Until it lands, the two shipped docs demos still render * identically to each other even with this renderer fix in place. - * - * The two fixtures differ in EXACTLY one key. Same `id`, same options, same - * order — so a difference in the rendered roots cannot come from anywhere but - * `orientation`, and the byte comparison stays a real measurement. */ import { describe, it, expect } from 'vitest'; @@ -55,20 +51,29 @@ import type { RadioGroupSchema } from '@object-ui/types'; // `hookTimeout`. See object-ui/no-dynamic-import-in-test-hook (objectui#3010). import '../../../renderers'; -const BASE = { - type: 'radio-group', - id: 'os6158-size', - options: [ - { value: 'sm', label: 'Small' }, - { value: 'md', label: 'Medium' }, - { value: 'lg', label: 'Large' }, - ], -} as const; - -const HORIZONTAL: RadioGroupSchema = { ...BASE, orientation: 'horizontal' }; -const VERTICAL: RadioGroupSchema = { ...BASE, orientation: 'vertical' }; +/** + * One factory, one parameter — so "the fixtures differ in EXACTLY one key" is + * a property of the code rather than a claim in a comment. Same `id`, same + * options, same order; a difference in the rendered roots cannot come from + * anywhere but `orientation`. + */ +function fixture(orientation?: 'horizontal' | 'vertical'): RadioGroupSchema { + return { + type: 'radio-group', + id: 'os6158-size', + options: [ + { value: 'sm', label: 'Small' }, + { value: 'md', label: 'Medium' }, + { value: 'lg', label: 'Large' }, + ], + ...(orientation ? { orientation } : {}), + }; +} + +const HORIZONTAL = fixture('horizontal'); +const VERTICAL = fixture('vertical'); /** `orientation` omitted entirely — the `@default 'vertical'` case. */ -const DEFAULTED: RadioGroupSchema = { ...BASE }; +const DEFAULTED = fixture(); function renderRoot(schema: RadioGroupSchema): HTMLElement { const Component = ComponentRegistry.get(schema.type);