Skip to content

feat(form,select,combobox,autocomplete): native form-submission props + corrected form guidance - #469

Draft
interacsean wants to merge 3 commits into
mainfrom
feat/core/form-native-submission-props
Draft

feat(form,select,combobox,autocomplete): native form-submission props + corrected form guidance#469
interacsean wants to merge 3 commits into
mainfrom
feat/core/form-native-submission-props

Conversation

@interacsean

@interacsean interacsean commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Two halves of one problem. Base UI's Select, Combobox, and Autocomplete roots already accept name, form, required, inputRef, and itemToStringValue, and its Form accepts id — but the AppShell wrapper Pick<> types filtered all of them out. Meanwhile the bundled app-shell-patterns skill documented a form model that didn't match the package.

Every new prop is optional and additive; no existing usage changes behaviour.

Part 1 — expose the props

Form accepts id, so a submit button rendered outside the form can target it via the native form attribute. That's what the common "Save in the page header, fields in the body" layout needs:

<Layout.Header
  title="Create product"
  actions={[<Button key="save" type="submit" form="product-form">Save</Button>]}
/>
<Form id="product-form" onFormSubmit={save}></Form>

Select / Combobox / Autocomplete accept name, form, required, inputRef — plus itemToStringValue on Select and Combobox. Added to both the Parts.Root picks and the standalone prop interfaces, then forwarded explicitly (the standalone components hand-pick props onto the root rather than spreading, so each render branch needed wiring).

Part 2 — correct the guidance

The skill described Form as "wired to react-hook-form" and Field as binding "to react-hook-form via name". Neither is true. Worse, all four form/* reference implementations ignored Form and hand-rolled <form onSubmit> + new FormData(...), skipping validation and error routing — contradicting the skill's own "use AppShell components, not raw HTML" rule.

The correction came from probing the runtime rather than reading types: onFormSubmit does not read FormData — it reduces over registered Field.Roots via field.getValue(). So every control, dropdowns included, participates just by being wrapped in a Field.Root name="…". No name on the control, no useState, no merging. The reference implementations lost all their state plumbing.

Three mechanisms, not one

Path Value source Needs name?
Form + Field.RootonFormSubmit Base UI field registry No
RHF Controller (contracts added in #413) RHF state No
Native FormData / plain <form> / server actions DOM hidden input Yes — what Part 1 adds

Behaviours documented that were previously undiscoverable

  • name is ignored inside a Field.Root — the field's name wins, for both the hidden input and the payload. Correct (it avoids duplicates) but was silently contradicted by the JSDoc. Now stated on all three components and pinned by a test.
  • Object items submit as a JSON string unless itemToStringValue is supplied. Items shaped { value, label } use value automatically.
  • Creatable Comboboxes can't customise the submitted valueuseCreatable owns itemToStringValue for its pending-item sentinel, so it's Omitted from the type (a compile error rather than a silent drop).

Judgement calls

  • Standalone id left pointing at the trigger. Repointing to the Base UI root would be a silent behaviour change for existing consumers. Root id is reachable via Parts.Root.
  • Pick<> not converted to Omit<>. Checkbox uses a denylist and therefore already had name; the dropdowns use an allowlist and didn't. Worth settling deliberately, but converting here would turn a focused change into an API-wide one. Raising separately.

Testing

pnpm type-check, pnpm lint, and the full packages/core suite (1605 tests) pass; catalogue type-check and check-generated-skills are green.

Per component: hidden input carries name/value, FormData picks it up, { value, label } and itemToStringValue serialisation, form= association, inputRef, required blocking submission and rendering the field error, user-selected values, and the Field.Root precedence. Plus Form id + detached submit button, and a regression guard on the Field.RootonFormSubmit path.

Notes for review

🤖 Generated with Claude Code

…n props

Base UI's Select, Combobox and Autocomplete roots already accept `name`,
`form`, `required`, `inputRef` and `itemToStringValue`, and its Form accepts
`id`, but the AppShell wrapper `Pick<>` types filtered all of them out. There
was no way to reach them from the public API.

- `Form` now accepts `id`, so a submit button rendered outside the form can
  target it via the native `form` attribute (Save in `Layout.Header`, fields
  in the body). The component already spread props through, so only the type
  was blocking it.
- `Select`, `Combobox` and `Autocomplete` accept `name`, `form`, `required`
  and `inputRef`; `Select` and `Combobox` also accept `itemToStringValue`.
  Added to the `Parts.Root` picks and the standalone prop interfaces, and
  forwarded explicitly — the standalone components hand-pick props onto the
  root rather than spreading, so each render branch needed wiring.

`itemToStringValue` is omitted from Combobox's creatable variants:
`useCreatable` derives its own so the pending-item sentinel serialises
correctly, and a consumer-supplied one would break it. Omitting it from the
type makes that a compile error rather than a silent drop.

The standalone `id` prop is left alone — it targets the trigger, not the
root, and repointing it would be a silent behaviour change. Root `id` is
reachable via `Parts.Root`.

Note this is a separate mechanism from `Form`'s `onFormSubmit`, which reduces
over registered `Field.Root`s via `field.getValue()` rather than reading
FormData. That path already worked without `name` on the control; what was
broken is *native* submission (plain `<form>`, `new FormData(form)`, server
actions), where these controls contributed nothing to the DOM. Tests pin both
mechanisms so they don't get conflated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Code Metrics Report

main (41ae0e3) #469 (33ebaf2) +/-
Coverage 90.1% 90.1% +0.0%
Test Execution Time 2m5s 1m39s -26s
Details
  |                     | main (41ae0e3) | #469 (33ebaf2) |  +/-  |
  |---------------------|----------------|----------------|-------|
+ | Coverage            |          90.1% |          90.1% | +0.0% |
  |   Files             |            152 |            152 |     0 |
  |   Lines             |           5201 |           5204 |    +3 |
+ |   Covered           |           4690 |           4693 |    +3 |
+ | Test Execution Time |           2m5s |          1m39s |  -26s |

Code coverage of files in pull request scope (93.3% → 93.3%)

Files Coverage +/- Status
packages/core/src/components/autocomplete/autocomplete-standalone.tsx 100.0% 0.0% modified
packages/core/src/components/autocomplete/autocomplete.tsx 90.6% 0.0% modified
packages/core/src/components/combobox/combobox-standalone.tsx 76.7% +0.4% modified
packages/core/src/components/combobox/combobox.tsx 97.7% 0.0% modified
packages/core/src/components/form/form.tsx 100.0% 0.0% modified
packages/core/src/components/select/select-standalone.tsx 95.8% +0.1% modified
packages/core/src/components/select/select.tsx 100.0% 0.0% modified

Reported by octocov

The bundled `app-shell-patterns` skill described `Form` as "wired to
react-hook-form" and `Field` as binding "to react-hook-form via `name`".
Neither is true: `Form`/`Field`/`Fieldset` wrap Base UI and own accessibility
wiring and visual state only, and react-hook-form stopped being a runtime
dependency in 1.4.0. Meanwhile the `form/*` reference implementations ignored
`Form` entirely and hand-rolled `<form onSubmit>` + `new FormData(...)`,
skipping validation and server-error routing — contradicting the skill's own
"use AppShell components, not raw HTML" rule.

All four patterns now use `Form` with `onFormSubmit`, and the guidance states
the model they implement. The key correction, established by probing the
runtime rather than reading the types: `onFormSubmit` does not read FormData,
it reduces over registered `Field.Root`s via `field.getValue()`. So every
control — `Select`, `Combobox` and `Autocomplete` included — participates just
by being wrapped in a `Field.Root name="…"`. No `name` on the control, no
`useState`, no merging in the submit handler.

Two behaviours that were previously undiscoverable are now documented:

- An object-valued dropdown submits as a JSON string unless `itemToStringValue`
  is supplied. Items shaped `{ value, label }` use `value` automatically.
- A page-header Save reaches a body form by matching `Form`'s `id` with a
  detached `<Button type="submit" form="…">`.

Both rely on props added in the preceding commit, so this stacks on it.

Also corrects the `Select` example in docs/components/form.md, which was built
from `Select.Trigger` / `Select.Popup` / `Select.Item`. That code could never
have compiled — verified against tsc: `Select` is the pre-assembled standalone
component with its parts under `Select.Parts.*`, and `Select.Popup` has never
existed in this codebase at all (AppShell's is `Select.Content`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@interacsean

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Code Review completed successfully!

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Generated by Code Review for issue #469 · 38 AIC · ⌖ 4.83 AIC · ⊞ 5.9K
Comment /review to run again

/**
* Identifies the field when a form is submitted. Base UI renders a hidden
* input under this name, so the selected value is picked up by native form
* submission — including `Form`'s `onFormSubmit`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[1/1 — Medium] Misleading JSDoc claim: name does NOT affect Form's onFormSubmit

The JSDoc here says the hidden input is "picked up by native form submission — including Form's onFormSubmit". The same claim appears in combobox-standalone.tsx line 64, autocomplete-standalone.tsx line 69, select.tsx inline comment, and combobox.tsx inline comment (and in the changeset body).

This directly contradicts the PR description itself, which explicitly states:

"Note this is a separate mechanism from Form's onFormSubmit, which collects values from registered Field.Roots keyed by the field's name — that path already worked without name on the control and is unchanged."

onFormSubmit reduces over Base UI's Field registry (Field.Roots), not over FormData. Adding name to the control does not cause onFormSubmit to pick it up; it only makes the value visible to native form submission (new FormData(form), uncontrolled <form>, server actions).

The "keeps working with Form + Field.Root" test confirms this — onFormSubmit fires because there's a Field.Root name="direction", not because Select has name="direction".

Suggested fix — replace "including Form's onFormSubmit" with a note that this is the native path only:

/**
 * Identifies the field when a form is submitted. Base UI renders a hidden
 * input under this name, so the selected value is picked up by **native**
 * form submission (`new FormData(form)`, plain `<form>`, server actions).
 *
 * This is a separate path from `Form`'s `onFormSubmit`, which reads
 * registered `Field.Root`s and already works without `name` on the control.
 *
 * For non-string items, pair this with `itemToStringValue` to control
 * how the value is serialised.
 */

Apply the equivalent correction to combobox-standalone.tsx, autocomplete-standalone.tsx, the inline comments in select.tsx / combobox.tsx, and the changeset body.

Verdict: Request Changes
The name/onFormSubmit conflation is the kind of misunderstanding that causes consumers to omit Field.Root when they actually need onFormSubmit, leading to silent data loss. The three-table summary in the PR description has it right; the JSDoc and source comments need to match.

… in tests

Addresses review findings on this PR.

`name` is silently ignored when the control sits inside a `Field.Root` — the
field's name wins and is what reaches both the hidden input and the
`onFormSubmit` payload. Verified: `<Field.Root name="fieldName"><Select
name="controlName"/></Field.Root>` renders one input named `fieldName`, and
`controlName` appears nowhere. That precedence is correct (it avoids duplicate
entries) but the JSDoc claimed the prop always names the hidden input, which is
false in the dominant AppShell usage. All three components now state it, and a
test pins it so it cannot drift silently.

The `required` test asserted `input.required === true` — an attribute check
that would still pass if Base UI stopped honouring the attribute. Replaced with
the behaviour that matters: submission is blocked and the matching
`Field.Error` renders.

Also documents the new props in `docs/components/{select,combobox,autocomplete}.md`,
which had no mention of `name`, `form`, `required`, `inputRef` or
`itemToStringValue` while the sibling `checkbox.md` documents exactly these.
Includes the creatable-Combobox limitation: `itemToStringValue` is unavailable
there, so a creatable combobox over object items cannot customise its submitted
value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@interacsean interacsean changed the title feat(form,select,combobox,autocomplete): expose native form-submission props feat(form,select,combobox,autocomplete): native form-submission props + corrected form guidance Aug 28, 2026
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.

1 participant