@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
@@ -47,6 +47,9 @@
47
47
  :form="form"
48
48
  :fields="fields"
49
49
  :tabs="tabs"
50
+ :layout="layout"
51
+ :label-cols="labelCols"
52
+ :card="card"
50
53
  :card-tabs="false"
51
54
  :context="item ?? undefined"
52
55
  :show-submit="false"
@@ -147,7 +150,7 @@
147
150
  :disabled="form?.processing || loading"
148
151
  @click="emit('delete')"
149
152
  >
150
- Delete
153
+ {{ deleteLabel }}
151
154
  </DButton>
152
155
  </div>
153
156
  <div class="d-flex gap-2">
@@ -161,7 +164,7 @@
161
164
  :disabled="form?.processing || loading"
162
165
  @click="emit('save')"
163
166
  >
164
- {{ isCreateMode ? 'Create' : 'Save Changes' }}
167
+ {{ isCreateMode ? createLabel : saveLabel }}
165
168
  </DButton>
166
169
  </div>
167
170
  </div>
@@ -176,6 +179,7 @@ import DButton from "../base/DButton.vue";
176
179
  import DSpinner from "../base/DSpinner.vue";
177
180
  import DXForm from "./DXForm.vue";
178
181
  import type { EditTab } from "./DXTable.vue";
182
+ import type { LabelCols } from "../../types";
179
183
 
180
184
  interface Props {
181
185
  /** Whether the modal is open (v-model:show). */
@@ -204,10 +208,38 @@ interface Props {
204
208
  pendingAction: 'save' | 'delete' | null;
205
209
  /** Delete endpoint — gates the Delete button. */
206
210
  deleteUrl?: string;
211
+ /** Field layout forwarded to the modal's DXForm (defaults handled upstream). */
212
+ layout?: 'vertical' | 'horizontal';
213
+ /** Label column width forwarded to DXForm (horizontal layout). */
214
+ labelCols?: LabelCols;
215
+ /** Wrap the modal form in a card (opt-in — off by default). */
216
+ card?: boolean;
217
+ /** Singular item noun for the default button labels (e.g. "customer"). */
218
+ itemName?: string;
219
+ /** Override the Save button label (edit mode). */
220
+ saveText?: string;
221
+ /** Override the Create button label (create mode). */
222
+ createText?: string;
223
+ /** Override the Delete button label. */
224
+ deleteText?: string;
207
225
  }
208
226
 
209
227
  const props = defineProps<Props>();
210
228
 
229
+ // Title-case the singular item noun for button labels ("customer" → "Customer",
230
+ // "sales order" → "Sales Order") — capitalize the first letter of each word.
231
+ const capitalizedItem = computed(() => {
232
+ const noun = props.itemName?.trim() || 'item';
233
+ return noun.replace(/\b\w/g, (char) => char.toUpperCase());
234
+ });
235
+
236
+ // Buttons default to item-named copy ("Save Customer" / "Create Customer" /
237
+ // "Delete Customer") — clearer than a generic "Save Changes" — overridable per
238
+ // action. Falls back to "Save Item" etc. when no `itemName` is set.
239
+ const saveLabel = computed(() => props.saveText ?? `Save ${capitalizedItem.value}`);
240
+ const createLabel = computed(() => props.createText ?? `Create ${capitalizedItem.value}`);
241
+ const deleteLabel = computed(() => props.deleteText ?? `Delete ${capitalizedItem.value}`);
242
+
211
243
  const emit = defineEmits<{
212
244
  /** v-model:show — the modal open state changed. */
213
245
  'update:show': [value: boolean];
@@ -1,5 +1,6 @@
1
1
  import { useForm, type UseFormReturn } from "./useForm";
2
- import type { FieldDefinition, FieldType } from "../types";
2
+ import type { FieldDefinition } from "../types";
3
+ import { resolveFieldDefault } from "../utils/formSchema";
3
4
 
4
5
  /**
5
6
  * Extended field definition that includes default value
@@ -30,35 +31,6 @@ export interface DefineFormReturn<TData extends Record<string, any>> {
30
31
  fields: FieldDefinition[];
31
32
  }
32
33
 
33
- /**
34
- * Helper to get default value for a field type
35
- */
36
- function getDefaultValueForType(type: FieldType): any {
37
- switch (type) {
38
- case "checkbox":
39
- case "switch":
40
- return false;
41
- case "number":
42
- case "currency":
43
- case "percentage":
44
- return 0;
45
- case "repeater":
46
- // Array-valued selections: checkbox-group previously fell through to
47
- // the "" default, which is the wrong shape for an array model.
48
- case "checkbox-group":
49
- case "switch-list":
50
- return [];
51
- case "image":
52
- case "file":
53
- return null;
54
- case "select":
55
- case "radio":
56
- return "";
57
- default:
58
- return "";
59
- }
60
- }
61
-
62
34
  /**
63
35
  * Define a form with type-safe field definitions.
64
36
  * Reduces duplication by inferring initial values from field definitions.
@@ -75,22 +47,14 @@ export function defineForm<const TFields extends readonly FormFieldDefinition[]>
75
47
  ): DefineFormReturn<InferFormData<TFields>> {
76
48
  type FormData = InferFormData<TFields>;
77
49
 
78
- // Extract initial data from field definitions.
79
- //
80
- // #125: `field.default ?? getDefaultValueForType(type)` cannot express a
81
- // *null* default — a select whose "none" option is `value: null` (which is
82
- // what the column stores) seeded '' (the type default), matched no option,
83
- // and rendered blank. This is the same bug #122 fixed in DXTable. Decide on
84
- // definedness, not nullishness, so null survives; an absent (or explicitly
85
- // `undefined`) default still falls back to the type default rather than
86
- // seeding `undefined`, which would drop the key from the JSON payload. This
87
- // matches DXRepeater's `!== undefined` rule, so all seeding sites agree.
50
+ // Extract initial data from field definitions. Seeding (definedness over
51
+ // nullishness so a `null` default survives — #122/#125 — plus a type-aware
52
+ // fallback and deep-cloning) is the shared `resolveFieldDefault` rule, so
53
+ // every seeding site (defineForm / useResourceEditor / DXRepeater) agrees
54
+ // by construction (#134).
88
55
  const initialData = fieldDefinitions.reduce(
89
56
  (acc, field) => {
90
- acc[field.key] =
91
- field.default !== undefined
92
- ? field.default
93
- : getDefaultValueForType(field.type);
57
+ acc[field.key] = resolveFieldDefault(field);
94
58
  return acc;
95
59
  },
96
60
  {} as Record<string, any>,
@@ -0,0 +1,61 @@
1
+ import { onMounted, ref, watch, type ComputedRef, type Ref } from "vue";
2
+ import type { FieldDefinition, FieldOption } from "../types";
3
+
4
+ /**
5
+ * Async option loading for a `DXField`: runs `field.optionsLoader` on mount and
6
+ * (when `field.reloadOptionsOnChange` is set) whenever the model changes,
7
+ * exposing the loaded options. A monotonic request token guards against
8
+ * out-of-order responses clobbering newer ones; loader failures are swallowed
9
+ * so the last good options (or the static `field.options` fallback) stay put.
10
+ *
11
+ * Extracted verbatim from DXField's inline logic (#135) — behaviour unchanged.
12
+ * `getField` is an accessor (props aren't destructurable without losing
13
+ * reactivity); `effectiveModel` is the model passed to the loader.
14
+ */
15
+ export interface UseAsyncOptionsReturn {
16
+ /** Options returned by the most recent successful load, or null if none yet. */
17
+ loadedOptions: Ref<FieldOption[] | null>;
18
+ /** Trigger a load (no-op when the field has no `optionsLoader`). */
19
+ loadOptions: () => Promise<void>;
20
+ }
21
+
22
+ export function useAsyncOptions(
23
+ getField: () => FieldDefinition,
24
+ effectiveModel: ComputedRef<any> | Ref<any>,
25
+ ): UseAsyncOptionsReturn {
26
+ const loadedOptions = ref<FieldOption[] | null>(null);
27
+
28
+ // Monotonic token so out-of-order async responses can't clobber newer ones.
29
+ let optionsRequestToken = 0;
30
+
31
+ async function loadOptions(): Promise<void> {
32
+ const field = getField();
33
+ if (!field.optionsLoader) return;
34
+ const token = ++optionsRequestToken;
35
+ try {
36
+ const options = await field.optionsLoader(effectiveModel.value);
37
+ // Ignore a stale response superseded by a newer load.
38
+ if (token === optionsRequestToken) loadedOptions.value = options;
39
+ } catch (error) {
40
+ // Swallow loader failures: keep the last successfully loaded options
41
+ // (or the static `field.options` fallback when none have loaded yet).
42
+ if (token === optionsRequestToken) {
43
+ // eslint-disable-next-line no-console
44
+ console.error(
45
+ `optionsLoader failed for field "${field.key}"`,
46
+ error,
47
+ );
48
+ }
49
+ }
50
+ }
51
+
52
+ onMounted(() => {
53
+ void loadOptions();
54
+ });
55
+
56
+ if (getField().reloadOptionsOnChange) {
57
+ watch(effectiveModel, () => void loadOptions(), { deep: true });
58
+ }
59
+
60
+ return { loadedOptions, loadOptions };
61
+ }
@@ -2,6 +2,12 @@ import { computed, ref, type Ref } from "vue";
2
2
  import { api } from "../utils/api";
3
3
  import { useForm } from "./useForm";
4
4
  import { useToast } from "./useToast";
5
+ import type { FieldDefinition } from "../types";
6
+ import {
7
+ resolveFieldDefault,
8
+ isFieldVisible as isFieldVisibleFor,
9
+ isSubmittableField,
10
+ } from "../utils/formSchema";
5
11
 
6
12
  /**
7
13
  * The subset of `DXTable`'s props the resource editor reads. Declared
@@ -11,7 +17,7 @@ import { useToast } from "./useToast";
11
17
  */
12
18
  export interface ResourceEditorProps<T = any> {
13
19
  /** Field definitions for the edit/create modal (enables edit-on-row-click). */
14
- editFields?: any[];
20
+ editFields?: FieldDefinition[];
15
21
  /** Tab definitions — only their `key` is read here (DXForm consumes the rest). */
16
22
  editTabs?: Array<{ key: string }>;
17
23
  /** Modal title, or a function of the edited row. */
@@ -108,58 +114,38 @@ export function useResourceEditor<T = any>(
108
114
  // and auto-switching to the first tab with a validation error.
109
115
 
110
116
  /*
111
- * A field's own starting value, used to seed a CREATE form and as the fallback
112
- * when a row lacks the key (#122).
113
- *
114
- * `field.default ?? ''` can't express a NULL default: a select whose "none"
115
- * option is `value: null` — which is what the column stores — seeds '' instead,
116
- * matches no option, and renders blank. Presence, not nullishness, decides here
117
- * too, so the create and edit paths agree about the same field.
118
- */
119
- const defaultValueFor = (field: any): any =>
120
- Object.prototype.hasOwnProperty.call(field, 'default') ? field.default : '';
121
-
122
- /*
123
- * Seeding an EDIT form from a row (#117).
124
- *
125
- * `row[key] ?? field.default` is wrong: it can't tell "the row has no such key"
126
- * from "the row's value IS null", so an explicitly-null column gets overwritten
127
- * by the field's default — a value the user never saw, on a field they may not
128
- * even be able to see. Presence, not nullishness, decides.
117
+ * Seeding an EDIT form from a row (#117): use the row's own value when it
118
+ * HAS the key (presence, not nullishness — an explicitly-null column must
119
+ * not be overwritten by the field's default), else the field's own default.
120
+ * The default comes from the shared `resolveFieldDefault` rule, so create
121
+ * and edit seeding agree with defineForm/DXRepeater (#134) — this path is
122
+ * now type-aware (an array field falls back to `[]`, not `''`).
129
123
  */
130
124
  const seedValueFor = (field: any, row: T): any => {
131
125
  if (Object.prototype.hasOwnProperty.call(row as any, field.key)) {
132
126
  return (row as any)[field.key];
133
127
  }
134
- return defaultValueFor(field);
128
+ return resolveFieldDefault(field);
135
129
  };
136
130
 
137
131
  /*
138
- * Whether a field is currently VISIBLE, by the same rule DXForm renders by
139
- * (`when` + the legacy `show`), against the same model (context + live data).
140
- * Kept in step with DXForm.isFieldVisible.
132
+ * The model field/tab predicates evaluate against: the edited row widened
133
+ * with the live form data (context + data), same as DXForm's `model`.
141
134
  */
142
135
  const editPredicateModel = computed(() => ({
143
136
  ...((selectedItem.value as any) ?? {}),
144
137
  ...(editForm.value?.data ?? {}),
145
138
  }));
146
139
 
147
- const isEditFieldVisible = (field: any): boolean => {
148
- const when = field.when;
149
- const whenOk =
150
- when === undefined
151
- ? true
152
- : typeof when === 'function'
153
- ? when(editPredicateModel.value)
154
- : when;
155
- const showOk = field.show ? field.show() : true;
156
- return whenOk && showOk;
157
- };
140
+ // Visibility by the one shared rule (`when` + legacy `show`), against the
141
+ // edit model — see formSchema.isFieldVisible (#134).
142
+ const isEditFieldVisible = (field: any): boolean =>
143
+ isFieldVisibleFor(field, editPredicateModel.value);
158
144
 
159
145
  // Fields whose value actually belongs in the payload. A presentational field
160
146
  // (submit: false) renders but holds no data — see FieldDefinition.submit.
161
147
  const submittableEditFields = computed(() =>
162
- (props.editFields ?? []).filter((field) => field.submit !== false),
148
+ (props.editFields ?? []).filter(isSubmittableField),
163
149
  );
164
150
 
165
151
  // Enforced at SUBMIT, not just at seeding: the modal still renders every field,
@@ -172,7 +158,7 @@ export function useResourceEditor<T = any>(
172
158
  .filter(
173
159
  (field) =>
174
160
  // Presentational: lays the form out, holds no data (#110).
175
- field.submit === false ||
161
+ !isSubmittableField(field) ||
176
162
  // Hidden by `when` at submit time (#117). Submitting a field
177
163
  // the user cannot see is a silent write — and with `default`
178
164
  // set it writes a value they never chose. Omitting the key
@@ -299,13 +285,13 @@ export function useResourceEditor<T = any>(
299
285
  if (!editForm.value) {
300
286
  const formData: Record<string, any> = {};
301
287
  submittableEditFields.value.forEach(field => {
302
- formData[field.key] = defaultValueFor(field);
288
+ formData[field.key] = resolveFieldDefault(field);
303
289
  });
304
290
  editForm.value = useForm(formData);
305
291
  } else {
306
292
  // Reset existing form to defaults
307
293
  submittableEditFields.value.forEach(field => {
308
- editForm.value.data[field.key] = defaultValueFor(field);
294
+ editForm.value.data[field.key] = resolveFieldDefault(field);
309
295
  });
310
296
  editForm.value.clearErrors();
311
297
  }
@@ -113,6 +113,16 @@ export { useToast } from "./composables/useToast";
113
113
 
114
114
  // Utils
115
115
  export { api } from "./utils/api";
116
+ // Pure form-schema helpers (#134) — the single source of defaulting, cloning,
117
+ // predicate resolution, visibility and submission-eligibility.
118
+ export {
119
+ defaultValueForType,
120
+ cloneDefault,
121
+ resolveFieldDefault,
122
+ resolvePredicate,
123
+ isFieldVisible,
124
+ isSubmittableField,
125
+ } from "./utils/formSchema";
116
126
 
117
127
  // Types
118
128
  export type {
@@ -122,6 +132,27 @@ export type {
122
132
  FormTab,
123
133
  MaybeFn,
124
134
  OptionsLoader,
135
+ // Strict, discriminated field types (#131) — opt-in; canonical in a future
136
+ // major. `FieldDef` is the union; the rest are its per-type members.
137
+ FieldDef,
138
+ BaseFieldDef,
139
+ TextFieldDef,
140
+ PasswordFieldDef,
141
+ NumberFieldDef,
142
+ DateFieldDef,
143
+ CurrencyFieldDef,
144
+ PercentageFieldDef,
145
+ TextareaFieldDef,
146
+ SelectFieldDef,
147
+ AutocompleteFieldDef,
148
+ CheckboxFieldDef,
149
+ CheckboxGroupFieldDef,
150
+ SwitchFieldDef,
151
+ SwitchListFieldDef,
152
+ RadioFieldDef,
153
+ FileFieldDef,
154
+ ComponentFieldDef,
155
+ RepeaterFieldDef,
125
156
  } from "./types";
126
157
 
127
158
  export type {
@@ -149,6 +180,8 @@ export type {
149
180
  BTableProvider,
150
181
  EditTab,
151
182
  BTableProviderContext,
183
+ DXTableApiAdapter,
184
+ DXTableSource,
152
185
  } from "./components/extended/DXTable.vue";
153
186
 
154
187
  export type {
@@ -380,6 +380,237 @@ export interface FieldDefinition {
380
380
  show?: () => boolean;
381
381
  }
382
382
 
383
+ // ───────────────────────────────────────────────────────────────────────────
384
+ // Strict, discriminated field types (#131)
385
+ //
386
+ // The permissive `FieldDefinition` above lets every option appear on every
387
+ // field type: `currencySymbol` compiles on a checkbox, `options` on a text
388
+ // input, `component` without `type: "component"`. The types below are the
389
+ // strict, `type`-keyed replacement — each variant permits only its own
390
+ // options, so the compiler catches an invalid field config at the call site.
391
+ //
392
+ // ADDITIVE for now: `FieldDefinition` stays canonical and these are opt-in
393
+ // (`import type { FieldDef } from "@omnitend/dashboard-for-laravel"`). A future
394
+ // major (#131 Phase 3) makes `FieldDef` canonical and removes the permissive
395
+ // interface with a codemod. **Until then, keep the two in step** — a prop added
396
+ // to `FieldDefinition` must be added to the matching variant(s) below. The doc
397
+ // comments live on `FieldDefinition`; variants stay terse to keep them aligned.
398
+ //
399
+ // Known limitation: the union rejects invalid FRESH object literals (excess
400
+ // property checking — the common `fields: [{ type: "checkbox", … }]` call site),
401
+ // but a PRE-DECLARED object with excess props (`const f = { type: "checkbox",
402
+ // currencySymbol: "£" }`) stays structurally assignable. Full exclusivity would
403
+ // need `never`-typed exclusion keys on every variant (or a StrictUnion helper) —
404
+ // heavy machinery with confusing errors for marginal gain on an opt-in type, so
405
+ // it's deliberately not done here.
406
+ // ───────────────────────────────────────────────────────────────────────────
407
+
408
+ /** Options common to every field variant (the non-`type`-specific props). */
409
+ export interface BaseFieldDef {
410
+ key: string;
411
+ label?: MaybeFn<string>;
412
+ required?: boolean;
413
+ help?: string;
414
+ hint?: MaybeFn<string>;
415
+ info?: MaybeFn<string>;
416
+ class?: string;
417
+ inputProps?: Record<string, any>;
418
+ span?: boolean;
419
+ layout?: "vertical" | "horizontal";
420
+ labelCols?: LabelCols;
421
+ hideLabel?: boolean;
422
+ default?: any;
423
+ disabled?: MaybeFn<boolean>;
424
+ disabledWhen?: (model: any) => boolean;
425
+ readonly?: MaybeFn<boolean>;
426
+ plaintext?: MaybeFn<boolean>;
427
+ submit?: boolean;
428
+ when?: MaybeFn<boolean>;
429
+ show?: () => boolean;
430
+ }
431
+
432
+ /** Mixins shared by several variants (not exported — internal composition). */
433
+ interface WithPlaceholder {
434
+ placeholder?: string;
435
+ }
436
+ interface WithNumericBounds {
437
+ step?: number | string;
438
+ min?: number | string;
439
+ max?: number | string;
440
+ }
441
+ interface WithSelectableOptions {
442
+ options?: FieldOption[];
443
+ optionsLoader?: OptionsLoader;
444
+ reloadOptionsOnChange?: boolean;
445
+ }
446
+
447
+ /** `text` / `email` / `url` / `tel` — a plain text-like `<input>`. */
448
+ export interface TextFieldDef extends BaseFieldDef, WithPlaceholder {
449
+ type: "text" | "email" | "url" | "tel";
450
+ }
451
+
452
+ /** `password` — text input with an optional reveal (eye) toggle. */
453
+ export interface PasswordFieldDef extends BaseFieldDef, WithPlaceholder {
454
+ type: "password";
455
+ revealable?: boolean;
456
+ }
457
+
458
+ /** `number` — numeric input with optional bounds. */
459
+ export interface NumberFieldDef
460
+ extends BaseFieldDef,
461
+ WithPlaceholder,
462
+ WithNumericBounds {
463
+ type: "number";
464
+ }
465
+
466
+ /** `date` / `datetime` / `datetime-local` / `time` — native date/time controls.
467
+ * `min`/`max`/`step` are valid on these inputs (e.g. `min="2024-01-01"`) and
468
+ * DXField forwards them, so the variant permits them. */
469
+ export interface DateFieldDef extends BaseFieldDef, WithNumericBounds {
470
+ type: "date" | "datetime" | "datetime-local" | "time";
471
+ }
472
+
473
+ /** `currency` — numeric input with a symbol affix and minor-unit handling. */
474
+ export interface CurrencyFieldDef
475
+ extends BaseFieldDef,
476
+ WithPlaceholder,
477
+ WithNumericBounds {
478
+ type: "currency";
479
+ currencySymbol?: string;
480
+ decimals?: number;
481
+ minorUnits?: boolean;
482
+ }
483
+
484
+ /** `percentage` — numeric input shown as a 0–100 percentage. */
485
+ export interface PercentageFieldDef
486
+ extends BaseFieldDef,
487
+ WithPlaceholder,
488
+ WithNumericBounds {
489
+ type: "percentage";
490
+ asFraction?: boolean;
491
+ }
492
+
493
+ /** `textarea` — multi-line text input. */
494
+ export interface TextareaFieldDef extends BaseFieldDef, WithPlaceholder {
495
+ type: "textarea";
496
+ rows?: number;
497
+ }
498
+
499
+ /** `select` — dropdown; `searchable` upgrades it to a filterable select. */
500
+ export interface SelectFieldDef
501
+ extends BaseFieldDef,
502
+ WithPlaceholder,
503
+ WithSelectableOptions {
504
+ type: "select";
505
+ searchable?: boolean;
506
+ }
507
+
508
+ /** `autocomplete` — free-text input with datalist suggestions. */
509
+ export interface AutocompleteFieldDef
510
+ extends BaseFieldDef,
511
+ WithPlaceholder,
512
+ WithSelectableOptions {
513
+ type: "autocomplete";
514
+ }
515
+
516
+ /** `checkbox` — a single boolean checkbox. */
517
+ export interface CheckboxFieldDef extends BaseFieldDef {
518
+ type: "checkbox";
519
+ }
520
+
521
+ /** `checkbox-group` — multiple checkboxes; model is an array of values. */
522
+ export interface CheckboxGroupFieldDef
523
+ extends BaseFieldDef,
524
+ WithSelectableOptions {
525
+ type: "checkbox-group";
526
+ }
527
+
528
+ /** `switch` — a toggle with contextual on/off text and on-state colour. */
529
+ export interface SwitchFieldDef extends BaseFieldDef {
530
+ type: "switch";
531
+ textWhenTrue?: MaybeFn<string>;
532
+ textWhenFalse?: MaybeFn<string>;
533
+ switchVariant?: "success" | "neutral";
534
+ }
535
+
536
+ /** `switch-list` — a labelled toggle row per option; model is an array. */
537
+ export interface SwitchListFieldDef
538
+ extends BaseFieldDef,
539
+ WithSelectableOptions {
540
+ type: "switch-list";
541
+ switchVariant?: "success" | "neutral";
542
+ }
543
+
544
+ /** `radio` — a radio group. */
545
+ export interface RadioFieldDef extends BaseFieldDef, WithSelectableOptions {
546
+ type: "radio";
547
+ }
548
+
549
+ /** `image` / `file` — file input (`image` also shows a preview). */
550
+ export interface FileFieldDef extends BaseFieldDef {
551
+ type: "image" | "file";
552
+ accept?: string;
553
+ }
554
+
555
+ /** `component` — escape hatch rendering `field.component`. */
556
+ export interface ComponentFieldDef extends BaseFieldDef {
557
+ type: "component";
558
+ /** Required on the strict variant: a `component` field with no component
559
+ * falls through to the generic input branch, so the union enforces it. */
560
+ component: Component;
561
+ }
562
+
563
+ /** `repeater` — a nested, repeatable sub-form driven by `fields`. */
564
+ export interface RepeaterFieldDef extends BaseFieldDef {
565
+ type: "repeater";
566
+ fields?: FieldDef[];
567
+ addLabel?: string;
568
+ minItems?: number;
569
+ maxItems?: number;
570
+ repeaterLayout?: "cards" | "table";
571
+ showRowIndex?: boolean;
572
+ softDeleteKey?: string;
573
+ }
574
+
575
+ /**
576
+ * Strict, discriminated replacement for `FieldDefinition`. Keyed on `type`,
577
+ * so each field only permits its own options. Opt-in today; canonical in a
578
+ * future major (#131). See the banner above.
579
+ */
580
+ export type FieldDef =
581
+ | TextFieldDef
582
+ | PasswordFieldDef
583
+ | NumberFieldDef
584
+ | DateFieldDef
585
+ | CurrencyFieldDef
586
+ | PercentageFieldDef
587
+ | TextareaFieldDef
588
+ | SelectFieldDef
589
+ | AutocompleteFieldDef
590
+ | CheckboxFieldDef
591
+ | CheckboxGroupFieldDef
592
+ | SwitchFieldDef
593
+ | SwitchListFieldDef
594
+ | RadioFieldDef
595
+ | FileFieldDef
596
+ | ComponentFieldDef
597
+ | RepeaterFieldDef;
598
+
599
+ /**
600
+ * Compile-time drift guard (#131): every strict variant must remain assignable
601
+ * to the permissive `FieldDefinition`. If a `FieldDef` variant grows a prop that
602
+ * `FieldDefinition` doesn't have (or types incompatibly), this fails to satisfy
603
+ * its `true` constraint and the build goes red. Purely type-level — no runtime.
604
+ * (The reverse direction — a prop added to `FieldDefinition` but forgotten on a
605
+ * variant — can't be asserted this way; the banner above is the reminder.)
606
+ */
607
+ type _AssertTrue<T extends true> = T;
608
+ // Exported (under an internal underscore name, not re-exported by the barrel)
609
+ // only so `noUnusedLocals` treats it as used — its value is the constraint check.
610
+ export type _FieldDefStaysASubset = _AssertTrue<
611
+ FieldDef extends FieldDefinition ? true : false
612
+ >;
613
+
383
614
  /**
384
615
  * A tab in a tabbed form. Groups a subset of fields and can be shown
385
616
  * conditionally or lazily mounted.