diff --git a/.changeset/7008-field-edit-widget-host-plumbing.md b/.changeset/7008-field-edit-widget-host-plumbing.md new file mode 100644 index 0000000000..fe56d13482 --- /dev/null +++ b/.changeset/7008-field-edit-widget-host-plumbing.md @@ -0,0 +1,33 @@ +--- +'@object-ui/fields': minor +'@object-ui/plugin-kanban': patch +--- + +`FieldEditWidget` now delivers the NON-DOM half of the contract it declares (objectui#7008). + +objectui#7009 made the factory forward its declared DOM pass-through block. The rest of +`FieldWidgetComponentProps` was still dropped: `error`, `onUploadingChange`, and the whole +"Host plumbing" block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`, +`emptyHint`, `onSelectRecord`, `onCreateNew`). A host could pass any of them with no type +error and the widget never received it — the "declared but not delivered" class this +package treats as first-class. + +`error` was the live one. `InlineFieldInput` has passed `error` into this factory since +PR #7109 and the factory dropped it, so an inline-edit control that had failed validation +never reported `aria-invalid`: a sighted user saw the red hint, a screen-reader user was +told nothing. The kanban `RequiredFieldsDialog` had the same hole from the other side — it +computes the validation state and could not hand it over — and now passes `error`, so its +controls are marked. Delivering `error` buys the a11y MARKING only; the message text stays +with the host, per the objectui#3222 contract. + +The keys travel through a new sibling executor, `toHostProps` (exported alongside +`toDomProps`), never through the DOM whitelist — none of them is DOM-legal, and routing a +`dataSource` adapter there is the `[object Object]` leak that whitelist exists to stop. +Three compile-time assertions make the two executors partition the contract, so a future +declared key cannot go undelivered silently. + +`dataSource` precedence is stated rather than left to emerge: a host's explicit +`dataSource` prop WINS over `SchemaRendererContext`. That is the order `LookupField` +already implements; the factory is a conduit and resolves nothing. A host that passes no +`dataSource` keeps reading the context exactly as before, so no in-repo host changes +behaviour. diff --git a/packages/fields/src/FieldEditWidget.tsx b/packages/fields/src/FieldEditWidget.tsx index 70ad0de26e..7d8556abc1 100644 --- a/packages/fields/src/FieldEditWidget.tsx +++ b/packages/fields/src/FieldEditWidget.tsx @@ -11,6 +11,9 @@ import type { FieldWidgetComponentProps } from './widgets/types.js'; // The package's own executor of the DOM pass-through declaration, reused here // rather than re-listed — see the note on this component's return statement. import { toDomProps } from './widgets/toDomProps.js'; +// The package's own executor of the NON-DOM half of the same declaration +// (objectui#7008) — a separate function because those keys are not DOM-legal. +import { toHostProps } from './widgets/toHostProps.js'; // The SAME dedicated widgets the form renders — reused for in-place editing // (e.g. the data grid's inline cell editor) so a select edits as a dropdown, a @@ -271,6 +274,40 @@ const COMPACT_EDIT_TYPES = new Set(['lookup', 'master_detail', 'user']); * each widget's own whitelist carries these onto the real focusable control, so * nothing here needs to know which element that is. A host that passes nothing * is unaffected. + * + * The host's NON-DOM set is forwarded WHOLE too (objectui#7008), through the + * sibling executor `toHostProps`. The DOM fix left the other half of the + * contract undelivered: `error`, `onUploadingChange` and the "Host plumbing" + * block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`, + * `emptyHint`, `onSelectRecord`, `onCreateNew`) still type-checked, read as + * supported, and never reached the widget. `error` was the live one: + * `InlineFieldInput` has passed `error={error}` since PR #7109 and this factory + * dropped it, so a control that had failed validation never reported + * `aria-invalid`. Forwarding is not a widening — every one of those keys is + * already declared on `FieldWidgetComponentProps`, the same argument #7009 + * landed on in this file. + * + * ⛔ They do NOT go through `toDomProps`. None of them is DOM-legal, and that + * whitelist is closed for exactly this reason — a `dataSource` adapter routed + * there becomes `dataSource="[object Object]"` on an ``, the leak the + * helper exists to prevent. `toHostProps`' direction-3 assertion makes the two + * sets provably disjoint, so the order of the two spreads below is not a + * question anyone has to answer again. + * + * ## `dataSource` precedence: the explicit prop WINS + * + * Delivering `dataSource` can change behaviour where before it could not + * arrive, because the relational widgets fall back to `SchemaRendererContext` + * (which the grid already provides). The precedence is therefore STATED rather + * than left to emerge: **a host's explicit `dataSource` prop wins over the + * context**. That is not a new decision — `LookupField` already resolves + * `props.dataSource ?? lookupField?.dataSource ?? fieldMeta?.dataSource ?? + * contextDataSource` and documents that order on the line that does it. This + * factory is a CONDUIT and resolves nothing: adding a resolution here would + * give `dataSource` a second author, the `field || schema` shape objectui#3233 + * removed. A host that passes no `dataSource` keeps reading the context exactly + * as before, so no in-repo host changes behaviour. The full per-key precedence + * table lives on `toHostProps`, next to the list it governs. */ export function FieldEditWidget( props: FieldWidgetComponentProps, @@ -324,9 +361,17 @@ export function FieldEditWidget( // The semantic props stay explicit and come AFTER the spread. They are not in // the whitelist, so there is no collision to resolve; ordering them this way // states that this component OWNS them and a host cannot displace them. + // + // `toHostProps` is the same reuse argument applied to the other half of the + // declaration (objectui#7008): the declared NON-DOM keys — `error` and the + // "Host plumbing" block — travel as COMPONENT props, never through the DOM + // whitelist, which is closed against exactly them. The two executors are + // asserted disjoint at compile time, so neither spread can shadow the other, + // and `compact` below still wins because the factory owns it. return ( cleanup()); + +/** `select` resolves to `SelectField`, whose trigger carries `aria-invalid`. */ +const SELECT_FIELD = { + name: 'stage', + type: 'select', + label: 'Stage', + options: [{ label: 'New', value: 'new' }], +} as never; + +/** `text` resolves to `TextField` — used only where the widget is irrelevant. */ +const TEXT_FIELD = { name: 'f', type: 'text', label: 'F' } as never; + +describe('FieldEditWidget delivers its declared NON-DOM block (objectui#7008)', () => { + it('forwards every declared host-plumbing key it is handed, at the factory boundary', () => { + const onUploadingChange = vi.fn(); + const onSelectRecord = vi.fn(); + const onCreateNew = vi.fn(); + const dataSource = { find: vi.fn() }; + + // `zzcanary` is the control, carried over from the #7009 pin: NOT declared + // on `FieldWidgetComponentProps` (passing it is a compile error, hence the + // cast), but an SDUI node or a field config can carry exactly such a key at + // runtime. Without it, "everything forwards now" would be + // indistinguishable from having reopened the bare `{...props}` spread. + const props = { + field: TEXT_FIELD, + value: '', + onChange: () => {}, + readonly: false, + // the two declared controlled-input keys the factory neither owns nor + // routes to the DOM + error: 'Required', + onUploadingChange, + // the declared "Host plumbing" block, minus `compact` (factory-owned) + dataSource, + dependentValues: { account: 'a1' }, + dependsOn: 'account', + dependsOnLabels: { account: 'Account' }, + emptyHint: 'Pick an account first', + onSelectRecord, + onCreateNew, + zzcanary: 'CANARY-STR', + } as unknown as FieldWidgetComponentProps; + + // Called as a plain function rather than rendered: it uses no hooks and its + // return value IS the widget element, so this reads the handoff itself. + const element = FieldEditWidget(props); + expect(element).not.toBeNull(); + const forwarded = element!.props as Record; + + // Exact set, not a subset — a subset check cannot see the control key, and + // an extra key appearing here is the leak this guards. + expect(Object.keys(forwarded).sort()).toEqual( + [ + // rendered by the factory itself + 'field', + 'value', + 'onChange', + 'readonly', + // the declared NON-DOM keys, via `toHostProps` + 'error', + 'onUploadingChange', + 'dataSource', + 'dependentValues', + 'dependsOn', + 'dependsOnLabels', + 'emptyHint', + 'onSelectRecord', + 'onCreateNew', + ].sort(), + ); + + // Identity, not just presence: a conduit hands over the host's own object. + expect(forwarded.dataSource).toBe(dataSource); + expect(forwarded.onSelectRecord).toBe(onSelectRecord); + expect(forwarded.onCreateNew).toBe(onCreateNew); + expect(forwarded.onUploadingChange).toBe(onUploadingChange); + expect(forwarded.error).toBe('Required'); + + // CONTROL: the undeclared authored key is still dropped. + expect(forwarded).not.toHaveProperty('zzcanary'); + }); + + it('CONTROL: a key the host did not pass stays ABSENT, not `undefined`', () => { + // The #7009 pin asserts an exact boundary set for a host that passes only + // DOM keys. Forwarding the non-DOM block as nine always-present + // `undefined`s would have broken that pin AND made the boundary unreadable + // — "what the host supplied" is the claim, so absence must survive. + const element = FieldEditWidget({ + field: TEXT_FIELD, + value: '', + onChange: () => {}, + } as FieldWidgetComponentProps); + expect(element).not.toBeNull(); + const forwarded = element!.props as Record; + + for (const key of [ + 'error', + 'onUploadingChange', + 'dataSource', + 'dependentValues', + 'dependsOn', + 'dependsOnLabels', + 'emptyHint', + 'onSelectRecord', + 'onCreateNew', + ]) { + expect(forwarded).not.toHaveProperty(key); + } + // CONTROL: the factory's own props are still there, so the assertion above + // is not passing because the handoff is empty. + expect(forwarded).toHaveProperty('field'); + expect(forwarded).toHaveProperty('value'); + }); + + it('`error` reaches a real control as `aria-invalid` — the live a11y defect', async () => { + const { getByTestId, rerender } = render( + {}} error="Required" />, + ); + // `SelectField` puts the DOM pass-through and `aria-invalid` on + // `SelectTrigger` — the focusable `button role="combobox"` the user and + // their screen reader actually meet (objectui#3306) — not on Radix `Root`, + // which renders no element. + const trigger = getByTestId('select-trigger-stage'); + expect(trigger.tagName).toBe('BUTTON'); + expect(trigger).toHaveAttribute('aria-invalid', 'true'); + + // CONTROL: the same widget, same host, no `error`. `SelectField` computes + // `!!error`, so a valid field SAYS "false" rather than staying mute — which + // makes this a real two-state reading and not "the attribute exists". + rerender( {}} />); + expect(getByTestId('select-trigger-stage')).toHaveAttribute('aria-invalid', 'false'); + }); + + it('`dataSource`: the explicit prop WINS over SchemaRendererContext', async () => { + // The one delivered key that can CHANGE behaviour rather than only add it: + // the relational widgets fall back to `SchemaRendererContext` (which the + // grid already provides), so delivering the prop creates a precedence + // question. `LookupField` already resolves "explicit prop > field-level > + // wrapper field > SchemaRendererContext > none"; the factory is a conduit + // and adds no second authority. This pins that the delivered prop is what + // the widget ends up querying. + const LOOKUP_FIELD = { name: 'account', type: 'lookup', reference_to: 'accounts' } as never; + const makeSource = () => ({ + find: vi.fn().mockResolvedValue([]), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'accounts' }), + }); + + const fromProp = makeSource(); + const fromContext = makeSource(); + + render( + + {}} + dataSource={fromProp} + /> + , + ); + + await waitFor(() => expect(fromProp.getObjectSchema).toHaveBeenCalledWith('accounts')); + expect(fromContext.getObjectSchema).not.toHaveBeenCalled(); + + cleanup(); + + // CONTROL: drop the prop and the SAME context source IS queried. Without + // this, "the context was not called" would be indistinguishable from a + // context that was never wired up in this test at all. + const contextOnly = makeSource(); + render( + + {}} /> + , + ); + await waitFor(() => expect(contextOnly.getObjectSchema).toHaveBeenCalledWith('accounts')); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 1ca938326d..a22a8afbd4 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -3347,6 +3347,15 @@ export { withFieldCarrier } from './withFieldCarrier.js'; export { toDomProps } from './widgets/toDomProps.js'; export type { DomProps } from './widgets/toDomProps.js'; +// The sibling executor for the NON-DOM half of the same declaration +// (objectui#7008): `error` plus the "Host plumbing" block, forwarded as +// COMPONENT props because none of them is DOM-legal. Exported alongside +// `toDomProps` because a host factory authored outside this repo needs the +// pair — reaching for only the first one is how `FieldEditWidget` came to +// deliver half the contract it declares. +export { toHostProps } from './widgets/toHostProps.js'; +export type { HostProps } from './widgets/toHostProps.js'; + // The native date/time control value adapters (objectui#3127). `DateTimeField` // is ISO-canonical on BOTH sides — it takes the record's ISO instant and hands // an ISO instant back, which is also the wire form the platform's `datetime` diff --git a/packages/fields/src/widgets/toDomProps.ts b/packages/fields/src/widgets/toDomProps.ts index 51464a1d77..a618567521 100644 --- a/packages/fields/src/widgets/toDomProps.ts +++ b/packages/fields/src/widgets/toDomProps.ts @@ -123,7 +123,16 @@ const DOM_PASS_THROUGH_KEYS = [ 'disabled', ] as const; -type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number]; +/** + * The keys this helper forwards. + * + * Exported so the SIBLING executor — `toHostProps`, which carries the declared + * NON-DOM keys (objectui#7008) — can subtract this set from the contract and + * assert that the two together cover every declared key exactly once. Without + * that subtraction there is no way to state "these keys are handled elsewhere" + * as a compile-time fact rather than as a comment. + */ +export type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number]; /** * Compile-time link to the declaration, direction 1 of 2: every key forwarded diff --git a/packages/fields/src/widgets/toHostProps.ts b/packages/fields/src/widgets/toHostProps.ts new file mode 100644 index 0000000000..625f65364a --- /dev/null +++ b/packages/fields/src/widgets/toHostProps.ts @@ -0,0 +1,208 @@ +/** + * 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. + */ + +import type { AriaAttributes } from 'react'; +import type { DomPassThroughKey } from './toDomProps.js'; +import type { FieldWidgetComponentProps } from './types.js'; + +/** + * The RUNTIME EXECUTOR of the NON-DOM half of {@link FieldWidgetComponentProps} + * (objectui#7008) — the sibling of `toDomProps`, and deliberately a SEPARATE + * function rather than more entries in that whitelist. + * + * ## The defect + * + * objectui#6909 / #7009 closed the DOM half at `FieldEditWidget`: the factory + * hands the widget `toDomProps(props)`, so `id` / `name` / `tabIndex` / + * `aria-*` / `data-*` and friends finally arrive. The contract also declares a + * "Host plumbing" block plus two controlled-input keys the factory neither owns + * nor routes to the DOM, and NOTHING carried those. A host could pass any of + * them with no type error and the widget never received it — this package's own + * first-class defect class, named in `toDomProps.ts`: "a key that type-checks, + * reads as supported, and silently never reaches the element" (objectui#3290's + * `aria-required`, objectui#3222's validation slot). + * + * Measured on `main` at `71d83a6b1`, the live victim was `error`: + * `InlineFieldInput` (`@object-ui/plugin-detail`, since PR #7109) already + * passes `error={error}` to `FieldEditWidget`, and the factory dropped it on + * the floor — so a control that had failed validation never reported + * `aria-invalid` and a screen-reader user was never told. The kanban + * `RequiredFieldsDialog` had the same hole from the other side: it computes the + * validation state, renders it in red text, and had no way to hand it over. + * + * ## Why NOT more keys in `DOM_PASS_THROUGH_KEYS` + * + * Because none of them is DOM-legal. That whitelist is deliberately closed and + * exists precisely to stop renderer plumbing reaching an element — its own + * header names `error`, `emptyHint`, `dataSource`, `dependentValues` and + * `dependsOn` as examples of what a blacklist would have failed to catch. Put + * them there and a `dataSource` adapter becomes `dataSource="[object Object]"` + * on an ``. They travel as COMPONENT props, which is what they are. + * + * ## Declared = enforced, in both directions + * + * The three assertions below make the two executors PARTITION the contract: + * every declared key is DOM-handled, forwarded here, or named as one the + * factory itself owns. A key added to `FieldWidgetComponentProps` that is none + * of those is a compile error, so the next non-DOM key cannot repeat this bug. + * + * ## Precedence: this is a CONDUIT, and never a second authority + * + * Several of these keys have a context-based fallback inside the widget that + * reads them, so DELIVERING one can change behaviour where before it could not + * arrive at all. The rule is stated once, here, and this function implements it + * by doing nothing: it forwards what the host passed and resolves NOTHING. + * Each key's precedence stays where its single reader already implements and + * documents it — + * + * - `dataSource`: **the explicit prop WINS** over `SchemaRendererContext`. + * `LookupField` already resolves exactly that order and says so on the line + * that does it: "explicit prop > field-level > wrapper field > + * SchemaRendererContext > none". Hosts that pass nothing keep reading the + * context, so the grid's inline editor is unaffected. + * - `dependentValues`: the explicit prop wins, then `ctx.formValues`, then + * `ctx.data` (`useCascadingOptions`, and `LookupField`'s own resolver). + * - `dependsOn`: the FIELD METADATA wins over the prop — the one documented + * inversion, stated on the key's own doc comment and implemented as + * `config?.dependsOn ?? dependsOnProp` in all four option widgets. + * - `emptyHint`: the host's value wins when supplied (objectui#3231). + * - `onCreateNew`: the prop wins over `field.onCreateNew`; `onSelectRecord` + * has no metadata carrier at all, so the prop is its ONLY one. + * + * Resolving any of that HERE would give each key a second author — the + * `field || schema` shape objectui#3233 spent a release removing. One key, one + * resolver, in the widget that reads it. + */ +const HOST_PLUMBING_KEYS = [ + /* ── Declared on the controlled-input block, but neither DOM-legal nor + owned by the factory ─────────────────────────────────────────────────── */ + // + // `error` is the published validation slot (`@objectstack/spec/ui`'s + // `FieldWidgetPropsSchema`). Its single documented consumer contract is + // "drive `aria-invalid` on the control"; the message TEXT stays with the + // host, so forwarding this cannot double-display anything. + 'error', + // `onUploadingChange` is forwarded for completeness of the declaration, not + // for a reachable consumer: its only readers are `FileField` / `ImageField`, + // and `file` / `image` are both in `INLINE_EXCLUDED_FIELD_TYPES`, so no + // widget reachable through `FieldEditWidget` reads it today. Delivering a + // declared key that happens to have no reader is the correct half of + // enforce-or-remove; withholding it would leave the contract lying. + 'onUploadingChange', + + /* ── The contract's own "Host plumbing" block, verbatim ────────────────── */ + // + // `compact` is the ONE member of that block deliberately absent here: the + // factory OWNS it, deriving it from the resolved field type + // (`COMPACT_EDIT_TYPES`) so a cell-sized relational picker renders as a + // single-line trigger. Forwarding it would give the sizing two authors, and + // the exclusion is named in the `FactoryOwnedKey` list below so the compiler + // treats it as a decision rather than an omission. + 'dataSource', + 'dependentValues', + 'dependsOn', + 'dependsOnLabels', + 'emptyHint', + 'onSelectRecord', + 'onCreateNew', +] as const; + +type HostPlumbingKey = (typeof HOST_PLUMBING_KEYS)[number]; + +/** + * The keys `FieldEditWidget` computes or renders ITSELF, and therefore neither + * executor forwards. + * + * `field` / `value` / `onChange` / `readonly` are the controlled-input contract + * the factory writes out explicitly; `compact` is derived from the resolved + * field type (see the note in the list above). Naming them here is what lets + * the partition assertion below be exact rather than a subset check. + */ +type FactoryOwnedKey = 'field' | 'value' | 'onChange' | 'readonly' | 'compact'; + +/** + * Every declared key that is NOT handled by the DOM executor, NOT one of the + * open attribute families, and NOT owned by the factory. + * + * Derived from the contract rather than typed out, so it tracks + * {@link FieldWidgetComponentProps} automatically. + */ +type NonDomDeclaredKey = Exclude< + keyof FieldWidgetComponentProps, + DomPassThroughKey | keyof AriaAttributes | `data-${string}` | FactoryOwnedKey +>; + +/** + * Direction 1 of 3: every key forwarded at runtime is declared on the contract. + * + * Catches: this helper forwards something the contract no longer declares. + */ +type EveryForwardedKeyIsDeclared = + HostPlumbingKey extends keyof FieldWidgetComponentProps ? true : never; +const _everyForwardedKeyIsDeclared: EveryForwardedKeyIsDeclared = true; +void _everyForwardedKeyIsDeclared; + +/** + * Direction 2 of 3: every declared non-DOM, non-factory-owned key is forwarded + * here. Adding a key to `FieldWidgetComponentProps` without adding it to + * {@link HOST_PLUMBING_KEYS} — or to {@link FactoryOwnedKey} — is a compile + * error. + * + * Catches: DECLARED BUT NOT DELIVERED, the failure this whole file exists to + * close. It is the direction no runtime test can see, because a test looks for + * props that ARRIVE, not for ones that go missing. + */ +type EveryDeclaredHostKeyIsForwarded = + NonDomDeclaredKey extends HostPlumbingKey ? true : never; +const _everyDeclaredHostKeyIsForwarded: EveryDeclaredHostKeyIsForwarded = true; +void _everyDeclaredHostKeyIsForwarded; + +/** + * Direction 3 of 3: the two executors are DISJOINT — nothing forwarded here is + * also a DOM pass-through key. + * + * Catches the specific mistake objectui#7008's ruling fences off: "route the + * host-plumbing keys through `toDomProps`". Add `error` or `dataSource` to + * `DOM_PASS_THROUGH_KEYS` and that file's own two assertions stay green (the + * keys ARE declared) — this one is what goes red, because the key would leave + * `NonDomDeclaredKey` while still being listed above. It is also what keeps the + * order of the two spreads at the call site a non-question. + */ +type EveryForwardedKeyIsNonDom = HostPlumbingKey extends NonDomDeclaredKey ? true : never; +const _everyForwardedKeyIsNonDom: EveryForwardedKeyIsNonDom = true; +void _everyForwardedKeyIsNonDom; + +/** The subset of `P` this helper forwards, with each key's declared type. */ +export type HostProps

= Pick>; + +/** + * Keep only the declared NON-DOM keys — the host plumbing a widget interprets + * as component props — and drop everything else. + * + * The companion to `toDomProps`, and used the same way. A host that passes + * nothing gets an empty object, so the widget's prop set is unchanged: + * + * ```tsx + * + * ``` + * + * Iterating the props (rather than the key list) mirrors `pickDomProps`, so a + * key the host did not mention stays ABSENT rather than arriving as + * `undefined` — which keeps the factory-boundary prop set readable as "what the + * host actually supplied". + */ +export function toHostProps

(props: P): HostProps

{ + const allowed: ReadonlySet = new Set(HOST_PLUMBING_KEYS); + const hostProps: Record = {}; + for (const key of Object.keys(props)) { + if (allowed.has(key)) { + hostProps[key] = (props as Record)[key]; + } + } + return hostProps as HostProps

; +} diff --git a/packages/plugin-kanban/src/RequiredFieldsDialog.tsx b/packages/plugin-kanban/src/RequiredFieldsDialog.tsx index f8e68fbddd..804761e6bf 100644 --- a/packages/plugin-kanban/src/RequiredFieldsDialog.tsx +++ b/packages/plugin-kanban/src/RequiredFieldsDialog.tsx @@ -114,9 +114,23 @@ export function RequiredFieldsDialog({ const isMissing = isMissingForRequired(values[f.name]); return ( // A wrapping `label` gives the control its accessible name - // implicitly — `FieldEditWidget` renders the widget itself and - // takes no `id` to associate with, and widening its contract - // belongs to `@object-ui/fields`, not to a caller. + // implicitly. This used to be justified with "`FieldEditWidget` + // renders the widget itself and takes no `id` to associate with, + // and widening its contract belongs to `@object-ui/fields`" — + // FALSE since objectui#7009 put `id` in `DOM_PASS_THROUGH_KEYS`: + // the factory takes an `id` and lands it on the real control. + // The reason is corrected rather than the markup changed + // (objectui#7008), because a dead constraint left in a comment is + // how the next reader concludes it still binds. + // + // The wrapping form is still the right choice here, for a reason + // that IS still true: this dialog renders whatever field types the + // target column made required, and several of them resolve to + // COMPOSITE controls with no single labelable element for a + // `htmlFor` to point at — `RadioField` renders `

`, `CheckboxesField` `
`, + // `AddressField` a set of sibling inputs. One wrapping `label` + // covers every type uniformly and mints no ids.