@payfit/unity-components 2.35.3 → 2.35.5
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/README.md +10 -0
- package/dist/esm/components/menu/parts/MenuSection.d.ts +1 -2
- package/dist/esm/components/menu/parts/RawMenuItem.d.ts +4 -5
- package/dist/esm/components/menu/parts/RawMenuItem.js +15 -14
- package/package.json +15 -10
- package/skills/unity-data-table/SKILL.md +512 -0
- package/skills/unity-find-component/SKILL.md +377 -0
- package/skills/unity-layout-and-styling/SKILL.md +400 -0
- package/skills/unity-migrate-from-midnight/SKILL.md +190 -0
- package/skills/unity-migrate-from-midnight/references/midnight-component-map.md +180 -0
- package/skills/unity-navigation/SKILL.md +331 -0
- package/skills/unity-overlays/SKILL.md +352 -0
- package/skills/unity-setup-feature-plugin/SKILL.md +55 -0
- package/skills/unity-tanstack-form/SKILL.md +349 -0
- package/skills/unity-tanstack-form/references/bound-field-components.md +67 -0
- package/skills/unity-tanstack-form/references/schema-adapters.md +108 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: unity-tanstack-form
|
|
3
|
+
description: >
|
|
4
|
+
Build any form in @payfit/unity-components with useTanstackUnityForm + Zod
|
|
5
|
+
(dynamic, translated). Layered wrapping: form.AppForm → form.Form →
|
|
6
|
+
form.AppField → field component. Composed API by default (TanstackTextField,
|
|
7
|
+
SelectField, NumberField, DatePickerField, CheckboxField, …); Atomic API
|
|
8
|
+
(field.Field + field.FieldLabel + field.TextInput) only when customizing
|
|
9
|
+
layout or parts. Schema adapters: StandardSchemaAdapter, ZodV3SchemaAdapter,
|
|
10
|
+
ZodV4SchemaAdapter. validators.onBlur is the default;
|
|
11
|
+
fieldRevalidateLogic for blur-then-change UX. React Hook Form
|
|
12
|
+
(useUnityForm + RHF *-field wrappers) is deprecated and will be REMOVED.
|
|
13
|
+
type: core
|
|
14
|
+
library: '@payfit/unity-components'
|
|
15
|
+
library_version: '2.x'
|
|
16
|
+
sources:
|
|
17
|
+
- 'PayFit/hr-apps:libs/shared/unity/components/src/hooks/use-tanstack-form.tsx'
|
|
18
|
+
- 'PayFit/hr-apps:libs/shared/unity/components/src/hooks/use-form.tsx'
|
|
19
|
+
- 'PayFit/hr-apps:libs/shared/unity/components/src/components/form/TanstackForm.tsx'
|
|
20
|
+
- 'PayFit/hr-apps:libs/shared/unity/components/src/components/form-field/TanstackFormField.tsx'
|
|
21
|
+
- 'PayFit/hr-apps:libs/shared/unity/components/src/adapters/zodAdapter.ts'
|
|
22
|
+
- 'PayFit/hr-apps:libs/shared/unity/components/src/utils/field-revalidate-logic.ts'
|
|
23
|
+
- 'PayFit/hr-apps:libs/shared/unity/components/src/docs/concepts/forms/Form Architecture Overview.mdx'
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
Build a Unity form with `useTanstackUnityForm` + Zod. RHF (`useUnityForm`) is deprecated and will be removed.
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { Button, useTanstackUnityForm } from '@payfit/unity-components'
|
|
32
|
+
import { z } from 'zod'
|
|
33
|
+
|
|
34
|
+
const schema = z.object({
|
|
35
|
+
email: z.email({ message: 'Invalid email' }),
|
|
36
|
+
password: z.string().min(8, { message: 'Password too short' }),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export function SignInForm() {
|
|
40
|
+
const form = useTanstackUnityForm({
|
|
41
|
+
defaultValues: { email: '', password: '' },
|
|
42
|
+
validators: { onBlur: schema },
|
|
43
|
+
onSubmit: async ({ value }) => {
|
|
44
|
+
await signIn(value)
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<form.AppForm>
|
|
50
|
+
<form.Form className="uy:space-y-200">
|
|
51
|
+
<form.AppField name="email">
|
|
52
|
+
{field => <field.TextField label="Email" type="email" />}
|
|
53
|
+
</form.AppField>
|
|
54
|
+
<form.AppField name="password">
|
|
55
|
+
{field => <field.PasswordField label="Password" />}
|
|
56
|
+
</form.AppField>
|
|
57
|
+
<Button variant="primary" type="submit">
|
|
58
|
+
Sign in
|
|
59
|
+
</Button>
|
|
60
|
+
</form.Form>
|
|
61
|
+
</form.AppForm>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Wrapping order is load-bearing: `form.AppForm` provides the form context, `form.Form` renders the `<form>` element and wires `handleSubmit`, `form.AppField` provides field context, and the render-prop `field` carries every bound field component (TextField, PasswordField, SelectField, …) plus the Atomic parts (Field, FieldLabel, TextInput, FieldFeedbackText, FieldHelperText, FieldRawContextualLink).
|
|
67
|
+
|
|
68
|
+
## Core Patterns
|
|
69
|
+
|
|
70
|
+
### Composed API (default)
|
|
71
|
+
|
|
72
|
+
Drop a single bound field component inside `<form.AppField>`. It bundles label, input, helper text, feedback, and a11y wiring.
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
<form.AppField name="firstName">
|
|
76
|
+
{field => (
|
|
77
|
+
<field.TextField
|
|
78
|
+
label="First name"
|
|
79
|
+
helperText="As it appears on your ID"
|
|
80
|
+
isRequired
|
|
81
|
+
/>
|
|
82
|
+
)}
|
|
83
|
+
</form.AppField>
|
|
84
|
+
|
|
85
|
+
<form.AppField name="country">
|
|
86
|
+
{field => (
|
|
87
|
+
<field.SelectField
|
|
88
|
+
label="Country"
|
|
89
|
+
options={[
|
|
90
|
+
{ value: 'fr', label: 'France' },
|
|
91
|
+
{ value: 'es', label: 'Spain' },
|
|
92
|
+
]}
|
|
93
|
+
/>
|
|
94
|
+
)}
|
|
95
|
+
</form.AppField>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Atomic API (only when customizing layout/parts)
|
|
99
|
+
|
|
100
|
+
Reach for Atomic when you need to interleave custom content between the label and the input, or swap an input for a non-standard control. Wraps every part in `field.Field`.
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
<form.AppField name="password">
|
|
104
|
+
{field => (
|
|
105
|
+
<field.Field>
|
|
106
|
+
<field.FieldLabel isRequired>Password</field.FieldLabel>
|
|
107
|
+
<field.FieldHelperText>Enter a strong password</field.FieldHelperText>
|
|
108
|
+
<field.TextInput type="password" />
|
|
109
|
+
<form.Subscribe selector={s => s.values.password}>
|
|
110
|
+
{password => (
|
|
111
|
+
<Text variant="bodySmallStrong">Length: {password.length}</Text>
|
|
112
|
+
)}
|
|
113
|
+
</form.Subscribe>
|
|
114
|
+
<field.FieldFeedbackText />
|
|
115
|
+
</field.Field>
|
|
116
|
+
)}
|
|
117
|
+
</form.AppField>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Validation timing
|
|
121
|
+
|
|
122
|
+
`validators.onBlur` is the default; use `onChange` only for fields that need live feedback (password strength meter, search-as-you-type). `fieldRevalidateLogic` gives "blur until first error, then change" UX without polluting form-level validators.
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
import { fieldRevalidateLogic, useTanstackUnityForm } from '@payfit/unity-components'
|
|
126
|
+
|
|
127
|
+
const form = useTanstackUnityForm({
|
|
128
|
+
defaultValues: { email: '', password: '' },
|
|
129
|
+
validators: { onBlur: z.object({ email: z.email() }) },
|
|
130
|
+
validationLogic: fieldRevalidateLogic({
|
|
131
|
+
whenPristine: 'blur',
|
|
132
|
+
whenDirty: 'change',
|
|
133
|
+
fields: ['password'],
|
|
134
|
+
}),
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
<form.AppField
|
|
138
|
+
name="password"
|
|
139
|
+
validators={{
|
|
140
|
+
onDynamic: ({ value }) =>
|
|
141
|
+
value.length < 8 ? 'Password too short' : undefined,
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
{field => <field.PasswordField label="Password" />}
|
|
145
|
+
</form.AppField>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Fields listed in `fieldRevalidateLogic.fields` MUST use `onDynamic`/`onDynamicAsync` as their sole validator and MUST NOT also appear in a form-level schema, or stale errors will linger.
|
|
149
|
+
|
|
150
|
+
### Optimal subscription with form.Subscribe + selector
|
|
151
|
+
|
|
152
|
+
Always pass a `selector` to `form.Subscribe`. A bare children-only subscription re-renders on every keystroke anywhere in the form.
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
<form.Subscribe selector={s => s.values.password}>
|
|
156
|
+
{password => <Text>Length: {password.length}</Text>}
|
|
157
|
+
</form.Subscribe>
|
|
158
|
+
|
|
159
|
+
<form.Subscribe selector={s => [s.canSubmit, s.isSubmitting] as const}>
|
|
160
|
+
{([canSubmit, isSubmitting]) => (
|
|
161
|
+
<Button type="submit" isDisabled={!canSubmit} isLoading={isSubmitting}>
|
|
162
|
+
Submit
|
|
163
|
+
</Button>
|
|
164
|
+
)}
|
|
165
|
+
</form.Subscribe>
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Common Mistakes
|
|
169
|
+
|
|
170
|
+
### CRITICAL Import useForm (legacy RHF) instead of useTanstackUnityForm
|
|
171
|
+
|
|
172
|
+
Wrong:
|
|
173
|
+
|
|
174
|
+
```tsx
|
|
175
|
+
import { useUnityForm } from '@payfit/unity-components'
|
|
176
|
+
|
|
177
|
+
const { methods, Form, FormField } = useUnityForm(schema)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Correct:
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
import { useTanstackUnityForm } from '@payfit/unity-components'
|
|
184
|
+
|
|
185
|
+
const form = useTanstackUnityForm({ validators: { onBlur: schema } })
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The legacy `use-form` hook is `@deprecated` but still exported; agents trained on older code reach for it and end up mixing RHF Controller with Tanstack field components, which breaks at runtime.
|
|
189
|
+
|
|
190
|
+
Fixed-but-legacy-risk: the legacy hook is still exported but will be removed in the next few weeks (after or alongside the rebrand). Never author new code with it.
|
|
191
|
+
|
|
192
|
+
Source: libs/shared/unity/components/src/hooks/use-form.tsx:79 (@deprecated JSDoc); maintainer interview
|
|
193
|
+
|
|
194
|
+
### CRITICAL Omit form.AppForm or form.AppField wrapping
|
|
195
|
+
|
|
196
|
+
Wrong:
|
|
197
|
+
|
|
198
|
+
```tsx
|
|
199
|
+
<form.Form>
|
|
200
|
+
<form.AppField name="email">
|
|
201
|
+
{field => <field.TextField label="Email" />}
|
|
202
|
+
</form.AppField>
|
|
203
|
+
</form.Form>
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Correct:
|
|
207
|
+
|
|
208
|
+
```tsx
|
|
209
|
+
<form.AppForm>
|
|
210
|
+
<form.Form>
|
|
211
|
+
<form.AppField name="email">
|
|
212
|
+
{field => <field.TextField label="Email" />}
|
|
213
|
+
</form.AppField>
|
|
214
|
+
</form.Form>
|
|
215
|
+
</form.AppForm>
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`useFormContext()` and `useFieldContext()` throw without their providers; Tanstack field components silently break (cannot read property of undefined).
|
|
219
|
+
|
|
220
|
+
Source: TanstackForm.tsx:36; TanstackFormField.tsx:63 (useFormContext/useFieldContext)
|
|
221
|
+
|
|
222
|
+
### CRITICAL Mix Tanstack field with react-hook-form Controller
|
|
223
|
+
|
|
224
|
+
Wrong:
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
const { control } = useForm()
|
|
228
|
+
<Controller control={control} name="email" render={() => (
|
|
229
|
+
<TanstackTextField label="Email" />
|
|
230
|
+
)} />
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Correct:
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
const form = useTanstackUnityForm({ validators: { onBlur: schema } })
|
|
237
|
+
<form.AppForm><form.AppField name="email">
|
|
238
|
+
{field => <field.TextField label="Email" />}
|
|
239
|
+
</form.AppField></form.AppForm>
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
RHF and Tanstack Form contexts do not interoperate; wrapping a Tanstack field in a Controller produces unmounted state and no validation.
|
|
243
|
+
|
|
244
|
+
Source: conceptual; RHF and Tanstack contexts do not interoperate
|
|
245
|
+
|
|
246
|
+
### HIGH Subscribe to whole form state instead of a selector
|
|
247
|
+
|
|
248
|
+
Wrong:
|
|
249
|
+
|
|
250
|
+
```tsx
|
|
251
|
+
<form.Subscribe>
|
|
252
|
+
{state => <div>Length: {state.values.password.length}</div>}
|
|
253
|
+
</form.Subscribe>
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Correct:
|
|
257
|
+
|
|
258
|
+
```tsx
|
|
259
|
+
<form.Subscribe selector={s => s.values.password}>
|
|
260
|
+
{password => <div>Length: {password.length}</div>}
|
|
261
|
+
</form.Subscribe>
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
A selector-less subscription re-renders the children on every keystroke anywhere in the form; pass a narrowing selector to scope to the slice you need.
|
|
265
|
+
|
|
266
|
+
Source: use-tanstack-form.stories.tsx:251 (StateIntegration story)
|
|
267
|
+
|
|
268
|
+
### MEDIUM Pick the wrong validation timing
|
|
269
|
+
|
|
270
|
+
Wrong:
|
|
271
|
+
|
|
272
|
+
```tsx
|
|
273
|
+
useTanstackUnityForm({ validators: { onChange: schema } })
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Correct:
|
|
277
|
+
|
|
278
|
+
```tsx
|
|
279
|
+
useTanstackUnityForm({ validators: { onBlur: schema } })
|
|
280
|
+
// or with revalidation:
|
|
281
|
+
// validationLogic: fieldRevalidateLogic({ fields: ['password'], whenDirty: 'change' })
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`onChange` fires on every keystroke (jarring) and `onSubmit` waits until submit (errors arrive too late); `onBlur` is the usual default, with `fieldRevalidateLogic` reserved for "blur until first error, then change".
|
|
285
|
+
|
|
286
|
+
Source: use-tanstack-form.stories.tsx:37-40; utils/field-revalidate-logic.ts
|
|
287
|
+
|
|
288
|
+
### HIGH Mix Composed and Atomic APIs in one field (or reach for Atomic by default)
|
|
289
|
+
|
|
290
|
+
Wrong:
|
|
291
|
+
|
|
292
|
+
```tsx
|
|
293
|
+
// Double-wrapping:
|
|
294
|
+
<form.AppField name="email">
|
|
295
|
+
{field => (
|
|
296
|
+
<field.Field>
|
|
297
|
+
<field.FieldLabel>Email</field.FieldLabel>
|
|
298
|
+
<field.TextField label="Email" />
|
|
299
|
+
</field.Field>
|
|
300
|
+
)}
|
|
301
|
+
</form.AppField>
|
|
302
|
+
// Or reaching for Atomic with no customization reason:
|
|
303
|
+
<form.AppField name="name">
|
|
304
|
+
{field => (
|
|
305
|
+
<field.Field>
|
|
306
|
+
<field.FieldLabel>Name</field.FieldLabel>
|
|
307
|
+
<field.TextInput />
|
|
308
|
+
<field.FieldFeedbackText />
|
|
309
|
+
</field.Field>
|
|
310
|
+
)}
|
|
311
|
+
</form.AppField>
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Correct:
|
|
315
|
+
|
|
316
|
+
```tsx
|
|
317
|
+
// Default (Composed):
|
|
318
|
+
<form.AppField name="name">
|
|
319
|
+
{field => <field.TextField label="Name" />}
|
|
320
|
+
</form.AppField>
|
|
321
|
+
// Atomic only when customizing layout/parts:
|
|
322
|
+
<form.AppField name="email">
|
|
323
|
+
{field => (
|
|
324
|
+
<field.Field>
|
|
325
|
+
<field.FieldLabel>Email</field.FieldLabel>
|
|
326
|
+
<CustomInline>
|
|
327
|
+
<field.TextInput />
|
|
328
|
+
<ExtraSlot />
|
|
329
|
+
</CustomInline>
|
|
330
|
+
<field.FieldFeedbackText />
|
|
331
|
+
</field.Field>
|
|
332
|
+
)}
|
|
333
|
+
</form.AppField>
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
`field.TextField` is the Composed API and already includes label/input/feedback; wrapping it in `field.Field` + `field.FieldLabel` double-wraps and breaks layout + a11y. Default to Composed; reach for Atomic only when you must customize the field's layout or swap a part — never as the standard pattern.
|
|
337
|
+
|
|
338
|
+
Source: TanstackTextField.tsx vs TanstackFormField.tsx + parts; maintainer interview (Composed is default)
|
|
339
|
+
|
|
340
|
+
## References
|
|
341
|
+
|
|
342
|
+
- [Bound field components](references/bound-field-components.md) — full inventory of `field.*` Composed components and their underlying base components.
|
|
343
|
+
- [Schema adapters](references/schema-adapters.md) — StandardSchemaAdapter, ZodV3SchemaAdapter, ZodV4SchemaAdapter and how `isFieldRequired` consumes them.
|
|
344
|
+
|
|
345
|
+
## See also
|
|
346
|
+
|
|
347
|
+
- `unity-migrate-from-midnight` — forms migrated off Midnight typically came with React Hook Form; that skill explains the Tanstack-only replacement path.
|
|
348
|
+
- `unity-layout-and-styling` — form layouts use Flex/Grid and `uy:*` utilities for spacing and responsive behavior.
|
|
349
|
+
- `unity-navigation` — when a form posts via a route action or links to a sibling step, use the router-aware `Link` from `@payfit/unity-components/integrations/tanstack-router`.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Bound field components (Composed API)
|
|
2
|
+
|
|
3
|
+
All 15 Tanstack-bound Composed field components, registered in `src/hooks/use-tanstack-form.tsx` via `createFormHook({ fieldComponents })`. They are exposed on the render-prop `field` object inside `<form.AppField>` WITHOUT the `Tanstack` prefix (e.g. the registered `TanstackTextField` is `field.TextField`).
|
|
4
|
+
|
|
5
|
+
The form-usage pattern is identical for every component:
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
<form.AppField name="fieldPath">
|
|
9
|
+
{field => <field.<Name> {...props} />}
|
|
10
|
+
</form.AppField>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Inventory
|
|
14
|
+
|
|
15
|
+
| Registered name (in `useTanstackUnityForm`) | Source component (`Tanstack*`) | Wraps (non-bound base) | Notes |
|
|
16
|
+
| ------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| `field.TextField` | `TanstackTextField` | `TextField` → `TanstackInput` / `TanstackTextArea` | `multiline` switches input/textarea. `type` (`text`/`password`/`email`/`tel`/`url`/`search`) only valid when `multiline !== true`. |
|
|
18
|
+
| `field.SelectField` | `TanstackSelectField` | `SelectField` → `TanstackSelect` | Single-select with `options: { value, label }[]`. |
|
|
19
|
+
| `field.MultiSelectField` | `TanstackMultiSelectField` | `MultiSelectField` → `TanstackMultiSelect` | Generic component; chips for selected items. |
|
|
20
|
+
| `field.NumberField` | `TanstackNumberField` | `NumberField` → `TanstackNumberInput` | Locale-aware number formatting. |
|
|
21
|
+
| `field.CheckboxField` | `TanstackCheckboxField` | `CheckboxField` → `TanstackCheckbox` | Single checkbox + label + feedback. |
|
|
22
|
+
| `field.CheckboxGroupField` | `TanstackCheckGroupField` | `CheckGroupField` → `TanstackCheckboxGroup` | Multi-checkbox group; value is `string[]`. Note the registered key is `CheckboxGroupField` even though the implementation is named `TanstackCheckGroupField`. |
|
|
23
|
+
| `field.RadioButtonGroupField` | `TanstackRadioButtonGroupField` | `RadioButtonGroupField` → `TanstackRadioButtonGroup` | Single-select radio group. |
|
|
24
|
+
| `field.SelectableButtonGroupField` | `TanstackSelectableButtonGroupField` | `SelectableButtonGroupField` → `TanstackSelectableButtonGroup` | Pill-style segmented control. |
|
|
25
|
+
| `field.SelectableCardCheckboxGroupField` | `TanstackSelectableCardCheckboxGroupField` | `SelectableCardCheckboxGroupField` → `TanstackSelectableCardCheckboxGroup` | Multi-select card grid. |
|
|
26
|
+
| `field.SelectableCardRadioGroupField` | `TanstackSelectableCardRadioGroupField` | `SelectableCardRadioGroupField` → `TanstackSelectableCardRadioGroup` | Single-select card grid. |
|
|
27
|
+
| `field.DatePickerField` | `TanstackDatePickerField` | `DatePickerField` → `TanstackDatePicker` | Single-date popover picker. |
|
|
28
|
+
| `field.DateRangePickerField` | `TanstackDateRangePickerField` | `DateRangePickerField` → `TanstackDateRangePicker` | Start/end range picker. |
|
|
29
|
+
| `field.ToggleSwitchField` | `TanstackToggleSwitchField` | `ToggleSwitchField` → `TanstackToggleSwitch` | Single boolean switch. |
|
|
30
|
+
| `field.ToggleSwitchGroupField` | `TanstackToggleSwitchGroupField` | `ToggleSwitchGroupField` → `TanstackToggleSwitchGroup` | Group of switches; value is a record. |
|
|
31
|
+
| `field.PasswordField` | `TanstackPasswordField` (registered as `PasswordField`) | `PasswordField` → `TanstackInput` (`type="password"`) | Adds visibility toggle button. The registered key is `PasswordField` (no `Tanstack` prefix in the source) — so usage is `field.PasswordField`. Prefer this over `<field.TextField type="password" />` for the visibility toggle. |
|
|
32
|
+
| `field.PhoneNumberField` | `TanstackPhoneNumberField` | `PhoneNumberField` → `TanstackPhoneNumberInput` | E.164-compatible phone input. |
|
|
33
|
+
|
|
34
|
+
## Atomic parts (always available on `field`)
|
|
35
|
+
|
|
36
|
+
These are the Atomic API parts registered alongside the Composed components. Use them only when customizing layout:
|
|
37
|
+
|
|
38
|
+
- `field.Field` — the wrapper (`TanstackFormField`). Provides a11y context.
|
|
39
|
+
- `field.FieldLabel` — label slot.
|
|
40
|
+
- `field.FieldHelperText` — helper text slot.
|
|
41
|
+
- `field.FieldFeedbackText` — validation error slot.
|
|
42
|
+
- `field.FieldRawContextualLink` — contextual link slot.
|
|
43
|
+
|
|
44
|
+
And the raw inputs (use Composed instead unless customizing):
|
|
45
|
+
|
|
46
|
+
- `field.TextInput` / `field.TextAreaInput`
|
|
47
|
+
- `field.CheckboxInput` / `field.CheckboxGroupInput`
|
|
48
|
+
- `field.NumberInput`
|
|
49
|
+
- `field.SelectInput` / `field.MultiSelectInput`
|
|
50
|
+
- `field.RadioButtonInput`
|
|
51
|
+
- `field.SelectableButtonGroupInput`
|
|
52
|
+
- `field.SelectableCardCheckboxGroupInput` / `field.SelectableCardRadioGroupInput`
|
|
53
|
+
- `field.DatePickerInput` / `field.DateRangePickerInput`
|
|
54
|
+
- `field.ToggleSwitchInput` / `field.ToggleSwitchGroupInput`
|
|
55
|
+
- `field.PhoneNumberInput`
|
|
56
|
+
|
|
57
|
+
## Form-scoped components
|
|
58
|
+
|
|
59
|
+
Available on `form` (not `field`):
|
|
60
|
+
|
|
61
|
+
- `<form.AppForm>` — form-context provider. Required outer wrapper.
|
|
62
|
+
- `<form.Form>` — `<form>` element wired to `handleSubmit`.
|
|
63
|
+
- `<form.AppField name="…">` — field-context provider; render-prop exposes `field`.
|
|
64
|
+
- `<form.Subscribe selector={…}>` — selector-scoped reactive subscription. Always pass `selector`.
|
|
65
|
+
- `<form.InlineFieldGroup>`, `<form.InlineFieldGroupHeader>`, `<form.InlineFieldGroupReadView>`, `<form.InlineFieldGroupEditView>` — inline edition layout (read view ↔ edit view toggling for grouped fields).
|
|
66
|
+
|
|
67
|
+
For the mapping source of truth see `src/hooks/use-tanstack-form.tsx` (the `createFormHook` call). The keys in `fieldComponents` and `formComponents` are exactly the names exposed on `field` / `form`.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Schema adapters
|
|
2
|
+
|
|
3
|
+
Schema adapters give the form-field organisms a uniform way to ask "is this path required?" across schemas authored in Zod 3, Zod 4, or any Standard Schema v1 implementation. Source: `src/adapters/`.
|
|
4
|
+
|
|
5
|
+
## Common interface
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// src/types/schema.ts
|
|
9
|
+
export interface StandardSchemaField {
|
|
10
|
+
isOptional: boolean
|
|
11
|
+
type: string
|
|
12
|
+
shape?: Record<string, StandardSchemaField>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface StandardSchema {
|
|
16
|
+
getField(path: string): StandardSchemaField | null
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`getField('preferences.marketing')` returns `null` if the path does not exist, otherwise `{ isOptional, type, shape }`. `shape` is only populated when the field resolves to a nested `ZodObject` (so callers can recurse into nested forms).
|
|
21
|
+
|
|
22
|
+
## Adapters
|
|
23
|
+
|
|
24
|
+
### ZodV3SchemaAdapter
|
|
25
|
+
|
|
26
|
+
Signature:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
new ZodV3SchemaAdapter(schema: z3.ZodObject<z3.ZodRawShape>)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- Reads structure from `schema.shape` and `field._def.typeName`.
|
|
33
|
+
- Detects optional with `field instanceof z3.ZodOptional`; unwraps via `field._def.innerType`.
|
|
34
|
+
- Source: `src/adapters/zodAdapter.ts` (lines 6–60).
|
|
35
|
+
|
|
36
|
+
### ZodV4SchemaAdapter
|
|
37
|
+
|
|
38
|
+
Signature:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
new ZodV4SchemaAdapter(schema: z4.ZodObject<z4.ZodRawShape>)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- Same shape traversal as v3, but uses `field.def.innerType` and `field.def.typeName` (no underscore — Zod 4 renamed `_def` to `def`).
|
|
45
|
+
- `field instanceof z4.ZodOptional` for optionality detection.
|
|
46
|
+
- Source: `src/adapters/zodAdapter.ts` (lines 62–117).
|
|
47
|
+
|
|
48
|
+
### StandardSchemaAdapter
|
|
49
|
+
|
|
50
|
+
Signature:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
new StandardSchemaAdapter(standardSchema: StandardSchemaV1)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- Stub implementation: `getField()` returns `{ isOptional: false, type: 'unknown', shape: undefined }` for any non-null path. Standard Schema's spec does not expose enough internal structure for richer introspection.
|
|
57
|
+
- Use this only for schemas that aren't Zod (e.g. Valibot, ArkType) — required-field inference will degrade to "always required".
|
|
58
|
+
- Source: `src/adapters/standardSchemaAdapter.ts`.
|
|
59
|
+
|
|
60
|
+
## How adapters auto-select
|
|
61
|
+
|
|
62
|
+
`createSchemaAdapter(schema)` (in `src/utils/createSchemaAdapter.ts`) picks an adapter from the schema's structural fingerprint:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { createSchemaAdapter } from '@payfit/unity-components'
|
|
66
|
+
|
|
67
|
+
const adapter = createSchemaAdapter(schema)
|
|
68
|
+
// schema._def && 'def' in schema → ZodV4SchemaAdapter
|
|
69
|
+
// schema._def && !('def' in schema) → ZodV3SchemaAdapter
|
|
70
|
+
// schema['~validate'] is a function → StandardSchemaAdapter
|
|
71
|
+
// otherwise → null
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
You rarely call this directly: every Composed field organism (e.g. `TanstackTextField`, `TanstackCheckboxField`, `TanstackToggleSwitchGroupField`) calls `createSchemaAdapter` + `isFieldRequired` internally to render the required indicator on the label.
|
|
75
|
+
|
|
76
|
+
## isFieldRequired
|
|
77
|
+
|
|
78
|
+
Consumer of the adapter, used inside every Composed field to decide whether to mark the label as required.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// src/components/form-field/utils/isFieldRequired.ts
|
|
82
|
+
export function isFieldRequired(
|
|
83
|
+
schema: StandardSchema | null | undefined,
|
|
84
|
+
fieldPath: string,
|
|
85
|
+
): boolean {
|
|
86
|
+
if (!schema) return false
|
|
87
|
+
const field = schema.getField(fieldPath)
|
|
88
|
+
return field ? !field.isOptional : false
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Behavior:
|
|
93
|
+
|
|
94
|
+
- No schema → `false` (no required indicator).
|
|
95
|
+
- Path not found in schema → `false` (treat as not required rather than crashing).
|
|
96
|
+
- Field is `ZodOptional` → `false`.
|
|
97
|
+
- Field is anything else → `true`.
|
|
98
|
+
|
|
99
|
+
This is why `<form.AppField name="email">{field => <field.TextField label="Email" />}</form.AppField>` automatically gets a required asterisk when `email` is `z.email()` and no asterisk when it's `z.email().optional()` — without any prop wiring.
|
|
100
|
+
|
|
101
|
+
## When to import an adapter explicitly
|
|
102
|
+
|
|
103
|
+
Almost never. The Composed field components call `createSchemaAdapter(schema)` themselves. Import an adapter directly only when:
|
|
104
|
+
|
|
105
|
+
- You are writing a custom field component outside the Composed inventory and need required-state inference.
|
|
106
|
+
- You are introspecting a schema for non-form purposes (form-derived UI, dynamic field rendering).
|
|
107
|
+
|
|
108
|
+
Even then, prefer `createSchemaAdapter(schema)` so the right version is picked at runtime.
|