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
30 changes: 30 additions & 0 deletions .changeset/labelling-display-declaration-4857.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@object-ui/core': minor
'@object-ui/components': minor
'@object-ui/fields': minor
---

`ComponentMeta.labelling` grows a third value: `'control' | 'group' | 'display'`
(objectui#4857, ruled jointly with objectui#4871 as the single repo-wide vocabulary for
"how does a host learn what a widget will render"). `'display'` declares a widget whose
whole surface is a pure display in EVERY state — no focusable control, nothing a
`<label for>` could ever reach.

The form renderer answers the declaration with the objectui#4788 host container (field
id + `aria-labelledby` + `aria-describedby` + `role="group"`) in the editable state too;
the `readonly === true` arm keeps its exact #4788 semantics for undeclared widgets. The
display-only four (`formula` / `summary` / `auto_number` / `vector`) declare `'display'`
— on the real object-form path they arrive `disabled`, never `readonly` (a deliberate
distinction this change does not touch), so their visible labels pointed `for` at an id
no element carried and their help text had zero consumers in every editable form.

`grid` was re-measured before being classified: its only bare-config focusable is the
auxiliary "Add line" button (routing `for` there would have label clicks insert rows),
and every realistic config is a table of per-cell inputs — a composite. It declares
`labelling: 'group'` and its root container now consumes the host id, name and
description, exactly like `address` / `checkboxes`.

Companion registry gate: `FIELD_WIDGET_LABELLING` (exported) is a `Record` keyed by the
field-widget map's own literal key union, so registering a widget without deciding its
labelling is a compile error rather than a silent fall-through to the dangling-`for`
path, and the declaration test asserts the registered meta agrees with it key by key.
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ beforeAll(() => {
ComponentRegistry.register('anchorprobe', AnchorProbe, { namespace: 'field' });
ComponentRegistry.register('textprobe', TextProbe, { namespace: 'field' });
ComponentRegistry.register('displayonlyprobe', DisplayOnlyProbe, { namespace: 'field' });
// The same face WITH the objectui#4857 declaration — the production shape of
// the display-only four (`formula` / `summary` / `auto_number` / `vector`).
ComponentRegistry.register('displaydeclaredprobe', DisplayOnlyProbe, {
namespace: 'field',
labelling: 'display',
});
ComponentRegistry.register('groupprobe', GroupProbe, { namespace: 'field', labelling: 'group' });
// NOT in the `field` namespace — the bare-name fallback, whose contract is
// `schema`, not `FieldWidgetComponentProps`.
Expand Down Expand Up @@ -352,3 +358,69 @@ describe('nothing else changed shape (the paths this issue does not touch)', ()
expect(screen.getByTestId('bare-name')).toBeInTheDocument();
});
});

describe('a `labelling: "display"` declaration extends the wrapper to the EDITABLE state (objectui#4857)', () => {
it('EDITABLE display-declared widget: wrapped, named, described — the #4788 shape without `readonly`', () => {
renderForm([FIELD('displaydeclaredprobe')], { f_displaydeclaredprobe: 'computed' });

const g = group('f_displaydeclaredprobe');
expect(g).not.toBeNull();
expect(g).toHaveAttribute('role', 'group');
expect(byId(g!.getAttribute('id'))).toBe(g);

const label = hostLabel('f_displaydeclaredprobe');
expect(label).not.toHaveAttribute('for');
const labelled = idrefs(g!, 'aria-labelledby');
expect(byId(labelled[0])).toBe(label);
expect(byId(labelled[1])).toBe(g);
expect(byId(idrefs(g!, 'aria-describedby')[0])).toHaveTextContent('Some help');
expect(g).not.toHaveAttribute('aria-invalid');
});

it('EDITABLE + `disabled`: the wrapper still applies — the real object-form path for computed fields', () => {
// ObjectForm maps `formula` / `summary` / `auto_number` to `disabled: true`,
// never `readonly: true` (a deliberate distinction the #4857 ruling kept —
// option 1, re-mapping it, was rejected). The gate reads the DECLARATION,
// so the disabled path is covered without touching that mapping.
renderForm([FIELD('displaydeclaredprobe', { disabled: true })], {
f_displaydeclaredprobe: 'computed',
});

const g = group('f_displaydeclaredprobe');
expect(g).not.toBeNull();
expect(g).toHaveAccessibleName('Label displaydeclaredprobe computed');
});

it('READONLY display-declared widget: both arms true, still exactly ONE wrapper', () => {
renderForm([FIELD('displaydeclaredprobe', { readonly: true })], {
f_displaydeclaredprobe: 'computed',
});

expect(
item('f_displaydeclaredprobe').querySelectorAll('[data-slot="readonly-field-group"]'),
).toHaveLength(1);
expect(item('f_displaydeclaredprobe').querySelectorAll('[role="group"]')).toHaveLength(1);
});

it('an UNDECLARED display-only widget stays on the single-control path while editable', () => {
// The fallback the declaration exists to replace: without `labelling:
// "display"` the host cannot know the widget renders no control, so the
// editable state keeps the (dangling) `for`. Pinned so the registry-level
// "registered ⇒ declared" gate in `@object-ui/fields` is what carries the
// guarantee, not a host-side guess.
renderForm([FIELD('displayonlyprobe')], { f_displayonlyprobe: 'computed' });

expect(group('f_displayonlyprobe')).toBeNull();
expect(hostLabel('f_displayonlyprobe')).toHaveAttribute('for');
});

it('a display declaration does NOT wrap a label-less field (nothing would name it)', () => {
renderForm(
[{ name: 'f_nolabel_display', type: 'displaydeclaredprobe', description: 'Some help' }],
{ f_nolabel_display: 'computed' },
);

expect(group('f_nolabel_display')).toBeNull();
expect(document.querySelector('[role="group"]')).toBeNull();
});
});
88 changes: 58 additions & 30 deletions packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,11 +395,10 @@ function normalizeFieldType(type: string): string {
* unregistered type — resolves to `'control'`: the single-control path, byte for
* byte what this renderer emitted before the declaration existed.
*/
function resolveFieldLabelling(type: string): 'control' | 'group' {
function resolveFieldLabelling(type: string): 'control' | 'group' | 'display' {
if (BUILTIN_FIELD_TYPES.has(type)) return 'control';
return ComponentRegistry.getMeta(normalizeFieldType(type), 'field')?.labelling === 'group'
? 'group'
: 'control';
const declared = ComponentRegistry.getMeta(normalizeFieldType(type), 'field')?.labelling;
return declared === 'group' || declared === 'display' ? declared : 'control';
}

/**
Expand All @@ -426,10 +425,16 @@ function resolvesToRegisteredFieldWidget(type: string): boolean {
}

/**
* The container a READONLY registered field widget's output is wrapped in, so
* the field's visible label NAMES and its visible help text DESCRIBES the
* surface that replaced the control (objectui#4788, maintainer ruling of
* 2026-08-16 — option E of the measured option set).
* The container a registered field widget's replacement-display output is
* wrapped in, so the field's visible label NAMES and its visible help text
* DESCRIBES the surface that replaced the control (objectui#4788, maintainer
* ruling of 2026-08-16 — option E of the measured option set). Reached two
* ways: a READONLY registered widget (#4788's original gate, unchanged), and —
* since objectui#4857 — a widget declared `labelling: 'display'`, whose whole
* surface is such a display in every state (`formula` / `summary` /
* `auto_number` / `vector`), so the wrapper applies in the editable state too.
* The `data-slot` keeps its original name: what this container wraps is a
* read-only display either way, whatever the form's own mode.
*
* ## What was broken
*
Expand Down Expand Up @@ -1890,28 +1895,48 @@ ComponentRegistry.register('form',
// the form schema has no owning object.
const fieldTestId = `field:${schema.objectName ? `${schema.objectName}.` : ''}${name}`;

const groupLabelled = resolveFieldLabelling(resolvedType) === 'group';
const fieldLabelling = resolveFieldLabelling(resolvedType);
const groupLabelled = fieldLabelling === 'group';

// A READONLY registered field widget renders a replacement display in
// place of its control, and drops every prop the host handed down with it
// (objectui#4788). The host therefore wraps that output in a named group
// of its own — see {@link ReadonlyFieldGroup} for the measurement and the
// shape. Three gates, each carrying its own reason:
// A registered field widget whose output is a replacement display drops
// every prop the host handed down, so the host wraps that output in a
// named group of its own — see {@link ReadonlyFieldGroup} for the
// measurement and the shape. Two ways a field gets here, one wrapper:
//
// - `readonly === true` (objectui#4788): the widget's readonly branch
// renders the display in place of its control. Unchanged semantics —
// this arm still keys off the field STATE, not the declaration, so an
// undeclared third-party widget keeps exactly the #4788 behaviour;
// - `labelling: 'display'` (objectui#4857): the widget declares that its
// surface is a pure display in EVERY state — `formula` / `summary` /
// `auto_number` / `vector` have no editable branch at all — so the
// wrapper applies in the editable state too. Without this arm those
// four lost the host id whenever the form was editable: on the real
// object-form path they arrive as `disabled`, not `readonly`
// (ObjectForm keeps that distinction deliberately — option 1 of the
// #4857 option set was rejected), so the #4788 gate never fired and
// the label's `for` dangled.
//
// Two further gates, each carrying its own reason:
//
// - `label`: with no visible label there is no naming channel to repair,
// and a `role="group"` that nothing names is the inert pair this issue
// exists to avoid. Standalone / label-less rendering therefore stays
// byte-identical, exactly as `toHostGroupProps` keeps it;
// - `!groupLabelled`: the seven group-labelled widgets already consume
// the host's id / name / description themselves (objectui#3961 →
// #3990 → #4005). Wrapping them too would nest a second group with the
// same name and take the id off the surface those PRs put it on;
// - `!groupLabelled`: the group-labelled widgets already consume the
// host's id / name / description themselves (objectui#3961 → #3990
// #4005). Wrapping them too would nest a second group with the same
// name and take the id off the surface those PRs put it on;
// - a registered FIELD widget: the builtin branch renders a real control
// inside `<FormControl>` in the readonly state too, so its label keeps
// a `for` that resolves to a labelable element — measured, and left
// alone.
const readonlyHostGroup =
readonly === true && !!label && !groupLabelled && resolvesToRegisteredFieldWidget(resolvedType);
// alone. (A `'display'` declaration can only come from a registered
// widget's meta, so for that arm this gate is belt-and-braces.)
const hostWrappedDisplay =
(readonly === true || fieldLabelling === 'display') &&
!!label &&
!groupLabelled &&
resolvesToRegisteredFieldWidget(resolvedType);

// The visible label is associated by IDREF instead of `for` — it gets an
// `id`, and the surface that answers to it gets `aria-labelledby`. Two
Expand All @@ -1928,14 +1953,15 @@ ComponentRegistry.register('form',
// list: an id containing a space would silently resolve to two ids,
// neither of which exists.
const hostLabelId =
label && (groupLabelled || readonlyHostGroup)
label && (groupLabelled || hostWrappedDisplay)
? `${labelIdPrefix}${String(name).replace(/\s+/g, '_')}-group-label`
: undefined;

// The widget-facing half. Only the group-labelled path hands the IDREF
// DOWN to the widget: on the readonly path the wrapper is the named
// surface, and passing the same id to the widget as well would give one
// label two consumers — the double channel #3978 removed.
// DOWN to the widget: on the host-wrapped path (readonly, or a declared
// `'display'` widget) the wrapper is the named surface, and passing the
// same id to the widget as well would give one label two consumers — the
// double channel #3978 removed.
const groupLabelId = groupLabelled ? hostLabelId : undefined;

return (
Expand All @@ -1962,10 +1988,12 @@ ComponentRegistry.register('form',
// beside the `aria-labelledby` would give one label two
// association channels, one of which is broken.
//
// A readonly registered widget (objectui#4788) reaches the
// same shape through the host's own wrapper, and needs the
// `for` gone for the same reason — it was measurably DANGLING
// there, pointing at an id no element in the document carried.
// A readonly registered widget (objectui#4788) — and a
// widget declared `labelling: 'display'`, in every state
// (objectui#4857) — reaches the same shape through the
// host's own wrapper, and needs the `for` gone for the same
// reason: it was measurably DANGLING there, pointing at an
// id no element in the document carried.
{...(hostLabelId ? { id: hostLabelId, htmlFor: undefined } : null)}
>
{label}
Expand Down Expand Up @@ -1999,7 +2027,7 @@ ComponentRegistry.register('form',
A readonly registered widget's output goes inside the host's
own named group (objectui#4788) — every other field is handed
to `<FormControl>`'s Slot exactly as before. */}
{withReadonlyHostGroup(readonlyHostGroup ? hostLabelId : undefined, renderFieldComponent(resolvedType, {
{withReadonlyHostGroup(hostWrappedDisplay ? hostLabelId : undefined, renderFieldComponent(resolvedType, {
...fieldProps,
// Specialized fields need the raw metadata object. `.field`
// is the declared metadata slot (#3090 — never the spec
Expand Down
21 changes: 18 additions & 3 deletions packages/core/src/registry/Registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ export type ComponentMeta = {
skipFallback?: boolean;
/**
* How a HOST must associate its own visible label with what this component
* renders (objectui#3961). Read by the form renderer; absent ⇒ `'control'`.
* renders (objectui#3961, extended by objectui#4857). Read by the form
* renderer; absent ⇒ `'control'`. This closed three-value vocabulary is the
* single repo-wide answer to "how does a host learn what a widget will
* render" (maintainer ruling of 2026-08-17, joint with objectui#4871) — no
* host may keep a local variant of it.
*
* - `'control'` — the component's outermost rendered element is a LABELABLE
* HTML element (`input` / `textarea` / `select` / `button` / …), so the host
Expand All @@ -77,15 +81,26 @@ export type ComponentMeta = {
* nothing and contributes no accessible name (`HTMLLabelElement.control` is
* `null`) — so the host must instead give its label an `id` and hand the
* component `aria-labelledby`, which associates by IDREF and works on any
* element.
* element. The COMPONENT consumes those keys on its own surface.
* - `'display'` — the rendered surface is a pure display in EVERY state:
* there is no focusable control and the component itself spreads nothing
* (computed / system-generated values such as `formula` / `summary` /
* `auto_number` / `vector`). The host must not emit a `<label for>` at all
* — no labelable element will ever exist for it to reach, in the editable
* state as much as the readonly one — and instead wraps the component's
* output in the host's own container carrying the field id,
* `aria-labelledby`, `aria-describedby` and `role="group"` (the
* objectui#4788 channel, driven by this declaration rather than by
* `readonly` alone). Unlike `'group'`, the WIDGET is not expected to
* consume anything: the host's wrapper is the named surface.
*
* This is a DECLARATION, not a guess: the host cannot infer it from the DOM a
* widget happens to render, and a widget that fails to declare it falls back
* to the `'control'` path where the dangling/inert `for` is caught by the
* label-association tests (objectui#3952) instead of silently producing an
* unlabelled group.
*/
labelling?: 'control' | 'group';
labelling?: 'control' | 'group' | 'display';
inputs?: ComponentInput[];
defaultProps?: Record<string, any>; // Default props when dropped
defaultChildren?: SchemaNode[]; // Default children when dropped
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ beforeAll(() => {
ComponentRegistry.register(type, Component as any, {
namespace: 'field',
skipFallback: true,
// The declaration under test, mirroring `FIELD_TYPES_GROUP_LABELLED`.
// The declaration under test, mirroring `FIELD_WIDGET_LABELLING`.
labelling: 'group',
});
}
Expand Down Expand Up @@ -302,7 +302,7 @@ describe('the group name does not swallow the sub-controls\' own names (objectui
describe('an undeclared single-control field keeps the plain `for` association (objectui#3961)', () => {
it('text: the label still points at the input, with no second naming channel', () => {
// The positive control for the DECLARATION. `text` is not in
// `FIELD_TYPES_GROUP_LABELLED`, so it must travel the unchanged path: a
// `FIELD_WIDGET_LABELLING`, so it must travel the unchanged path: a
// working `for`, and NO `aria-labelledby` — two channels naming one control
// is what #3290 / #3222 / #3952 each refused.
renderForm([{ name: 'f_text', label: 'Plain Text', type: 'text' }]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ beforeAll(() => {
ComponentRegistry.register(type, Component as any, {
namespace: 'field',
skipFallback: true,
// The declaration under test, mirroring `FIELD_TYPES_GROUP_LABELLED`.
// The declaration under test, mirroring `FIELD_WIDGET_LABELLING`.
labelling: 'group',
});
}
Expand Down Expand Up @@ -674,7 +674,7 @@ describe('STANDALONE readonly widgets are unchanged (objectui#3990)', () => {

it('the shared options-empty box emits nothing for a widget that is not group-labelled', () => {
// The positive control for `OptionsEmptyState`'s new prop. The single
// `SelectField` is NOT in `FIELD_TYPES_GROUP_LABELLED` — its label keeps a
// `SelectField` is NOT in `FIELD_WIDGET_LABELLING` — its label keeps a
// plain, working `for` — so the shared box must stay attribute-for-attribute
// what it was: no `role`, no IDREF, no host id.
render(
Expand Down
Loading
Loading