@vtex/faststore 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/dist/actions.json +181 -0
- package/dist/catalog.json +1543 -495
- package/dist/client.d.ts +2 -1
- package/dist/client.js +2 -1
- package/dist/components.d.ts +28 -7
- package/dist/components.js +31 -12
- package/dist/docs/access-code-sign-in.md +1 -1
- package/dist/docs/address-summary.md +24 -3
- package/dist/docs/alert-dialog.md +204 -0
- package/dist/docs/billing-address.md +284 -0
- package/dist/docs/card-entry.md +250 -0
- package/dist/docs/cardholder-document.md +142 -0
- package/dist/docs/client-preferences-fieldset.md +148 -0
- package/dist/docs/client-preferences.md +2 -1
- package/dist/docs/client-profile-fieldset.md +184 -0
- package/dist/docs/client-profile.md +1 -1
- package/dist/docs/document-field.md +58 -4
- package/dist/docs/field.md +317 -0
- package/dist/docs/form.md +307 -0
- package/dist/docs/identity-edit.md +6 -6
- package/dist/docs/identity-summary.md +5 -5
- package/dist/docs/installments.md +248 -0
- package/dist/docs/my-account-profile.md +1 -0
- package/dist/docs/order-placed-payment-summary.md +138 -0
- package/dist/docs/payment-method-badges.md +232 -0
- package/dist/docs/payment-methods.md +1 -0
- package/dist/docs/payment.md +218 -0
- package/dist/docs/phone-field.md +58 -4
- package/dist/docs/place-order.md +157 -0
- package/dist/docs/save-card.md +186 -0
- package/dist/docs/saved-cards.md +218 -0
- package/dist/docs/select.md +465 -0
- package/dist/docs/shipping-address.md +110 -258
- package/dist/index.d.ts +4 -1
- package/dist/internal/client.d.ts +3 -1
- package/dist/internal/client.js +3 -1
- package/dist/matter/access-code-sign-in/access-code-sign-in.js +9 -8
- package/dist/matter/access-code-sign-in/access-code-sign-in.js.map +1 -1
- package/dist/matter/billing-address/billing-address.d.ts +165 -0
- package/dist/matter/billing-address/billing-address.js +408 -0
- package/dist/matter/billing-address/billing-address.js.map +1 -0
- package/dist/matter/card-entry/card-capture.d.ts +25 -0
- package/dist/matter/card-entry/card-capture.js +25 -0
- package/dist/matter/card-entry/card-capture.js.map +1 -0
- package/dist/matter/card-entry/card-entry.d.ts +120 -0
- package/dist/matter/card-entry/card-entry.js +832 -0
- package/dist/matter/card-entry/card-entry.js.map +1 -0
- package/dist/matter/cardholder-document/cardholder-document.d.ts +45 -0
- package/dist/matter/cardholder-document/cardholder-document.js +70 -0
- package/dist/matter/cardholder-document/cardholder-document.js.map +1 -0
- package/dist/matter/client-preferences/client-preferences-fieldset.d.ts +77 -0
- package/dist/matter/client-preferences/client-preferences-fieldset.js +299 -0
- package/dist/matter/client-preferences/client-preferences-fieldset.js.map +1 -0
- package/dist/matter/client-preferences/client-preferences.d.ts +3 -5
- package/dist/matter/client-preferences/client-preferences.js +6 -6
- package/dist/matter/client-preferences/client-preferences.js.map +1 -1
- package/dist/matter/client-profile/client-profile.d.ts +4 -6
- package/dist/matter/client-profile/client-profile.js +9 -6
- package/dist/matter/client-profile/client-profile.js.map +1 -1
- package/dist/matter/client-profile-form/client-profile-fieldset.d.ts +119 -0
- package/dist/matter/client-profile-form/client-profile-fieldset.js +488 -0
- package/dist/matter/client-profile-form/client-profile-fieldset.js.map +1 -0
- package/dist/matter/identity-edit/identity-edit.js +2 -2
- package/dist/matter/identity-edit/identity-edit.js.map +1 -1
- package/dist/matter/identity-summary/identity-summary.js +2 -2
- package/dist/matter/identity-summary/identity-summary.js.map +1 -1
- package/dist/matter/installments/installments-select.d.ts +42 -0
- package/dist/matter/installments/installments-select.js +167 -0
- package/dist/matter/installments/installments-select.js.map +1 -0
- package/dist/matter/installments/installments.d.ts +58 -0
- package/dist/matter/installments/installments.js +176 -0
- package/dist/matter/installments/installments.js.map +1 -0
- package/dist/matter/installments/use-installments-selection.d.ts +29 -0
- package/dist/matter/installments/use-installments-selection.js +95 -0
- package/dist/matter/installments/use-installments-selection.js.map +1 -0
- package/dist/matter/my-account-profile/my-account-profile.js +4 -4
- package/dist/matter/my-account-profile/my-account-profile.js.map +1 -1
- package/dist/matter/order-placed-payment-summary/order-placed-payment-summary.d.ts +63 -0
- package/dist/matter/order-placed-payment-summary/order-placed-payment-summary.js +305 -0
- package/dist/matter/order-placed-payment-summary/order-placed-payment-summary.js.map +1 -0
- package/dist/matter/payment/payment-card-capture.d.ts +19 -0
- package/dist/matter/payment/payment-card-capture.js +129 -0
- package/dist/matter/payment/payment-card-capture.js.map +1 -0
- package/dist/matter/payment/payment-controller.d.ts +24 -0
- package/dist/matter/payment/payment-controller.js +107 -0
- package/dist/matter/payment/payment-controller.js.map +1 -0
- package/dist/matter/payment/payment-method-badges.d.ts +57 -0
- package/dist/matter/payment/payment-method-badges.js +192 -0
- package/dist/matter/payment/payment-method-badges.js.map +1 -0
- package/dist/matter/payment/payment.d.ts +89 -0
- package/dist/matter/payment/payment.js +263 -0
- package/dist/matter/payment/payment.js.map +1 -0
- package/dist/matter/payment/use-payment-method-selection.d.ts +302 -0
- package/dist/matter/payment/use-payment-method-selection.js +458 -0
- package/dist/matter/payment/use-payment-method-selection.js.map +1 -0
- package/dist/matter/payment/use-payment-submit-gate.d.ts +111 -0
- package/dist/matter/payment/use-payment-submit-gate.js +144 -0
- package/dist/matter/payment/use-payment-submit-gate.js.map +1 -0
- package/dist/matter/place-order/place-order.d.ts +74 -0
- package/dist/matter/place-order/place-order.js +379 -0
- package/dist/matter/place-order/place-order.js.map +1 -0
- package/dist/matter/product-list/product-list.d.ts +1 -0
- package/dist/matter/product-list/product-list.js.map +1 -1
- package/dist/matter/product-shelf/product-shelf.d.ts +1 -0
- package/dist/matter/product-shelf/product-shelf.js +2 -0
- package/dist/matter/product-shelf/product-shelf.js.map +1 -1
- package/dist/matter/region/region.js +1 -1
- package/dist/matter/region/region.js.map +1 -1
- package/dist/matter/save-card/save-card.d.ts +49 -0
- package/dist/matter/save-card/save-card.js +188 -0
- package/dist/matter/save-card/save-card.js.map +1 -0
- package/dist/matter/saved-cards/saved-cards.d.ts +79 -0
- package/dist/matter/saved-cards/saved-cards.js +387 -0
- package/dist/matter/saved-cards/saved-cards.js.map +1 -0
- package/dist/matter/shipping-address/country-descriptors.js +20 -10
- package/dist/matter/shipping-address/country-descriptors.js.map +1 -1
- package/dist/matter/shipping-address/shipping-address.d.ts +113 -174
- package/dist/matter/shipping-address/shipping-address.js +480 -791
- package/dist/matter/shipping-address/shipping-address.js.map +1 -1
- package/dist/matter/variant-selector/variant-selector.js +1 -1
- package/dist/particles/address-summary/address-summary.d.ts +24 -0
- package/dist/particles/address-summary/address-summary.js +83 -2
- package/dist/particles/address-summary/address-summary.js.map +1 -1
- package/dist/particles/alert-dialog/alert-dialog.d.ts +68 -0
- package/dist/particles/alert-dialog/alert-dialog.js +187 -0
- package/dist/particles/alert-dialog/alert-dialog.js.map +1 -0
- package/dist/particles/checkbox/checkbox.d.ts +7 -3
- package/dist/particles/checkbox/checkbox.js +4 -1
- package/dist/particles/checkbox/checkbox.js.map +1 -1
- package/dist/particles/document-field/document-field.d.ts +48 -5
- package/dist/particles/document-field/document-field.js +189 -32
- package/dist/particles/document-field/document-field.js.map +1 -1
- package/dist/particles/field/bound-field-registry.js +52 -0
- package/dist/particles/field/bound-field-registry.js.map +1 -0
- package/dist/particles/field/field.d.ts +116 -0
- package/dist/particles/field/field.js +497 -6
- package/dist/particles/field/field.js.map +1 -1
- package/dist/particles/fieldset/fieldset.js +2 -2
- package/dist/particles/fieldset/fieldset.js.map +1 -1
- package/dist/particles/form/form.d.ts +104 -0
- package/dist/particles/form/form.js +485 -0
- package/dist/particles/form/form.js.map +1 -0
- package/dist/particles/phone-field/phone-field.d.ts +38 -10
- package/dist/particles/phone-field/phone-field.js +183 -39
- package/dist/particles/phone-field/phone-field.js.map +1 -1
- package/dist/particles/postal-code-field/postal-code-field.js +2 -2
- package/dist/particles/postal-code-field/postal-code-field.js.map +1 -1
- package/dist/particles/select/field-select.d.ts +43 -0
- package/dist/particles/select/field-select.js +150 -0
- package/dist/particles/select/field-select.js.map +1 -0
- package/dist/particles/select/select.d.ts +2 -0
- package/dist/particles/select/select.js.map +1 -1
- package/dist/particles/shared/compose-refs.js +21 -0
- package/dist/particles/shared/compose-refs.js.map +1 -0
- package/dist/schema.graphql +701 -2
- package/dist/sdk/actions/analytics-action-provider.d.ts +15 -0
- package/dist/sdk/actions/analytics-action-provider.js +19 -0
- package/dist/sdk/actions/analytics-action-provider.js.map +1 -0
- package/dist/sdk/actions/index.d.ts +3 -1
- package/dist/sdk/actions/index.js +3 -1
- package/dist/sdk/actions/payment-action-provider.d.ts +22 -0
- package/dist/sdk/actions/payment-action-provider.js +26 -0
- package/dist/sdk/actions/payment-action-provider.js.map +1 -0
- package/dist/sdk/actions/types.d.ts +6 -2
- package/dist/sdk/analytics/analytics-destination.d.ts +43 -0
- package/dist/sdk/analytics/analytics-destination.js +90 -0
- package/dist/sdk/analytics/analytics-destination.js.map +1 -0
- package/dist/sdk/analytics/use-analytics-actions.d.ts +57 -0
- package/dist/sdk/analytics/use-analytics-actions.js +108 -0
- package/dist/sdk/analytics/use-analytics-actions.js.map +1 -0
- package/dist/sdk/cart/use-cart-actions.js +1 -1
- package/dist/sdk/data/entity-resolution-context.js +31 -0
- package/dist/sdk/data/entity-resolution-context.js.map +1 -0
- package/dist/sdk/data/entity-subscriber.js +18 -8
- package/dist/sdk/data/entity-subscriber.js.map +1 -1
- package/dist/sdk/data/index.js +1 -0
- package/dist/sdk/data/page-reactive-data-root.d.ts +36 -0
- package/dist/sdk/data/page-reactive-data-root.js +4 -2
- package/dist/sdk/data/page-reactive-data-root.js.map +1 -1
- package/dist/sdk/data/reactive-data-provider.js +7 -3
- package/dist/sdk/data/reactive-data-provider.js.map +1 -1
- package/dist/sdk/data/reactive-list-item-data-provider.js +12 -4
- package/dist/sdk/data/reactive-list-item-data-provider.js.map +1 -1
- package/dist/sdk/data/use-reactive-data-settled.js +39 -0
- package/dist/sdk/data/use-reactive-data-settled.js.map +1 -0
- package/dist/sdk/data/use-reactive-entity.js +5 -1
- package/dist/sdk/data/use-reactive-entity.js.map +1 -1
- package/dist/sdk/generated/fragment-types.js +2 -0
- package/dist/sdk/generated/fragment-types.js.map +1 -1
- package/dist/sdk/generated/graphql.js.map +1 -1
- package/dist/sdk/i18n/messages/en-US.js +3 -0
- package/dist/sdk/i18n/messages/en-US.json +3 -0
- package/dist/sdk/i18n/messages/en-US.json.d.ts +3 -0
- package/dist/sdk/i18n/messages/es-ES.js +3 -0
- package/dist/sdk/i18n/messages/es-ES.json +3 -0
- package/dist/sdk/i18n/messages/pt-BR.js +3 -0
- package/dist/sdk/i18n/messages/pt-BR.json +3 -0
- package/dist/sdk/identity/use-identity-actions.js +3 -2
- package/dist/sdk/identity/use-identity-actions.js.map +1 -1
- package/dist/sdk/payment/gateway-origin.js +35 -0
- package/dist/sdk/payment/gateway-origin.js.map +1 -0
- package/dist/sdk/payment/gateway.d.ts +85 -0
- package/dist/sdk/payment/gateway.js +183 -0
- package/dist/sdk/payment/gateway.js.map +1 -0
- package/dist/sdk/payment/operations.d.ts +52 -0
- package/dist/sdk/payment/operations.js +223 -0
- package/dist/sdk/payment/operations.js.map +1 -0
- package/dist/sdk/payment/security-code-mask.js +71 -0
- package/dist/sdk/payment/security-code-mask.js.map +1 -0
- package/dist/sdk/payment/submission-registry.d.ts +33 -0
- package/dist/sdk/payment/submission-registry.js +58 -0
- package/dist/sdk/payment/submission-registry.js.map +1 -0
- package/dist/sdk/payment/use-payment-actions.d.ts +78 -0
- package/dist/sdk/payment/use-payment-actions.js +276 -0
- package/dist/sdk/payment/use-payment-actions.js.map +1 -0
- package/dist/sdk/payment/use-place-order.d.ts +65 -0
- package/dist/sdk/payment/use-place-order.js +640 -0
- package/dist/sdk/payment/use-place-order.js.map +1 -0
- package/dist/sdk/shipping/use-shipping-actions.js +1 -1
- package/dist/utils/loggable-error-name.js +49 -0
- package/dist/utils/loggable-error-name.js.map +1 -0
- package/dist/utils/resolve-authored-copy.js +18 -0
- package/dist/utils/resolve-authored-copy.js.map +1 -0
- package/dist/utils/substitute-template.js +33 -0
- package/dist/utils/substitute-template.js.map +1 -0
- package/package.json +2 -1
- package/src/styles/tailwind.css +34 -0
- package/dist/docs/client-profile-form.md +0 -192
- package/dist/matter/client-profile-form/client-profile-form.d.ts +0 -126
- package/dist/matter/client-profile-form/client-profile-form.js +0 -662
- package/dist/matter/client-profile-form/client-profile-form.js.map +0 -1
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# Field
|
|
2
|
+
|
|
3
|
+
**Category:** layout
|
|
4
|
+
**Purpose:** Composable anatomy for a form field — label, input, description, error, and validity feedback — that behaves standalone or binds to a react-hook-form field through a context contract.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## When to use
|
|
9
|
+
|
|
10
|
+
- Any form field: a shipping-address line, a profile field, a checkout opt-in, a standalone search box.
|
|
11
|
+
- Inside a bound Control (a matter component that seeds `FieldBinding` from an entity's data) when the
|
|
12
|
+
anatomy needs to be reordered, restyled, or have a part omitted, without a new component.
|
|
13
|
+
- As the closed anatomy it already is today — `Field.Root` + `Field.Label` + `Field.Control` (+
|
|
14
|
+
`Field.Description` / `Field.Error`) with no binding above it — for any field a matter renders itself.
|
|
15
|
+
|
|
16
|
+
## When NOT to use
|
|
17
|
+
|
|
18
|
+
- A boolean field's control — use `Field.Checkbox` (drives the shipped `Checkbox` particle), not
|
|
19
|
+
`Field.Control` (renders a native `<input>`).
|
|
20
|
+
- A field whose value is picked from a list — out of scope here; `FieldSelect` (in the select particle) is the bound
|
|
21
|
+
part for that, and the contract's `options` member is reserved for it.
|
|
22
|
+
- A submit action or a whole fieldset's write — those are `FormSubmit`/the fieldset tasks, not this
|
|
23
|
+
particle.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Composition
|
|
28
|
+
|
|
29
|
+
`Field.Root` is the anatomy's entry point. The five other shipped parts, plus the new `Field.Checkbox`,
|
|
30
|
+
are children (directly or nested) of it — none of them require `Field.Root` at the type level, but every
|
|
31
|
+
shipped composition places them there for the label/control/error ARIA wiring Base UI owns.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
Field.Root (providesContext: 'field')
|
|
35
|
+
├── Field.Label (requiresContext: 'field')
|
|
36
|
+
├── Field.Control (requiresContext: 'field')
|
|
37
|
+
├── Field.Description (requiresContext: 'field')
|
|
38
|
+
├── Field.Error (requiresContext: 'field')
|
|
39
|
+
├── Field.Validity (requiresContext: 'field'; render-prop, reads Base UI's validity state)
|
|
40
|
+
└── Field.Checkbox (requiresContext: 'field'; new — wraps Checkbox.Root)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`providesContext`/`requiresContext` are the compiler's static check for a *schema-authored* composition —
|
|
44
|
+
they say "some ancestor must provide `field`", not "`FieldBinding` is that ancestor". At runtime,
|
|
45
|
+
`FieldBinding` is the only thing that ever supplies the context; `Field.Root` provides the *contract*
|
|
46
|
+
value for its compiler declaration but pushes no React context of its own.
|
|
47
|
+
|
|
48
|
+
Child order reflects authored order — an author reorders `Field.Label` and `Field.Control`, or places an
|
|
49
|
+
icon between them, and the DOM follows. No part receives the field name as a prop; whatever an author
|
|
50
|
+
composes inside a bound Control edits that Control's field, and there is no way to spell a different one.
|
|
51
|
+
|
|
52
|
+
### The field contract (`FieldBinding` / `useField`)
|
|
53
|
+
|
|
54
|
+
`FieldBinding` is a **code-level export, never catalogued** — it carries no DOM of its own and never
|
|
55
|
+
appears in a schema. A bound Control (shipped per-entity by later tasks, e.g. a shipping-address street
|
|
56
|
+
field) renders it internally, seeded from the entity's data:
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
<FieldBinding name="shippingAddress.street" label="Street" defaultValue={address.street}>
|
|
60
|
+
<Field.Root>
|
|
61
|
+
<Field.Label />
|
|
62
|
+
<Field.Control />
|
|
63
|
+
<Field.Error />
|
|
64
|
+
</Field.Root>
|
|
65
|
+
</FieldBinding>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Every part below a `FieldBinding` reads the contract via `useField()` and, when a react-hook-form
|
|
69
|
+
`FormProvider` is also present, renders its **bound** behaviour (reads/writes through react-hook-form).
|
|
70
|
+
With no contract, or a contract but no `FormProvider`, every part renders its plain, unbound behaviour —
|
|
71
|
+
identical to a `Field.Root` composed with no binding at all. `useField()` returns `null` rather than
|
|
72
|
+
throwing when no contract is present, because eight particles and four matters render `Field` parts with
|
|
73
|
+
no contract today.
|
|
74
|
+
|
|
75
|
+
The contract carries **no live value** — only `name` and static facts (`label`, `placeholder`,
|
|
76
|
+
`defaultValue`, `rule`, `options`, and the input semantics). It never changes while the shopper types, so
|
|
77
|
+
typing re-renders only the part subscribed to that value through react-hook-form, not every part under the
|
|
78
|
+
binding.
|
|
79
|
+
|
|
80
|
+
`defaultValue` is not adopted once and forgotten: the bound part re-seeds the field whenever it re-renders
|
|
81
|
+
with a `defaultValue` that differs from the one already applied, provided react-hook-form reports the field
|
|
82
|
+
clean. A field the shopper has typed into is left alone — a changed `defaultValue` reaching a dirty field is
|
|
83
|
+
skipped, not queued, so it is picked up on a later render once the field becomes clean again.
|
|
84
|
+
|
|
85
|
+
A bound part narrows the contract's `defaultValue` to its own value type before registering it —
|
|
86
|
+
`useBoundField`'s `shape.normalise` (`TEXT_FIELD_VALUE` coerces a non-string to `''`, `BOOLEAN_FIELD_VALUE`
|
|
87
|
+
runs it through `Boolean(...)`) — and that same narrowed value is also the dirty baseline react-hook-form
|
|
88
|
+
compares against, never the raw seed.
|
|
89
|
+
|
|
90
|
+
### The fieldset seam (`useFieldsetField` / `useFieldsetFieldNames` / `useNoAncestorFieldset`)
|
|
91
|
+
|
|
92
|
+
`useFieldsetField(namespace, key)` always returns `${namespace}.${key}`, and registers that full name with
|
|
93
|
+
the enclosing fieldset registry for as long as the caller is mounted. With no registry above the caller,
|
|
94
|
+
that registration is a no-op — it returns the joined name and throws nothing. With a registry above that
|
|
95
|
+
declares a **different** namespace, the call **throws during render** instead of registering: the message
|
|
96
|
+
names both the registry's own namespace and the full name the call was refused under, so it is the control
|
|
97
|
+
that asked for the wrong namespace whose render fails first. That throw is uncaught — nothing in this
|
|
98
|
+
repository installs an `ErrorBoundary` — so it unwinds the whole render past the fieldset: a sibling
|
|
99
|
+
composed after the offending control never reaches its own render either, and the subtree comes back
|
|
100
|
+
blank. Naming the offender in the message is what tells a reader which control to fix; it does not confine
|
|
101
|
+
the failure to that control alone. A bound Control calls it to tell its fieldset which field name it
|
|
102
|
+
mounted, so the fieldset can later submit exactly the fields on screen.
|
|
103
|
+
|
|
104
|
+
A fieldset builds the registry it provides in one of two ways. `useFieldsetFieldNames(namespace)` is the
|
|
105
|
+
built-in path: it returns `{ registry, getFieldNames }`. `registry.registerFieldName` refcounts per name —
|
|
106
|
+
two callers of one name, however each arrived, collapse to one entry that is released only once the last
|
|
107
|
+
holder releases, and the release function it hands back is at-most-once (calling it again after the first
|
|
108
|
+
call does nothing). `getFieldNames()` lists every name currently held, in first-registration order. The
|
|
109
|
+
registry object is memoised on `namespace`, so a fieldset that re-points at a different namespace hands out
|
|
110
|
+
a new registry object, and every control still mounted re-registers under the new namespace in the same
|
|
111
|
+
commit.
|
|
112
|
+
|
|
113
|
+
The other way to build a registry is providing `FieldsetFieldContext` directly with a hand-written
|
|
114
|
+
`{ namespace, registerFieldName }` object — the context is exported for exactly that, and nothing requires
|
|
115
|
+
going through the hook. A control that calls `useFieldsetField` gets the namespace throw either way, since
|
|
116
|
+
that check reads `registry.namespace` and lives in `useFieldsetField` itself, never in `registerFieldName` —
|
|
117
|
+
so the namespace check is never lost for a normal control; only a caller that bypasses `useFieldsetField`
|
|
118
|
+
and reaches `registerFieldName` directly escapes it, on either kind of registry. Refcounting for that same
|
|
119
|
+
direct caller depends on which registry it reached: on one built by `useFieldsetFieldNames`, it is
|
|
120
|
+
refcounted exactly like a caller that arrived through `useFieldsetField` — two callers of one name collapse
|
|
121
|
+
to one entry either way (see above). On a hand-written registry, it gets whatever that particular
|
|
122
|
+
`registerFieldName` implementation wrote, which may not refcount at all. The structural type,
|
|
123
|
+
`{ namespace: string; registerFieldName: (name: string) => () => void }`, does not enforce either property
|
|
124
|
+
at the type level — but the contract's own documentation requires both, as provider obligations:
|
|
125
|
+
refcounting, and that the returned function releases at most once.
|
|
126
|
+
|
|
127
|
+
Every fieldset also calls `useNoAncestorFieldset(componentName)` once, a one-line guard that throws during
|
|
128
|
+
render when it finds an ancestor fieldset already providing field registration for the subtree — fieldsets
|
|
129
|
+
compose as siblings under one `FormRoot`, never nested. `ShippingAddressFieldset`, `ClientProfileFieldset`
|
|
130
|
+
and `ClientPreferencesFieldset` all provide their registry via `useFieldsetFieldNames` today.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Components
|
|
135
|
+
|
|
136
|
+
### Field.Root
|
|
137
|
+
|
|
138
|
+
Form field container that connects a label, input, and validation messages via ARIA. Declares
|
|
139
|
+
`providesContext: 'field'`.
|
|
140
|
+
|
|
141
|
+
| Prop | Type | Default | Notes |
|
|
142
|
+
|------|------|---------|-------|
|
|
143
|
+
| `disabled` | boolean | `false` | Explicit prop always wins over the contract's `disabled`. |
|
|
144
|
+
| `invalid` | boolean | — | Explicit prop always wins over the value react-hook-form derives when bound. |
|
|
145
|
+
| `name` | string | — | Explicit prop always wins over the contract's `name` when bound. |
|
|
146
|
+
|
|
147
|
+
Bound behaviour: passes the contract's `name` to Base UI's `name`; derives `invalid` from
|
|
148
|
+
react-hook-form's field state and passes it as a prop. `dirty`/`touched` are three-state: while
|
|
149
|
+
react-hook-form has not registered (mounted) the contract's name, both are `undefined`, so Base UI's own
|
|
150
|
+
internal tracking still drives `data-dirty`/`data-touched` for a control it never registers — a raw Base
|
|
151
|
+
UI control placed directly under the bound `Field.Root`, rather than through a `Field.*` adapter. Once
|
|
152
|
+
react-hook-form registers the name, its own dirty/touched state for that name always wins instead, in
|
|
153
|
+
both directions, so `data-dirty`/`data-touched` come off the rendered element as soon as react-hook-form
|
|
154
|
+
reports the field clean/untouched again — this applies identically to `dirty` and `touched`.
|
|
155
|
+
|
|
156
|
+
### Field.Label
|
|
157
|
+
|
|
158
|
+
Accessible label, associated with `Field.Control`'s input via ARIA. No props beyond `className`/`children`.
|
|
159
|
+
|
|
160
|
+
Bound behaviour: a **childless** `Field.Label` renders the contract's `label`; with children, the children
|
|
161
|
+
win.
|
|
162
|
+
|
|
163
|
+
### Field.Control
|
|
164
|
+
|
|
165
|
+
Renders a native `<input>` directly — it *is* the input, not a wrapper. No props beyond native input
|
|
166
|
+
attributes.
|
|
167
|
+
|
|
168
|
+
Bound behaviour: registers with react-hook-form using the contract's `name`, `rule` (as `rules`), and
|
|
169
|
+
`defaultValue`; wires `value`, `onChange`, `onBlur`, and `ref` from the resulting controller field. An
|
|
170
|
+
explicit `placeholder`, `disabled`, `readOnly`, `autoComplete`, or `inputMode` prop wins over the
|
|
171
|
+
contract's; `aria-required` is set when the field resolves to required.
|
|
172
|
+
|
|
173
|
+
Requiredness resolves in `useBoundField`, the one hook every bound control passes through, as
|
|
174
|
+
`required = baselineRequired || authorRaise` and nothing else. The fieldset's `rule.required` is the
|
|
175
|
+
baseline; a control may raise a field to required and never lower one. Read-only is deliberately not
|
|
176
|
+
a term: a field that is read-only and required stays required, and an unsatisfiable form is the
|
|
177
|
+
declared baseline's fault. The one resolved value drives the announcement, the validation and the
|
|
178
|
+
error copy together, so they cannot disagree — including a blank check beside `required`, because
|
|
179
|
+
`required` alone treats a space-filled string as present.
|
|
180
|
+
|
|
181
|
+
### Field.Description
|
|
182
|
+
|
|
183
|
+
Helper text, linked to the control via `aria-describedby`. No props beyond `className`/`children`.
|
|
184
|
+
Unchanged whether bound or not — the contract carries no description text.
|
|
185
|
+
|
|
186
|
+
### Field.Error
|
|
187
|
+
|
|
188
|
+
Validation error message, linked to the control via `aria-describedby`.
|
|
189
|
+
|
|
190
|
+
| Prop | Type | Default | Notes |
|
|
191
|
+
|------|------|---------|-------|
|
|
192
|
+
| `match` | `boolean \| keyof ValidityState` | — | Unbound path only (see below). |
|
|
193
|
+
|
|
194
|
+
Unbound behaviour: `match` keeps Base UI's documented meaning — `true` always shows the error; a
|
|
195
|
+
`ValidityState` key shows it only when that native flag is set; omitted, it falls back to Base UI's own
|
|
196
|
+
native-validity default.
|
|
197
|
+
|
|
198
|
+
Bound behaviour: renders `children ?? <the react-hook-form error's message>` for the contract's name, and
|
|
199
|
+
renders nothing when there is no error. `match` is not read on this path — visibility is driven by
|
|
200
|
+
react-hook-form's error, not by native constraint validation (which the shipped `Field.Control` never
|
|
201
|
+
opts into: there is no real `required` HTML attribute, only `aria-required`).
|
|
202
|
+
|
|
203
|
+
### Field.Validity
|
|
204
|
+
|
|
205
|
+
Render-prop slot for custom messages derived from Base UI's own validity state.
|
|
206
|
+
|
|
207
|
+
**Bound behaviour is not transparent.** `Field.Root` only feeds Base UI's `invalid` flag from
|
|
208
|
+
react-hook-form; that flips `validity.valid` to `false`, so `data-invalid` and a bound `Field.Error` both
|
|
209
|
+
react correctly. It does **not** feed `.error`/`.errors` — those come from Base UI's own internal
|
|
210
|
+
`validityData`, which Base UI writes only from its own commit cycle (native constraint validation, or its
|
|
211
|
+
`validate` prop, run on change, on blur, or via an imperative `actionsRef.validate()`). A field with no
|
|
212
|
+
native HTML constraint and no `validate` prop — every shipped bound `Field.Control` — never triggers that
|
|
213
|
+
cycle. A required field left empty and submitted renders `valid: false` with `error: ''`:
|
|
214
|
+
|
|
215
|
+
```tsx
|
|
216
|
+
<Field.Validity>{(validity) => (validity.validity.valid ? null : <span>{validity.error}</span>)}</Field.Validity>
|
|
217
|
+
// Invalid after submit, but `validity.error` is '' — renders an empty <span>, not the rule's message.
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Use `Field.Error` for a bound field's error text — it reads react-hook-form's error directly instead of
|
|
221
|
+
Base UI's own validity data. `Field.Validity` stays useful for `.validity.valid`/`.filled`/`.focused`, and
|
|
222
|
+
for a control that also carries a real native HTML constraint.
|
|
223
|
+
|
|
224
|
+
### Field.Checkbox
|
|
225
|
+
|
|
226
|
+
New bound part: an adapter over the shipped `Checkbox` particle, not a change to it. Reads the field
|
|
227
|
+
contract, binds through `useBoundField`, and drives `Checkbox.Root`.
|
|
228
|
+
|
|
229
|
+
| Prop | Type | Notes |
|
|
230
|
+
|------|------|-------|
|
|
231
|
+
| `className` | string | Forwarded to `Checkbox.Root`. |
|
|
232
|
+
| `children` | ReactNode | Forwarded to `Checkbox.Root` — compose `Checkbox.Indicator` here for the visual check mark. `Field.Checkbox` renders no default indicator. |
|
|
233
|
+
|
|
234
|
+
Bound behaviour: renders `Checkbox.Root` with `checked={Boolean(field.value)}`,
|
|
235
|
+
`onCheckedChange={field.onChange}`, `onBlur={field.onBlur}` and `ref={field.ref}` — so react-hook-form
|
|
236
|
+
hears the checkbox's blur (a form running `mode: 'onBlur'`/`'onTouched'` treats it like every other
|
|
237
|
+
field) and can move focus to it after a failed submit — seeded from the contract's `defaultValue`/`rule`,
|
|
238
|
+
plus `disabled` from the contract. It also renders `data-fs-field-checkbox=""` on the same host element
|
|
239
|
+
as `Checkbox.Root`'s own `data-fs-checkbox-root=""`, so a brand can target the bound checkbox without
|
|
240
|
+
touching the generic one. Unbound behaviour: renders `Checkbox.Root` with `children` and `className`
|
|
241
|
+
only. `Checkbox.Root` itself gains no `requiresContext` from this pairing — every standalone checkbox
|
|
242
|
+
stays placeable anywhere — but its prop set opened to Base UI's own span props, so `ref`, `onBlur` and
|
|
243
|
+
any `data-*` attribute reach the rendered host element through a rest spread.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Canonical schema example
|
|
248
|
+
|
|
249
|
+
`FieldBinding` and the two hooks are never catalogued, so a **bound** composition can only be authored in
|
|
250
|
+
code — a matter's Control renders `FieldBinding` internally. A schema can only author the **unbound**
|
|
251
|
+
anatomy, which is the same shape that ships on `main` today:
|
|
252
|
+
|
|
253
|
+
```json
|
|
254
|
+
{
|
|
255
|
+
"email-field": {
|
|
256
|
+
"type": "FieldRoot",
|
|
257
|
+
"style": ["flex", "flex-col", "gap-1"],
|
|
258
|
+
"children": ["email-label", "email-control", "email-description"]
|
|
259
|
+
},
|
|
260
|
+
"email-label": { "type": "FieldLabel", "props": { "children": "Email address" } },
|
|
261
|
+
"email-control": { "type": "FieldControl", "props": { "type": "email", "placeholder": "you@example.com" } },
|
|
262
|
+
"email-description": { "type": "FieldDescription", "props": { "children": "We'll never share your email with anyone." } }
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Common mistakes
|
|
269
|
+
|
|
270
|
+
- **Passing the field name as a prop to a part.** No part accepts one. The only way to name a field is a
|
|
271
|
+
`FieldBinding` above it — whatever an author composes inside a bound Control edits that Control's field.
|
|
272
|
+
- **Expecting the field contract to carry the live value.** It never does, by design (Decision 1 of the
|
|
273
|
+
spec that added binding) — read the value through react-hook-form (`getValues`, `useWatch`), not
|
|
274
|
+
through `useField()`. Do not call `useController` yourself to get at it: it is only ever called once
|
|
275
|
+
per bound part, inside `useBoundField`, and a second call site for the same name would register a
|
|
276
|
+
duplicate binding.
|
|
277
|
+
- **Nesting a bound Control inside another bound Control.** Unsupported; nothing enforces it today.
|
|
278
|
+
- **Using `Field.Control` for a boolean field.** It renders a native `<input>`; use `Field.Checkbox`, which
|
|
279
|
+
drives `Checkbox.Root`.
|
|
280
|
+
- **Passing an unstable `rule` or `options` object to a Control.** The contract memoises on these values —
|
|
281
|
+
a churning identity costs re-renders (every subscriber below the binding re-renders), never correctness.
|
|
282
|
+
- **Expecting `Field.Error`'s `match` prop to do anything on a bound composition.** It is read only on the
|
|
283
|
+
unbound path; a bound `Field.Error`'s visibility is driven by react-hook-form's error for the contract's
|
|
284
|
+
name.
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## Styling
|
|
289
|
+
|
|
290
|
+
Every part renders its own `data-fs-*` hook and accepts `className`; none ships default styling.
|
|
291
|
+
`Field.Checkbox` additionally renders `data-fs-field-checkbox=""` on the same host element as
|
|
292
|
+
`Checkbox.Root`'s own `data-fs-checkbox-root=""`, so a brand can target the bound checkbox without touching
|
|
293
|
+
the generic one.
|
|
294
|
+
|
|
295
|
+
| Component | What you style | Plausible classes |
|
|
296
|
+
|---|---|---|
|
|
297
|
+
| `FieldRoot` | Stacks label/control/description/error | `flex flex-col gap-1` |
|
|
298
|
+
| `FieldLabel` | Label typography | `text-sm font-medium text-gray-700` |
|
|
299
|
+
| `FieldControl` | The input box; target `data-invalid:` for the error state | `border border-gray-300 rounded-md px-3 py-2 text-sm data-invalid:border-red-400` |
|
|
300
|
+
| `FieldDescription` | Helper text, quieter than the label | `text-xs text-gray-500` |
|
|
301
|
+
| `FieldError` | Error text, typically red | `text-xs text-red-500` |
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Related
|
|
306
|
+
|
|
307
|
+
- [[checkbox]] — the generic particle `Field.Checkbox` adapts. `Checkbox.Root` itself stays placeable
|
|
308
|
+
standalone, outside any form; it gains no form-specific requirement from this pairing.
|
|
309
|
+
- [[fieldset]] — the visual fieldset/legend particle. Unrelated to the write-boundary fieldset that
|
|
310
|
+
provides `useFieldsetField`'s registry — `ShippingAddressFieldset`, `ClientProfileFieldset` and
|
|
311
|
+
`ClientPreferencesFieldset`, documented above.
|
|
312
|
+
- [[document-field]], [[phone-field]] — field-shaped composites that read the field contract themselves
|
|
313
|
+
(through `useField()`/`useFormContext()`) rather than through a `Field.*` adapter, because they own
|
|
314
|
+
masking, progressive formatting and their own validators that an adapter has no way to reach past.
|
|
315
|
+
- [[email-field]], [[password-field]], [[number-field]], [[postal-code-field]], [[access-code-field]] —
|
|
316
|
+
particles that already compose `Field.Root` and its parts with their own closed anatomy; still
|
|
317
|
+
unaffected by the field contract until their own tasks convert them.
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# Form
|
|
2
|
+
|
|
3
|
+
**Category:** action
|
|
4
|
+
**Purpose:** The composable form boundary — one `<form>`, one react-hook-form instance, and a registry of the fieldsets mounted inside it — so a single submit can validate and persist every entity a shopper changed in one press.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## When to use
|
|
9
|
+
|
|
10
|
+
- Any composition where a submit needs to persist more than one entity behind one button — a
|
|
11
|
+
checkout step with a name, a phone and a delivery address, for example.
|
|
12
|
+
- As the outermost boundary for a fieldset (a shipping-address fieldset, a profile fieldset —
|
|
13
|
+
shipped by later tasks) — a fieldset does nothing outside a `FormRoot`, because it has nowhere to
|
|
14
|
+
register.
|
|
15
|
+
- Wherever `FormSubmit`, `FormEnterSubmit`, `FormError`, `FormClear` or `FormReset` are needed — all
|
|
16
|
+
five require the context only `FormRoot` provides, and can be placed anywhere inside it, not only as
|
|
17
|
+
its direct children.
|
|
18
|
+
|
|
19
|
+
## When NOT to use
|
|
20
|
+
|
|
21
|
+
- A single, self-contained write with its own explicit button and no reason to ever compose with
|
|
22
|
+
another entity — nothing in the catalog takes this shape today; every write-capable matter either
|
|
23
|
+
registers a fieldset with `FormRoot` or writes on change, with no component of its own `<form>`.
|
|
24
|
+
- A boolean or list field's own anatomy — that is `Field.Root`/`Field.Checkbox`/`Field.Select` (see
|
|
25
|
+
[[field]]), composed *inside* the fieldset a `FormRoot` wraps, not a `Form` part itself.
|
|
26
|
+
- A visual `<fieldset>`/`<legend>` grouping with no write behaviour — that is `Fieldset.Root`/
|
|
27
|
+
`Fieldset.Legend` (see [[fieldset]]), an unrelated, purely presentational particle sharing the word
|
|
28
|
+
"fieldset" by coincidence.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Composition
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
FormRoot (providesContext: 'form')
|
|
36
|
+
├── FormSubmit (requiresContext: 'form')
|
|
37
|
+
├── FormEnterSubmit (requiresContext: 'form')
|
|
38
|
+
├── FormError (requiresContext: 'form')
|
|
39
|
+
├── FormClear (requiresContext: 'form')
|
|
40
|
+
├── FormReset (requiresContext: 'form')
|
|
41
|
+
└── a fieldset (a later task — registers itself with FormRoot)
|
|
42
|
+
└── a bound Control (shipped by the field contract — see [[field]])
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`FormSubmit`, `FormEnterSubmit`, `FormError`, `FormClear` and `FormReset` are siblings of each other and
|
|
46
|
+
of whatever fieldset(s) `FormRoot` wraps — none of them needs to be adjacent to the others, or to the
|
|
47
|
+
fieldset, in the tree. Child order under `FormRoot` reflects visual order: where an author places
|
|
48
|
+
`FormSubmit`/`FormEnterSubmit` relative to the fields is where the button renders.
|
|
49
|
+
|
|
50
|
+
`providesContext`/`requiresContext` are the compiler's static check for a schema-authored page — every
|
|
51
|
+
one of the five parts fails the build with a `context-constraint-violation` diagnostic if no `FormRoot`
|
|
52
|
+
is an ancestor. At runtime, each part's own hook (`useFormRegistry`) throws the same failure as a
|
|
53
|
+
defence for JSX composed outside the compiler (Storybook, tests, manual code).
|
|
54
|
+
|
|
55
|
+
`FormRoot` knows fieldsets, never fields: it reaches a field name only through a fieldset
|
|
56
|
+
registration's `getFieldNames()`, never by reading `Field.*` parts or the field contract directly. A
|
|
57
|
+
fieldset is a code-level concept (round 1's `FieldsetFieldContext`/`useFieldsetField` seam, plus the
|
|
58
|
+
`FieldsetRegistration` contract this task ships) — it is not itself catalogued or schema-authorable,
|
|
59
|
+
the same way `FieldBinding` is not.
|
|
60
|
+
|
|
61
|
+
### The registry contract (`FieldsetRegistration` / `useFormRegistry`)
|
|
62
|
+
|
|
63
|
+
A fieldset registers itself once, for as long as it is mounted. Its `label` is the kebab-case rendering of
|
|
64
|
+
its own `namespace` (`shippingAddress` → `shipping-address`) — a machine identifier, never shopper copy;
|
|
65
|
+
see *FormError* below for the one place it surfaces:
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
const { registerFieldset } = useFormRegistry();
|
|
69
|
+
|
|
70
|
+
useEffect(
|
|
71
|
+
() =>
|
|
72
|
+
registerFieldset({
|
|
73
|
+
namespace: 'shippingAddress',
|
|
74
|
+
label: 'shipping-address',
|
|
75
|
+
getFieldNames: () => ['shippingAddress.street', 'shippingAddress.city'],
|
|
76
|
+
toPayload: (values) => ({ street: values.street, city: values.city }),
|
|
77
|
+
dispatch: (payload) => actions.shippingAddress.update(payload),
|
|
78
|
+
}),
|
|
79
|
+
[registerFieldset],
|
|
80
|
+
);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`FormRoot` calls `dispatch` for a fieldset only when one of the fields it currently has mounted is dirty —
|
|
84
|
+
"dirty" means "the shopper touched a field here", not "differs from the seed" (see *Common
|
|
85
|
+
mistakes*). `FormRoot` destructures `dirtyFields` from `formState` during render, which is what
|
|
86
|
+
subscribes it, and walks each mounted field's dot-path through that object. Reading dirtiness inside the submit callback
|
|
87
|
+
instead would subscribe nothing, leaving the gate dependent on whichever composed part happened to
|
|
88
|
+
open a subscription of its own. Registrations dispatch in the order they were first inserted, one at a time; a
|
|
89
|
+
later registration under the same namespace replaces the earlier one in place rather than moving it —
|
|
90
|
+
this is how the same fieldset survives its own effect cleaning up and re-running.
|
|
91
|
+
|
|
92
|
+
`registerFieldset` throws when the namespace collides with one a *different*, still-mounted fieldset
|
|
93
|
+
already holds, and when it is a prefix or a descendant of one already registered (`profile` alongside
|
|
94
|
+
`profile.address`). Two fieldsets sharing a namespace would silently collapse into one, leaving the
|
|
95
|
+
displaced fieldset mounted, editable and permanently unwritable while submit still reports success; a
|
|
96
|
+
nested namespace would make the parent dispatch on the child's edits and marshal the child's values
|
|
97
|
+
into the parent's payload. Both are unusable by construction, so `FormRoot` fails fast at registration
|
|
98
|
+
rather than degrading silently.
|
|
99
|
+
|
|
100
|
+
### Implicit submission
|
|
101
|
+
|
|
102
|
+
By default, `FormRoot` cancels HTML implicit submission for its own text-entry inputs: pressing Enter
|
|
103
|
+
in a field does nothing, because a stray Enter mid-address would commit a write the shopper has not
|
|
104
|
+
finished composing. Whether Enter submits is chosen by composition, not by a prop: mount `FormSubmit`
|
|
105
|
+
to keep that cancelling default, or `FormEnterSubmit` to allow it — there is no `submitOnEnter` prop
|
|
106
|
+
and no schema entry for the behaviour on either part.
|
|
107
|
+
|
|
108
|
+
The allowance is form-scoped, not fieldset-scoped: a `FormEnterSubmit` placed inside one fieldset of a
|
|
109
|
+
multi-fieldset `FormRoot` allows Enter to submit from *any* text-entry input of that `FormRoot`, not
|
|
110
|
+
only the fieldset it happens to sit in — `FormRoot` knows fieldsets, never fields, so there is no
|
|
111
|
+
narrower rule and no path to one. Two mounted `FormEnterSubmit`s compose (unmounting one leaves the
|
|
112
|
+
allowance the other still holds in place), and both `FormSubmit` and `FormEnterSubmit` can be mounted
|
|
113
|
+
together, in which case Enter still submits exactly once.
|
|
114
|
+
|
|
115
|
+
Enter that commits an IME candidate — the keystroke a Japanese, Chinese or Korean shopper uses to
|
|
116
|
+
choose a word, not to submit — is cancelled either way, allowance or not. Choosing a word must never
|
|
117
|
+
send a half-finished form.
|
|
118
|
+
|
|
119
|
+
An author-supplied `onKeyDown` on `FormRoot` runs first, unconditionally, before either behaviour: it
|
|
120
|
+
can cancel Enter on a form that otherwise allows it, but it cannot force Enter to submit on a form that
|
|
121
|
+
cancels it, because the built-in guard's own `preventDefault()` runs after the author's handler.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Components
|
|
126
|
+
|
|
127
|
+
### FormRoot
|
|
128
|
+
|
|
129
|
+
The only `<form>` in a composition. Renders `noValidate`, wires
|
|
130
|
+
`onSubmit={handleSubmit(onValid, onInvalid)}`, and provides both react-hook-form's context and the
|
|
131
|
+
fieldset registry to every descendant. Declares no `actions` — every write stays declared by the
|
|
132
|
+
fieldset that owns it.
|
|
133
|
+
|
|
134
|
+
No props beyond the native `<form>` attributes (`onSubmit` is owned internally and cannot be passed).
|
|
135
|
+
|
|
136
|
+
### FormSubmit
|
|
137
|
+
|
|
138
|
+
| Prop | Type | Default | Notes |
|
|
139
|
+
|------|------|---------|-------|
|
|
140
|
+
| `label` | string | — | Rendered when there are no `children`. Falls through to the message catalog (`fs/form.submit`) when unset. |
|
|
141
|
+
| `children` | ReactNode | — | Wins over `label` when present. |
|
|
142
|
+
|
|
143
|
+
Text resolves `children ?? label ?? formatMessage({ id: 'fs/form.submit' })` — there is no hard-coded
|
|
144
|
+
English default. An author who wants copy other than the catalog's own must set `label` or `children`
|
|
145
|
+
explicitly.
|
|
146
|
+
|
|
147
|
+
A `type="submit"` `Button`, disabled while `formState.isSubmitting`. Pressing it validates every
|
|
148
|
+
registered field first; only once every field is valid does it skip untouched fieldsets, dispatch the
|
|
149
|
+
dirty ones in registration order, and collect what failed. Pressing Enter in a text input does *not*
|
|
150
|
+
submit — see *Implicit submission* above and `FormEnterSubmit` below for a form where it does.
|
|
151
|
+
|
|
152
|
+
### FormEnterSubmit
|
|
153
|
+
|
|
154
|
+
| Prop | Type | Default | Notes |
|
|
155
|
+
|------|------|---------|-------|
|
|
156
|
+
| `label` | string | — | Rendered when there are no `children`. Falls through to the message catalog (`fs/form.submit`) when unset. |
|
|
157
|
+
| `children` | ReactNode | — | Wins over `label` when present. |
|
|
158
|
+
|
|
159
|
+
Renders the same button as `FormSubmit`, sharing its text resolution (the same `fs/form.submit` id —
|
|
160
|
+
there is no separate id for the Enter variant, since both parts render the same word) and its
|
|
161
|
+
`data-fs-form-submit`/`data-fs-form-submit-pending` hooks — there is no separate styling hook for this
|
|
162
|
+
part. While mounted, it additionally allows Enter to
|
|
163
|
+
submit the enclosing `FormRoot` from any of its text-entry inputs (see *Implicit submission* above for
|
|
164
|
+
the form-wide blast radius and the IME carve-out).
|
|
165
|
+
|
|
166
|
+
### FormError
|
|
167
|
+
|
|
168
|
+
| Prop | Type | Default | Notes |
|
|
169
|
+
|------|------|---------|-------|
|
|
170
|
+
| `fallbackMessage` | string | `'Something went wrong. Please try again.'` | Shown for a fieldset whose write threw rather than resolving `userErrors`. |
|
|
171
|
+
|
|
172
|
+
Renders nothing when the last submit produced no failures. Otherwise, one group per failing fieldset,
|
|
173
|
+
carrying `data-fs-form-error-item={label}` — a styling/selector hook, never rendered as text. The visible
|
|
174
|
+
text inside each group is each failing `userError`'s own `message`, or `fallbackMessage` when the dispatch
|
|
175
|
+
rejected or never settled.
|
|
176
|
+
|
|
177
|
+
### FormClear
|
|
178
|
+
|
|
179
|
+
| Prop | Type | Default | Notes |
|
|
180
|
+
|------|------|---------|-------|
|
|
181
|
+
| `label` | string | — | Rendered when there are no `children`. Falls through to the message catalog (`fs/form.clear`) when unset. |
|
|
182
|
+
| `children` | ReactNode | — | Wins over `label` when present. |
|
|
183
|
+
|
|
184
|
+
A `type="button"` `Button` that empties every field registered by every fieldset inside the enclosing
|
|
185
|
+
`FormRoot` — not only the fieldset nearest to it in the tree. Each bound part registers how its own
|
|
186
|
+
field is cleared, to its own empty value (`false` for a boolean field, `''` otherwise), and marks dirty
|
|
187
|
+
only the fields whose value actually changed: a field already empty stays clean. A registered name with
|
|
188
|
+
no bound part currently mounted is left alone. The following submit dispatches a write for every
|
|
189
|
+
fieldset that had something to clear, and nothing for one where clearing was a no-op. There is no prop
|
|
190
|
+
to scope it to one fieldset; place it only where clearing the whole form is the intended action.
|
|
191
|
+
|
|
192
|
+
### FormReset
|
|
193
|
+
|
|
194
|
+
| Prop | Type | Default | Notes |
|
|
195
|
+
|------|------|---------|-------|
|
|
196
|
+
| `label` | string | — | Rendered when there are no `children`. Falls through to the message catalog (`fs/form.reset`) when unset. |
|
|
197
|
+
| `children` | ReactNode | — | Wins over `label` when present. |
|
|
198
|
+
|
|
199
|
+
A `type="button"` `Button` that returns every registered field name to react-hook-form's baseline for
|
|
200
|
+
that name, and clears its dirty/touched state. Before a fieldset's first successful write, that
|
|
201
|
+
baseline is whatever the field's own seed resolves to; it becomes "what was last written" only after a
|
|
202
|
+
successful submit.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Canonical schema example
|
|
207
|
+
|
|
208
|
+
`FormSubmit`, `FormEnterSubmit`, `FormError`, `FormClear` and `FormReset` are schema-authorable; a
|
|
209
|
+
fieldset is not (it is a code-level composition today, the same way a bound `Field.Control` is). A
|
|
210
|
+
schema can author the form's boundary and its actions around wherever a fieldset renders itself:
|
|
211
|
+
|
|
212
|
+
```json
|
|
213
|
+
{
|
|
214
|
+
"checkout-step": {
|
|
215
|
+
"type": "FormRoot",
|
|
216
|
+
"style": ["flex", "flex-col", "gap-4"],
|
|
217
|
+
"children": ["shipping-address-fieldset", "step-error", "step-submit"]
|
|
218
|
+
},
|
|
219
|
+
"step-error": { "type": "FormError" },
|
|
220
|
+
"step-submit": { "type": "FormSubmit", "props": { "label": "Save and continue" } }
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`shipping-address-fieldset` is a matter component (shipped by a later task) that renders its own
|
|
225
|
+
fields and calls `registerFieldset` internally — it is placed in the schema like any other
|
|
226
|
+
component, but nothing about its internals is schema-authorable.
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Common mistakes
|
|
231
|
+
|
|
232
|
+
- **Expecting a submit with nothing touched to dispatch anything.** It does not — `FormSubmit`'s skip
|
|
233
|
+
gate is per-fieldset `dirtyFields`, which the RFC and this doc both call "touched", not "changed".
|
|
234
|
+
Typing a value and then typing the original seed back still leaves that fieldset dirty and dispatches
|
|
235
|
+
a request that changes nothing.
|
|
236
|
+
- **Reading `formState.isDirty` to decide whether to submit.** Whole-form `isDirty` is `true` from the
|
|
237
|
+
moment any field is seeded and never reliably returns to `false` under this design — `FormRoot` never
|
|
238
|
+
reads it, and neither should a composition built on top of it.
|
|
239
|
+
- **Expecting `FormReset` to mean "clear the form".** That is `FormClear`. `FormReset` returns fields to
|
|
240
|
+
a baseline; before any successful write, that baseline is whatever the field's own seed resolves to, so
|
|
241
|
+
Reset reverts to the original value rather than emptying it — the two look the same only when a seed
|
|
242
|
+
happens to be empty. They diverge for good the moment something has been saved, when the baseline
|
|
243
|
+
becomes what was last written.
|
|
244
|
+
- **Placing `FormClear` expecting it to clear only the nearest fieldset.** It has no scoping prop by
|
|
245
|
+
design (Decision, engineer-approved): pressing it empties every field of every fieldset registered
|
|
246
|
+
anywhere in the enclosing `FormRoot`, and the following submit dispatches a write for each one. Place
|
|
247
|
+
it only where clearing the whole form is genuinely the intended action.
|
|
248
|
+
- **Nesting a second `FormRoot` inside the first.** Unsupported; nothing enforces it today, and two
|
|
249
|
+
`useForm()` instances would not share state. Compose fieldsets as siblings under one `FormRoot`
|
|
250
|
+
instead.
|
|
251
|
+
- **Confusing this with `Fieldset.Root`/`Fieldset.Legend`.** Those are a purely visual `<fieldset>`
|
|
252
|
+
wrapper (see [[fieldset]]) with no write behaviour and no relationship to the registry this particle
|
|
253
|
+
defines.
|
|
254
|
+
- **Giving `FormRoot` an `actions` selector.** It declares none by design (Decision 1) — a write always
|
|
255
|
+
belongs to the fieldset that dispatches it, so `catalog.json` can still say which component performs
|
|
256
|
+
it.
|
|
257
|
+
- **Reaching for a `submitOnEnter` prop.** There is none, on either part, by design: whether Enter
|
|
258
|
+
submits is chosen by composing `FormSubmit` or `FormEnterSubmit`, never by a flag.
|
|
259
|
+
- **Placing `FormEnterSubmit` inside one fieldset and expecting the allowance to stay scoped to it.**
|
|
260
|
+
It does not — the allowance is form-wide, because `FormRoot` knows fieldsets, never fields. Enter in
|
|
261
|
+
*any* text-entry input of that `FormRoot` submits, not only the ones in the fieldset the part sits in.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Styling
|
|
266
|
+
|
|
267
|
+
| Component | What you style | Plausible classes |
|
|
268
|
+
|---|---|---|
|
|
269
|
+
| `FormRoot` | The `<form>` element — typically stacks its fieldsets and actions | `flex flex-col gap-6` |
|
|
270
|
+
| `FormSubmit` | The primary action button; target `data-fs-form-submit-pending` for the in-flight state | `bg-blue-600 text-white rounded-md px-4 py-2 data-fs-form-submit-pending:opacity-60` |
|
|
271
|
+
| `FormError` | The failure summary; carries `role="alert"` | `text-sm text-red-600 space-y-2` |
|
|
272
|
+
| `FormClear` | A secondary action, typically quieter than Submit | `text-sm text-gray-500 underline` |
|
|
273
|
+
| `FormReset` | A secondary action, typically quieter than Submit | `text-sm text-gray-500 underline` |
|
|
274
|
+
|
|
275
|
+
```json
|
|
276
|
+
{
|
|
277
|
+
"checkout-step": {
|
|
278
|
+
"type": "FormRoot",
|
|
279
|
+
"style": ["flex", "flex-col", "gap-6"],
|
|
280
|
+
"children": ["shipping-address-fieldset", "step-error", "step-actions"]
|
|
281
|
+
},
|
|
282
|
+
"step-error": { "type": "FormError", "style": ["text-sm", "text-red-600", "space-y-2"] },
|
|
283
|
+
"step-actions": {
|
|
284
|
+
"type": "Container",
|
|
285
|
+
"style": ["flex", "gap-2"],
|
|
286
|
+
"children": ["step-reset", "step-submit"]
|
|
287
|
+
},
|
|
288
|
+
"step-reset": { "type": "FormReset", "style": ["text-sm", "text-gray-500", "underline"] },
|
|
289
|
+
"step-submit": {
|
|
290
|
+
"type": "FormSubmit",
|
|
291
|
+
"props": { "label": "Save and continue" },
|
|
292
|
+
"style": ["bg-blue-600", "text-white", "rounded-md", "px-4", "py-2"]
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## Related
|
|
300
|
+
|
|
301
|
+
- [[field]] — the field anatomy and the field contract a bound Control renders inside a fieldset; also
|
|
302
|
+
documents the fieldset *field-name* seam (`useFieldsetField`), the sibling scope to this particle's
|
|
303
|
+
*fieldset* registry.
|
|
304
|
+
- [[fieldset]] — the unrelated, purely visual `<fieldset>`/`<legend>` particle. Shares a name with the
|
|
305
|
+
write-boundary "fieldset" concept this doc uses, but has no code relationship to it.
|
|
306
|
+
- [[button]] — the particle every `Form` action wraps (`Button` with `type="submit"` or `type="button"`,
|
|
307
|
+
plus this particle's own `data-fs-form-*` hooks).
|
|
@@ -10,9 +10,9 @@ rather than a shared mechanism: the two differ in exactly the parts that are dom
|
|
|
10
10
|
starts on the summary and needs an explicit cancel; that one derives its starting state from whether an
|
|
11
11
|
address exists and closes itself with no cancel at all.
|
|
12
12
|
|
|
13
|
-
The mode is owned here, not authored in a page (ADR-0006). `
|
|
14
|
-
learns whether anything collapses around it, which is what lets the same
|
|
15
|
-
step, or on a page of its own.
|
|
13
|
+
The mode is owned here, not authored in a page (ADR-0006). The composed `FormRoot`/`ClientProfileFieldset`
|
|
14
|
+
pair announces nothing and never learns whether anything collapses around it, which is what lets the same
|
|
15
|
+
form sit in a section, in a wizard step, or on a page of its own.
|
|
16
16
|
|
|
17
17
|
## Why no mode name reaches the schema
|
|
18
18
|
|
|
@@ -51,8 +51,8 @@ resolution error rather than a silently dead branch that blanks the section.
|
|
|
51
51
|
- Starts on the summary. Unlike the delivery section, nothing about the profile data decides this: the
|
|
52
52
|
summary is always the right first thing to show a shopper who has one.
|
|
53
53
|
- The mode moves only on this section's own buttons, `EditButton` and `CancelButton`. Saving the form does
|
|
54
|
-
not close the section,
|
|
55
|
-
|
|
54
|
+
not close the section, so the form's own fields and controls stay on screen after a save rather than
|
|
55
|
+
being replaced by the summary.
|
|
56
56
|
- **Self-contained.** It observes nothing outside itself and reports nothing: no other section opens or
|
|
57
57
|
closes as a result of anything happening here. Whether finishing a form should collapse the section that
|
|
58
58
|
holds it is a sequencing question, deliberately left to the flow-of-steps RFC (#611) rather than
|
|
@@ -68,7 +68,7 @@ IdentityEditController
|
|
|
68
68
|
│ └─ IdentitySummaryView → IdentityEditButton
|
|
69
69
|
└─ IdentitySummaryRoot
|
|
70
70
|
├─ IdentitySummaryView → IdentitySummarySelectedSummary
|
|
71
|
-
└─ IdentityEditForm →
|
|
71
|
+
└─ IdentityEditForm → FormRoot(ClientProfileFieldset) + IdentityEditCancelButton
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
## Accessibility
|