@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.
- package/README.md +385 -236
- package/dist/index.cjs +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +454 -146
- package/dist/index.js +1518 -1022
- package/package.json +30 -26
- package/scripts/setup-skills.js +78 -0
- package/skills/foormjs-vue/.placeholder +0 -0
- package/skills/foormjs-vue/SKILL.md +53 -0
- package/skills/foormjs-vue/composables.md +189 -0
- package/skills/foormjs-vue/core.md +279 -0
- package/skills/foormjs-vue/custom-components.md +677 -0
- package/skills/foormjs-vue/defaults.md +266 -0
- package/skills/foormjs-vue/rendering.md +175 -0
- package/dist/index.umd.cjs +0 -3
- package/dist/style.css +0 -1
|
@@ -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
|
+
×
|
|
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
|
+
×
|
|
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)">×</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
|