Skip to content

Export field.root pieces for custom chrome (#163) - #174

Merged
timkindberg merged 2 commits into
mainfrom
feat/163-field-root-pieces
Sep 10, 2026
Merged

Export field.root pieces for custom chrome (#163)#174
timkindberg merged 2 commits into
mainfrom
feat/163-field-root-pieces

Conversation

@timkindberg

@timkindberg timkindberg commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Summary

Lifted off the #164 spike working tree, where this was tangled with the layout work — it stands alone. Rebased on main after #172 merged.

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 that root wraps.

useFieldRootSlots({ node, overrides }) takes the same props object defaults.field.root receives and hands back { label, description, control } with overlays applied:

function KitFieldRoot(props) {
  const { label, description, control } = useFieldRootSlots(props)
  return <FormControl>{label}{description}{control}</FormControl>
}

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.

DefaultFieldRoot now uses the same renderPart helper useFieldRootSlots does, 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.control overlay in the test, and both were right.

The (c: FieldControl & { Default(): ReactNode }) annotation was noise — ControlOverrideOf reads the handle's live parts.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, so c is the whole control union and node.path === is a runtime test the compiler can't lift into the brand — and FieldNode deliberately isn't widget-discriminated (ADR 029 §5, v60). Typing the handler ControlOverride<'input'> states the kind instead of testing for it.

That did surface a genuine gap: keyed layout children should narrow — the brand knows the widget, and useInterceptRules already spends it — but LayoutNode hands back a plain EField. 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)
  • New field-root-pieces.test.tsx: custom chrome replaces div.jsf-field, overlays still resolve, no FormFrame a11y leaks in, parts.control overlay is honored unenriched
  • injected-errors.test.tsx still green — the native root's a11y is unchanged
  • Wire it in the VNDLY hookFormDefaults spike and confirm Chakra owns the error chrome end to end

The 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

<D
of={node}
parts={{
control: (c: FieldControl & { Default(): ReactNode }) =>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this type necessary or can this just be inferred as just control: (c) => ?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' ? (

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this ternary necessary or should the c.kind already know it's definitely always an 'input'??

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@timkindberg
timkindberg marked this pull request as ready for review September 10, 2026 02:55
@timkindberg
timkindberg force-pushed the feat/163-field-root-pieces branch from e5ae809 to 9d83c08 Compare September 10, 2026 17:08
@timkindberg
timkindberg merged commit a9a4b24 into main Sep 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Export composable field.root pieces for custom chrome

1 participant