@foormjs/vue 0.2.3 → 0.2.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.
@@ -0,0 +1,677 @@
1
+ # Creating Custom Components — @foormjs/vue
2
+
3
+ > The `TFoormComponentProps` contract, responsibility matrix for each field kind, and complete examples aligned with the default components.
4
+
5
+ ## Concepts
6
+
7
+ Every component in the `types` and `components` maps receives the same `TFoormComponentProps` interface from OoField. Your component's job depends on the **field kind** it handles:
8
+
9
+ - **Leaf fields** (text, select, radio, checkbox) — render input + label + error + hint + description + remove button
10
+ - **Structural fields** (object, array, tuple) — render title + iterator/items + remove button + optional N/A
11
+ - **Union field** — manage variant state + render inner variant field
12
+ - **Phantom fields** (paragraph, action) — render display-only content, no model binding
13
+
14
+ ## Component Responsibility Matrix
15
+
16
+ This table shows what each type-component is responsible for rendering. The default components handle all of these — your custom components must too.
17
+
18
+ | Responsibility | text/select/radio/checkbox | object | array | union | tuple | paragraph | action |
19
+ | ------------------------------ | --------------------------------- | --------------------------------- | ------------------------------------- | --------------------------------- | --------------------------------- | --------- | -------------------- |
20
+ | **Label** | Yes | No (renders title instead) | No (renders title instead) | No | No (renders title instead) | No | No |
21
+ | **Title** (`@foorm.title`) | No | Yes (h2 at root, h3 nested) | Yes | No | Yes | No | No |
22
+ | **Description** | Yes | No | No | No | No | No | No |
23
+ | **Hint** | Yes | No | No | No | No | No | No |
24
+ | **Error message** | Yes | Yes (structural error) | Yes (array-level error) | No | Yes (tuple-level error) | No | No |
25
+ | **Remove/clear button** | Yes (array item + optional clear) | Yes (array item + optional clear) | No | No | Yes (array item + optional clear) | No | No |
26
+ | **Variant picker** | Yes (inline next to label) | Yes (inline in header) | Yes (in header) | No (provides context only) | Yes (inline in header) | No | No |
27
+ | **Optional N/A toggle** | Yes | Yes | Yes | Yes | Yes | No | No |
28
+ | **OoIterator** | No | Yes (for sub-fields) | No (manual v-for) | No | No (manual v-for) | No | No |
29
+ | **OoField children** | No | No (OoIterator does it) | Yes (per array item) | Yes (inner variant) | Yes (per position) | No | No |
30
+ | **Change event** | No (OoField handles on blur) | No | Yes (array-add/remove via composable) | Yes (union-switch via composable) | No | No | No |
31
+ | **Action event** | No | No | No | No | No | No | Yes (emits `action`) |
32
+ | **`useConsumeUnionContext()`** | **Yes** | **Yes** | **Yes** | No | **Yes** | No | No |
33
+ | **`v-show="!hidden"`** | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
34
+
35
+ ### Key rules
36
+
37
+ - **Every component** must handle `v-show="!hidden"` for dynamic visibility.
38
+ - **Leaf components** handle ALL display chrome: label, description, hint, error, remove button, variant picker, optional N/A.
39
+ - **ALL non-phantom components** (leaf AND structural) must call `useConsumeUnionContext()`. This reads and clears the `__foorm_union` context so children don't inherit it. The return value tells you whether to render a variant picker.
40
+ - **The variant picker** is rendered by whichever component is the direct child of a union. When a primitive is a union variant (e.g., `string | { name: string }`), the **leaf component** (text input) renders the picker inline next to its label. When an object is a union variant, the **object component** renders it in its header.
41
+ - **The remove/clear button** serves two purposes: removing an array item (`onRemove`) and clearing an optional field to N/A state (`onToggleOptional(false)`). Both are the component's responsibility (not OoField's). If a text input appears as a primitive array item, the text component renders the remove button. Same component shows the × clear button when the field is optional.
42
+ - **Change events** for field value updates are handled by OoField (on blur). Array and union composables emit their own change events (`array-add`, `array-remove`, `union-switch`).
43
+
44
+ ## The TFoormComponentProps Interface
45
+
46
+ ```ts
47
+ import type { TFoormComponentProps } from '@foormjs/vue'
48
+
49
+ const props = defineProps<TFoormComponentProps<string>>() // generic = value type
50
+ ```
51
+
52
+ | Prop | Type | Used by |
53
+ | ------------------ | ----------------------------- | -------------------------------------------------- |
54
+ | `model` | `{ value: V }` | Leaf fields — bind with `v-model="model.value"` |
55
+ | `value` | `unknown?` | Phantom fields — display value from `@foorm.value` |
56
+ | `onBlur` | `() => void` | Leaf fields — triggers validation |
57
+ | `error` | `string?` | All non-phantom — validation error message |
58
+ | `label` | `string?` | Leaf fields — field label |
59
+ | `description` | `string?` | Leaf fields — field description |
60
+ | `hint` | `string?` | Leaf fields — hint text below input |
61
+ | `placeholder` | `string?` | Leaf fields — input placeholder |
62
+ | `disabled` | `boolean?` | All — disabled state |
63
+ | `hidden` | `boolean?` | All — controls `v-show` |
64
+ | `readonly` | `boolean?` | Leaf fields — read-only state |
65
+ | `optional` | `boolean?` | All — whether the field is optional |
66
+ | `required` | `boolean?` | Leaf fields — whether required |
67
+ | `type` | `string` | All — the field type string |
68
+ | `options` | `TFoormEntryOptions[]?` | select, radio — resolved options |
69
+ | `maxLength` | `number?` | text — max length constraint |
70
+ | `autocomplete` | `string?` | text — HTML autocomplete value |
71
+ | `altAction` | `TFoormAltAction?` | action — `{ id, label }` |
72
+ | `name` | `string?` | Leaf fields — field name |
73
+ | `field` | `FoormFieldDef?` | Structural/union — access extended properties |
74
+ | `title` | `string?` | object, array, tuple — section title |
75
+ | `level` | `number?` | object, array, tuple — nesting depth (0 = root) |
76
+ | `class` | `string \| object?` | All — CSS classes from `@foorm.fn.classes` |
77
+ | `style` | `string \| object?` | All — inline styles from `@foorm.fn.styles` |
78
+ | `onRemove` | `() => void?` | Leaf/object/tuple in arrays — remove callback |
79
+ | `canRemove` | `boolean?` | Same — whether removal is allowed |
80
+ | `removeLabel` | `string?` | Same — remove button label |
81
+ | `arrayIndex` | `number?` | Items in arrays — zero-based index |
82
+ | `onToggleOptional` | `(enabled: boolean) => void?` | All optional — enable/disable toggle |
83
+
84
+ ## Leaf Field Components
85
+
86
+ Leaf components handle ALL display responsibilities: label, description, hint, error, optional N/A, remove button, **and variant picker** (when inside a union).
87
+
88
+ ### Best practice: Create a reusable field shell
89
+
90
+ The default components (OoInput, OoSelect, OoRadio, OoCheckbox) all delegate their chrome to an internal `OoFieldShell` wrapper — which is NOT exported. The recommended approach for custom components is the same: **create your own reusable shell** that handles the shared responsibilities once, then each leaf component only provides the input element.
91
+
92
+ This is the pattern the defaults use, and it keeps every leaf component trivially simple.
93
+
94
+ #### `FieldShell.vue` — Reusable wrapper for all your leaf components
95
+
96
+ ```vue
97
+ <script setup lang="ts">
98
+ import type { TFoormComponentProps, TFoormUnionContext } from '@foormjs/vue'
99
+ import { useConsumeUnionContext, formatIndexedLabel } from '@foormjs/vue'
100
+ import { computed, useId } from 'vue'
101
+
102
+ const props = defineProps<TFoormComponentProps & { idPrefix?: string }>()
103
+
104
+ // ── Accessibility IDs ──
105
+ const id = useId()
106
+ const prefix = props.idPrefix ?? 'field'
107
+ const inputId = `${prefix}-${id}`
108
+ const errorId = `${prefix}-${id}-err`
109
+ const descId = `${prefix}-${id}-desc`
110
+
111
+ // ── Union context (reads & clears — prevents leak to children) ──
112
+ const unionCtx: TFoormUnionContext | undefined = useConsumeUnionContext()
113
+ const hasVariantPicker = unionCtx !== undefined && unionCtx.variants.length > 1
114
+
115
+ // ── Display label with array index: "Name #1", "Name #2" ──
116
+ const displayLabel = computed(() => formatIndexedLabel(props.label, props.arrayIndex))
117
+ const optionalEnabled = computed(() => props.model?.value !== undefined)
118
+ </script>
119
+
120
+ <template>
121
+ <div class="field" v-show="!hidden">
122
+ <!-- Header row: label/custom header on left, action buttons on right -->
123
+ <div
124
+ v-if="
125
+ displayLabel ||
126
+ onRemove ||
127
+ (optional && optionalEnabled) ||
128
+ hasVariantPicker ||
129
+ $slots.header
130
+ "
131
+ class="field-header"
132
+ >
133
+ <div class="field-header-content">
134
+ <!-- Allow override for radio/checkbox custom header layout -->
135
+ <template v-if="$slots.header">
136
+ <slot
137
+ name="header"
138
+ :input-id="inputId"
139
+ :desc-id="descId"
140
+ :optional-enabled="optionalEnabled"
141
+ />
142
+ </template>
143
+ <template v-else>
144
+ <label v-if="displayLabel" :for="inputId">{{ displayLabel }}</label>
145
+ <span v-if="description" :id="descId">{{ description }}</span>
146
+ </template>
147
+
148
+ <!-- Variant picker (when this field is a direct child of a union) -->
149
+ <div v-if="hasVariantPicker" class="variant-picker">
150
+ <button
151
+ v-for="(v, vi) in unionCtx!.variants"
152
+ :key="vi"
153
+ type="button"
154
+ :class="{ active: vi === unionCtx!.currentIndex.value }"
155
+ @click="unionCtx!.changeVariant(vi)"
156
+ >
157
+ {{ v.label }}
158
+ </button>
159
+ </div>
160
+ </div>
161
+
162
+ <div v-if="(optional && optionalEnabled) || onRemove" class="field-actions">
163
+ <button v-if="optional && optionalEnabled" type="button" @click="onToggleOptional?.(false)">
164
+ &times;
165
+ </button>
166
+ <button v-if="onRemove" type="button" :disabled="!canRemove" @click="onRemove">
167
+ {{ removeLabel || 'Remove' }}
168
+ </button>
169
+ </div>
170
+ </div>
171
+
172
+ <!-- Optional N/A state -->
173
+ <template v-if="optional && !optionalEnabled">
174
+ <div class="no-data" @click="onToggleOptional?.(true)">No Data — Click to Edit</div>
175
+ </template>
176
+ <template v-else>
177
+ <!-- Input slot — each leaf component provides its input here -->
178
+ <slot :input-id="inputId" :error-id="errorId" :desc-id="descId" />
179
+ <!-- Extra slot for checkbox description, etc. -->
180
+ <slot name="after-input" :desc-id="descId" />
181
+ <!-- Error or hint -->
182
+ <div :id="errorId" v-if="error || hint" :role="error ? 'alert' : undefined">
183
+ {{ error || hint }}
184
+ </div>
185
+ </template>
186
+ </div>
187
+ </template>
188
+ ```
189
+
190
+ **What this handles (so leaf components don't have to):**
191
+
192
+ - `useConsumeUnionContext()` — reads and clears union context
193
+ - Variant picker rendering (inline next to label)
194
+ - Label with array index formatting (`formatIndexedLabel`)
195
+ - Description display
196
+ - Remove button (when in array)
197
+ - Optional clear button + N/A state
198
+ - Error/hint display
199
+ - Accessibility IDs (`inputId`, `errorId`, `descId`)
200
+ - `v-show="!hidden"`
201
+
202
+ ### Custom Text Input (`text` / `password` / `number`)
203
+
204
+ With the shell, each leaf component only provides its input element:
205
+
206
+ ```vue
207
+ <script setup lang="ts">
208
+ import type { TFoormComponentProps } from '@foormjs/vue'
209
+ import FieldShell from './FieldShell.vue'
210
+
211
+ defineProps<TFoormComponentProps<string>>()
212
+ </script>
213
+
214
+ <template>
215
+ <FieldShell v-bind="$props" id-prefix="input">
216
+ <template #default="{ inputId, errorId, descId }">
217
+ <input
218
+ :id="inputId"
219
+ v-model="model.value"
220
+ @blur="onBlur"
221
+ :type="type"
222
+ :placeholder="placeholder"
223
+ :disabled="disabled"
224
+ :readonly="readonly"
225
+ :maxlength="maxLength"
226
+ :autocomplete="autocomplete"
227
+ :name="name"
228
+ :aria-required="required || undefined"
229
+ :aria-invalid="!!error || undefined"
230
+ :aria-describedby="error || hint ? errorId : description ? descId : undefined"
231
+ />
232
+ </template>
233
+ </FieldShell>
234
+ </template>
235
+ ```
236
+
237
+ Compare this with the default `OoInput` — the structure is identical: `v-bind="$props"` passes all foorm props to the shell, and the default slot receives accessibility IDs for the input element.
238
+
239
+ ### Custom Select (`select`)
240
+
241
+ ```vue
242
+ <script setup lang="ts">
243
+ import type { TFoormComponentProps } from '@foormjs/vue'
244
+ import { optKey, optLabel } from '@foormjs/atscript'
245
+ import FieldShell from './FieldShell.vue'
246
+
247
+ defineProps<TFoormComponentProps<string>>()
248
+ </script>
249
+
250
+ <template>
251
+ <FieldShell v-bind="$props" id-prefix="select">
252
+ <template #default="{ inputId, errorId, descId }">
253
+ <select
254
+ :id="inputId"
255
+ v-model="model.value"
256
+ @change="onBlur"
257
+ @blur="onBlur"
258
+ :disabled="disabled"
259
+ :name="name"
260
+ :aria-required="required || undefined"
261
+ :aria-invalid="!!error || undefined"
262
+ :aria-describedby="error || hint ? errorId : description ? descId : undefined"
263
+ >
264
+ <option v-if="placeholder" value="" disabled>{{ placeholder }}</option>
265
+ <option v-for="opt in options" :key="optKey(opt)" :value="optKey(opt)">
266
+ {{ optLabel(opt) }}
267
+ </option>
268
+ </select>
269
+ </template>
270
+ </FieldShell>
271
+ </template>
272
+ ```
273
+
274
+ Import `optKey`/`optLabel` from `@foormjs/atscript` — options can be `string` or `{ key, label }`.
275
+
276
+ ### Custom Radio Group (`radio`)
277
+
278
+ Radio uses the `#header` slot to render its own label + description layout (label as `<span>` instead of `<label :for>`, since the group uses `role="radiogroup"`):
279
+
280
+ ```vue
281
+ <script setup lang="ts">
282
+ import type { TFoormComponentProps } from '@foormjs/vue'
283
+ import { optKey, optLabel } from '@foormjs/atscript'
284
+ import FieldShell from './FieldShell.vue'
285
+
286
+ defineProps<TFoormComponentProps<string>>()
287
+ </script>
288
+
289
+ <template>
290
+ <FieldShell v-bind="$props" id-prefix="radio">
291
+ <template #header="{ inputId, descId }">
292
+ <span :id="inputId" class="label">{{ label }}</span>
293
+ <span v-if="description" :id="descId">{{ description }}</span>
294
+ </template>
295
+ <template #default="{ inputId, errorId, descId }">
296
+ <div
297
+ role="radiogroup"
298
+ :aria-labelledby="inputId"
299
+ :aria-required="required || undefined"
300
+ :aria-invalid="!!error || undefined"
301
+ :aria-describedby="error || hint ? errorId : description ? descId : undefined"
302
+ >
303
+ <label v-for="opt in options" :key="optKey(opt)">
304
+ <input
305
+ type="radio"
306
+ :value="optKey(opt)"
307
+ v-model="model.value"
308
+ @change="onBlur"
309
+ @blur="onBlur"
310
+ :name="name"
311
+ :disabled="disabled"
312
+ />
313
+ {{ optLabel(opt) }}
314
+ </label>
315
+ </div>
316
+ </template>
317
+ </FieldShell>
318
+ </template>
319
+ ```
320
+
321
+ ### Custom Checkbox (`checkbox`)
322
+
323
+ Checkbox uses `#header` for the optional-only label and `#after-input` for description placement:
324
+
325
+ ```vue
326
+ <script setup lang="ts">
327
+ import type { TFoormComponentProps } from '@foormjs/vue'
328
+ import FieldShell from './FieldShell.vue'
329
+
330
+ defineProps<TFoormComponentProps<boolean>>()
331
+ </script>
332
+
333
+ <template>
334
+ <FieldShell v-bind="$props" id-prefix="checkbox">
335
+ <template #header="{ optionalEnabled }">
336
+ <span v-if="optional && !optionalEnabled" class="label">{{ label }}</span>
337
+ </template>
338
+ <template #default="{ inputId, errorId, descId }">
339
+ <label :for="inputId">
340
+ <input
341
+ :id="inputId"
342
+ type="checkbox"
343
+ :checked="!!model.value"
344
+ @change="
345
+ model.value = ($event.target as HTMLInputElement).checked
346
+ onBlur()
347
+ "
348
+ @blur="onBlur"
349
+ :name="name"
350
+ :disabled="disabled"
351
+ :aria-invalid="!!error || undefined"
352
+ :aria-describedby="error || hint ? errorId : description ? descId : undefined"
353
+ />
354
+ {{ label }}
355
+ </label>
356
+ </template>
357
+ <template #after-input="{ descId }">
358
+ <span v-if="description" :id="descId">{{ description }}</span>
359
+ </template>
360
+ </FieldShell>
361
+ </template>
362
+ ```
363
+
364
+ ## Structural Field Components
365
+
366
+ Structural components render a **title** (not label), iterate sub-fields, and handle the remove button for when they appear inside arrays. They must call `useConsumeUnionContext()`.
367
+
368
+ ### Custom Object (`object`)
369
+
370
+ **Responsibilities:** title, sub-field iteration via OoIterator, remove button, variant picker (from union context), optional N/A, error.
371
+
372
+ ```vue
373
+ <script setup lang="ts">
374
+ import type { TFoormComponentProps } from '@foormjs/vue'
375
+ import type { FoormObjectFieldDef } from '@foormjs/atscript'
376
+ import { isObjectField } from '@foormjs/atscript'
377
+ import { OoIterator, useConsumeUnionContext, formatIndexedLabel } from '@foormjs/vue'
378
+ import { computed } from 'vue'
379
+
380
+ const props = defineProps<TFoormComponentProps>()
381
+
382
+ // Access nested form definition
383
+ const objectDef = isObjectField(props.field!)
384
+ ? (props.field as FoormObjectFieldDef).objectDef
385
+ : undefined
386
+
387
+ // MUST call — clears union context so children don't inherit it
388
+ const unionCtx = useConsumeUnionContext()
389
+
390
+ // Format title with array index: "Address #1", "Address #2"
391
+ const displayTitle = computed(() => formatIndexedLabel(props.title, props.arrayIndex))
392
+ const optionalEnabled = computed(() => props.model?.value !== undefined)
393
+ </script>
394
+
395
+ <template>
396
+ <div class="object-group" v-show="!hidden">
397
+ <!-- Header: title + variant picker + remove button -->
398
+ <div v-if="displayTitle || onRemove || unionCtx" class="object-header">
399
+ <component :is="level === 0 ? 'h2' : 'h3'" v-if="displayTitle">{{ displayTitle }}</component>
400
+
401
+ <!-- Variant picker (when inside a union with multiple variants) -->
402
+ <div v-if="unionCtx && unionCtx.variants.length > 1" class="variant-picker">
403
+ <button
404
+ v-for="(v, vi) in unionCtx.variants"
405
+ :key="vi"
406
+ :class="{ active: vi === unionCtx.currentIndex.value }"
407
+ @click="unionCtx.changeVariant(vi)"
408
+ type="button"
409
+ >
410
+ {{ v.label }}
411
+ </button>
412
+ </div>
413
+
414
+ <!-- Optional clear button -->
415
+ <button v-if="optional && optionalEnabled" type="button" @click="onToggleOptional?.(false)">
416
+ &times;
417
+ </button>
418
+ <!-- Remove button (when this object is an array item) -->
419
+ <button v-if="onRemove" type="button" :disabled="!canRemove" @click="onRemove">
420
+ {{ removeLabel || 'Remove' }}
421
+ </button>
422
+ </div>
423
+
424
+ <!-- Optional N/A state -->
425
+ <template v-if="optional && !optionalEnabled">
426
+ <div class="no-data" @click="onToggleOptional?.(true)">No Data — Click to Edit</div>
427
+ </template>
428
+ <template v-else>
429
+ <div v-if="error" class="object-error">{{ error }}</div>
430
+ <!-- OoIterator renders all sub-fields -->
431
+ <OoIterator v-if="objectDef" :def="objectDef" />
432
+ </template>
433
+ </div>
434
+ </template>
435
+ ```
436
+
437
+ ### Custom Array (`array`)
438
+
439
+ **Responsibilities:** title, item rendering via OoField (NOT OoIterator), add button (with variant dropdown for unions), remove button passed to items, optional N/A, error. Change events (`array-add`/`array-remove`) are handled by the `useFoormArray` composable.
440
+
441
+ ```vue
442
+ <script setup lang="ts">
443
+ import type { TFoormComponentProps } from '@foormjs/vue'
444
+ import type { FoormArrayFieldDef } from '@foormjs/atscript'
445
+ import { isArrayField } from '@foormjs/atscript'
446
+ import { OoField, useFoormArray, useConsumeUnionContext } from '@foormjs/vue'
447
+ import { computed } from 'vue'
448
+
449
+ const props = defineProps<TFoormComponentProps>()
450
+
451
+ const arrayField = isArrayField(props.field!) ? (props.field as FoormArrayFieldDef) : undefined
452
+
453
+ // MUST call — clears union context
454
+ const unionCtx = useConsumeUnionContext()
455
+
456
+ const optionalEnabled = computed(() => Array.isArray(props.model?.value))
457
+
458
+ // useFoormArray manages all array state + emits array-add/array-remove change events
459
+ const {
460
+ arrayValue, // ComputedRef<unknown[]> — current items
461
+ itemKeys, // string[] — stable v-for keys
462
+ getItemField, // (index) => FoormFieldDef — field def per item
463
+ isUnion, // boolean — items are union type?
464
+ unionVariants, // FoormUnionVariant[] — variants (if union)
465
+ addItem, // (variantIndex?) => void — add new item
466
+ removeItem, // (index) => void — remove item
467
+ canAdd, // ComputedRef<boolean> — respects @expect.maxLength
468
+ canRemove, // ComputedRef<boolean> — respects @expect.minLength
469
+ addLabel, // string — from @foorm.array.add.label
470
+ removeLabel: arrayRemoveLabel, // string — from @foorm.array.remove.label
471
+ } = useFoormArray(
472
+ arrayField!,
473
+ computed(() => props.disabled ?? false)
474
+ )
475
+ </script>
476
+
477
+ <template>
478
+ <div class="array-field" v-show="!hidden">
479
+ <!-- Title -->
480
+ <component :is="level === 0 ? 'h2' : 'h3'" v-if="title">{{ title }}</component>
481
+
482
+ <!-- Optional N/A state -->
483
+ <template v-if="optional && !optionalEnabled">
484
+ <div class="no-data" @click="onToggleOptional?.(true)">No Data — Click to Edit</div>
485
+ </template>
486
+ <template v-else>
487
+ <!-- Render each item as OoField — pass remove props -->
488
+ <OoField
489
+ v-for="(_item, i) in arrayValue"
490
+ :key="itemKeys[i]"
491
+ :field="getItemField(i)"
492
+ :on-remove="() => removeItem(i)"
493
+ :can-remove="canRemove"
494
+ :remove-label="arrayRemoveLabel"
495
+ :array-index="i"
496
+ />
497
+
498
+ <!-- Add button: simple for non-union, variant dropdown for union -->
499
+ <div v-if="!isUnion">
500
+ <button type="button" :disabled="!canAdd" @click="addItem(0)">
501
+ {{ addLabel }}
502
+ </button>
503
+ </div>
504
+ <div v-else>
505
+ <button
506
+ v-for="(v, vi) in unionVariants"
507
+ :key="vi"
508
+ type="button"
509
+ :disabled="!canAdd"
510
+ @click="addItem(vi)"
511
+ >
512
+ {{ addLabel }} — {{ v.label }}
513
+ </button>
514
+ </div>
515
+
516
+ <div v-if="error" class="array-error">{{ error }}</div>
517
+ </template>
518
+ </div>
519
+ </template>
520
+ ```
521
+
522
+ ### Custom Tuple (`tuple`)
523
+
524
+ **Responsibilities:** title, fixed-length item rendering via OoField, remove button, variant picker (from union context), optional N/A, error.
525
+
526
+ ```vue
527
+ <script setup lang="ts">
528
+ import type { TFoormComponentProps } from '@foormjs/vue'
529
+ import type { FoormTupleFieldDef } from '@foormjs/atscript'
530
+ import { isTupleField } from '@foormjs/atscript'
531
+ import { OoField, useConsumeUnionContext, formatIndexedLabel } from '@foormjs/vue'
532
+ import { computed } from 'vue'
533
+
534
+ const props = defineProps<TFoormComponentProps>()
535
+
536
+ const tupleField = isTupleField(props.field!) ? (props.field as FoormTupleFieldDef) : undefined
537
+
538
+ // MUST call — clears union context
539
+ const unionCtx = useConsumeUnionContext()
540
+ const optionalEnabled = computed(() => props.model?.value !== undefined)
541
+ const displayTitle = computed(() => formatIndexedLabel(props.title, props.arrayIndex))
542
+ </script>
543
+
544
+ <template>
545
+ <div class="tuple-field" v-show="!hidden">
546
+ <div v-if="displayTitle || onRemove" class="tuple-header">
547
+ <component :is="level === 0 ? 'h2' : 'h3'" v-if="displayTitle">{{ displayTitle }}</component>
548
+ <button v-if="onRemove" type="button" :disabled="!canRemove" @click="onRemove">
549
+ {{ removeLabel || 'Remove' }}
550
+ </button>
551
+ </div>
552
+
553
+ <template v-if="optional && !optionalEnabled">
554
+ <div class="no-data" @click="onToggleOptional?.(true)">No Data — Click to Edit</div>
555
+ </template>
556
+ <template v-else>
557
+ <!-- Fixed-length: one OoField per position -->
558
+ <OoField
559
+ v-if="tupleField"
560
+ v-for="(itemField, i) in tupleField.itemFields"
561
+ :key="i"
562
+ :field="itemField"
563
+ />
564
+ <div v-if="error" class="tuple-error">{{ error }}</div>
565
+ </template>
566
+ </div>
567
+ </template>
568
+ ```
569
+
570
+ ## Union Component
571
+
572
+ **Responsibilities:** variant state management via `useFoormUnion`, provides `__foorm_union` context for children (so the inner object/tuple can render an inline variant picker), renders inner variant as OoField, optional N/A. Does NOT render label, title, remove button, or variant picker directly — it **provides** the context and the inner field handles those.
573
+
574
+ ```vue
575
+ <script setup lang="ts">
576
+ import type { TFoormComponentProps } from '@foormjs/vue'
577
+ import { OoField, useFoormUnion } from '@foormjs/vue'
578
+
579
+ const props = defineProps<TFoormComponentProps>()
580
+
581
+ // useFoormUnion manages variant state + provides __foorm_union context + emits union-switch
582
+ const {
583
+ unionField, // ComputedRef<FoormUnionFieldDef | undefined>
584
+ hasMultipleVariants, // ComputedRef<boolean>
585
+ localUnionIndex, // Ref<number> — current variant index
586
+ innerField, // ComputedRef<FoormFieldDef | undefined>
587
+ changeVariant, // (newIndex) => void — switch variant (stashes data)
588
+ optionalEnabled, // ComputedRef<boolean>
589
+ } = useFoormUnion(props)
590
+ </script>
591
+
592
+ <template>
593
+ <div class="union-field" v-show="!hidden">
594
+ <!-- Optional N/A state -->
595
+ <template v-if="optional && !optionalEnabled">
596
+ <div class="no-data" @click="onToggleOptional?.(true)">No Data — Click to Edit</div>
597
+ </template>
598
+ <template v-else>
599
+ <!-- Optional clear button -->
600
+ <button v-if="optional" type="button" @click="onToggleOptional?.(false)">&times;</button>
601
+
602
+ <!-- Render the selected variant — `:key` forces re-render on switch -->
603
+ <OoField
604
+ v-if="innerField"
605
+ :key="localUnionIndex"
606
+ :field="innerField"
607
+ :array-index="arrayIndex"
608
+ :on-remove="onRemove"
609
+ :can-remove="canRemove"
610
+ :remove-label="removeLabel"
611
+ />
612
+ </template>
613
+ </div>
614
+ </template>
615
+ ```
616
+
617
+ **Why no variant picker here?** The union provides `__foorm_union` context. The child component (object, tuple, or leaf field) consumes it via `useConsumeUnionContext()` and renders the variant picker inline in its header. This avoids double-rendering the picker at both the union and child level.
618
+
619
+ ## Phantom Field Components
620
+
621
+ No model binding, no validation, no label/error/hint. Simple display-only.
622
+
623
+ ### Custom Paragraph (`paragraph`)
624
+
625
+ ```vue
626
+ <script setup lang="ts">
627
+ import type { TFoormComponentProps } from '@foormjs/vue'
628
+ const props = defineProps<TFoormComponentProps>()
629
+ </script>
630
+
631
+ <template>
632
+ <p v-show="!hidden" aria-live="polite">{{ value }}</p>
633
+ </template>
634
+ ```
635
+
636
+ Use `value` (not `model.value`) — comes from `@foorm.value` or `@foorm.fn.value`.
637
+
638
+ ### Custom Action Button (`action`)
639
+
640
+ ```vue
641
+ <script setup lang="ts">
642
+ import type { TFoormComponentProps } from '@foormjs/vue'
643
+ const props = defineProps<TFoormComponentProps<never>>()
644
+ const emit = defineEmits<{ (e: 'action', name: string): void }>()
645
+ </script>
646
+
647
+ <template>
648
+ <div v-show="!hidden">
649
+ <button type="button" :disabled="disabled" @click="altAction && emit('action', altAction.id)">
650
+ {{ altAction?.label }}
651
+ </button>
652
+ </div>
653
+ </template>
654
+ ```
655
+
656
+ The emitted `action` event is caught by OoField and forwarded to OoForm's `action`/`unsupported-action` event.
657
+
658
+ ## Best Practices
659
+
660
+ - **Create a reusable field shell for leaf components** — handle `useConsumeUnionContext()`, variant picker, label, description, error/hint, remove button, and optional N/A in one place. Each leaf component then only provides the input element via a slot. This mirrors how the defaults work with the internal OoFieldShell.
661
+ - Always handle `v-show="!hidden"` in every component (the shell handles it for leaf fields)
662
+ - Always handle `onRemove`/`canRemove`/`removeLabel` (array removal) and `onToggleOptional` (optional clear to N/A) in leaf and object/tuple components — if you skip it, users can't remove array items or clear optional fields
663
+ - Always call `useConsumeUnionContext()` in ALL non-phantom components (leaf AND structural) — it reads and clears the union context. For leaf fields, the return value tells you whether to render an inline variant picker
664
+ - Call `onBlur()` on blur events in leaf fields — without it, `'on-blur'` and `'touched-on-blur'` validation never triggers
665
+ - Use `optKey()`/`optLabel()` from `@foormjs/atscript` for option handling in select/radio
666
+ - Use `formatIndexedLabel()` from `@foormjs/vue` for array item titles in structural components
667
+ - Use type guards (`isObjectField`, `isArrayField`, `isUnionField`, `isTupleField`) before accessing extended field properties
668
+
669
+ ## Gotchas
670
+
671
+ - `model` is `{ value: V }` (a wrapper object), not a Vue ref — use `model.value` for binding
672
+ - `value` (without model) is for phantom fields only (paragraph, action)
673
+ - Change events for leaf fields are emitted by OoField on blur — your component does NOT need to emit them
674
+ - Array and union change events are emitted by `useFoormArray`/`useFoormUnion` composables — your component does NOT emit them directly
675
+ - `onBlur` must be called for validation timing — the default OoSelect calls it on both `@change` and `@blur`
676
+ - `arrayIndex` is zero-based but `formatIndexedLabel` displays one-based (#1, #2)
677
+ - Union components pass through `onRemove`/`canRemove`/`removeLabel`/`arrayIndex` to the inner OoField — the inner component renders the remove button, not the union