From dcbb0dc7926f3d7fd3554a350491486195a2b591 Mon Sep 17 00:00:00 2001 From: interacsean Date: Tue, 25 Aug 2026 10:03:54 +1000 Subject: [PATCH 1/3] feat(form,select,combobox,autocomplete): expose native form-submission props MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `
`, `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 --- .changeset/lucky-pandas-attend.md | 21 +++ .../autocomplete-standalone.test.tsx | 67 ++++++++ .../autocomplete/autocomplete-standalone.tsx | 33 ++++ .../components/autocomplete/autocomplete.tsx | 14 +- .../combobox/combobox-standalone.test.tsx | 90 +++++++++++ .../combobox/combobox-standalone.tsx | 65 +++++++- .../core/src/components/combobox/combobox.tsx | 7 + .../core/src/components/form/form.test.tsx | 38 +++++ packages/core/src/components/form/form.tsx | 21 ++- .../select/select-standalone.test.tsx | 153 ++++++++++++++++++ .../components/select/select-standalone.tsx | 47 ++++++ .../core/src/components/select/select.tsx | 12 ++ 12 files changed, 562 insertions(+), 6 deletions(-) create mode 100644 .changeset/lucky-pandas-attend.md diff --git a/.changeset/lucky-pandas-attend.md b/.changeset/lucky-pandas-attend.md new file mode 100644 index 000000000..db1c56017 --- /dev/null +++ b/.changeset/lucky-pandas-attend.md @@ -0,0 +1,21 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Let forms be driven natively: `Form` now accepts `id`, and `Select`, `Combobox`, and `Autocomplete` accept `name`, `form`, `required`, and `inputRef` (plus `itemToStringValue` on `Select` and `Combobox`). These props were already supported by the underlying Base UI roots but were filtered out by the wrapper `Pick<>` types, so there was no way to reach them. + +**`Form` `id`** — a submit button rendered outside the form can now target it with the native `form` attribute, which is what the common "Save in the page header, fields in the body" layout needs: + +```tsx +Save]} +/> +… +``` + +**`name` on the dropdowns** — Base UI renders a hidden input under that name, so the selected value is now visible to native submission: `new FormData(form)`, an uncontrolled `
`, and server actions. Previously these controls contributed nothing to the DOM payload, so a native form silently submitted without them. + +For non-string items, `itemToStringValue` controls serialisation (items shaped `{ value, label }` use `value` automatically). It is not available on `Combobox`'s creatable variants, which derive it internally so the pending-item sentinel serialises correctly. + +Note this is a _separate_ mechanism from `Form`'s `onFormSubmit`, which collects values from registered `Field.Root`s keyed by the field's `name` — that path already worked without `name` on the control and is unchanged. diff --git a/packages/core/src/components/autocomplete/autocomplete-standalone.test.tsx b/packages/core/src/components/autocomplete/autocomplete-standalone.test.tsx index 08d51ae31..981d2257b 100644 --- a/packages/core/src/components/autocomplete/autocomplete-standalone.test.tsx +++ b/packages/core/src/components/autocomplete/autocomplete-standalone.test.tsx @@ -434,3 +434,70 @@ describe("Autocomplete (standalone, grouped)", () => { }); }); }); + +// ============================================================================ +// Form participation — Autocomplete's value is the raw input string, so `name` +// applies directly to the text input; no value-serialisation hook is needed. +// ============================================================================ + +describe("Autocomplete — form participation", () => { + it("applies name to the input", () => { + const { container } = render( + , + ); + expect(container.querySelector('input[name="fruit"]')).not.toBeNull(); + }); + + it("is picked up by native FormData", () => { + const { container } = render( + + + , + ); + const form = container.querySelector("form") as HTMLFormElement; + expect(new FormData(form).get("fruit")).toBe("Cherry"); + }); + + it("reflects text typed by the user", async () => { + const user = userEvent.setup(); + const { container } = render( +
+ + , + ); + + await user.type(screen.getByRole("combobox", { name: "Fruit" }), "Ban"); + + const form = container.querySelector("form") as HTMLFormElement; + expect(new FormData(form).get("fruit")).toBe("Ban"); + }); + + it("associates the input with an outer form via `form`", () => { + const { container } = render( +
+
+ +
, + ); + const input = container.querySelector('input[name="fruit"]') as HTMLInputElement; + expect(input.getAttribute("form")).toBe("outer"); + }); + + it("marks the input required", () => { + const { container } = render( + , + ); + const input = container.querySelector('input[name="fruit"]') as HTMLInputElement; + expect(input.required).toBe(true); + }); + + it("Autocomplete.Async also applies name", async () => { + const fetcher = vi.fn().mockResolvedValue(suggestions); + const { container } = render( + , + ); + await waitFor(() => { + expect(container.querySelector('input[name="fruit"]')).not.toBeNull(); + }); + }); +}); diff --git a/packages/core/src/components/autocomplete/autocomplete-standalone.tsx b/packages/core/src/components/autocomplete/autocomplete-standalone.tsx index f4a6b4c42..800c3d295 100644 --- a/packages/core/src/components/autocomplete/autocomplete-standalone.tsx +++ b/packages/core/src/components/autocomplete/autocomplete-standalone.tsx @@ -64,6 +64,23 @@ interface AutocompletePropsBase { "aria-labelledby"?: string; /** ID applied to the combobox input element. */ id?: string; + /** + * Identifies the field when a form is submitted, so the current text is + * picked up by native form submission — including `Form`'s `onFormSubmit`. + * + * Autocomplete's value is the raw input string, so no value-serialisation + * hook is needed (unlike `Select` and `Combobox`). + */ + name?: string; + /** + * `id` of the form that owns the input. Use when the autocomplete is + * rendered outside the `` element it belongs to. + */ + form?: string; + /** Whether a value must be entered before the owning form can be submitted. */ + required?: boolean; + /** Ref to the underlying input element. */ + inputRef?: React.Ref; } // --- Autocomplete (static) --- @@ -95,6 +112,10 @@ function AutocompleteStandalone(props: AutocompleteStandaloneProps) { "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, id, + name, + form, + required, + inputRef, } = props; const mapItem = (mapItemProp ?? defaultMapItem) as (item: T) => MappedItem; @@ -134,6 +155,10 @@ function AutocompleteStandalone(props: AutocompleteStandaloneProps) { defaultValue={defaultValue} onValueChange={onValueChange} disabled={disabled} + name={name} + form={form} + required={required} + inputRef={inputRef} > (props: AutocompleteAsyncStandaloneProps< "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, id, + name, + form, + required, + inputRef, } = props; const async = useAsync({ fetcher, onFetchError }); @@ -220,6 +249,10 @@ function AutocompleteAsyncStandalone(props: AutocompleteAsyncStandaloneProps< onValueChange={handleValueChange} filter={null} disabled={disabled} + name={name} + form={form} + required={required} + inputRef={inputRef} > = Pick< AutocompleteRootProps, - "value" | "defaultValue" | "onValueChange" | "filter" | "disabled" | "children" + | "value" + | "defaultValue" + | "onValueChange" + | "filter" + | "disabled" + | "children" + // Form participation. Autocomplete's value is the raw input string, so no + // `itemToStringValue` is needed (Base UI omits it here for that reason). + | "name" + | "form" + | "required" + | "inputRef" + | "id" > & { items?: readonly Value[]; }; diff --git a/packages/core/src/components/combobox/combobox-standalone.test.tsx b/packages/core/src/components/combobox/combobox-standalone.test.tsx index d807170f6..78e4f9569 100644 --- a/packages/core/src/components/combobox/combobox-standalone.test.tsx +++ b/packages/core/src/components/combobox/combobox-standalone.test.tsx @@ -623,3 +623,93 @@ describe("Combobox (standalone, grouped)", () => { }); }); }); + +// ============================================================================ +// Form participation — `name` renders a hidden input so the selected value is +// read by native submission (`FormData`, plain ``, server actions). +// `Form`'s `onFormSubmit` is a separate path that reads registered `Field.Root`s. +// ============================================================================ + +describe("Combobox — form participation", () => { + it("renders a hidden input carrying name and value", () => { + const { container } = render( + , + ); + const input = container.querySelector('input[name="fruit"]') as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.value).toBe("Banana"); + }); + + it("is picked up by native FormData", () => { + const { container } = render( + + + , + ); + const form = container.querySelector("form") as HTMLFormElement; + expect(new FormData(form).get("fruit")).toBe("Cherry"); + }); + + it("serialises arbitrary object items via itemToStringValue", () => { + const warehouses = [ + { id: 7, name: "Warehouse A" }, + { id: 9, name: "Warehouse B" }, + ]; + const { container } = render( +
+ ({ label: item.name, key: String(item.id) })} + itemToStringValue={(item) => String(item.id)} + aria-label="Warehouse" + /> + , + ); + const form = container.querySelector("form") as HTMLFormElement; + expect(new FormData(form).get("warehouse")).toBe("7"); + }); + + it("associates the hidden input with an outer form via `form`", () => { + const { container } = render( +
+
+ +
, + ); + const input = container.querySelector('input[name="fruit"]') as HTMLInputElement; + expect(input.getAttribute("form")).toBe("outer"); + }); + + it("exposes the hidden input through inputRef", () => { + const ref = { current: null } as React.RefObject; + render(); + expect(ref.current).toBeInstanceOf(HTMLInputElement); + expect(ref.current?.name).toBe("fruit"); + }); + + it("Combobox.Async also renders the hidden input", async () => { + const fetcher = vi.fn().mockResolvedValue(fruits); + const { container } = render( + , + ); + await waitFor(() => { + expect(container.querySelector('input[name="fruit"]')).not.toBeNull(); + }); + }); + + it("forwards name on the creatable variant", () => { + const items = [{ id: "1", label: "Apple" }]; + const { container } = render( + ({ label: item.label, key: item.id })} + onCreateItem={(value) => ({ id: value, label: value })} + aria-label="Fruit" + />, + ); + expect(container.querySelector('input[name="fruit"]')).not.toBeNull(); + }); +}); diff --git a/packages/core/src/components/combobox/combobox-standalone.tsx b/packages/core/src/components/combobox/combobox-standalone.tsx index 085cf3ba5..05e05bf40 100644 --- a/packages/core/src/components/combobox/combobox-standalone.tsx +++ b/packages/core/src/components/combobox/combobox-standalone.tsx @@ -58,6 +58,32 @@ interface ComboboxPropsBase { "aria-labelledby"?: string; /** ID applied to the combobox input element. */ id?: string; + /** + * 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`. + * + * For non-string items, pair this with `itemToStringValue` to control how + * the value is serialised. + */ + name?: string; + /** + * `id` of the form that owns the hidden input. Use when the combobox is + * rendered outside the `` element it belongs to. + */ + form?: string; + /** Whether a value must be chosen before the owning form can be submitted. */ + required?: boolean; + /** Ref to the hidden input that carries the value during form submission. */ + inputRef?: React.Ref; + /** + * Converts a non-string item to the string written into the hidden input on + * form submission. Items shaped `{ value, label }` use `value` automatically. + * + * Distinct from `mapItem`, which controls what the user sees. Not available + * on the creatable variants, which own value serialisation. + */ + itemToStringValue?: (item: T) => string; } interface ComboboxPropsSingle extends ComboboxPropsBase { @@ -120,6 +146,29 @@ interface CreatableInternalProps { onValueChange?: ((value: T | null) => void) | ((value: T[]) => void); onCreateItem: (value: string) => T | false | Promise; formatCreateLabel?: (value: string) => string; + name?: string; + form?: string; + required?: boolean; + inputRef?: React.Ref; +} + +/** + * Pull the props that make the hidden input part of native form submission. + * `itemToStringValue` is deliberately absent: the creatable variants derive it + * from `useCreatable` so the pending-item sentinel serialises correctly. + */ +function pickFormProps(props: { + name?: string; + form?: string; + required?: boolean; + inputRef?: React.Ref; +}) { + return { + name: props.name, + form: props.form, + required: props.required, + inputRef: props.inputRef, + }; } /** Pull the accessibility props that should be forwarded to the input. */ @@ -150,8 +199,10 @@ type ComboboxStaticPlainProps = // -- Creatable -- type ComboboxStaticCreatableProps = - | ({ items: T[] } & CreatableProps & Omit, "mapItem">) - | ({ items: T[] } & CreatableProps & Omit, "mapItem">); + | ({ items: T[] } & CreatableProps & + Omit, "mapItem" | "itemToStringValue">) + | ({ items: T[] } & CreatableProps & + Omit, "mapItem" | "itemToStringValue">); // ============================================================================ // Shared internal layout — single place for single vs multiple rendering @@ -389,6 +440,7 @@ function ComboboxStaticCreatable(props: ComboboxStaticCreatabl container={container} inputProps={pickInputProps(props)} rootProps={{ + ...pickFormProps(props), items: creatable.items, value: value ?? creatable.value, onValueChange: creatable.onValueChange, @@ -448,8 +500,12 @@ type ComboboxAsyncPlainProps = // -- Creatable -- type ComboboxAsyncCreatableProps = - | (ComboboxAsyncOwnProps & CreatableProps & Omit, "mapItem">) - | (ComboboxAsyncOwnProps & CreatableProps & Omit, "mapItem">); + | (ComboboxAsyncOwnProps & + CreatableProps & + Omit, "mapItem" | "itemToStringValue">) + | (ComboboxAsyncOwnProps & + CreatableProps & + Omit, "mapItem" | "itemToStringValue">); // ============================================================================ // Combobox.Async — base (no creatable) @@ -592,6 +648,7 @@ function ComboboxAsyncCreatable(props: ComboboxAsyncCreatableP container={container} inputProps={pickInputProps(props)} rootProps={{ + ...pickFormProps(props), items: creatable.items, filter: null, value: value ?? creatable.value, diff --git a/packages/core/src/components/combobox/combobox.tsx b/packages/core/src/components/combobox/combobox.tsx index 0b133f515..2d4ef594f 100644 --- a/packages/core/src/components/combobox/combobox.tsx +++ b/packages/core/src/components/combobox/combobox.tsx @@ -25,6 +25,13 @@ type ComboboxRootProps = Pi | "itemToStringValue" | "disabled" | "children" + // Form participation: Base UI renders a hidden input named `name`, which is + // what native submission (and `Form`'s `onFormSubmit`) reads. + | "name" + | "form" + | "required" + | "inputRef" + | "id" >; function ComboboxRoot( diff --git a/packages/core/src/components/form/form.test.tsx b/packages/core/src/components/form/form.test.tsx index 988ec44bb..ea5c0759f 100644 --- a/packages/core/src/components/form/form.test.tsx +++ b/packages/core/src/components/form/form.test.tsx @@ -152,4 +152,42 @@ describe("Form", () => { await user.click(screen.getByRole("button", { name: "Reset" })); expect((screen.getByRole("textbox", { name: "Email" }) as HTMLInputElement).value).toBe(""); }); + + // ========================================================================== + // `id` — lets a submit button outside the form submit it via the native + // `form` attribute (e.g. actions rendered in a page header). + // ========================================================================== + + it("sets id on the form element", () => { + const { container } = render( + + Content + , + ); + expect(container.querySelector("form")?.id).toBe("product-form"); + }); + + it("is submitted by a submit button rendered outside the form", async () => { + const user = userEvent.setup(); + const onFormSubmit = vi.fn(); + + render( +
+ +
+ + SKU + + +
+
, + ); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(onFormSubmit).toHaveBeenCalledTimes(1); + expect(onFormSubmit.mock.calls[0][0]).toMatchObject({ sku: "ABC-1234" }); + }); }); diff --git a/packages/core/src/components/form/form.tsx b/packages/core/src/components/form/form.tsx index 289280387..f15a9b5bd 100644 --- a/packages/core/src/components/form/form.tsx +++ b/packages/core/src/components/form/form.tsx @@ -22,7 +22,7 @@ type FormSubmitEventDetails = // form values when a type argument is provided (e.g. `>`). type FormProps = Record> = Pick< React.ComponentProps, - "errors" | "actionsRef" | "validationMode" | "noValidate" | "ref" | "className" | "style" + "errors" | "actionsRef" | "validationMode" | "noValidate" | "ref" | "className" | "style" | "id" > & { children: React.ReactNode; /** @@ -106,6 +106,25 @@ type FormProps = Record> = P * * * ``` + * + * @example + * ### Submitting from outside the form + * Give the form an `id` and point a detached submit button at it with the + * native `form` attribute. Useful when the save action lives in a page + * header or a dialog footer rather than beside the fields. + * ```tsx + * + * Save + * , + * ]} + * /> + *
save(values)}> + * … + *
+ * ``` */ function Form = Record>({ className, diff --git a/packages/core/src/components/select/select-standalone.test.tsx b/packages/core/src/components/select/select-standalone.test.tsx index fba92c11d..10fd07fc3 100644 --- a/packages/core/src/components/select/select-standalone.test.tsx +++ b/packages/core/src/components/select/select-standalone.test.tsx @@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event"; import { renderRHFForm } from "../../../tests/rhf-test-utils"; import { Field } from "../field"; import { Select } from "./select-standalone"; +import { Form } from "../form"; afterEach(() => { cleanup(); @@ -659,3 +660,155 @@ describe("Select.Async (standalone)", () => { }); }); }); + +// ============================================================================ +// Form participation +// +// Two distinct mechanisms are at play, and they are worth keeping straight: +// +// 1. `Form`'s `onFormSubmit` collects values from registered `Field.Root`s, +// keyed by the *Field's* name — not from the DOM. That path already works +// without `name` on the control. +// 2. `name` puts a hidden input in the DOM, which is what native submission +// reads: `new FormData(form)`, a plain uncontrolled `
`, and server +// actions. That is what these props add. +// ============================================================================ + +describe("Select — form participation", () => { + const items = ["Up", "Down"]; + + const objectItems = [ + { value: "jp", label: "Japan" }, + { value: "us", label: "United States" }, + ]; + + it("renders a hidden input carrying name and value", () => { + const { container } = render( + +
, + ); + const form = container.querySelector("form") as HTMLFormElement; + expect(new FormData(form).get("direction")).toBe("Down"); + }); + + it("serialises `{ value, label }` items using `value`", () => { + const { container } = render( +
+ ({ label: item.name, key: String(item.id) })} + itemToStringValue={(item) => String(item.id)} + aria-label="Warehouse" + /> +
, + ); + const form = container.querySelector("form") as HTMLFormElement; + expect(new FormData(form).get("warehouse")).toBe("9"); + }); + + it("associates the hidden input with an outer form via `form`", () => { + const { container } = render( +
+
+ ); + expect(ref.current).toBeInstanceOf(HTMLInputElement); + expect(ref.current?.name).toBe("direction"); + }); + + it("marks the hidden input required", () => { + const { container } = render( + +
, + ); + + await user.click(screen.getByRole("combobox", { name: "Direction" })); + await user.click(await screen.findByRole("option", { name: "Down" })); + + const form = container.querySelector("form") as HTMLFormElement; + expect(new FormData(form).get("direction")).toBe("Down"); + }); + + it("Select.Async also renders the hidden input", async () => { + const fetcher = vi.fn().mockResolvedValue(items); + const { container } = render( + , + ); + await waitFor(() => { + expect(container.querySelector('input[name="direction"]')).not.toBeNull(); + }); + }); + + it("keeps working with Form + Field.Root, which reads registered fields", async () => { + const user = userEvent.setup(); + const onFormSubmit = vi.fn(); + render( +
+ + Direction + + +
+``` + +**2. Native submission** — a plain `
`, `new FormData(form)`, or a server action. This reads the +DOM, so each control needs its own `name`. `Select`, `Combobox`, and `Autocomplete` gained `name` +(plus `form`, `required`, `inputRef`) in 1.13.0; before that they contributed nothing to a native +payload. + +**3. React Hook Form** — optional, consumer-installed. RHF owns the values; drive the control with +`value` / `onValueChange` from a `Controller` and spread `fieldState` onto `Field.Root`. Warranted +for cross-field validation, field arrays, or a Zod resolver — not for ordinary CRUD forms. + +#### Object items + +Under mechanism 1, a non-string item is serialised into the submitted value: + +| Item shape | Value in `onFormSubmit` | +| -------------------------------------- | ------------------------------------ | +| `string` | the string | +| `{ value, label }` | `value`, automatically | +| any other object | **JSON string** — usually not wanted | +| any other object + `itemToStringValue` | whatever that function returns | + +So for arbitrary objects, pass `itemToStringValue` to choose the submitted key. It is distinct from +`mapItem`, which controls what the user sees: + +```tsx + ({ label: w.name, key: String(w.id) })} + itemToStringValue={(w) => String(w.id)} +/> +``` + +Multi-select submits an array. + ### `Fieldset` **Import:** `import { Fieldset } from '@tailor-platform/app-shell'` diff --git a/catalogue/src/pattern/form/modal/PATTERN.md b/catalogue/src/pattern/form/modal/PATTERN.md index 0c1c454ef..e77eb00dd 100644 --- a/catalogue/src/pattern/form/modal/PATTERN.md +++ b/catalogue/src/pattern/form/modal/PATTERN.md @@ -4,7 +4,7 @@ name: Modal Form category: pattern subcategory: form description: Default form pattern for Create/Edit — keeps user in context on the parent screen -requiredImports: [Dialog, Button, Form, Field, Input] +requiredImports: [Dialog, Button, Form, Field, Layout] tags: [form, modal, dialog, create, edit, inline-add] do: - Default for most Create and Edit forms — keeps user in context on parent screen @@ -39,6 +39,30 @@ dont: - Dialog renders full-screen sheet below 1024px; centered max-w-md at 1024–1280px - Route-driven variant requires both parent path and create/edit path to render the same component - `onOpenChange` must navigate back — just calling `setOpen(false)` leaves the URL broken +- Use ``, never a bare `` — see **Form state** below +- Cancel must be `type="button"`; inside a `` an untyped ` - + diff --git a/catalogue/src/pattern/form/modal/modal-form.tsx b/catalogue/src/pattern/form/modal/modal-form.tsx index 2dbe9fd3d..2e6ea8103 100644 --- a/catalogue/src/pattern/form/modal/modal-form.tsx +++ b/catalogue/src/pattern/form/modal/modal-form.tsx @@ -1,8 +1,14 @@ /* pattern: form/modal */ -import { Button, Dialog, Input, Field } from "@tailor-platform/app-shell"; +import { Button, Dialog, Field, Form } from "@tailor-platform/app-shell"; + +type Address = { + label: string; + street: string; + city: string; +}; type Props = { - onSave: (data: { label: string; street: string; city: string }) => void; + onSave: (data: Address) => void; }; export default function ModalForm({ onSave }: Props) { @@ -14,36 +20,34 @@ export default function ModalForm({ onSave }: Props) { Add address Add a shipping address to this order. -
{ - e.preventDefault(); - const formData = new FormData(e.currentTarget); - onSave({ - label: formData.get("label") as string, - street: formData.get("street") as string, - city: formData.get("city") as string, - }); - }} - > + {/* + * `Form` + `onFormSubmit` replaces a hand-rolled `` + + * `FormData`: values arrive parsed, and validation runs before the + * handler fires. Every text field is uncontrolled — no state needed. + */} + noValidate onFormSubmit={(values) => onSave(values)}>
Label - } /> + + Label is required. Street - } /> + + Street is required. City - } /> + + City is required.
}>Cancel - + ); diff --git a/catalogue/src/pattern/form/sectioned/PATTERN.md b/catalogue/src/pattern/form/sectioned/PATTERN.md index 612e0dbc7..53b446d03 100644 --- a/catalogue/src/pattern/form/sectioned/PATTERN.md +++ b/catalogue/src/pattern/form/sectioned/PATTERN.md @@ -4,7 +4,7 @@ name: Sectioned Form category: pattern subcategory: form description: Complex form with 15+ fields organized into named fieldset sections -requiredImports: [Layout, Form, Fieldset, Field, Input, Select, Combobox, Button] +requiredImports: [Layout, Card, Form, Fieldset, Field, Combobox, Button] tags: [form, sections, fieldset, settings, complex] do: - Form is complex with 15+ fields or multiple grouped sections (Identity, Pricing, Inventory) @@ -30,10 +30,32 @@ dont: - Max ~6 sections — more than that is too hard to scan; promote to `form/wizard` - Required-marker convention must be consistent across all sections -- Section legends must match anchor-nav labels +- One `Card.Root` per section, titled via `Card.Header title` + `description`; `Fieldset.Root` + inside supplies the field grouping and the responsive grid +- Save/Cancel belong in `Layout.Header`, wired with `, - , ]} /> - { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const entries: Record = {}; - formData.forEach((value, key) => { - entries[key] = value as string; - }); - onSave(entries); - }} - className="space-y-8" + onSave(values)} > - - Identity -
- - Name - } /> - - - SKU - } /> - - - Description - } /> - -
-
+ {/* Anchor nav — each entry targets its section's Card by id. */} + + + + + + + + Name + + Name is required. + + + SKU + + SKU is required. + + + Description + + + + + - - Pricing -
- - Price - } /> - - - Currency - + -
- )} - {currentStep === 2 && ( -
-

- Review your import — 42 products will be created. -

-
- )} - {currentStep === 3 && ( -
-

Import complete! 42 products created.

-
- )} - - + )} + + {step === 2 && ( + + + Start date + + Start date is required. + + + Estimate (hours) + + + + )} + + {step === 3 && ( +
+
Title
+
{draft.title}
+
Owner
+
{draft.owner || "—"}
+
Start date
+
{draft.startDate || "—"}
+
Estimate
+
{draft.estimate ? `${draft.estimate}h` : "—"}
+
+ )} + + -
- - -
+
+ + +
+
); diff --git a/docs/components/form.md b/docs/components/form.md index 223ae6c8e..793cb374f 100644 --- a/docs/components/form.md +++ b/docs/components/form.md @@ -47,6 +47,7 @@ A form element with consolidated error handling and validation. Wraps every chil | `validationMode` | `"onSubmit" \| "onBlur" \| "onChange"` | `"onSubmit"` | Controls when field validation fires. | | `noValidate` | `boolean` | - | Disables native browser validation UI (recommended — AppShell renders its own). | | `actionsRef` | `React.Ref<{ validate: () => void }>` | - | Ref to imperatively trigger validation from outside the submit flow. | +| `id` | `string` | - | Applied to the `
` element. Lets a submit button outside the form target it via the native `form` attribute. | | `className` | `string` | - | Additional CSS classes for the `` element. | ### External Errors @@ -136,21 +137,76 @@ A compound component that groups all parts of a form field and manages its valid `Field.Control` can be omitted when using a Base UI-backed AppShell component (e.g. `Select`, `Combobox`). The component registers itself with the `Field` context automatically, inheriting label association and validation state. ```tsx +const [country, setCountry] = React.useState(null); + Country - + ` 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 --- .changeset/lucky-pandas-attend.md | 2 +- docs/components/autocomplete.md | 15 +++++++ docs/components/combobox.md | 19 ++++++++ docs/components/select.md | 14 ++++++ .../autocomplete/autocomplete-standalone.tsx | 8 +++- .../combobox/combobox-standalone.tsx | 9 +++- .../select/select-standalone.test.tsx | 45 ++++++++++++++++--- .../components/select/select-standalone.tsx | 9 +++- 8 files changed, 110 insertions(+), 11 deletions(-) diff --git a/.changeset/lucky-pandas-attend.md b/.changeset/lucky-pandas-attend.md index db1c56017..156465fd9 100644 --- a/.changeset/lucky-pandas-attend.md +++ b/.changeset/lucky-pandas-attend.md @@ -18,4 +18,4 @@ Let forms be driven natively: `Form` now accepts `id`, and `Select`, `Combobox`, For non-string items, `itemToStringValue` controls serialisation (items shaped `{ value, label }` use `value` automatically). It is not available on `Combobox`'s creatable variants, which derive it internally so the pending-item sentinel serialises correctly. -Note this is a _separate_ mechanism from `Form`'s `onFormSubmit`, which collects values from registered `Field.Root`s keyed by the field's `name` — that path already worked without `name` on the control and is unchanged. +Note this is a _separate_ mechanism from `Form`'s `onFormSubmit`, which collects values from registered `Field.Root`s keyed by the field's `name` — that path already worked without `name` on the control and is unchanged. Consequently, **inside a `Field.Root` the field's `name` wins and the control's own `name` is ignored**; set it only when the control is used outside a `Field.Root`. diff --git a/docs/components/autocomplete.md b/docs/components/autocomplete.md index 7692aca88..0cc08b456 100644 --- a/docs/components/autocomplete.md +++ b/docs/components/autocomplete.md @@ -165,6 +165,21 @@ const { } = Autocomplete.Parts; ``` +## Form submission + +Inside a `Field.Root`, the field's `name` identifies the value and these props are unnecessary — +see [Form](./form.md). They matter for **native** submission: a plain ``, `new FormData(form)`, +or a server action. + +| Prop | Type | Default | Description | +| ---------- | ----------------------------- | ------- | ------------------------------------------------------------------------------------------------- | +| `name` | `string` | - | Names the input for native submission. **Ignored inside a `Field.Root`** — the field's name wins. | +| `form` | `string` | - | `id` of the owning form, when the control is rendered outside it. | +| `required` | `boolean` | `false` | Blocks submission until a value is chosen, surfacing the matching `Field.Error`. | +| `inputRef` | `React.Ref` | - | Ref to the underlying text input (use for React Hook Form's `field.ref`). | + +Autocomplete's value is the raw input string, so no value-serialisation hook is needed. + ## Examples ### Controlled Autocomplete diff --git a/docs/components/combobox.md b/docs/components/combobox.md index 009ba6e40..4981034ca 100644 --- a/docs/components/combobox.md +++ b/docs/components/combobox.md @@ -208,6 +208,25 @@ const { } = Combobox.Parts; ``` +## Form submission + +Inside a `Field.Root`, the field's `name` identifies the value and these props are unnecessary — +see [Form](./form.md). They matter for **native** submission: a plain ``, `new FormData(form)`, +or a server action. + +| Prop | Type | Default | Description | +| ------------------- | ----------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | `string` | - | Names the hidden input for native submission. **Ignored inside a `Field.Root`** — the field's name wins. | +| `form` | `string` | - | `id` of the owning form, when the control is rendered outside it. | +| `required` | `boolean` | `false` | Blocks submission until a value is chosen, surfacing the matching `Field.Error`. | +| `inputRef` | `React.Ref` | - | Ref to the hidden input (use for React Hook Form's `field.ref`). | +| `itemToStringValue` | `(item: T) => string` | - | Serialises a non-string item for submission. Items shaped `{ value, label }` use `value` automatically. Not available on the creatable variant, which derives its own. | + +`itemToStringValue` is not accepted on the creatable variant (`onCreateItem`): `Combobox` derives +its own there so the pending-item sentinel serialises correctly. A creatable combobox over object +items therefore cannot customise the submitted value — use the non-creatable variant, or map the +value in your submit handler. + ## Examples ### Controlled Combobox diff --git a/docs/components/select.md b/docs/components/select.md index 400857403..9fed2e1cf 100644 --- a/docs/components/select.md +++ b/docs/components/select.md @@ -163,6 +163,20 @@ If the fetcher throws or rejects, `Select.Async` renders a built-in inline error const { Root, Trigger, Value, Content, Item, Group, GroupLabel, Separator } = Select.Parts; ``` +## Form submission + +Inside a `Field.Root`, the field's `name` identifies the value and these props are unnecessary — +see [Form](./form.md). They matter for **native** submission: a plain ``, `new FormData(form)`, +or a server action. + +| Prop | Type | Default | Description | +| ------------------- | ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------- | +| `name` | `string` | - | Names the hidden input for native submission. **Ignored inside a `Field.Root`** — the field's name wins. | +| `form` | `string` | - | `id` of the owning form, when the control is rendered outside it. | +| `required` | `boolean` | `false` | Blocks submission until a value is chosen, surfacing the matching `Field.Error`. | +| `inputRef` | `React.Ref` | - | Ref to the hidden input (use for React Hook Form's `field.ref`). | +| `itemToStringValue` | `(item: T) => string` | - | Serialises a non-string item for submission. Items shaped `{ value, label }` use `value` automatically. | + ## Examples ### Controlled Select diff --git a/packages/core/src/components/autocomplete/autocomplete-standalone.tsx b/packages/core/src/components/autocomplete/autocomplete-standalone.tsx index 800c3d295..c7c1d8551 100644 --- a/packages/core/src/components/autocomplete/autocomplete-standalone.tsx +++ b/packages/core/src/components/autocomplete/autocomplete-standalone.tsx @@ -66,10 +66,16 @@ interface AutocompletePropsBase { id?: string; /** * Identifies the field when a form is submitted, so the current text is - * picked up by native form submission — including `Form`'s `onFormSubmit`. + * picked up by **native** form submission (`new FormData(form)`, a plain + * ``, server actions). * * Autocomplete's value is the raw input string, so no value-serialisation * hook is needed (unlike `Select` and `Combobox`). + * + * **Inside a `Field.Root`, the field's `name` wins and this prop is ignored** — + * the hidden input is named after the field. Set it only when the control is + * used outside a `Field.Root` (or when it must differ from the field name, + * which it cannot). */ name?: string; /** diff --git a/packages/core/src/components/combobox/combobox-standalone.tsx b/packages/core/src/components/combobox/combobox-standalone.tsx index 05e05bf40..286824fa1 100644 --- a/packages/core/src/components/combobox/combobox-standalone.tsx +++ b/packages/core/src/components/combobox/combobox-standalone.tsx @@ -60,11 +60,16 @@ interface ComboboxPropsBase { id?: string; /** * 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`. + * input under this name, so the selected value is picked up by **native** + * form submission (`new FormData(form)`, a plain ``, server actions). * * For non-string items, pair this with `itemToStringValue` to control how * the value is serialised. + * + * **Inside a `Field.Root`, the field's `name` wins and this prop is ignored** — + * the hidden input is named after the field. Set it only when the control is + * used outside a `Field.Root` (or when it must differ from the field name, + * which it cannot). */ name?: string; /** diff --git a/packages/core/src/components/select/select-standalone.test.tsx b/packages/core/src/components/select/select-standalone.test.tsx index 10fd07fc3..a2df53a21 100644 --- a/packages/core/src/components/select/select-standalone.test.tsx +++ b/packages/core/src/components/select/select-standalone.test.tsx @@ -762,12 +762,24 @@ describe("Select — form participation", () => { expect(ref.current?.name).toBe("direction"); }); - it("marks the hidden input required", () => { - const { container } = render( - + Direction is required. + + + , ); - const input = container.querySelector('input[name="direction"]') as HTMLInputElement; - expect(input.required).toBe(true); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(onFormSubmit).not.toHaveBeenCalled(); + expect(screen.getByText("Direction is required.")).not.toBeNull(); }); it("reflects the value chosen by the user", async () => { @@ -795,6 +807,29 @@ describe("Select — form participation", () => { }); }); + it("defers to the Field's name when nested in a Field.Root", async () => { + const user = userEvent.setup(); + const onFormSubmit = vi.fn(); + const { container } = render( +
+ + Direction + {/* The control's own `name` is deliberately different — the Field's + name must win, so this one never reaches the DOM or the payload. */} +