From 0b31387f6403cc6b57dc4ba1889052ee70d8d42a Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Wed, 14 Oct 2026 12:44:43 +1100 Subject: [PATCH] Add Form with necessity-indicator inheritance (#803) --- .agents/papercuts.md | 16 + .../docs/components/actions/button.mdx | 4 +- .../content/docs/components/forms/form.mdx | 48 +++ .../content/docs/components/forms/meta.json | 9 +- apps/docs/content/docs/components/meta.json | 1 + .../docs/components/primitives/field.mdx | 16 +- apps/docs/content/docs/docs/forms.mdx | 98 +++-- .../content/docs/docs/react-hook-form.mdx | 49 +-- apps/docs/content/docs/docs/tanstack-form.mdx | 58 +-- apps/docs/content/docs/docs/validation.mdx | 94 +++-- apps/docs/src/examples/button/form-action.tsx | 5 +- apps/docs/src/examples/form/basic.tsx | 37 ++ apps/docs/src/examples/forms/build-a-form.tsx | 27 +- .../examples/forms/constraint-validation.tsx | 9 +- .../src/examples/forms/custom-validation.tsx | 19 +- apps/docs/src/examples/forms/field-group.tsx | 30 ++ .../forms/react-hook-form-checkbox.tsx | 20 +- .../src/examples/forms/react-hook-form.tsx | 32 +- .../src/examples/forms/server-validation.tsx | 64 +++ .../examples/forms/tanstack-form-checkbox.tsx | 18 +- .../docs/src/examples/forms/tanstack-form.tsx | 30 +- apps/docs/src/examples/forms/validation.tsx | 51 ++- apps/docs/src/lib/guide-prop-audit-data.ts | 8 + docs/CONVENTIONS.md | 3 + packages/@luke-ui/react/package.json | 1 + .../core/checkbox-field/checkbox-field.tsx | 12 +- .../core/combobox-field/combobox-field.tsx | 4 +- .../react/src/core/form/form.browser.test.tsx | 367 ++++++++++++++++++ .../@luke-ui/react/src/core/form/form.tsx | 69 ++++ .../src/core/packed-consumer/react-fixture.ts | 10 +- .../packed-consumer/react-package.test.ts | 21 +- .../react/src/core/primitives/field/field.tsx | 29 +- .../primitives/field/inline-label-text.tsx | 6 +- .../react/src/core/primitives/field/label.tsx | 14 +- .../primitives/field/necessity-context.ts | 10 + .../src/core/select-field/select-field.tsx | 4 +- .../src/core/switch-field/switch-field.tsx | 12 +- .../react/src/core/test-utils/render.tsx | 17 +- .../text-input-field/text-input-field.tsx | 4 +- packages/@luke-ui/react/src/exports/form.ts | 2 + .../react/src/exports/primitives/field.ts | 18 +- packages/@luke-ui/react/vitest.config.ts | 1 + 42 files changed, 1025 insertions(+), 322 deletions(-) create mode 100644 apps/docs/content/docs/components/forms/form.mdx create mode 100644 apps/docs/src/examples/form/basic.tsx create mode 100644 apps/docs/src/examples/forms/field-group.tsx create mode 100644 apps/docs/src/examples/forms/server-validation.tsx create mode 100644 packages/@luke-ui/react/src/core/form/form.browser.test.tsx create mode 100644 packages/@luke-ui/react/src/core/form/form.tsx create mode 100644 packages/@luke-ui/react/src/core/primitives/field/necessity-context.ts create mode 100644 packages/@luke-ui/react/src/exports/form.ts diff --git a/.agents/papercuts.md b/.agents/papercuts.md index 84a31575..389a3b04 100644 --- a/.agents/papercuts.md +++ b/.agents/papercuts.md @@ -92,3 +92,19 @@ evidence shows it does not help. Silence in this log is not evidence. - Problem: Review still proposed a prohibited appearance assertion even though TESTING.md forbids testing computed appearance unless layout is the contract, and the write-tests skill requires reviewers to consult that guidance. + +## Component generator sorts the whole root components meta pages list + +- Seen: 2026-10-13 (Form PR 2 for #796). `pnpm run generate:component --args form forms No` rewrote + `apps/docs/content/docs/components/meta.json` so section headers and page entries were + alphabetised, and added a bare `"forms"` entry. `check:docs` then reported + `component-guide-inventory` mismatches for primitives and forms until the file was restored and + only `forms/form` was inserted. +- Where: `packages/turbo-generators/src/apply-creation-plan.ts` `array-add-sorted`; + `packages/turbo-generators/src/component-creation-plan.ts` root `components/meta.json` edit. + Governing rule: docs/COMPONENTS.md component creation; docs inventory in + `apps/docs/src/lib/component-guide-inventory.ts`. +- Problem: Root metadata interleaves `---Section---` markers with `group/name` pages in a fixed + sidebar order. Sorting the entire `pages` array destroys that order, and the edit value is the + docs group name rather than `group/name`, so generating into an existing group both scrambles + navigation and fails inventory until the root meta is fixed by hand. diff --git a/apps/docs/content/docs/components/actions/button.mdx b/apps/docs/content/docs/components/actions/button.mdx index e2b6285e..80b83235 100644 --- a/apps/docs/content/docs/components/actions/button.mdx +++ b/apps/docs/content/docs/components/actions/button.mdx @@ -71,8 +71,8 @@ icons for you, so an `Icon` needs no `size` prop. Pass one only to override it. `onPress` handles the interaction. `pressAction` performs the resulting operation. When both are set, `onPress` runs first, then `pressAction`. -Prefer a native form Action when the operation is a form submission. Pass the form’s pending state -to `Button`. This example uses React’s +Prefer a React form Action when the operation is a form submission. Pass the Action to Luke UI's +[`Form`](/components/forms/form) and the form's pending state to `Button`. This example uses React's [`useActionState`](https://react.dev/reference/react/useActionState). diff --git a/apps/docs/content/docs/components/forms/form.mdx b/apps/docs/content/docs/components/forms/form.mdx new file mode 100644 index 00000000..47752d40 --- /dev/null +++ b/apps/docs/content/docs/components/forms/form.mdx @@ -0,0 +1,48 @@ +--- +title: Form +description: Groups fields for submission, validation, and a shared necessity indicator. +source: packages/@luke-ui/react/src/exports/form.ts +reactAria: https://react-spectrum.adobe.com/react-aria/Form.html +--- + +`Form` groups fields for submission. Beyond a native HTML form, it shares a necessity indicator, +validation behaviour, and server errors with nested Luke UI fields. + +Use a layout component such as [`Stack`](/components/layout/stack) to arrange its contents. Prefer +`action` for ordinary submissions and `onSubmit` when you need the submit event or a form library. +Read [Submit](/docs/forms#submit) for the differences. + + + +## Necessity indicators + +Set `necessityIndicator` on `Form` to choose how nested fields identify required or optional values. +A field's own `necessityIndicator` overrides the Form setting. When neither sets one, required +labels display an asterisk. + +The indicator changes the label. It does not make a field required. Set `isRequired` on each +required field. + +`Form` does not generate a legend that explains required or optional markers. Read +[Required fields](/docs/forms#required-fields) for when to add one and how the three indicators +differ. + +## Validation + +By default, native validation prevents submission when a field is invalid. Set +`validationBehavior="aria"` when a form library handles validation. An individual field can override +the Form setting. + +Pass server errors through `validationErrors`, keyed by each field's `name`. + +Read [Validation](/docs/validation) for validation rules, error precedence, and when messages appear +or clear. + +## Accessibility + +Provide an accessible name for the form landmark when the page has more than one form, or when the +form's purpose is not clear from surrounding content. Pass `aria-label` or `aria-labelledby`. + +## API + + diff --git a/apps/docs/content/docs/components/forms/meta.json b/apps/docs/content/docs/components/forms/meta.json index 16520a5f..67f39da2 100644 --- a/apps/docs/content/docs/components/forms/meta.json +++ b/apps/docs/content/docs/components/forms/meta.json @@ -1,4 +1,11 @@ { "title": "Forms", - "pages": ["checkbox-field", "combobox-field", "select-field", "switch-field", "text-input-field"] + "pages": [ + "checkbox-field", + "combobox-field", + "form", + "select-field", + "switch-field", + "text-input-field" + ] } diff --git a/apps/docs/content/docs/components/meta.json b/apps/docs/content/docs/components/meta.json index d8589b7c..abacfb56 100644 --- a/apps/docs/content/docs/components/meta.json +++ b/apps/docs/content/docs/components/meta.json @@ -15,6 +15,7 @@ "---Forms---", "forms/checkbox-field", "forms/combobox-field", + "forms/form", "forms/select-field", "forms/switch-field", "forms/text-input-field", diff --git a/apps/docs/content/docs/components/primitives/field.mdx b/apps/docs/content/docs/components/primitives/field.mdx index 3eefd4ca..545e26d5 100644 --- a/apps/docs/content/docs/components/primitives/field.mdx +++ b/apps/docs/content/docs/components/primitives/field.mdx @@ -38,14 +38,12 @@ the control when clicked. ## Required fields -`necessityIndicator` changes the visible marker on `FieldLabel` and `InlineLabelText`. It does not -make the control required. Set `isRequired` on the control root, or `required` on a native control -next to the label part. +`necessityIndicator` controls the visible marker on `FieldLabel` and `InlineLabelText`. When +omitted, it inherits from the surrounding [`Form`](/components/forms/form). Without either setting, +required labels use `'required-asterisk'`. -`'required-asterisk'` and `'required-label'` mark required labels. `'optional-label'` marks -definitely optional labels and includes “(optional)” in the accessible name. A composed `Field` -named only through `aria-label` or `aria-labelledby` has no `label` to render, so it shows no -marker. +The indicator does not make a control required. Set `isRequired` on the control root, or `required` +on an associated native input. `isRequired` on `FieldLabel` or `InlineLabelText` describes the control's state for the marker. It does not set the control's required semantics. Place it on the label part next to a native control @@ -58,7 +56,9 @@ with `required`. Inside a Luke UI control root, the root's required state wins. ``` -Read [Required fields](/docs/forms#required-fields) for the three indicator values. +A composed `Field` named only through `aria-label` or `aria-labelledby` has no `label` to render, so +it shows no marker. Read [Required fields](/docs/forms#required-fields) for the three indicator +values and when to add a legend. ## Validation messages diff --git a/apps/docs/content/docs/docs/forms.mdx b/apps/docs/content/docs/docs/forms.mdx index dbb5d4c2..e8042915 100644 --- a/apps/docs/content/docs/docs/forms.mdx +++ b/apps/docs/content/docs/docs/forms.mdx @@ -1,10 +1,12 @@ --- title: Forms description: - Build an accessible native form from Luke UI's field components, and handle submit and reset. + Build an accessible form from Luke UI's Form and field components, and handle submit and reset. --- -A Luke UI form uses the native HTML `
` element. Compose it from Luke UI fields and buttons. +A Luke UI form uses [`Form`](/components/forms/form). Compose it from Luke UI fields and buttons. +Put fields in a layout primitive such as [`Stack`](/components/layout/stack). `Form` is not a layout +wrapper. @@ -33,20 +35,39 @@ an error message appears. ## Required fields -Set `isRequired` when someone must complete a field. The browser blocks submission until the field -has a value. +Requiredness has three parts: -Choose how a label marks that state with `necessityIndicator`: +- **Semantics.** Set `isRequired` on each field that must be completed. With native validation, the + browser blocks submission while a required field is empty. When `validationBehavior="aria"` is + set, the control remains required, but the application or form library handles validation and + submission. +- **Visible marker.** Choose how the label shows that state with `necessityIndicator`. +- **Accessible name.** Required markers stay visual. Assistive technology reads required state from + the control. An optional marker with `'optional-label'` becomes part of the accessible name. -- `'required-asterisk'` adds a visual `*` after a required label. Assistive technology reads - required state from the control, not from the marker. -- `'required-label'` adds visible "(required)" after a required label. Assistive technology still - reads required state from the control. -- `'optional-label'` adds "(optional)" after a definitely optional label. That text is part of the - accessible name. +Choose a marker with `necessityIndicator`: + +- `'required-asterisk'` adds a visual `*` after a required label. +- `'required-label'` adds visible "(required)" after a required label. +- `'optional-label'` adds "(optional)" after a definitely optional label. + +Set `necessityIndicator` on [`Form`](/components/forms/form) to use the same convention across its +fields. An individual field can override that setting. When neither the field nor its Form specifies +an indicator, required labels display an asterisk. A field with only `aria-label` or `aria-labelledby` shows no marker and gets no "(optional)" suffix. -When `necessityIndicator` is omitted, the field uses `'required-asterisk'`. + +When required fields use an asterisk, explain what the marker means near the start of the form. When +using `'optional-label'`, explain that fields are required unless marked optional. +`'required-label'` states requiredness in the label itself, so a separate legend is usually +unnecessary. The indicator changes the label, not the field's required state. + +## Related fields + +Group related controls with a native `
` and `` when they share one question or +topic, such as a set of notification options. The legend names the group. + + ## Controlled and uncontrolled fields @@ -60,47 +81,54 @@ For a controlled `CheckboxField` or `SwitchField`, pass `isSelected` and `onChan ## Submit -Pass `onSubmit` to the `` element. Call `event.preventDefault()`, then read the values from -`FormData`. +Prefer Form's `action` for ordinary submissions. Pass a React Action, often from `useActionState`, +so the handler receives `FormData` without a submit event. Use the pending flag for the submit +[`Button`](/components/actions/button). After a successful Action, React resets uncontrolled fields. +Return server errors from the Action and pass them through `validationErrors`. Read +[Server validation](/docs/validation#server-validation) for that pattern. -```tsx -function handleSubmit(event: FormEvent) { - event.preventDefault(); - const data = new FormData(event.currentTarget); - const email = data.get('email'); -} -``` +Use `onSubmit` when the handler needs the submit event: calling `preventDefault`, reading the form +element, or wiring a form library. Libraries such as React Hook Form and TanStack Form expect +`onSubmit`. They own pending state and errors themselves. Read +[React Hook Form](/docs/react-hook-form) and [TanStack Form](/docs/tanstack-form) for those +integrations. + +React's [form Actions](https://react.dev/reference/react-dom/components/form#props) document +`action` and pending behaviour in detail. Give the submit button `type="submit"`. ## Reset -Give a button `type="reset"` to restore uncontrolled fields. The browser restores their starting -values. +Avoid a `type="reset"` button in ordinary forms. Someone can clear completed work by accident. + +When a form genuinely needs to restore defaults, a reset control can be appropriate. The browser +restores uncontrolled fields to their starting values. Controlled fields keep the state React owns, +so an `onReset` handler must reset that state too. -An `onReset` handler must reset state that React owns. +That is separate from the automatic reset after a successful Action, which restores uncontrolled +fields without a reset button. -```tsx -const INITIAL_EMAIL = ''; -const [email, setEmail] = useState(INITIAL_EMAIL); +## Fields outside the form element - setEmail(INITIAL_EMAIL)}> - - -; -``` +A field outside the form element can participate in native submission through the HTML `form` +attribute. That association does not establish React ancestry. If the field is outside `Form`'s +React subtree, it does not inherit the shared necessity indicator, validation behaviour, or server +errors. Set `necessityIndicator` and `validationBehavior` on the field itself, and pass server +errors through its `errorMessage`. ## Validation Luke UI supports browser validation, custom validation, and server validation. The -[Validation guide](/docs/validation) explains each approach. +[Validation guide](/docs/validation) explains each approach, including focus after an unsuccessful +submission. - Set native constraints such as `isRequired`, `minLength`, `pattern`, or `type="email"` for browser validation. - Pass `validate` for a custom rule. Pass a controlled `errorMessage` for feedback that must update on every keystroke. -- Pass `validationErrors` to React Aria's `Form` for server errors. Key each error by the field - `name`. +- Pass `validationErrors` to [`Form`](/components/forms/form) for server errors. Key each error by + the field `name`. Start with native form behaviour. When a form library owns the form state, read [React Hook Form](/docs/react-hook-form) or [TanStack Form](/docs/tanstack-form). diff --git a/apps/docs/content/docs/docs/react-hook-form.mdx b/apps/docs/content/docs/docs/react-hook-form.mdx index d6e0d56d..0ba14361 100644 --- a/apps/docs/content/docs/docs/react-hook-form.mdx +++ b/apps/docs/content/docs/docs/react-hook-form.mdx @@ -24,9 +24,15 @@ const form = useForm({ ## Integrate components -Wrap each control in `Controller`. Its `render` prop hands you `field` and `fieldState`. Pass -`field.value`, `field.onChange`, and `field.onBlur` to the control, and give `field.ref` to -`inputRef`. +Wrap the fields in Luke UI's [`Form`](/components/forms/form). Set `validationBehavior="aria"` so +React Hook Form controls validation without browser submission blocking it. When every required +field should show a clear marker, set `necessityIndicator="required-label"`. + +Wrap each control in `Controller`. Pass `field.value`, `field.onChange`, and `field.onBlur` to the +control, give `field.ref` to `inputRef`, and pass `field.name` as the HTML `name`. Set `isRequired` +on fields the schema requires. Schema validation does not set required control semantics on its own. + +Pass `fieldState.error?.message` to `errorMessage`. A non-empty message marks the field invalid. @@ -39,32 +45,8 @@ around the control, so `inputRef` is what reaches the input underneath. For `Sel library's ref through `triggerRef` to reach the trigger button. A primitive that renders the control itself, such as `ComboboxInput`, takes `field.ref` on `ref`. -## Validation - -Read the message from `fieldState.error` and pass it to `errorMessage`. The message marks the field -invalid. - -```tsx - ( - - )} -/> -``` - -Set `validationBehavior="aria"` on every field a `Controller` wraps. Read -[Validation](/docs/validation#let-the-browser-validate) for why native behaviour blocks the submit -event before the library can run. +Read [Validation](/docs/validation) for controlled errors versus native validation. Read +[Required fields](/docs/forms#required-fields) for necessity indicators. ## Focus the first invalid field @@ -76,12 +58,9 @@ Set `shouldFocusError: false` on `useForm` to turn this off. ## Submitting data -Wrap the submit handler in `form.handleSubmit`. It runs the schema first, then calls the handler -with the values. - -```tsx -
saveAccount(values))}> -``` +Pass `form.handleSubmit(onSubmit)` to Form's `onSubmit`. React Hook Form validates the fields before +calling your handler. Prefer `onSubmit` over Form's `action` for this integration, because the +library needs the submit event. ## Continue learning diff --git a/apps/docs/content/docs/docs/tanstack-form.mdx b/apps/docs/content/docs/docs/tanstack-form.mdx index 4ff756fa..390fb1a2 100644 --- a/apps/docs/content/docs/docs/tanstack-form.mdx +++ b/apps/docs/content/docs/docs/tanstack-form.mdx @@ -29,8 +29,16 @@ value types come from `defaultValues`, and TypeScript checks the schema against ## Integrate components -Give `form.Field` a `name` and a children function. Read the value from `field.state.value`, pass -`field.handleChange` to `onChange`, and pass `field.handleBlur` to `onBlur`. +Wrap the fields in Luke UI's [`Form`](/components/forms/form). Set `validationBehavior="aria"` so +TanStack Form controls validation without browser submission blocking it. When every required field +should show a clear marker, set `necessityIndicator="required-label"`. + +Give `form.Field` a `name` and a children function. Pass `field.state.value`, `field.handleChange`, +and `field.handleBlur` to the control, and pass `field.name` as the HTML `name`. Set `isRequired` on +fields the schema requires. Schema validation does not set required control semantics on its own. + +Pass the current validation message from `field.state.meta.errors` to `errorMessage`. A non-empty +message marks the field invalid. @@ -38,34 +46,13 @@ A checkbox or switch reads its value from `isSelected`. -## Validation - -Read the message from `field.state.meta.errors` and pass it to `errorMessage`. The message marks the -field invalid. - -```tsx - - {(field) => ( - - )} - -``` - -Set `validationBehavior="aria"` on every field a `form.Field` wraps. Read -[Validation](/docs/validation#let-the-browser-validate) for why native behaviour blocks the submit -event before the library can run. +Read [Validation](/docs/validation) for more about error presentation. Read +[Required fields](/docs/forms#required-fields) for necessity indicators. ## Focus the first invalid field -TanStack Form leaves focus where it is after a failed submission. Hold a ref to the `` element -and search it for the first control marked `aria-invalid` from `onSubmitInvalid`. +TanStack Form leaves focus where it is after a failed submission. Hold a ref to the Form element and +search it for the first control marked `aria-invalid` from `onSubmitInvalid`. ```tsx const FOCUSABLE_SELECTOR = @@ -93,20 +80,9 @@ takes a plain `ref`. ## Submitting data -Call `form.handleSubmit()` from the form's `onSubmit`, after `event.preventDefault()`. - -```tsx - { - event.preventDefault(); - void form.handleSubmit(); - }} - ref={formRef} -> -``` - -TanStack Form runs the schema, then calls `onSubmit` with the values or `onSubmitInvalid` with the -form API. +Prevent the browser's default submission in Form's `onSubmit`, then call `form.handleSubmit()`. +TanStack Form validates the values and calls `onSubmit` or `onSubmitInvalid`. Prefer `onSubmit` over +Form's `action` for this integration, because the library needs the submit event. ## Continue learning diff --git a/apps/docs/content/docs/docs/validation.mdx b/apps/docs/content/docs/docs/validation.mdx index 92654cd3..2986e71b 100644 --- a/apps/docs/content/docs/docs/validation.mdx +++ b/apps/docs/content/docs/docs/validation.mdx @@ -20,24 +20,38 @@ Set `isRequired` on `TextInputField`, `ComboboxField`, `SelectField`, `CheckboxF `pattern` on `TextInputField` to state a rule for the text. Pass `type="email"` or `type="url"` to check the value's format. -Leave out `errorMessage`. The field renders the message the constraint produces. It appears on blur -or on submit. +Leave out `errorMessage`. The field renders the message the constraint produces. Constraint errors +normally appear after someone blurs a field or submits the form. -Pass `validationBehavior="native"` to block submission until every field passes. This is the -default. +Native validation is the default. It prevents form submission when a field fails validation, so a +form-library submit handler never runs. -Pass `validationBehavior="aria"` to mark an invalid field for assistive technology. The form still -submits. Set this on every field a form library wraps, so the library stays the only thing deciding -whether the form is valid. Native behaviour calls `setCustomValidity` on a rejected field, so the -browser blocks the submit event and the library never runs. +Set `validationBehavior="aria"` on [`Form`](/components/forms/form) when a form library owns +validation. This lets the library receive the submit event and decide whether the form is valid. An +individual field can override the Form setting. + +### Native validation messages + +Constraint messages come from the browser. They follow the browser or operating system language +setting. + +Keep the browser message when it matches the product language and names the fix clearly enough. Pass +your own copy when the message must use the field's wording, match a style guide, or name a product +rule the browser cannot describe. + +On composed fields such as `TextInputField`, `errorMessage` is React content for a controlled error. +To customise a constraint message from the field's validity state, compose the +[field primitives](/components/primitives/field) and pass a render function to `FieldError`. For a +rule the browser cannot check, prefer `validate` on the field instead. ## Custom validation Pass a `validate` function for a rule the browser cannot check, such as a reserved username. The function takes the value and returns the validation message, or `null` when the value is valid. -Leave out `errorMessage`. The field renders the message `validate` returns. +Leave out `errorMessage`. The field renders the message `validate` returns. Those messages normally +appear after blur or submit, not between keystrokes. @@ -47,15 +61,13 @@ messages, but the field does not render them as a list. It joins them with a spa a single run-on sentence, and even two short messages can fill two lines in a 288px-wide form column. -With the default validation behaviour, the field shows the validation message when someone commits -the value, for example on blur or submit, rather than between keystrokes. - ## Realtime validation Pass a controlled `errorMessage` for feedback that must update on every keystroke, such as password strength. You decide validity here, not the field. Make the field controlled with `value` and `onChange`, and compute `errorMessage` from that value on every render. An absent message keeps the -field valid, so `undefined`, `null`, `false`, and an empty string all mean no error. +field valid, so `undefined`, `null`, `false`, and an empty string all mean no error. The message +appears when the application supplies a non-empty value and clears when that message is removed. ```tsx const [password, setPassword] = useState(''); @@ -70,40 +82,50 @@ const isTooShort = password.length > 0 && password.length < 12; />; ``` -This is the exception. Constraint and `validate` messages normally appear after the value is -committed. This does not distract someone while they type. - ## Form libraries Pass the library's current error message as `errorMessage`. The field goes invalid as soon as the -library reports one, and clears once the library clears it. Read -[React Hook Form](/docs/react-hook-form) and [TanStack Form](/docs/tanstack-form) for the full -setup, including `validationBehavior="aria"` so the library stays the only thing deciding whether -the form is valid. +library reports one, and clears once the library clears it. Set `validationBehavior="aria"` on +`Form` so native validation does not block the library's submit handler. + +Read [React Hook Form](/docs/react-hook-form) and [TanStack Form](/docs/tanstack-form) for the full +setup. ## Server validation -Pass server errors through `Form` from `react-aria-components`. `@luke-ui/react` does not export -`Form`, so import it separately. +Pass server errors through Luke UI's [`Form`](/components/forms/form) as `validationErrors`, keyed +by each field's `name`. Server errors appear immediately. Form clears a field's server error when +the value is committed, for example when the field blurs after an edit. -```tsx -import { Form } from 'react-aria-components'; +A React Action can return those errors with the submitted values. Pass the Action to Form's +`action`, feed `validationErrors` from `useActionState`, and restore each field with `defaultValue` +so values survive an unsuccessful response. After a successful Action, React resets uncontrolled +fields. Pass the pending state to the submit [`Button`](/components/actions/button). - - -; -``` + + +Try the username `admin` to see a server error. Change the value and leave the field to clear it, +then choose another name to submit successfully. + +A non-empty field-level `errorMessage` overrides that field's entry in `validationErrors`. Clearing +the field error reveals the outstanding server error if the value has not been committed. Committing +a new value also clears that field's server error. -Pass `validationErrors` with an object keyed by each field's `name`. Each field shows its message as -soon as you pass `validationErrors`. The message clears once someone changes the field's value. +## Focus after validation -## When errors appear and clear +With native validation, an unsuccessful submission focuses the first invalid field. Keep that +default when each field's message is enough to fix the problem. -An error appears on blur or on submit. It never appears between keystrokes. An error clears as soon -as the value changes. +Override it when the form shows an error summary, or when focus should land somewhere else first. +Pass `onInvalid` on [`Form`](/components/forms/form) and call `preventDefault` to stop that focus +move, then move focus yourself. See +[React Aria's Form focus management](https://react-aria.adobe.com/Form#focus-management) for an +error-summary pattern. -The controlled `errorMessage` pattern in the realtime validation section is the exception. It -updates on every keystroke because that is its purpose. +Server errors from `validationErrors` appear after the Action returns. They do not use that +invalid-event focus path. Form libraries often manage focus themselves. Read +[React Hook Form](/docs/react-hook-form#focus-the-first-invalid-field) when that library owns +validation. ## Always pair an invalid field with a message @@ -139,7 +161,7 @@ An error message has two jobs: name the problem, and name the fix. - Build a native form from Luke UI fields. + Build a form from Luke UI Form and fields. Hand validity to React Hook Form with validationBehavior. diff --git a/apps/docs/src/examples/button/form-action.tsx b/apps/docs/src/examples/button/form-action.tsx index b1752efa..3f6280b7 100644 --- a/apps/docs/src/examples/button/form-action.tsx +++ b/apps/docs/src/examples/button/form-action.tsx @@ -1,15 +1,16 @@ import { Button } from '@luke-ui/react/button'; +import { Form } from '@luke-ui/react/form'; import { useActionState } from 'react'; export default () => { const [, formAction, isPending] = useActionState(save, null); return ( -
+ -
+ ); }; diff --git a/apps/docs/src/examples/form/basic.tsx b/apps/docs/src/examples/form/basic.tsx new file mode 100644 index 00000000..e4bb970a --- /dev/null +++ b/apps/docs/src/examples/form/basic.tsx @@ -0,0 +1,37 @@ +import { Button } from '@luke-ui/react/button'; +import { Cluster } from '@luke-ui/react/cluster'; +import { Form } from '@luke-ui/react/form'; +import { Stack } from '@luke-ui/react/stack'; +import { Text } from '@luke-ui/react/text'; +import { TextInputField } from '@luke-ui/react/text-input-field'; +import type { SubmitEvent } from 'react'; +import { useState } from 'react'; + +export default () => { + const [submitted, setSubmitted] = useState(''); + + function handleSubmit(event: SubmitEvent) { + event.preventDefault(); + const data = new FormData(event.currentTarget); + const familyName = data.get('familyName'); + setSubmitted(typeof familyName === 'string' ? familyName : ''); + } + + return ( + +
+ + All fields are required unless marked optional. + + + + + + +
+ + {submitted ? `Submitted: ${submitted}` : '\u00a0'} + +
+ ); +}; diff --git a/apps/docs/src/examples/forms/build-a-form.tsx b/apps/docs/src/examples/forms/build-a-form.tsx index 3fcbf198..333565b1 100644 --- a/apps/docs/src/examples/forms/build-a-form.tsx +++ b/apps/docs/src/examples/forms/build-a-form.tsx @@ -1,24 +1,17 @@ import { Button } from '@luke-ui/react/button'; import { Cluster } from '@luke-ui/react/cluster'; +import { Form } from '@luke-ui/react/form'; import { Stack } from '@luke-ui/react/stack'; import { Text } from '@luke-ui/react/text'; import { TextInputField } from '@luke-ui/react/text-input-field'; -import type { FormEvent } from 'react'; -import { useState } from 'react'; +import { useActionState } from 'react'; export default () => { - const [submittedEmail, setSubmittedEmail] = useState(''); - - function handleSubmit(event: FormEvent) { - event.preventDefault(); - const data = new FormData(event.currentTarget); - const email = data.get('email'); - setSubmittedEmail(typeof email === 'string' ? email : ''); - } + const [submittedEmail, formAction, isPending] = useActionState(submitEmail, ''); return ( -
setSubmittedEmail('')} onSubmit={handleSubmit}> + { type="email" /> - - + -
+ {submittedEmail ? `Submitted: ${submittedEmail}` : '\u00a0'} @@ -41,3 +35,8 @@ export default () => { ); }; + +async function submitEmail(_previous: string, formData: FormData) { + const email = formData.get('email'); + return typeof email === 'string' ? email : ''; +} diff --git a/apps/docs/src/examples/forms/constraint-validation.tsx b/apps/docs/src/examples/forms/constraint-validation.tsx index a20114ea..05bf5a91 100644 --- a/apps/docs/src/examples/forms/constraint-validation.tsx +++ b/apps/docs/src/examples/forms/constraint-validation.tsx @@ -1,19 +1,18 @@ import { Button } from '@luke-ui/react/button'; import { Cluster } from '@luke-ui/react/cluster'; +import { Form } from '@luke-ui/react/form'; import { Stack } from '@luke-ui/react/stack'; import { TextInputField } from '@luke-ui/react/text-input-field'; export default () => { return ( -
+ - - - + -
+ ); }; diff --git a/apps/docs/src/examples/forms/custom-validation.tsx b/apps/docs/src/examples/forms/custom-validation.tsx index 9bdc1ff8..f114171a 100644 --- a/apps/docs/src/examples/forms/custom-validation.tsx +++ b/apps/docs/src/examples/forms/custom-validation.tsx @@ -1,5 +1,6 @@ import { Button } from '@luke-ui/react/button'; import { Cluster } from '@luke-ui/react/cluster'; +import { Form } from '@luke-ui/react/form'; import { Stack } from '@luke-ui/react/stack'; import { TextInputField } from '@luke-ui/react/text-input-field'; import type { SubmitEvent } from 'react'; @@ -20,20 +21,18 @@ export default () => { } return ( -
+ - - - + -
+ ); }; diff --git a/apps/docs/src/examples/forms/field-group.tsx b/apps/docs/src/examples/forms/field-group.tsx new file mode 100644 index 00000000..6516e29a --- /dev/null +++ b/apps/docs/src/examples/forms/field-group.tsx @@ -0,0 +1,30 @@ +import { Button } from '@luke-ui/react/button'; +import { CheckboxField } from '@luke-ui/react/checkbox-field'; +import { Cluster } from '@luke-ui/react/cluster'; +import { Form } from '@luke-ui/react/form'; +import { Stack } from '@luke-ui/react/stack'; +import type { SubmitEvent } from 'react'; + +export default () => { + function handleSubmit(event: SubmitEvent) { + event.preventDefault(); + } + + return ( +
+ +
+ Email notifications + + + + + +
+ + + +
+
+ ); +}; diff --git a/apps/docs/src/examples/forms/react-hook-form-checkbox.tsx b/apps/docs/src/examples/forms/react-hook-form-checkbox.tsx index 96537927..9959c44d 100644 --- a/apps/docs/src/examples/forms/react-hook-form-checkbox.tsx +++ b/apps/docs/src/examples/forms/react-hook-form-checkbox.tsx @@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Button } from '@luke-ui/react/button'; import { CheckboxField } from '@luke-ui/react/checkbox-field'; import { Cluster } from '@luke-ui/react/cluster'; +import { Form } from '@luke-ui/react/form'; import { Stack } from '@luke-ui/react/stack'; import { Text } from '@luke-ui/react/text'; import { Controller, useForm } from 'react-hook-form'; @@ -21,30 +22,35 @@ export default () => { return ( -
undefined)}> + undefined)} + validationBehavior="aria" + > ( - + render={({ field, fieldState }) => { + return ( - - )} + ); + }} /> -
+ {form.formState.isSubmitSuccessful ? 'Terms accepted.' : '\u00a0'} diff --git a/apps/docs/src/examples/forms/react-hook-form.tsx b/apps/docs/src/examples/forms/react-hook-form.tsx index ca48996a..5559ad03 100644 --- a/apps/docs/src/examples/forms/react-hook-form.tsx +++ b/apps/docs/src/examples/forms/react-hook-form.tsx @@ -1,6 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Button } from '@luke-ui/react/button'; import { Cluster } from '@luke-ui/react/cluster'; +import { Form } from '@luke-ui/react/form'; import { Stack } from '@luke-ui/react/stack'; import { Text } from '@luke-ui/react/text'; import { TextInputField } from '@luke-ui/react/text-input-field'; @@ -20,47 +21,54 @@ export default () => { return ( -
undefined)}> + undefined)} + validationBehavior="aria" + > ( - + render={({ field, fieldState }) => { + return ( - - )} + ); + }} /> ( - + render={({ field, fieldState }) => { + return ( - - )} + ); + }} /> -
+ {form.formState.isSubmitSuccessful ? `Submitted: ${form.getValues('name')}` : '\u00a0'} diff --git a/apps/docs/src/examples/forms/server-validation.tsx b/apps/docs/src/examples/forms/server-validation.tsx new file mode 100644 index 00000000..0b63b172 --- /dev/null +++ b/apps/docs/src/examples/forms/server-validation.tsx @@ -0,0 +1,64 @@ +import { Button } from '@luke-ui/react/button'; +import { Cluster } from '@luke-ui/react/cluster'; +import { Form } from '@luke-ui/react/form'; +import { Stack } from '@luke-ui/react/stack'; +import { Text } from '@luke-ui/react/text'; +import { TextInputField } from '@luke-ui/react/text-input-field'; +import { useActionState } from 'react'; + +type FormState = { + errors?: { username?: string }; + values?: { username?: string }; +}; + +const takenUsernames = new Set(['admin', 'root', 'support']); + +export default () => { + const [state, formAction, isPending] = useActionState(createAccount, {}); + + return ( + +
+ + + + + + +
+ + + {state.values?.username && !state.errors + ? `Created account for ${state.values.username}.` + : '\u00a0'} + + +
+ ); +}; + +async function createAccount(_previous: FormState, formData: FormData): Promise { + await new Promise((resolve) => { + setTimeout(resolve, 400); + }); + + const usernameValue = formData.get('username'); + const username = typeof usernameValue === 'string' ? usernameValue : ''; + const values = { username }; + + if (takenUsernames.has(username.trim().toLowerCase())) { + return { + errors: { username: 'That username is taken. Choose another.' }, + values, + }; + } + + return { values }; +} diff --git a/apps/docs/src/examples/forms/tanstack-form-checkbox.tsx b/apps/docs/src/examples/forms/tanstack-form-checkbox.tsx index 0cf5ce6d..9da79f9e 100644 --- a/apps/docs/src/examples/forms/tanstack-form-checkbox.tsx +++ b/apps/docs/src/examples/forms/tanstack-form-checkbox.tsx @@ -1,6 +1,7 @@ import { Button } from '@luke-ui/react/button'; import { CheckboxField } from '@luke-ui/react/checkbox-field'; import { Cluster } from '@luke-ui/react/cluster'; +import { Form } from '@luke-ui/react/form'; import { Stack } from '@luke-ui/react/stack'; import { Text } from '@luke-ui/react/text'; import { revalidateLogic, useForm } from '@tanstack/react-form'; @@ -38,33 +39,36 @@ export default () => { return ( -
{ event.preventDefault(); void form.handleSubmit(); }} ref={formRef} + validationBehavior="aria" > - {(field) => ( - + {(field) => { + return ( - - )} + ); + }} -
+ state.isSubmitSuccessful}> diff --git a/apps/docs/src/examples/forms/tanstack-form.tsx b/apps/docs/src/examples/forms/tanstack-form.tsx index f37bccd5..9048cc79 100644 --- a/apps/docs/src/examples/forms/tanstack-form.tsx +++ b/apps/docs/src/examples/forms/tanstack-form.tsx @@ -1,5 +1,6 @@ import { Button } from '@luke-ui/react/button'; import { Cluster } from '@luke-ui/react/cluster'; +import { Form } from '@luke-ui/react/form'; import { Stack } from '@luke-ui/react/stack'; import { Text } from '@luke-ui/react/text'; import { TextInputField } from '@luke-ui/react/text-input-field'; @@ -37,47 +38,52 @@ export default () => { return ( -
{ event.preventDefault(); void form.handleSubmit(); }} ref={formRef} + validationBehavior="aria" > - {(field) => ( - + {(field) => { + return ( - - )} + ); + }} - {(field) => ( - + {(field) => { + return ( - - )} + ); + }} -
+ (state.isSubmitSuccessful ? state.values.name : '')}> diff --git a/apps/docs/src/examples/forms/validation.tsx b/apps/docs/src/examples/forms/validation.tsx index 129749b8..4039ddb5 100644 --- a/apps/docs/src/examples/forms/validation.tsx +++ b/apps/docs/src/examples/forms/validation.tsx @@ -2,6 +2,7 @@ import { Button } from '@luke-ui/react/button'; import { CheckboxField } from '@luke-ui/react/checkbox-field'; import { Cluster } from '@luke-ui/react/cluster'; import { ComboboxField, ComboboxItem } from '@luke-ui/react/combobox-field'; +import { Form } from '@luke-ui/react/form'; import { Stack } from '@luke-ui/react/stack'; import { TextInputField } from '@luke-ui/react/text-input-field'; import type { SubmitEvent } from 'react'; @@ -35,38 +36,32 @@ export default () => { } return ( -
+ - - - - - - {(item) => {item.label}} - - - - - + + + {(item) => {item.label}} + + -
+ ); }; diff --git a/apps/docs/src/lib/guide-prop-audit-data.ts b/apps/docs/src/lib/guide-prop-audit-data.ts index 5b50fa0b..ac420e77 100644 --- a/apps/docs/src/lib/guide-prop-audit-data.ts +++ b/apps/docs/src/lib/guide-prop-audit-data.ts @@ -61,6 +61,14 @@ export const GUIDE_TAUGHT_PROPS: Readonly>> 'validationBehavior', 'value', ], + 'forms/form.mdx::packages/@luke-ui/react/src/core/form/form.tsx::FormProps': [ + 'action', + 'necessityIndicator', + 'onReset', + 'onSubmit', + 'validationBehavior', + 'validationErrors', + ], 'forms/combobox-field.mdx::packages/@luke-ui/react/src/core/combobox-field/combobox-field.tsx::ComboboxFieldProps': [ 'aria-label', diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index c4346aef..031a0a9a 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -5,6 +5,9 @@ Use strict TypeScript. Import types with `import type`. Local imports include the explicit `.js` extension. +Only modules in `src/exports/` re-export symbols defined in another module. Internal code imports +symbols directly from their defining modules. + ## Formatting `oxfmt` owns formatting. The repo uses tabs, 100-column wrapping, single quotes in TypeScript, and diff --git a/packages/@luke-ui/react/package.json b/packages/@luke-ui/react/package.json index 2feb696e..d241101c 100644 --- a/packages/@luke-ui/react/package.json +++ b/packages/@luke-ui/react/package.json @@ -31,6 +31,7 @@ "./container": "./dist/container.js", "./em": "./dist/em.js", "./emoji": "./dist/emoji.js", + "./form": "./dist/form.js", "./grid": "./dist/grid.js", "./heading": "./dist/heading.js", "./icon": "./dist/icon.js", diff --git a/packages/@luke-ui/react/src/core/checkbox-field/checkbox-field.tsx b/packages/@luke-ui/react/src/core/checkbox-field/checkbox-field.tsx index ab0c19f0..8604d2c8 100644 --- a/packages/@luke-ui/react/src/core/checkbox-field/checkbox-field.tsx +++ b/packages/@luke-ui/react/src/core/checkbox-field/checkbox-field.tsx @@ -6,13 +6,13 @@ import { CheckboxLabel, CheckboxRoot, } from '../primitives/checkbox/checkbox.js'; -import type { NecessityIndicator } from '../primitives/field/field.js'; +import { InlineField } from '../primitives/field/field.js'; +import { InlineLabelText } from '../primitives/field/inline-label-text.js'; import { - InlineField, isInvalidFromErrorMessage, normalizeErrorMessage, -} from '../primitives/field/field.js'; -import { InlineLabelText } from '../primitives/field/inline-label-text.js'; +} from '../primitives/field/is-invalid-from-error-message.js'; +import type { NecessityIndicator } from '../primitives/field/necessity-context.js'; import type { DistributiveOmit } from '../types/distributive-omit.js'; import type { Prettify } from '../types/prettify.js'; @@ -32,8 +32,8 @@ interface _CheckboxFieldProps extends _CheckboxFieldOmit { */ label: Exclude; /** - * Controls the visible marker for required or optional controls. - * @default 'required-asterisk' + * Controls how the label identifies required or optional values. Inherits from `Form`, or uses + * `'required-asterisk'` when unset. */ necessityIndicator?: NecessityIndicator; } diff --git a/packages/@luke-ui/react/src/core/combobox-field/combobox-field.tsx b/packages/@luke-ui/react/src/core/combobox-field/combobox-field.tsx index 8971bd81..bf0a3bd4 100644 --- a/packages/@luke-ui/react/src/core/combobox-field/combobox-field.tsx +++ b/packages/@luke-ui/react/src/core/combobox-field/combobox-field.tsx @@ -19,11 +19,11 @@ import { ComboboxTrayTrigger } from '../primitives/combobox/tray-trigger.js'; import { ComboboxTray } from '../primitives/combobox/tray.js'; import { ComboboxTrigger } from '../primitives/combobox/trigger.js'; import type { FieldAccessibleNameProps } from '../primitives/field/field.js'; +import { Field } from '../primitives/field/field.js'; import { - Field, isInvalidFromErrorMessage, normalizeErrorMessage, -} from '../primitives/field/field.js'; +} from '../primitives/field/is-invalid-from-error-message.js'; import type { DistributiveOmit } from '../types/distributive-omit.js'; import type { Prettify } from '../types/prettify.js'; diff --git a/packages/@luke-ui/react/src/core/form/form.browser.test.tsx b/packages/@luke-ui/react/src/core/form/form.browser.test.tsx new file mode 100644 index 00000000..e841b31f --- /dev/null +++ b/packages/@luke-ui/react/src/core/form/form.browser.test.tsx @@ -0,0 +1,367 @@ +import { Button } from '@luke-ui/react/button'; +import { CheckboxField } from '@luke-ui/react/checkbox-field'; +import { ComboboxField, ComboboxItem } from '@luke-ui/react/combobox-field'; +import { Form } from '@luke-ui/react/form'; +import type { NecessityIndicator } from '@luke-ui/react/form'; +import { TextInputField } from '@luke-ui/react/text-input-field'; +import { createRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { expect, test } from 'vite-plus/test'; +import { page, userEvent } from 'vite-plus/test/context'; +import { expectNoAxeViolations } from '../test-utils/axe.js'; +import { expectForwardsDomProps, expectHtmlElement } from '../test-utils/forwarding.js'; +import { getDescribedText } from '../test-utils/get-described-text.js'; +import { MOBILE_SCREEN_WIDTH, mockScreenWidth } from '../test-utils/mock-screen-width.js'; +import { render } from '../test-utils/render.js'; +import { Stack } from '../test-utils/visual.js'; + +type CountryItem = { + id: string; + label: string; +}; + +const countryItems: Array = [ + { id: 'au', label: 'Australia' }, + { id: 'ca', label: 'Canada' }, +]; + +const renderCountryItem = (item: CountryItem) => {item.label}; + +function textbox(name: string): HTMLInputElement { + const element = page.getByRole('textbox', { name }).element(); + if (!(element instanceof HTMLInputElement)) throw new Error(`Expected a textbox "${name}".`); + return element; +} + +function checkbox(name: string): HTMLInputElement { + const element = page.getByRole('checkbox', { name }).element(); + if (!(element instanceof HTMLInputElement)) throw new Error(`Expected a checkbox "${name}".`); + return element; +} + +/** Portals a nested Form outside the outer form element while keeping React ancestry for context. */ +function NestedFormThroughPortal(props: { + children: ReactNode; + nestedNecessityIndicator?: NecessityIndicator; +}) { + const { children, nestedNecessityIndicator } = props; + const [host, setHost] = useState(null); + + return ( + <> +
+
+ {host + ? createPortal( + + {children} +
, + host, + ) + : null} + + + ); +} + +function FormScene() { + return ( +
+ + + + + +
+ ); +} + +test('Form forwards className, data attributes, id, and ref to its form element', () => { + const ref = createRef(); + const { container } = render( +
+ Content +
, + ); + const target = expectHtmlElement(container.firstElementChild, 'Expected Form element.'); + + expect(target.tagName).toBe('FORM'); + expectForwardsDomProps(target, ref); +}); + +test('the Form scene has no axe violations', async () => { + const { container } = render(); + + await expectNoAxeViolations(container); +}); + +test('a composed field inherits Form necessityIndicator through an explicit undefined prop', () => { + render( +
+ + , + ); + + expect(textbox('Middle name (optional)').labels?.[0]?.textContent).toBe( + 'Middle name\u00A0(optional)', + ); +}); + +test('a composed field necessityIndicator overrides Form', () => { + render( +
+ + , + ); + + expect(textbox('Family name').labels?.[0]?.textContent).toBe('Family name\u00A0(required)'); +}); + +test('an unset nested Form does not shadow an outer Form necessityIndicator', () => { + const { container } = render( + + + , + ); + + expect(container.querySelectorAll('form')).toHaveLength(2); + expect(container.querySelector('form form')).toBeNull(); + expect(textbox('Middle name (optional)').labels?.[0]?.textContent).toBe( + 'Middle name\u00A0(optional)', + ); +}); + +test('a nested Form with an explicit necessityIndicator overrides the outer Form', () => { + const { container } = render( + + + , + ); + + expect(container.querySelectorAll('form')).toHaveLength(2); + expect(container.querySelector('form form')).toBeNull(); + expect(textbox('Family name').labels?.[0]?.textContent).toBe('Family name\u00A0(required)'); +}); + +test('toggling Form necessityIndicator preserves uncontrolled field value and focus', async () => { + const { rerender } = render( +
+ + , + ); + + const input = textbox('Middle name'); + await userEvent.click(input); + await userEvent.keyboard('Ada'); + expect(input.value).toBe('Ada'); + expect(input).toHaveFocus(); + + rerender( +
+ + , + ); + + const afterOptional = textbox('Middle name (optional)'); + expect(afterOptional.value).toBe('Ada'); + expect(afterOptional).toHaveFocus(); + expect(afterOptional.labels?.[0]?.textContent).toBe('Middle name\u00A0(optional)'); + + rerender( +
+ + , + ); + + const afterUnset = textbox('Middle name'); + expect(afterUnset.value).toBe('Ada'); + expect(afterUnset).toHaveFocus(); + expect(afterUnset.labels?.[0]?.textContent).toBe('Middle name'); +}); + +test('CheckboxField inherits Form necessityIndicator and can override it locally', () => { + render( +
+ + + , + ); + + expect(checkbox('Newsletter (optional)').closest('label')?.textContent).toContain('(optional)'); + expect(checkbox('Terms').closest('label')?.textContent).toContain('*'); +}); + +test('Form forwards the native submit event to onSubmit', async () => { + let submitted = false; + render( +
{ + event.preventDefault(); + submitted = true; + }} + > + + + , + ); + + await userEvent.click(page.getByRole('button', { name: 'Submit' })); + expect(submitted).toBe(true); +}); + +test('native validation blocks submission for a required empty TextInputField', async () => { + let submitted = false; + render( +
{ + event.preventDefault(); + submitted = true; + }} + > + + + , + ); + + await userEvent.click(page.getByRole('button', { name: 'Submit' })); + expect(submitted).toBe(false); + await expect.poll(() => textbox('Name').getAttribute('aria-invalid')).toBe('true'); +}); + +test("validationBehavior='aria' allows submission for a required empty TextInputField", async () => { + let submitted = false; + render( +
{ + event.preventDefault(); + submitted = true; + }} + validationBehavior="aria" + > + + + , + ); + + await userEvent.click(page.getByRole('button', { name: 'Submit' })); + expect(submitted).toBe(true); +}); + +test('native validation blocks submission for a required empty ComboboxField tray', async () => { + mockScreenWidth(MOBILE_SCREEN_WIDTH); + let submitted = false; + render( +
{ + event.preventDefault(); + submitted = true; + }} + > + + defaultItems={countryItems} + isRequired + label="Country" + name="country" + > + {renderCountryItem} + + + , + ); + + await userEvent.click(page.getByRole('button', { name: 'Submit' })); + expect(submitted).toBe(false); +}); + +test("validationBehavior='aria' on Form allows submission for a required empty ComboboxField tray", async () => { + mockScreenWidth(MOBILE_SCREEN_WIDTH); + let submitted = false; + render( +
{ + event.preventDefault(); + submitted = true; + }} + validationBehavior="aria" + > + + defaultItems={countryItems} + isRequired + label="Country" + name="country" + > + {renderCountryItem} + + + , + ); + + await userEvent.click(page.getByRole('button', { name: 'Submit' })); + expect(submitted).toBe(true); +}); + +test('ComboboxField validationBehavior overrides Form for the tray fallback', async () => { + mockScreenWidth(MOBILE_SCREEN_WIDTH); + let submitted = false; + render( +
{ + event.preventDefault(); + submitted = true; + }} + validationBehavior="native" + > + + defaultItems={countryItems} + isRequired + label="Country" + name="country" + validationBehavior="aria" + > + {renderCountryItem} + + + , + ); + + await userEvent.click(page.getByRole('button', { name: 'Submit' })); + expect(submitted).toBe(true); +}); + +test('a controlled errorMessage wins over Form validationErrors, then removing it restores the server error', async () => { + function Harness() { + const [clientError, setClientError] = useState( + 'Choose a different username.', + ); + + return ( + <> +
+ + + + + ); + } + + render(); + expect(getDescribedText(textbox('Username'))).toBe('Choose a different username.'); + + // Remove `errorMessage` while retaining `validationErrors`. Do not type to clear: React Aria + // clears server errors on editing. + await userEvent.click(page.getByRole('button', { name: 'Clear client error' })); + expect(getDescribedText(textbox('Username'))).toBe('That username is taken.'); +}); + +test('an empty errorMessage falls back to Form validationErrors', () => { + render( +
+ + , + ); + + expect(getDescribedText(textbox('Username'))).toBe('That username is taken.'); +}); diff --git a/packages/@luke-ui/react/src/core/form/form.tsx b/packages/@luke-ui/react/src/core/form/form.tsx new file mode 100644 index 00000000..18a566d5 --- /dev/null +++ b/packages/@luke-ui/react/src/core/form/form.tsx @@ -0,0 +1,69 @@ +import type { JSX, Ref, SubmitEventHandler } from 'react'; +import { use } from 'react'; +import type { FormProps as RacFormProps } from 'react-aria-components/Form'; +import { Form as RacForm } from 'react-aria-components/Form'; +import type { NecessityIndicator } from '../primitives/field/necessity-context.js'; +import { FormNecessityContext } from '../primitives/field/necessity-context.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; +import type { Prettify } from '../types/prettify.js'; + +type _FormOmit = DistributiveOmit; + +interface _FormProps extends _FormOmit { + /** + * React form Action or URL for ordinary submissions. Prefer this over `onSubmit` when you do not + * need the submit event. + */ + action?: RacFormProps['action']; + /** + * Controls how nested fields identify required or optional values. A field's own setting takes + * precedence. + */ + necessityIndicator?: NecessityIndicator; + /** Called when the form is reset. */ + onReset?: RacFormProps['onReset']; + /** + * Called when the form submits. Use this when you need the submit event, such as calling + * `preventDefault` or wiring a form library. + */ + onSubmit?: SubmitEventHandler; + /** + * Determines whether native validation can block submission. Use `'aria'` when a form library + * handles validation. + * @default 'native' + */ + validationBehavior?: RacFormProps['validationBehavior']; + /** Server validation errors keyed by field name. */ + validationErrors?: RacFormProps['validationErrors']; + /** Ref forwarded to the form element. */ + ref?: Ref; +} + +/** Props for the `Form` component. */ +export type FormProps = Prettify<_FormProps>; + +/** + * Groups fields for submission and shares necessity indicators, validation behaviour, and server + * errors. + * + * Use a layout component such as `Stack` to arrange the fields. + */ +export function Form(props: FormProps): JSX.Element { + const { children, className, necessityIndicator, onSubmit, ...restProps } = props; + const parentNecessityIndicator = use(FormNecessityContext); + + // Always wrap so toggling the prop does not remount the form subtree. Pass through the parent + // value when unset so an unset Form does not shadow an outer Form. + return ( + + + {children} + + + ); +} diff --git a/packages/@luke-ui/react/src/core/packed-consumer/react-fixture.ts b/packages/@luke-ui/react/src/core/packed-consumer/react-fixture.ts index 1bb4a586..86522336 100644 --- a/packages/@luke-ui/react/src/core/packed-consumer/react-fixture.ts +++ b/packages/@luke-ui/react/src/core/packed-consumer/react-fixture.ts @@ -25,6 +25,7 @@ export const bundleBoundaries: Array = [ exportName: 'Button', }, { composes: [], entry: 'box', exportName: 'Box' }, + { composes: [], entry: 'form', exportName: 'Form' }, { composes: [], entry: 'theme', exportName: 'vars' }, ]; @@ -83,6 +84,7 @@ import { Blockquote } from '@luke-ui/react/blockquote'; import { Box } from '@luke-ui/react/box'; import { Button } from '@luke-ui/react/button'; import { CheckboxField } from '@luke-ui/react/checkbox-field'; +import { Form } from '@luke-ui/react/form'; import { Icon } from '@luke-ui/react/icon'; import { Provider } from '@luke-ui/react/provider'; import { SelectField, SelectItem } from '@luke-ui/react/select-field'; @@ -104,7 +106,13 @@ export function App({ onHydrated }) { { display: 'flex', gap: 'sp8' }, h(Blockquote, null, 'Hello world'), h(Icon, { name: 'chevronDown', 'aria-label': 'Expand' }), - h(TextInputField, { label: 'Name' }), + // Form and TextInputField come from separate published entrypoints so a duplicated + // context instance in the built package would fail to pass the optional marker. + h( + Form, + { necessityIndicator: 'optional-label' }, + h(TextInputField, { label: 'Name' }), + ), h(CheckboxField, { label: 'Example checkbox' }), h(SwitchField, { label: 'Example switch' }), h( diff --git a/packages/@luke-ui/react/src/core/packed-consumer/react-package.test.ts b/packages/@luke-ui/react/src/core/packed-consumer/react-package.test.ts index 127ffaae..5a10f006 100644 --- a/packages/@luke-ui/react/src/core/packed-consumer/react-package.test.ts +++ b/packages/@luke-ui/react/src/core/packed-consumer/react-package.test.ts @@ -31,6 +31,7 @@ const WORKSPACE_PROTOCOL_PATTERN = /^(?:catalog|file|link|portal|workspace):/; const ASSET_EXPORT_PATTERN = /\.(?:css|svg)$/; const CUSTOM_PROPERTY_PATTERN = /--luke-[a-z-]+:/; const SYMBOL_PATTERN = /]*\bid="/; +const WHITESPACE_PATTERN = /\s+/g; /** Every cascade layer the shared stylesheet uses, in precedence order. */ const LAYER_ORDER = ['base', 'luke-ui', 'luke-ui.reset', 'luke-ui.recipes', 'luke-ui.utilities']; const MODULE_SCRIPT_PATTERN = /