feat(form,select,combobox,autocomplete): native form-submission props + corrected form guidance - #469
feat(form,select,combobox,autocomplete): native form-submission props + corrected form guidance#469interacsean wants to merge 3 commits into
Conversation
…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>
Code Metrics Report
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%)
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>
|
/review |
|
✅ Code Review completed successfully! |
There was a problem hiding this comment.
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`. |
There was a problem hiding this comment.
[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'sonFormSubmit, which collects values from registeredField.Roots keyed by the field'sname— that path already worked withoutnameon 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>
Summary
Two halves of one problem. Base UI's
Select,Combobox, andAutocompleteroots already acceptname,form,required,inputRef, anditemToStringValue, and itsFormacceptsid— but the AppShell wrapperPick<>types filtered all of them out. Meanwhile the bundledapp-shell-patternsskill 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
Formacceptsid, so a submit button rendered outside the form can target it via the nativeformattribute. That's what the common "Save in the page header, fields in the body" layout needs:Select/Combobox/Autocompleteacceptname,form,required,inputRef— plusitemToStringValueonSelectandCombobox. Added to both theParts.Rootpicks 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
Formas "wired to react-hook-form" andFieldas binding "to react-hook-form vianame". Neither is true. Worse, all fourform/*reference implementations ignoredFormand 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:
onFormSubmitdoes not readFormData— it reduces over registeredField.Roots viafield.getValue(). So every control, dropdowns included, participates just by being wrapped in aField.Root name="…". Nonameon the control, nouseState, no merging. The reference implementations lost all their state plumbing.Three mechanisms, not one
name?Form+Field.Root→onFormSubmitController(contracts added in #413)FormData/ plain<form>/ server actionsBehaviours documented that were previously undiscoverable
nameis ignored inside aField.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.itemToStringValueis supplied. Items shaped{ value, label }usevalueautomatically.useCreatableownsitemToStringValuefor its pending-item sentinel, so it'sOmitted from the type (a compile error rather than a silent drop).Judgement calls
idleft pointing at the trigger. Repointing to the Base UI root would be a silent behaviour change for existing consumers. Rootidis reachable viaParts.Root.Pick<>not converted toOmit<>.Checkboxuses a denylist and therefore already hadname; 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 fullpackages/coresuite (1605 tests) pass; catalogue type-check andcheck-generated-skillsare green.Per component: hidden input carries name/value,
FormDatapicks it up,{ value, label }anditemToStringValueserialisation,form=association,inputRef,requiredblocking submission and rendering the field error, user-selected values, and theField.Rootprecedence. PlusFormid+ detached submit button, and a regression guard on theField.Root→onFormSubmitpath.Notes for review
41ae0e3, after refactor(components): standardize component family entrypoints #464 moved these files into per-family directories.e2c8c63.🤖 Generated with Claude Code