@omnitend/dashboard-for-laravel 0.33.0 → 0.34.0

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.
Files changed (50) hide show
  1. package/dist/charts.js +82 -90
  2. package/dist/charts.js.map +1 -1
  3. package/dist/charts.umd.cjs +1 -1
  4. package/dist/charts.umd.cjs.map +1 -1
  5. package/dist/components/base/DButton.vue.d.ts +1 -1
  6. package/dist/components/charts/DXBarChart.vue.d.ts +3 -18
  7. package/dist/components/charts/DXLineChart.vue.d.ts +3 -18
  8. package/dist/components/charts/useThemedChart.d.ts +35 -0
  9. package/dist/components/extended/DXChoiceField.vue.d.ts +26 -0
  10. package/dist/components/extended/DXDashboardSidebar.vue.d.ts +1 -1
  11. package/dist/components/extended/DXField.vue.d.ts +10 -113
  12. package/dist/components/extended/DXFieldShell.vue.d.ts +73 -0
  13. package/dist/components/extended/DXNumericField.vue.d.ts +23 -0
  14. package/dist/components/extended/DXRepeater.vue.d.ts +4 -4
  15. package/dist/components/extended/DXRepeaterCards.vue.d.ts +45 -0
  16. package/dist/components/extended/DXTable.vue.d.ts +91 -2
  17. package/dist/components/extended/DXTableEditorModal.vue.d.ts +17 -2
  18. package/dist/components/extended/DXUserAvatar.vue.d.ts +1 -1
  19. package/dist/composables/useAsyncOptions.d.ts +20 -0
  20. package/dist/composables/useResourceEditor.d.ts +2 -1
  21. package/dist/dashboard-for-laravel.js +9987 -10054
  22. package/dist/dashboard-for-laravel.js.map +1 -1
  23. package/dist/dashboard-for-laravel.umd.cjs +7 -7
  24. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  25. package/dist/index.d.ts +3 -2
  26. package/dist/style.css +1 -1
  27. package/dist/types/index.d.ts +146 -0
  28. package/dist/utils/formSchema.d.ts +59 -0
  29. package/docs/public/api-reference.json +464 -30
  30. package/docs/public/docs-map.md +1 -1
  31. package/docs/public/llms.txt +5 -2
  32. package/package.json +1 -1
  33. package/resources/js/components/charts/DXBarChart.vue +4 -43
  34. package/resources/js/components/charts/DXLineChart.vue +4 -43
  35. package/resources/js/components/charts/useThemedChart.ts +69 -0
  36. package/resources/js/components/extended/DXChoiceField.vue +114 -0
  37. package/resources/js/components/extended/DXField.vue +328 -615
  38. package/resources/js/components/extended/DXFieldShell.vue +124 -0
  39. package/resources/js/components/extended/DXForm.vue +9 -5
  40. package/resources/js/components/extended/DXNumericField.vue +144 -0
  41. package/resources/js/components/extended/DXRepeater.vue +53 -175
  42. package/resources/js/components/extended/DXRepeaterCards.vue +133 -0
  43. package/resources/js/components/extended/DXTable.vue +250 -37
  44. package/resources/js/components/extended/DXTableEditorModal.vue +34 -2
  45. package/resources/js/composables/defineForm.ts +8 -44
  46. package/resources/js/composables/useAsyncOptions.ts +61 -0
  47. package/resources/js/composables/useResourceEditor.ts +24 -38
  48. package/resources/js/index.ts +33 -0
  49. package/resources/js/types/index.ts +231 -0
  50. package/resources/js/utils/formSchema.ts +126 -0
@@ -1,9 +1,18 @@
1
1
  <!--
2
2
  @component
3
3
  Renders a single `FieldDefinition` of any type (text, select, autocomplete, checkbox, switch, repeater, currency, percentage, file/image, component, etc.) with its label, validation error, hint and help. Used by `DXForm` and `DXTable`'s edit modal; exposes `value`, `span`, `info`, `info-popover`, `hint` and `repeater-row` slots for per-field customisation.
4
+
5
+ Structure (#135): a thin DISPATCHER. The shared chrome (label + validation
6
+ feedback + hint + help + info) lives in `DXFieldShell`; the numeric controls
7
+ (currency/percentage/number) in `DXNumericField`; the choice controls
8
+ (select/searchable/radio/checkbox-group) in `DXChoiceField`; and the async
9
+ option loading in the `useAsyncOptions` composable. This file decides which
10
+ control to render inside the shell by `field.type`. None of those pieces are
11
+ exported — they exist only to keep this component factored.
4
12
  -->
5
13
  <template>
6
- <!-- Full-width span field: delegate entirely to the #span slot -->
14
+ <!-- Full-width span field: delegate entirely to the #span slot, bypassing
15
+ the label and built-in control (and the shell). -->
7
16
  <div v-if="field.span" :class="field.class || 'mb-3'">
8
17
  <!--
9
18
  @slot Replaces the entire field with custom full-width content when `field.span` is set, bypassing the label and built-in control.
@@ -21,329 +30,45 @@
21
30
  />
22
31
  </div>
23
32
 
24
- <!-- Checkbox (horizontal layout): wrapped in DFormGroup for the same
25
- label/content column split as other field types, instead of the
26
- label-beside-control layout below. The inline label beside the
27
- checkbox itself is kept (not deduplicated against the outer label)
28
- so the control's accessible name doesn't depend on aria-labelledby
29
- association through the fieldset alone — but its `info` popover is
30
- omitted here, since rendering the same popover trigger twice (once
31
- per label) would be a duplicated interactive element, not just
32
- duplicated text. -->
33
- <DFormGroup
34
- v-else-if="field.type === 'checkbox' && isHorizontal"
35
- :class="field.class || 'mb-3'"
36
- v-bind="horizontalAttrs"
33
+ <!-- Every other field type shares the same chrome via DXFieldShell; the
34
+ control that goes inside is chosen by field.type in the default slot. -->
35
+ <DXFieldShell
36
+ v-else
37
+ :field="field"
38
+ :form="form"
39
+ :error-key="errorKey"
40
+ :model="model"
41
+ :mode="shellMode"
42
+ :wrapper-class="field.class || 'mb-3'"
43
+ :group-attrs="horizontalAttrs"
44
+ :label="resolvedLabel"
45
+ :info="resolvedInfo"
46
+ :hide-label="hideLabel"
47
+ :hint-in-label="hintInLabel"
48
+ :resolved-hint="resolvedHint"
37
49
  >
38
- <!-- The row label (left column) names the control; the checkbox's own
39
- label is kept for screen readers but hidden visually so it doesn't
40
- duplicate the row label. In horizontal layout the hint sits beneath
41
- the label here rather than below the control. -->
42
- <template v-if="!hideLabel" #label>
43
- <DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
44
- <small
45
- v-if="isHorizontal && (resolvedHint || $slots.hint)"
46
- class="form-text text-muted d-block dx-field-hint"
47
- >
48
- <slot name="hint" :field="field" :model="model">{{
49
- resolvedHint
50
- }}</slot>
51
- </small>
52
- </template>
53
- <slot
54
- v-if="$slots.value"
55
- name="value"
56
- :field="field"
57
- :model="model"
58
- :value="fieldValue"
59
- :update="setValue"
60
- />
61
- <DFormCheckbox
62
- v-else
63
- v-model="fieldValue"
64
- :disabled="isDisabled || isReadonly || isPlaintext"
65
- v-bind="controlPropsWithGuards"
66
- >
67
- <DXFieldLabel :label="resolvedLabel" class="visually-hidden" />
68
- </DFormCheckbox>
69
-
70
- <DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
71
- {{ form.getError(errorKey) }}
72
- </DFormInvalidFeedback>
73
- <slot name="info" :field="field" :model="model" />
74
- <DFormText v-if="!hintInLabel && (resolvedHint || $slots.hint)" class="text-muted">
75
- <slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
76
- </DFormText>
77
- <DFormText v-if="field.help">{{ field.help }}</DFormText>
78
- </DFormGroup>
79
-
80
- <!-- Checkbox (vertical, default): no label wrapper, label sits beside the control -->
81
- <div v-else-if="field.type === 'checkbox'" :class="field.class || 'mb-3'">
82
- <!--
83
- @slot Replaces the built-in control with a custom value editor.
84
- @binding {FieldDefinition} field The field definition being rendered.
85
- @binding {any} model The model passed to field predicates (defaults to the live form data).
86
- @binding {any} value The current field value.
87
- @binding {(value: any) => void} update Setter that writes the value back and clears the field's validation error.
88
- -->
89
- <slot
90
- v-if="$slots.value"
91
- name="value"
92
- :field="field"
93
- :model="model"
94
- :value="fieldValue"
95
- :update="setValue"
96
- />
97
- <DFormCheckbox
98
- v-else
99
- v-model="fieldValue"
100
- :disabled="isDisabled || isReadonly || isPlaintext"
101
- v-bind="controlPropsWithGuards"
102
- >
103
- <DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
104
- </DFormCheckbox>
105
-
106
- <DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
107
- {{ form.getError(errorKey) }}
108
- </DFormInvalidFeedback>
109
- <!--
110
- @slot Rich info block rendered below the control.
111
- @binding {FieldDefinition} field The field definition being rendered.
112
- @binding {any} model The model passed to field predicates (defaults to the live form data).
113
- -->
114
- <slot name="info" :field="field" :model="model" />
115
- <DFormText v-if="resolvedHint || $slots.hint" class="text-muted">
50
+ <!-- Forward the field's hint slot (content + props) only when present,
51
+ so the shell's `$slots.hint` reflects reality (it gates the hint
52
+ rendering); the info slot is a pure pass-through. -->
53
+ <template v-if="$slots.hint" #hint="hintProps">
116
54
  <!--
117
- @slot Overrides the field's hint text shown below the control.
55
+ @slot Overrides the field's hint text shown below the control (or, in horizontal layout, in the label column).
118
56
  @binding {FieldDefinition} field The field definition being rendered.
119
57
  @binding {any} model The model passed to field predicates (defaults to the live form data).
120
58
  -->
121
- <slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
122
- </DFormText>
123
- <DFormText v-if="field.help">{{ field.help }}</DFormText>
124
- </div>
125
-
126
- <!-- Switch (horizontal layout): same column-split treatment as checkbox
127
- above, including omitting `info` from the inner label to avoid a
128
- duplicated popover trigger. -->
129
- <DFormGroup
130
- v-else-if="field.type === 'switch' && isHorizontal"
131
- :class="field.class || 'mb-3'"
132
- v-bind="horizontalAttrs"
133
- >
134
- <!-- The row label (left column) names the control. The switch's own
135
- inner text is shown only when the field opts into contextual on/off
136
- text (e.g. "Product is current"); otherwise it falls back to the row
137
- label and is hidden visually (kept for screen readers). In horizontal
138
- layout the hint sits beneath the label here rather than below the
139
- control. -->
140
- <template v-if="!hideLabel" #label>
141
- <DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
142
- <small
143
- v-if="isHorizontal && (resolvedHint || $slots.hint)"
144
- class="form-text text-muted d-block dx-field-hint"
145
- >
146
- <slot name="hint" :field="field" :model="model">{{
147
- resolvedHint
148
- }}</slot>
149
- </small>
59
+ <slot name="hint" v-bind="hintProps" />
150
60
  </template>
151
- <slot
152
- v-if="$slots.value"
153
- name="value"
154
- :field="field"
155
- :model="model"
156
- :value="fieldValue"
157
- :update="setValue"
158
- />
159
- <DXSwitch
160
- v-else
161
- v-model="switchModel"
162
- :on-variant="field.switchVariant"
163
- :disabled="isDisabled || isReadonly || isPlaintext"
164
- v-bind="controlPropsWithGuards"
165
- >
166
- <DXFieldLabel
167
- :label="switchText"
168
- :class="switchHasContextualText ? undefined : 'visually-hidden'"
169
- />
170
- </DXSwitch>
171
-
172
- <DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
173
- {{ form.getError(errorKey) }}
174
- </DFormInvalidFeedback>
175
- <slot name="info" :field="field" :model="model" />
176
- <DFormText v-if="!hintInLabel && (resolvedHint || $slots.hint)" class="text-muted">
177
- <slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
178
- </DFormText>
179
- <DFormText v-if="field.help">{{ field.help }}</DFormText>
180
- </DFormGroup>
181
-
182
- <!-- Switch (vertical, default): toggle with contextual on/off text (DXSwitch owns the filled-box on-state style) -->
183
- <div
184
- v-else-if="field.type === 'switch'"
185
- :class="field.class || 'mb-3'"
186
- >
187
- <!--
188
- @slot Replaces the built-in control with a custom value editor.
189
- @binding {FieldDefinition} field The field definition being rendered.
190
- @binding {any} model The model passed to field predicates (defaults to the live form data).
191
- @binding {any} value The current field value.
192
- @binding {(value: any) => void} update Setter that writes the value back and clears the field's validation error.
193
- -->
194
- <slot
195
- v-if="$slots.value"
196
- name="value"
197
- :field="field"
198
- :model="model"
199
- :value="fieldValue"
200
- :update="setValue"
201
- />
202
- <DXSwitch
203
- v-else
204
- v-model="switchModel"
205
- :on-variant="field.switchVariant"
206
- :disabled="isDisabled || isReadonly || isPlaintext"
207
- v-bind="controlPropsWithGuards"
208
- >
209
- <DXFieldLabel :label="switchText" :info="resolvedInfo" />
210
- </DXSwitch>
211
-
212
- <DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
213
- {{ form.getError(errorKey) }}
214
- </DFormInvalidFeedback>
215
- <!--
216
- @slot Rich info block rendered below the control.
217
- @binding {FieldDefinition} field The field definition being rendered.
218
- @binding {any} model The model passed to field predicates (defaults to the live form data).
219
- -->
220
- <slot name="info" :field="field" :model="model" />
221
- <DFormText v-if="resolvedHint || $slots.hint" class="text-muted">
61
+ <template #info="infoProps">
222
62
  <!--
223
- @slot Overrides the field's hint text shown below the control.
63
+ @slot Rich info block rendered below the control.
224
64
  @binding {FieldDefinition} field The field definition being rendered.
225
65
  @binding {any} model The model passed to field predicates (defaults to the live form data).
226
66
  -->
227
- <slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
228
- </DFormText>
229
- <DFormText v-if="field.help">{{ field.help }}</DFormText>
230
- </div>
231
-
232
- <!-- Switch-list (#160): a list of labelled boolean toggles as config, not
233
- markup. One real DFormGroup per option row — the ONLY correct source of
234
- the label/content grid (hardcoding col classes is exactly the drift
235
- this field exists to end) — with the option text in the label column
236
- and a compact track switch (not the filled-box DXSwitch, too heavy in
237
- a dense list) in the content column. The model is an array of selected
238
- option values, like checkbox-group. -->
239
- <div v-else-if="field.type === 'switch-list'" :class="field.class || 'mb-3'">
240
- <div
241
- v-if="!hideLabel && field.label"
242
- class="dx-switch-list-heading text-muted fw-semibold"
243
- >
244
- <DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
245
- </div>
246
- <div
247
- v-for="option in resolvedOptions ?? []"
248
- :key="String(option.value)"
249
- class="dx-switch-list-row"
250
- >
251
- <DFormGroup v-bind="switchListRowAttrs" class="mb-0 align-items-center">
252
- <template #label>
253
- <DXFieldLabel
254
- :label="option.text"
255
- :title="option.description"
256
- />
257
- </template>
258
- <div class="dx-switch-list-control">
259
- <DFormCheckbox
260
- switch
261
- :class="field.switchVariant === 'neutral' ? 'switch-neutral' : undefined"
262
- :model-value="switchListIsOn(option)"
263
- :disabled="isDisabled || isReadonly || isPlaintext || option.disabled === true"
264
- :aria-label="option.text"
265
- @update:model-value="setSwitchListOn(option, $event === true)"
266
- />
267
- <!--
268
- @slot Extra per-row content beside a switch-list row's toggle (e.g. a notes input the consumer binds to their own model).
269
- @binding {FieldOption} option The option this row renders.
270
- @binding {boolean} on Whether the row's switch is currently on.
271
- -->
272
- <slot
273
- name="switch-list-item"
274
- :option="option"
275
- :on="switchListIsOn(option)"
276
- />
277
- </div>
278
- </DFormGroup>
279
- </div>
280
-
281
- <DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
282
- {{ form.getError(errorKey) }}
283
- </DFormInvalidFeedback>
284
- <slot name="info" :field="field" :model="model" />
285
- <DFormText v-if="resolvedHint || $slots.hint" class="text-muted">
286
- <slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
287
- </DFormText>
288
- <DFormText v-if="field.help">{{ field.help }}</DFormText>
289
- </div>
290
-
291
- <!-- Repeater: nested, repeatable sub-form -->
292
- <div v-else-if="field.type === 'repeater'" :class="field.class || 'mb-3'">
293
- <DFormGroup v-bind="repeaterHorizontalAttrs">
294
- <template v-if="!hideLabel" #label>
295
- <DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
296
- </template>
297
- <DXRepeater
298
- :form="form"
299
- :field="field"
300
- :key-path="keyPath"
301
- :model="model"
302
- >
303
- <!-- Forward repeater row slot for custom row layouts -->
304
- <template v-if="$slots['repeater-row']" #row="rowProps">
305
- <!-- @slot Custom layout for a single repeater row, forwarded to `DXRepeater`'s `row` slot with its row props (row index, item, remove handler, etc.). -->
306
- <slot name="repeater-row" v-bind="rowProps" />
307
- </template>
308
- </DXRepeater>
309
- </DFormGroup>
310
- <!--
311
- @slot Rich info block rendered below the control.
312
- @binding {FieldDefinition} field The field definition being rendered.
313
- @binding {any} model The model passed to field predicates (defaults to the live form data).
314
- -->
315
- <slot name="info" :field="field" :model="model" />
316
- <DFormText v-if="resolvedHint || $slots.hint" class="text-muted">
317
- <!--
318
- @slot Overrides the field's hint text shown below the control.
319
- @binding {FieldDefinition} field The field definition being rendered.
320
- @binding {any} model The model passed to field predicates (defaults to the live form data).
321
- -->
322
- <slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
323
- </DFormText>
324
- <DFormText v-if="field.help">{{ field.help }}</DFormText>
325
- </div>
326
-
327
- <!-- Standard labelled field -->
328
- <DFormGroup v-else :class="field.class || 'mb-3'" v-bind="horizontalAttrs">
329
- <!-- Label with optional info popover. Omitted entirely (not just
330
- emptied) when hideLabel is set, so BFormGroup doesn't reserve a
331
- label column/row for it (see DXRepeater's table layout). In
332
- horizontal layout the hint sits beneath the label here rather than
333
- below the control. -->
334
- <template v-if="!hideLabel" #label>
335
- <DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
336
- <small
337
- v-if="isHorizontal && (resolvedHint || $slots.hint)"
338
- class="form-text text-muted d-block dx-field-hint"
339
- >
340
- <slot name="hint" :field="field" :model="model">{{
341
- resolvedHint
342
- }}</slot>
343
- </small>
67
+ <slot name="info" v-bind="infoProps" />
344
68
  </template>
345
69
 
346
- <!-- Custom value slot overrides the built-in control -->
70
+ <!-- Custom value slot overrides the built-in control (checkbox / switch
71
+ / standard types; not switch-list or repeater). -->
347
72
  <!--
348
73
  @slot Replaces the built-in control with a custom value editor.
349
74
  @binding {FieldDefinition} field The field definition being rendered.
@@ -352,256 +77,288 @@
352
77
  @binding {(value: any) => void} update Setter that writes the value back and clears the field's validation error.
353
78
  -->
354
79
  <slot
355
- v-if="$slots.value"
80
+ v-if="showValueSlot && $slots.value"
356
81
  name="value"
357
82
  :field="field"
358
83
  :model="model"
359
84
  :value="fieldValue"
360
85
  :update="setValue"
361
86
  />
362
-
363
- <!-- Component escape hatch -->
364
- <component
365
- :is="field.component"
366
- v-else-if="field.type === 'component' && field.component"
367
- v-model="fieldValue"
368
- :field="field"
369
- :model="model"
370
- :disabled="isDisabled || isReadonly || isPlaintext"
371
- v-bind="controlPropsWithGuards"
372
- />
373
-
374
- <!-- Textarea. Error clearing happens in the v-model setter. -->
375
- <DFormTextarea
376
- v-else-if="field.type === 'textarea'"
377
- v-model="fieldValue"
378
- :required="field.required"
379
- :placeholder="field.placeholder"
380
- :rows="field.rows || 3"
381
- :state="fieldState"
382
- :disabled="isDisabled"
383
- :readonly="isReadonly || isPlaintext"
384
- :plaintext="isPlaintext"
385
- v-bind="inputPropsWithGuards"
386
- />
387
-
388
- <!-- Searchable select: type to filter a long option list, while the
389
- model keeps the option's VALUE (an id), not the typed text. The
390
- `autocomplete` type below models the text, so it can't back a
391
- foreign key. -->
392
- <template v-else-if="field.type === 'select' && field.searchable">
393
- <DAutocomplete
394
- v-if="searchableOptionsReady"
87
+ <template v-else>
88
+ <!-- Checkbox (horizontal): the row label (shell #label) names the
89
+ control; the checkbox's own label is kept for screen readers
90
+ but hidden visually so it doesn't duplicate the row label. -->
91
+ <DFormCheckbox
92
+ v-if="field.type === 'checkbox' && isHorizontal"
395
93
  v-model="fieldValue"
396
- :options="resolvedOptions"
397
- :placeholder="field.placeholder"
398
- :required="field.required"
399
- :state="fieldState"
400
94
  :disabled="isDisabled || isReadonly || isPlaintext"
401
- open-on-focus
402
95
  v-bind="controlPropsWithGuards"
403
- />
404
- <!-- BAutocomplete resolves the input's display text from the model
405
- AT MOUNT and doesn't re-derive it when `options` arrive later,
406
- so an async list would leave the raw id showing ("51" instead
407
- of "Waitrose") — and a long list is exactly the case that loads
408
- async. Hold the control until the options are in. -->
409
- <DFormInput v-else placeholder="Loading…" disabled />
410
- </template>
411
-
412
- <!-- Select (sync or async options) -->
413
- <DFormSelect
414
- v-else-if="field.type === 'select'"
415
- v-model="fieldValue"
416
- :required="field.required"
417
- :options="resolvedOptions"
418
- :state="fieldState"
419
- :disabled="isDisabled || isReadonly || isPlaintext"
420
- v-bind="controlPropsWithGuards"
421
- />
422
-
423
- <!-- Radio group -->
424
- <DFormRadioGroup
425
- v-else-if="field.type === 'radio'"
426
- v-model="fieldValue"
427
- :options="resolvedOptions"
428
- :required="field.required"
429
- :state="fieldState"
430
- :disabled="isDisabled || isReadonly || isPlaintext"
431
- v-bind="controlPropsWithGuards"
432
- />
433
-
434
- <!-- Checkbox group: "pick any of N" — the model is an ARRAY of the
435
- checked option values (default the field to []). -->
436
- <DFormCheckboxGroup
437
- v-else-if="field.type === 'checkbox-group'"
438
- v-model="fieldValue"
439
- :options="resolvedOptions"
440
- :state="fieldState"
441
- :disabled="isDisabled || isReadonly || isPlaintext"
442
- v-bind="controlPropsWithGuards"
443
- />
96
+ >
97
+ <DXFieldLabel :label="resolvedLabel" class="visually-hidden" />
98
+ </DFormCheckbox>
444
99
 
445
- <!-- File / image upload -->
446
- <div v-else-if="field.type === 'image' || field.type === 'file'">
447
- <DFormInput
448
- type="file"
449
- :accept="field.accept || (field.type === 'image' ? 'image/*' : undefined)"
450
- :required="field.required"
451
- :state="fieldState"
100
+ <!-- Checkbox (vertical): label sits beside the control -->
101
+ <DFormCheckbox
102
+ v-else-if="field.type === 'checkbox'"
103
+ v-model="fieldValue"
452
104
  :disabled="isDisabled || isReadonly || isPlaintext"
453
105
  v-bind="controlPropsWithGuards"
454
- @change="handleFileChange"
455
- />
456
- <img
457
- v-if="field.type === 'image' && imagePreview"
458
- :src="imagePreview"
459
- alt="Preview"
460
- class="dx-field-image-preview mt-2"
461
- />
462
- </div>
463
-
464
- <!-- Currency: the DXCurrencyInput leaf (#152) owns the symbol prepend,
465
- the blur-padded display (#69), empty -> null, and the minor-units
466
- model scaling (#116). -->
467
- <DXCurrencyInput
468
- v-else-if="field.type === 'currency'"
469
- :model-value="fieldValue"
470
- :currency-symbol="field.currencySymbol || '£'"
471
- :decimals="field.decimals ?? 2"
472
- :minor-units="field.minorUnits ?? false"
473
- :step="field.step"
474
- :required="field.required"
475
- :placeholder="field.placeholder"
476
- :min="field.min"
477
- :max="field.max"
478
- :state="fieldState"
479
- :disabled="isDisabled"
480
- :readonly="isReadonly || isPlaintext"
481
- :plaintext="isPlaintext"
482
- v-bind="inputPropsWithGuards"
483
- @update:model-value="setValue($event)"
484
- />
106
+ >
107
+ <DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
108
+ </DFormCheckbox>
109
+
110
+ <!-- Switch (horizontal): the switch's inner text shows only when the
111
+ field opts into contextual on/off text; otherwise it falls back
112
+ to the row label and is hidden visually (kept for a11y). -->
113
+ <DXSwitch
114
+ v-else-if="field.type === 'switch' && isHorizontal"
115
+ v-model="switchModel"
116
+ :on-variant="field.switchVariant"
117
+ :disabled="isDisabled || isReadonly || isPlaintext"
118
+ v-bind="controlPropsWithGuards"
119
+ >
120
+ <DXFieldLabel
121
+ :label="switchText"
122
+ :class="switchHasContextualText ? undefined : 'visually-hidden'"
123
+ />
124
+ </DXSwitch>
485
125
 
486
- <!-- Percentage: numeric input with an affix -->
487
- <DInputGroup v-else-if="field.type === 'percentage'">
488
- <DFormInput
489
- v-model="numericInputValue"
490
- type="number"
491
- :required="field.required"
492
- :placeholder="field.placeholder"
493
- :step="field.step"
494
- :min="field.min"
495
- :max="field.max"
496
- :state="fieldState"
497
- :disabled="isDisabled"
498
- :readonly="isReadonly || isPlaintext"
499
- :plaintext="isPlaintext"
500
- v-bind="inputPropsWithGuards"
501
- />
502
-
503
- <template #append>
504
- <span class="input-group-text">%</span>
126
+ <!-- Switch (vertical): toggle with contextual on/off text -->
127
+ <DXSwitch
128
+ v-else-if="field.type === 'switch'"
129
+ v-model="switchModel"
130
+ :on-variant="field.switchVariant"
131
+ :disabled="isDisabled || isReadonly || isPlaintext"
132
+ v-bind="controlPropsWithGuards"
133
+ >
134
+ <DXFieldLabel :label="switchText" :info="resolvedInfo" />
135
+ </DXSwitch>
136
+
137
+ <!-- Switch-list (#160): a list of labelled boolean toggles as config,
138
+ not markup. One real DFormGroup per option row (the ONLY correct
139
+ source of the label/content grid) with the option text in the
140
+ label column and a compact track switch in the content column.
141
+ The model is an array of selected option values, like
142
+ checkbox-group. -->
143
+ <template v-else-if="field.type === 'switch-list'">
144
+ <div
145
+ v-if="!hideLabel && field.label"
146
+ class="dx-switch-list-heading text-muted fw-semibold"
147
+ >
148
+ <DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
149
+ </div>
150
+ <div
151
+ v-for="option in resolvedOptions ?? []"
152
+ :key="String(option.value)"
153
+ class="dx-switch-list-row"
154
+ >
155
+ <DFormGroup
156
+ v-bind="switchListRowAttrs"
157
+ class="mb-0 align-items-center"
158
+ >
159
+ <template #label>
160
+ <DXFieldLabel
161
+ :label="option.text"
162
+ :title="option.description"
163
+ />
164
+ </template>
165
+ <div class="dx-switch-list-control">
166
+ <DFormCheckbox
167
+ switch
168
+ :class="field.switchVariant === 'neutral' ? 'switch-neutral' : undefined"
169
+ :model-value="switchListIsOn(option)"
170
+ :disabled="isDisabled || isReadonly || isPlaintext || option.disabled === true"
171
+ :aria-label="option.text"
172
+ @update:model-value="setSwitchListOn(option, $event === true)"
173
+ />
174
+ <!--
175
+ @slot Extra per-row content beside a switch-list row's toggle (e.g. a notes input the consumer binds to their own model).
176
+ @binding {FieldOption} option The option this row renders.
177
+ @binding {boolean} on Whether the row's switch is currently on.
178
+ -->
179
+ <slot
180
+ name="switch-list-item"
181
+ :option="option"
182
+ :on="switchListIsOn(option)"
183
+ />
184
+ </div>
185
+ </DFormGroup>
186
+ </div>
505
187
  </template>
506
- </DInputGroup>
507
188
 
508
- <!-- Autocomplete: a free-text input with a datalist of suggestions
509
- (sync or async options). Unlike select, a value not in the list can
510
- still be typed and submitted; the consumer validates. -->
511
- <template v-else-if="field.type === 'autocomplete'">
512
- <DFormInput
513
- v-model="fieldValue"
514
- type="text"
515
- :list="datalistId"
516
- :required="field.required"
517
- :placeholder="field.placeholder"
518
- :state="fieldState"
519
- :disabled="isDisabled"
520
- :readonly="isReadonly || isPlaintext"
521
- :plaintext="isPlaintext"
522
- autocomplete="off"
523
- v-bind="inputPropsWithGuards"
524
- />
525
- <datalist :id="datalistId">
526
- <option
527
- v-for="opt in resolvedOptions"
528
- :key="String(opt.value)"
529
- :value="String(opt.value)"
189
+ <!-- Repeater: nested, repeatable sub-form. Renders its own label
190
+ group (a table-layout repeater keeps its label above, so it uses
191
+ repeaterHorizontalAttrs rather than the field's horizontal
192
+ layout). The shell contributes only the trailing block here. -->
193
+ <DFormGroup
194
+ v-else-if="field.type === 'repeater'"
195
+ v-bind="repeaterHorizontalAttrs"
196
+ >
197
+ <template v-if="!hideLabel" #label>
198
+ <DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
199
+ </template>
200
+ <DXRepeater
201
+ :form="form"
202
+ :field="field"
203
+ :key-path="keyPath"
204
+ :model="model"
530
205
  >
531
- {{ opt.text }}
532
- </option>
533
- </datalist>
534
- </template>
206
+ <!-- Forward repeater row slot for custom row layouts -->
207
+ <template v-if="$slots['repeater-row']" #row="rowProps">
208
+ <!-- @slot Custom layout for a single repeater row, forwarded to `DXRepeater`'s `row` slot with its row props (row index, item, remove handler, etc.). -->
209
+ <slot name="repeater-row" v-bind="rowProps" />
210
+ </template>
211
+ </DXRepeater>
212
+ </DFormGroup>
213
+
214
+ <!-- Standard labelled field: the control goes inside the shell's
215
+ group (label column + trailing block). -->
216
+ <template v-else>
217
+ <!-- Component escape hatch -->
218
+ <component
219
+ :is="field.component"
220
+ v-if="field.type === 'component' && field.component"
221
+ v-model="fieldValue"
222
+ :field="field"
223
+ :model="model"
224
+ :disabled="isDisabled || isReadonly || isPlaintext"
225
+ v-bind="controlPropsWithGuards"
226
+ />
535
227
 
536
- <!-- Password with a reveal toggle (the default for `type: "password"`).
537
- Typing a long password blind is the one place the eye really earns
538
- its keep, so it's opt-out (`revealable: false`) rather than opt-in. -->
539
- <DInputGroup v-else-if="isRevealablePassword">
540
- <DFormInput
541
- v-model="textFieldModel"
542
- :required="field.required"
543
- :placeholder="field.placeholder"
544
- :state="fieldState"
545
- :disabled="isDisabled"
546
- :readonly="isReadonly"
547
- v-bind="passwordInputProps"
548
- />
549
- <template #append>
550
- <DButton
551
- variant="outline-secondary"
552
- :icon="passwordRevealed ? 'eye-slash' : 'eye'"
553
- :aria-label="passwordRevealed ? 'Hide password' : 'Show password'"
554
- :aria-pressed="passwordRevealed"
228
+ <!-- Textarea. Error clearing happens in the v-model setter. -->
229
+ <DFormTextarea
230
+ v-else-if="field.type === 'textarea'"
231
+ v-model="fieldValue"
232
+ :required="field.required"
233
+ :placeholder="field.placeholder"
234
+ :rows="field.rows || 3"
235
+ :state="fieldState"
555
236
  :disabled="isDisabled"
556
- @click="passwordRevealed = !passwordRevealed"
237
+ :readonly="isReadonly || isPlaintext"
238
+ :plaintext="isPlaintext"
239
+ v-bind="inputPropsWithGuards"
557
240
  />
558
- </template>
559
- </DInputGroup>
560
-
561
- <!-- Text-based inputs (text/email/password/number/url/tel/date/time/datetime) -->
562
- <DFormInput
563
- v-else
564
- v-model="textFieldModel"
565
- :type="inputType"
566
- :required="field.required"
567
- :placeholder="field.placeholder"
568
- :step="field.step"
569
- :min="field.min"
570
- :max="field.max"
571
- :state="fieldState"
572
- :disabled="isDisabled"
573
- :readonly="isReadonly || isPlaintext"
574
- :plaintext="isPlaintext"
575
- v-bind="inputPropsWithGuards"
576
- />
577
241
 
578
- <!-- Validation error -->
579
- <DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
580
- {{ form.getError(errorKey) }}
581
- </DFormInvalidFeedback>
242
+ <!-- Choice controls: select (plain or searchable), radio,
243
+ checkbox-group. -->
244
+ <DXChoiceField
245
+ v-else-if="isChoiceType"
246
+ :field="field"
247
+ :model-value="fieldValue"
248
+ :options="resolvedOptions"
249
+ :state="fieldState"
250
+ :disabled="isDisabled || isReadonly || isPlaintext"
251
+ :control-props="controlPropsWithGuards"
252
+ :options-ready="searchableOptionsReady"
253
+ @update:model-value="setValue"
254
+ />
582
255
 
583
- <!-- Optional rich info block -->
584
- <!--
585
- @slot Rich info block rendered below the control.
586
- @binding {FieldDefinition} field The field definition being rendered.
587
- @binding {any} model The model passed to field predicates (defaults to the live form data).
588
- -->
589
- <slot name="info" :field="field" :model="model" />
256
+ <!-- File / image upload -->
257
+ <div v-else-if="field.type === 'image' || field.type === 'file'">
258
+ <DFormInput
259
+ type="file"
260
+ :accept="field.accept || (field.type === 'image' ? 'image/*' : undefined)"
261
+ :required="field.required"
262
+ :state="fieldState"
263
+ :disabled="isDisabled || isReadonly || isPlaintext"
264
+ v-bind="controlPropsWithGuards"
265
+ @change="handleFileChange"
266
+ />
267
+ <img
268
+ v-if="field.type === 'image' && imagePreview"
269
+ :src="imagePreview"
270
+ alt="Preview"
271
+ class="dx-field-image-preview mt-2"
272
+ />
273
+ </div>
590
274
 
591
- <!-- Hint (dynamic, slot-overridable). In horizontal layout it renders in
592
- the label column instead (see the #label slot above). -->
593
- <DFormText v-if="!hintInLabel && (resolvedHint || $slots.hint)" class="text-muted">
594
- <!--
595
- @slot Overrides the field's hint text shown below the control.
596
- @binding {FieldDefinition} field The field definition being rendered.
597
- @binding {any} model The model passed to field predicates (defaults to the live form data).
598
- -->
599
- <slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
600
- </DFormText>
275
+ <!-- Numeric controls: currency, percentage, number. -->
276
+ <DXNumericField
277
+ v-else-if="isNumericType"
278
+ :field="field"
279
+ :model-value="fieldValue"
280
+ :state="fieldState"
281
+ :is-disabled="isDisabled"
282
+ :is-readonly="isReadonly"
283
+ :is-plaintext="isPlaintext"
284
+ :input-props="inputPropsWithGuards"
285
+ @update:model-value="setValue"
286
+ />
601
287
 
602
- <!-- Static help text -->
603
- <DFormText v-if="field.help">{{ field.help }}</DFormText>
604
- </DFormGroup>
288
+ <!-- Autocomplete: a free-text input with a datalist of
289
+ suggestions (sync or async options). Unlike select, a value
290
+ not in the list can still be typed and submitted; the
291
+ consumer validates. -->
292
+ <template v-else-if="field.type === 'autocomplete'">
293
+ <DFormInput
294
+ v-model="fieldValue"
295
+ type="text"
296
+ :list="datalistId"
297
+ :required="field.required"
298
+ :placeholder="field.placeholder"
299
+ :state="fieldState"
300
+ :disabled="isDisabled"
301
+ :readonly="isReadonly || isPlaintext"
302
+ :plaintext="isPlaintext"
303
+ autocomplete="off"
304
+ v-bind="inputPropsWithGuards"
305
+ />
306
+ <datalist :id="datalistId">
307
+ <option
308
+ v-for="opt in resolvedOptions"
309
+ :key="String(opt.value)"
310
+ :value="String(opt.value)"
311
+ >
312
+ {{ opt.text }}
313
+ </option>
314
+ </datalist>
315
+ </template>
316
+
317
+ <!-- Password with a reveal toggle (the default for
318
+ `type: "password"`). Typing a long password blind is the one
319
+ place the eye really earns its keep, so it's opt-out
320
+ (`revealable: false`) rather than opt-in. -->
321
+ <DInputGroup v-else-if="isRevealablePassword">
322
+ <DFormInput
323
+ v-model="textFieldModel"
324
+ :required="field.required"
325
+ :placeholder="field.placeholder"
326
+ :state="fieldState"
327
+ :disabled="isDisabled"
328
+ :readonly="isReadonly"
329
+ v-bind="passwordInputProps"
330
+ />
331
+ <template #append>
332
+ <DButton
333
+ variant="outline-secondary"
334
+ :icon="passwordRevealed ? 'eye-slash' : 'eye'"
335
+ :aria-label="passwordRevealed ? 'Hide password' : 'Show password'"
336
+ :aria-pressed="passwordRevealed"
337
+ :disabled="isDisabled"
338
+ @click="passwordRevealed = !passwordRevealed"
339
+ />
340
+ </template>
341
+ </DInputGroup>
342
+
343
+ <!-- Text-based inputs (text/email/password/url/tel/date/time/datetime) -->
344
+ <DFormInput
345
+ v-else
346
+ v-model="textFieldModel"
347
+ :type="inputType"
348
+ :required="field.required"
349
+ :placeholder="field.placeholder"
350
+ :step="field.step"
351
+ :min="field.min"
352
+ :max="field.max"
353
+ :state="fieldState"
354
+ :disabled="isDisabled"
355
+ :readonly="isReadonly || isPlaintext"
356
+ :plaintext="isPlaintext"
357
+ v-bind="inputPropsWithGuards"
358
+ />
359
+ </template>
360
+ </template>
361
+ </DXFieldShell>
605
362
  </template>
606
363
 
607
364
  <script setup lang="ts">
@@ -609,7 +366,6 @@ import {
609
366
  computed,
610
367
  defineAsyncComponent,
611
368
  onBeforeUnmount,
612
- onMounted,
613
369
  provide,
614
370
  ref,
615
371
  useId,
@@ -620,18 +376,15 @@ import { dxFieldInfoPopoverKey } from "./dxFieldContext";
620
376
  import DFormGroup from "../base/DFormGroup.vue";
621
377
  import DFormInput from "../base/DFormInput.vue";
622
378
  import DFormTextarea from "../base/DFormTextarea.vue";
623
- import DFormSelect from "../base/DFormSelect.vue";
624
- import DFormRadioGroup from "../base/DFormRadioGroup.vue";
625
- import DFormCheckboxGroup from "../base/DFormCheckboxGroup.vue";
626
379
  import DFormCheckbox from "../base/DFormCheckbox.vue";
627
380
  import DXSwitch from "./DXSwitch.vue";
628
- import DFormInvalidFeedback from "../base/DFormInvalidFeedback.vue";
629
- import DFormText from "../base/DFormText.vue";
630
381
  import DInputGroup from "../base/DInputGroup.vue";
631
- import DAutocomplete from "../base/DAutocomplete.vue";
632
382
  import DButton from "../base/DButton.vue";
633
383
  import DXFieldLabel from "./DXFieldLabel.vue";
634
- import DXCurrencyInput from "./DXCurrencyInput.vue";
384
+ import DXFieldShell from "./DXFieldShell.vue";
385
+ import DXNumericField from "./DXNumericField.vue";
386
+ import DXChoiceField from "./DXChoiceField.vue";
387
+ import { useAsyncOptions } from "../../composables/useAsyncOptions";
635
388
  import type { UseFormReturn } from "../../composables/useForm";
636
389
  import type { FieldDefinition, FieldOption, FieldType, LabelCols } from "../../types";
637
390
  import { getByPath, setByPath } from "../../utils/objectPath";
@@ -760,6 +513,33 @@ const horizontalAttrs = computed<Record<string, any>>(() =>
760
513
  // hint below the control.
761
514
  const hintInLabel = computed(() => isHorizontal.value && !props.hideLabel);
762
515
 
516
+ // Which shape DXFieldShell should render: `group` (a DFormGroup with a label
517
+ // column, holding the control + trailing) for the standard field and the
518
+ // horizontal checkbox/switch; `plain` (a div, the control owning its own label
519
+ // or heading) for the vertical checkbox/switch, switch-list and repeater.
520
+ const shellMode = computed<"group" | "plain">(() => {
521
+ const type = props.field.type;
522
+ if (type === "checkbox" || type === "switch") {
523
+ return isHorizontal.value ? "group" : "plain";
524
+ }
525
+ if (type === "switch-list" || type === "repeater") return "plain";
526
+ return "group";
527
+ });
528
+
529
+ // The custom value slot applies to the checkbox/switch/standard branches, but
530
+ // not switch-list or repeater (which never honoured it).
531
+ const showValueSlot = computed(
532
+ () => props.field.type !== "switch-list" && props.field.type !== "repeater",
533
+ );
534
+
535
+ const isChoiceType = computed(() =>
536
+ ["select", "radio", "checkbox-group"].includes(props.field.type),
537
+ );
538
+
539
+ const isNumericType = computed(() =>
540
+ ["currency", "percentage", "number"].includes(props.field.type),
541
+ );
542
+
763
543
  // A `switch` renders visible text inside the control only when the field opts
764
544
  // into contextual on/off text (textWhenTrue/textWhenFalse) — e.g. "Product is
765
545
  // current". Without it, `switchText` falls back to the row label, which would
@@ -843,41 +623,6 @@ const textFieldModel = computed({
843
623
  set: (value: any) => setValue(value),
844
624
  });
845
625
 
846
- // For `percentage` fields with `asFraction`, the model holds a 0–1 fraction but
847
- // the input shows/edits a 0–100 percentage. Scale on read/write, rounding away
848
- // binary-float artefacts (0.2 * 100 = 20.000000000000004). Currency and plain
849
- // percentage fields pass straight through.
850
- const isFractionPercentage = computed(
851
- () => props.field.type === "percentage" && props.field.asFraction === true,
852
- );
853
-
854
- const numericInputValue = computed({
855
- get: () => {
856
- const value = fieldValue.value;
857
- if (!isFractionPercentage.value) return value;
858
- if (value === null || value === undefined || value === "") return value;
859
- const num = Number(value);
860
- if (!Number.isFinite(num)) return value;
861
- return Math.round(num * 100 * 1e6) / 1e6;
862
- },
863
- set: (value: any) => {
864
- if (!isFractionPercentage.value) {
865
- setValue(value);
866
- return;
867
- }
868
- if (value === null || value === undefined || value === "") {
869
- setValue(value);
870
- return;
871
- }
872
- const num = Number(value);
873
- if (!Number.isFinite(num)) {
874
- setValue(value);
875
- return;
876
- }
877
- setValue(Math.round((num / 100) * 1e9) / 1e9);
878
- },
879
- });
880
-
881
626
  const NUMERIC_TYPES: ReadonlySet<FieldType> = new Set([
882
627
  "number",
883
628
  "currency",
@@ -1081,43 +826,12 @@ const datalistId = useId();
1081
826
 
1082
827
  // ————————————————— async options
1083
828
 
1084
- const loadedOptions = ref<FieldOption[] | null>(null);
829
+ const { loadedOptions } = useAsyncOptions(() => props.field, effectiveModel);
1085
830
 
1086
831
  const resolvedOptions = computed<FieldOption[] | undefined>(
1087
832
  () => loadedOptions.value ?? props.field.options,
1088
833
  );
1089
834
 
1090
- // Monotonic token so out-of-order async responses can't clobber newer ones.
1091
- let optionsRequestToken = 0;
1092
-
1093
- async function loadOptions(): Promise<void> {
1094
- if (!props.field.optionsLoader) return;
1095
- const token = ++optionsRequestToken;
1096
- try {
1097
- const options = await props.field.optionsLoader(effectiveModel.value);
1098
- // Ignore a stale response superseded by a newer load.
1099
- if (token === optionsRequestToken) loadedOptions.value = options;
1100
- } catch (error) {
1101
- // Swallow loader failures: keep the last successfully loaded options
1102
- // (or the static `field.options` fallback when none have loaded yet).
1103
- if (token === optionsRequestToken) {
1104
- // eslint-disable-next-line no-console
1105
- console.error(
1106
- `optionsLoader failed for field "${props.field.key}"`,
1107
- error,
1108
- );
1109
- }
1110
- }
1111
- }
1112
-
1113
- onMounted(() => {
1114
- void loadOptions();
1115
- });
1116
-
1117
- if (props.field.reloadOptionsOnChange) {
1118
- watch(effectiveModel, () => void loadOptions(), { deep: true });
1119
- }
1120
-
1121
835
  // ————————————————— image preview
1122
836
 
1123
837
  const objectUrl = ref<string | null>(null);
@@ -1179,5 +893,4 @@ onBeforeUnmount(() => {
1179
893
  border: 1px solid var(--bs-border-color);
1180
894
  border-radius: var(--bs-border-radius);
1181
895
  }
1182
-
1183
896
  </style>