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 `
,
+ );
+
+ 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 = /