Export field.root pieces for custom chrome (#163) - #174
Conversation
| <D | ||
| of={node} | ||
| parts={{ | ||
| control: (c: FieldControl & { Default(): ReactNode }) => |
There was a problem hiding this comment.
is this type necessary or can this just be inferred as just control: (c) => ?
There was a problem hiding this comment.
Noise — removed. ControlOverrideOf already reads the handle's live parts.control, so control: (c) => infers on its own. I checked it wasn't just tolerated: tsc -p packages/react/tsconfig.test.json is clean with the annotation gone.
| of={node} | ||
| parts={{ | ||
| control: (c: FieldControl & { Default(): ReactNode }) => | ||
| c.kind === 'input' ? ( |
There was a problem hiding this comment.
is this ternary necessary or should the c.kind already know it's definitely always an 'input'??
There was a problem hiding this comment.
Real, but avoidable — and it exposed a gap worth its own issue.
c.kind can't know it's an input here. The handler came off the function intercept floor, which fires for every node in the tree; node.path === 'username' is a runtime test the compiler can't lift into the brand. And it won't come from the node either: FieldNode is deliberately one interface with widget as a resolved label, not a discriminant on parts.control, so nothing that handles nodes narrows on widget (ADR 029 §5, v60).
What you can do is state the kind instead of testing for it. ControlOverride<'input'> was already exported for exactly this, so the test now reads:
const KitControl: ControlOverride<'input'> = (c) => (
<input {...c.attrs} data-testid="kit-control" />
)c.attrs is HtmlInputAttrs, no branch, no dead : null arm.
The part that should be automatic isn't, though. Reach the same field through the branded, FormShape-keyed door and you still get the union:
layout={(root, { Default }) => (
<Default of={root.children.username} parts={{ control: (c) => <input {...c.attrs} /> }} />
)}
// TS2339: Property 'attrs' does not exist on … choicegroup …The brand knows username's widget is input, and useInterceptRules already spends it — FieldPartsData<TS['fields'][P]['widget'], …> pre-narrows Control there. layout throws it away: LayoutNode's field branch hands back a plain EField<Origin>. Filed as #176 to make the keyed door match the typed-rules door; kept out of this PR since it's layout surface, not field.root pieces.
A UI-kit `field.root` (Chakra FormControl, an RHF wrapper) had no way to
resolve `parts.*` overlays without re-implementing the native root's slot
loop, and no way to reach the error primitives it wraps.
`useFieldRootSlots({ node, overrides })` takes the same props object
`defaults.field.root` receives and returns `{ label, description, control }`
with overlays applied. It stops there on purpose: it does NOT wire
aria-invalid / aria-describedby / the error list, because the host control
that motivates a custom root already emits those and would fight ours.
Baking them in would make this seam a Chakra adapter by another name.
A root that DOES want FormFrame's a11y composes the three pieces the native
root is built from, now public: `useInjectedFieldErrors`,
`enrichControlErrorA11y`, `FieldErrorsList`.
`DefaultFieldRoot` now shares the same `renderPart` helper, so the native
and custom paths cannot drift in how an overlay is resolved.
ADR 052 already called this an IOC seam rather than a host adapter; amended
it with what shipped and with the a11y-ownership call.
Co-authored-by: Cursor <cursoragent@cursor.com>
…arts.control overlay Review on #174: neither was load-bearing. The `(c: FieldControl & { Default(): ReactNode })` annotation was noise — `ControlOverrideOf` already reads the handle's live `parts.control`, so `(c) =>` infers. The `c.kind === 'input'` branch was real but avoidable. A function intercept fires for every node, so `c` is the whole control union and `node.path ===` is a runtime check the compiler cannot lift into the brand. Typing the handler `ControlOverride<'input'>` states the kind instead of testing for it. Keyed `layout` children should not need either — the brand knows the widget — but they do today: #176. Co-authored-by: Cursor <cursoragent@cursor.com>
e5ae809 to
9d83c08
Compare
Summary
Lifted off the #164 spike working tree, where this was tangled with the layout work — it stands alone. Rebased on
mainafter #172 merged.A UI-kit
field.root(ChakraFormControl, an RHF wrapper) had no way to resolveparts.*overlays without re-implementing the native root's slot loop, and no way to reach the error primitives that root wraps.useFieldRootSlots({ node, overrides })takes the same props objectdefaults.field.rootreceives and hands back{ label, description, control }with overlays applied:It stops there on purpose. It does not wire
aria-invalid/aria-describedby/ the error list, because the host control that motivates a custom root already emits those and would fight ours. Baking them in would make this seam a Chakra adapter by another name — the thing ADR 052 says not to build. A root that does want FormFrame's a11y composes the three pieces the native root is built from, now public:useInjectedFieldErrors,enrichControlErrorA11y,FieldErrorsList.DefaultFieldRootnow uses the samerenderParthelperuseFieldRootSlotsdoes, so the native and custom paths can't drift in how an overlay resolves.ADR 052 already framed this as an IOC seam rather than a host adapter; amended with what shipped and with the a11y-ownership call.
Closes #163.
Review round
Both comments were on the
parts.controloverlay in the test, and both were right.The
(c: FieldControl & { Default(): ReactNode })annotation was noise —ControlOverrideOfreads the handle's liveparts.control, so(c) =>infers.The
c.kind === 'input'branch was real but avoidable. The handler came off the function intercept floor, which fires for every node, socis the whole control union andnode.path ===is a runtime test the compiler can't lift into the brand — andFieldNodedeliberately isn't widget-discriminated (ADR 029 §5, v60). Typing the handlerControlOverride<'input'>states the kind instead of testing for it.That did surface a genuine gap: keyed
layoutchildren should narrow — the brand knows the widget, anduseInterceptRulesalready spends it — butLayoutNodehands back a plainEField. Filed as #176, kept out of this PR since it's layout surface.Test plan
npm run gate(typecheck + lint + format + 254 react tests / 403 total)field-root-pieces.test.tsx: custom chrome replacesdiv.jsf-field, overlays still resolve, no FormFrame a11y leaks in,parts.controloverlay is honored unenrichedinjected-errors.test.tsxstill green — the native root's a11y is unchangedhookFormDefaultsspike and confirm Chakra owns the error chrome end to endThe VNDLY schedule-meeting spike is the second implementation ADR 008 asks for, and it already runs this shape locally — the remaining box is confirming the export surface end to end, not discovering it.
Made with Cursor