@mapomodule/form 2.0.0-beta.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 (60) hide show
  1. package/README.md +155 -0
  2. package/dist/index.d.cts +1 -0
  3. package/dist/index.d.mts +1 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.mjs +34 -0
  6. package/dist/module.d.cts +2 -0
  7. package/dist/module.d.mts +2 -0
  8. package/dist/module.d.ts +2 -0
  9. package/dist/module.json +9 -0
  10. package/dist/module.mjs +18 -0
  11. package/dist/types.d.mts +13 -0
  12. package/package.json +78 -0
  13. package/src/__tests__/__mocks__/nuxt-imports.ts +5 -0
  14. package/src/__tests__/registry.test.ts +105 -0
  15. package/src/__tests__/useFormFromSchema.test.ts +415 -0
  16. package/src/__tests__/useMapoForm.test.ts +319 -0
  17. package/src/index.ts +49 -0
  18. package/src/module.ts +213 -0
  19. package/src/runtime/components/MapoFocusPortal.vue +154 -0
  20. package/src/runtime/components/MapoForm.vue +247 -0
  21. package/src/runtime/components/MapoFormField.vue +350 -0
  22. package/src/runtime/components/MapoFormFlatSection.vue +163 -0
  23. package/src/runtime/components/MapoFormGroup.vue +283 -0
  24. package/src/runtime/components/MapoFormTabs.vue +116 -0
  25. package/src/runtime/components/MapoUnknownField.vue +74 -0
  26. package/src/runtime/components/fields/MapoDateField.vue +98 -0
  27. package/src/runtime/components/fields/MapoDateTimeField.vue +192 -0
  28. package/src/runtime/components/fields/MapoFileField.vue +106 -0
  29. package/src/runtime/components/fields/MapoFksField.vue +151 -0
  30. package/src/runtime/components/fields/MapoMapField.vue +117 -0
  31. package/src/runtime/components/fields/MapoMapFieldClient.vue +92 -0
  32. package/src/runtime/components/fields/MapoRepeater.vue +553 -0
  33. package/src/runtime/components/fields/MapoRepeaterItem.vue +409 -0
  34. package/src/runtime/components/fields/MapoSeoPreview.vue +137 -0
  35. package/src/runtime/components/fields/MapoTimeField.vue +59 -0
  36. package/src/runtime/components/fields/MapoWygEditor.vue +256 -0
  37. package/src/runtime/components/fields/nui/NuiCheckbox.vue +8 -0
  38. package/src/runtime/components/fields/nui/NuiInput.vue +8 -0
  39. package/src/runtime/components/fields/nui/NuiSelectMenu.vue +27 -0
  40. package/src/runtime/components/fields/nui/NuiSlider.vue +8 -0
  41. package/src/runtime/components/fields/nui/NuiSwitch.vue +8 -0
  42. package/src/runtime/components/fields/nui/NuiTextarea.vue +8 -0
  43. package/src/runtime/components/fields/nui/useNuiFieldAttrs.ts +30 -0
  44. package/src/runtime/composables/defineFormField.ts +62 -0
  45. package/src/runtime/composables/useCurrentLang.ts +16 -0
  46. package/src/runtime/composables/useFocusMode.ts +86 -0
  47. package/src/runtime/composables/useFormDraft.ts +119 -0
  48. package/src/runtime/composables/useFormFromSchema.ts +253 -0
  49. package/src/runtime/composables/useMapoForm.ts +484 -0
  50. package/src/runtime/composables/useProgressiveDisclosure.ts +101 -0
  51. package/src/runtime/composables/useUnsavedChangesGuard.ts +58 -0
  52. package/src/runtime/devtools/DevtoolsFormsPage.vue +256 -0
  53. package/src/runtime/plugins/form-registry.ts +43 -0
  54. package/src/runtime/registry/defaults.ts +97 -0
  55. package/src/runtime/types/descriptor.ts +411 -0
  56. package/src/runtime/types/index.ts +40 -0
  57. package/src/runtime/types/layout.ts +22 -0
  58. package/src/runtime/types/registry.ts +98 -0
  59. package/src/runtime/utils/flattenFieldGroups.ts +86 -0
  60. package/src/vue-shim.d.ts +12 -0
@@ -0,0 +1,411 @@
1
+ import type { Component } from "vue";
2
+
3
+ /** Lookup table for decrementing depth at compile time. */
4
+ type Prev = [never, 0, 1, 2, 3, 4];
5
+
6
+ /**
7
+ * Recursively builds all valid dotted-path keys of T up to depth D.
8
+ * Arrays are not expanded — repeater items declare their own nested `fields`.
9
+ * When T is the default `Record<string, unknown>`, this resolves to `string`
10
+ * so untyped field arrays remain unconstrained.
11
+ */
12
+ export type DeepKeyOf<T, D extends number = 4> = [D] extends [never]
13
+ ? never
14
+ : T extends object
15
+ ? {
16
+ [K in keyof T & string]:
17
+ | K
18
+ | (NonNullable<T[K]> extends unknown[]
19
+ ? never
20
+ : NonNullable<T[K]> extends object
21
+ ? `${K}.${DeepKeyOf<NonNullable<T[K]>, Prev[D]>}`
22
+ : never);
23
+ }[keyof T & string]
24
+ : never;
25
+
26
+ /**
27
+ * All field types recognised by the default Mapo form registry.
28
+ * Use {@link FieldType} when you need to accept custom (string) types too.
29
+ */
30
+ export enum KnownFieldType {
31
+ Text = "text",
32
+ Textarea = "textarea",
33
+ Email = "email",
34
+ Url = "url",
35
+ Number = "number",
36
+ Boolean = "boolean",
37
+ Switch = "switch",
38
+ Slider = "slider",
39
+ Color = "color",
40
+ File = "file",
41
+ Select = "select",
42
+ Fks = "fks",
43
+ M2m = "m2m",
44
+ Date = "date",
45
+ Time = "time",
46
+ Datetime = "datetime",
47
+ Editor = "editor",
48
+ Seo = "seo",
49
+ Map = "map",
50
+ Repeater = "repeater",
51
+ Media = "media",
52
+ MediaM2m = "media-m2m",
53
+ EnhancedMedia = "enhanced-media",
54
+ }
55
+
56
+ /**
57
+ * Accepts any {@link KnownFieldType} value **or** an arbitrary string for custom types
58
+ * registered by the consumer via `defineFormField()` or `nuxt.config`.
59
+ */
60
+ export type FieldType = `${KnownFieldType}` | (string & {});
61
+
62
+ /**
63
+ * Custom get/set accessor pair for a field.
64
+ * Use this to redirect reads/writes to a different model path or to apply
65
+ * a transformation (e.g. serialise a Date object to an ISO string).
66
+ */
67
+ export interface FieldAccessor<TValue = unknown, TModel = unknown> {
68
+ get?: (ctx: { model: TModel; val: TValue; lang?: string }) => TValue;
69
+ set?: (ctx: { model: TModel; val: TValue; lang?: string }) => TValue;
70
+ }
71
+
72
+ /**
73
+ * Common properties shared by every field descriptor.
74
+ * All concrete descriptor types extend this interface.
75
+ */
76
+ interface FieldBase<T> {
77
+ /** Model key. Accepts a direct `keyof T` or a dotted path up to 4 levels deep (e.g. `"data.hero.title"`). */
78
+ key: DeepKeyOf<T>;
79
+ /** Human-readable label displayed above the field. */
80
+ label?: string;
81
+ /** Adds a required validation rule and marks the field visually. */
82
+ required?: boolean;
83
+ /** Renders the field in read-only mode (no user input allowed). */
84
+ readonly?: boolean;
85
+ /** `v-if` predicate — removes the field from the DOM entirely when `false`. */
86
+ visible?: (ctx: { model: T }) => boolean;
87
+ /** `v-show` predicate — hides the field while keeping it in the DOM. */
88
+ show?: (ctx: { model: T }) => boolean;
89
+ /** Callback fired after the field value changes. */
90
+ onChange?: (ctx: { model: T; val: unknown }) => void;
91
+ /**
92
+ * Synchronous client-side validation.
93
+ * Return `null` if the value is valid, or an error message string otherwise.
94
+ * The first argument is the current field value, typed as the union of all
95
+ * model values (plus `null` / `undefined`) so TypeScript can contextually type
96
+ * the callback parameter without falling back to implicit `any`.
97
+ */
98
+ validate?: (
99
+ val: T[keyof T & string] | null | undefined,
100
+ ctx: { model: T },
101
+ ) => string | null;
102
+ /**
103
+ * Asynchronous validation (e.g. uniqueness check, server-side rule).
104
+ * Runs debounced after every value change.
105
+ * Return `null` if valid, or an error message string otherwise.
106
+ */
107
+ validateAsync?: (
108
+ val: T[keyof T & string] | null | undefined,
109
+ ctx: { model: T },
110
+ ) => Promise<string | null>;
111
+ /** Debounce delay in milliseconds for `validateAsync`. Default: `600`. */
112
+ validateAsyncDebounce?: number;
113
+ /** Custom get/set accessor. Overrides the registry-level accessor for this field. */
114
+ accessor?: FieldAccessor;
115
+ /** When `true`, reads/writes `model.translations[currentLang][key]` instead of `model[key]`. */
116
+ translatable?: boolean;
117
+ /** When `true`, propagates the value to all available language translations simultaneously. */
118
+ synci18n?: boolean;
119
+ /** Group name. Fields sharing the same group are rendered inside a collapsible group card. */
120
+ group?: string;
121
+ /**
122
+ * Sub-tab name inside the group card. When set, the field is placed in a
123
+ * tab bar rendered within its parent group card. Requires `group` to be set.
124
+ *
125
+ * @example
126
+ * { group: 'seo', subtab: 'basic', key: 'meta_title' }
127
+ * { group: 'seo', subtab: 'advanced', key: 'og_image' }
128
+ * // → "seo" group card shows a "basic" / "advanced" tab bar inside
129
+ */
130
+ subtab?: string;
131
+ /**
132
+ * Tab name. Fields sharing the same tab are rendered under the same tab panel.
133
+ *
134
+ * Supports nested tabs via an array path or a `/`-separated string:
135
+ * - `tab: 'settings'` — top-level tab (backward-compatible)
136
+ * - `tab: ['settings', 'seo']` — nested: "seo" sub-tab inside "settings"
137
+ * - `tab: 'settings/seo'` — equivalent slash-separated form
138
+ */
139
+ tab?: string | string[];
140
+ /**
141
+ * Column span (out of 12) for the field wrapper.
142
+ * Accepts a plain number or a responsive breakpoint map `{ sm, md, lg }`.
143
+ * Default: `12` (full width).
144
+ */
145
+ cols?: number | { sm?: number; md?: number; lg?: number };
146
+ /** CSS class applied to the outer field wrapper element. */
147
+ class?: string | string[] | Record<string, boolean>;
148
+ /**
149
+ * When `true`, the field renders an expand button that fills the group container.
150
+ * The group becomes a full-screen overlay and all other fields are hidden.
151
+ * Useful for large textarea, rich-text editors, maps, etc.
152
+ */
153
+ expandable?: boolean;
154
+ /** Debounce delay in milliseconds for the `update:modelValue` emit. Default: `300`. Use `0` for immediate. */
155
+ debounce?: number;
156
+ /**
157
+ * Extra props forwarded verbatim to the underlying input component via `v-bind`
158
+ * (e.g. Nuxt UI props: `icon`, `variant`, `color`, `size`…).
159
+ *
160
+ * Use `attrs` for the field's own typed configuration; `passthrough` is the
161
+ * escape hatch for anything the wrapped component accepts.
162
+ */
163
+ passthrough?: Record<string, unknown>;
164
+ /** Direct component override. Takes priority over `registry.mapping[type]`. */
165
+ is?: Component;
166
+ /**
167
+ * When `true`, this field's value is excluded from draft snapshots saved to localStorage.
168
+ * Use for sensitive fields (passwords, API keys, tokens) that should never be persisted client-side.
169
+ */
170
+ noDraft?: boolean;
171
+ }
172
+
173
+ // ─── Discriminated union for each KnownFieldType ─────────────────────────────
174
+
175
+ /**
176
+ * Descriptor for plain text input (`type: 'text'`), multi-line textarea
177
+ * (`type: 'textarea'`), and native email / url inputs.
178
+ */
179
+ export interface TextDescriptor<
180
+ T = Record<string, unknown>,
181
+ > extends FieldBase<T> {
182
+ type:
183
+ | `${KnownFieldType.Text}`
184
+ | `${KnownFieldType.Textarea}`
185
+ | `${KnownFieldType.Email}`
186
+ | `${KnownFieldType.Url}`;
187
+ attrs?: {
188
+ placeholder?: string;
189
+ maxLength?: number;
190
+ minLength?: number;
191
+ /** Visible rows for `type: 'textarea'`. */
192
+ rows?: number;
193
+ };
194
+ }
195
+
196
+ /** Descriptor for numeric input (`type: 'number'`) and range slider (`type: 'slider'`). */
197
+ export interface NumberDescriptor<
198
+ T = Record<string, unknown>,
199
+ > extends FieldBase<T> {
200
+ type: `${KnownFieldType.Number}` | `${KnownFieldType.Slider}`;
201
+ attrs?: { min?: number; max?: number; step?: number; placeholder?: string };
202
+ }
203
+
204
+ /** Descriptor for checkbox (`type: 'boolean'`) and toggle switch (`type: 'switch'`). */
205
+ export interface BooleanDescriptor<
206
+ T = Record<string, unknown>,
207
+ > extends FieldBase<T> {
208
+ type: `${KnownFieldType.Boolean}` | `${KnownFieldType.Switch}`;
209
+ }
210
+
211
+ /**
212
+ * Descriptor for a single-value or multi-value select menu.
213
+ * Provide the choices via `attrs.items` (Nuxt UI shape) or the legacy
214
+ * `attrs.options` (Mapo v1 shape).
215
+ */
216
+ export interface SelectDescriptor<
217
+ T = Record<string, unknown>,
218
+ > extends FieldBase<T> {
219
+ type: `${KnownFieldType.Select}`;
220
+ attrs: {
221
+ /** Choices in Nuxt UI shape (or plain strings) — forwarded to `USelectMenu`. */
222
+ items?: ReadonlyArray<string | { label: string; value: unknown }>;
223
+ /** Choices in Mapo v1 shape (`text`/`value`). Used when `items` is absent. */
224
+ options?: ReadonlyArray<{ text: string; value: unknown }>;
225
+ multiple?: boolean;
226
+ placeholder?: string;
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Descriptor for FK autocomplete (`type: 'fks'`) and many-to-many autocomplete (`type: 'm2m'`).
232
+ * Requires `attrs.endpoint`.
233
+ */
234
+ export interface FksDescriptor<
235
+ T = Record<string, unknown>,
236
+ > extends FieldBase<T> {
237
+ type: `${KnownFieldType.Fks}` | `${KnownFieldType.M2m}`;
238
+ attrs: {
239
+ endpoint: string;
240
+ itemText?: string;
241
+ itemValue?: string;
242
+ returnObject?: boolean;
243
+ multiple?: boolean;
244
+ };
245
+ }
246
+
247
+ /** Descriptor for date (`type: 'date'`), time (`type: 'time'`), and datetime (`type: 'datetime'`) pickers. */
248
+ export interface DateDescriptor<
249
+ T = Record<string, unknown>,
250
+ > extends FieldBase<T> {
251
+ type:
252
+ | `${KnownFieldType.Date}`
253
+ | `${KnownFieldType.Time}`
254
+ | `${KnownFieldType.Datetime}`;
255
+ attrs?: {
256
+ min?: string;
257
+ max?: string;
258
+ granularity?: string;
259
+ /** Timezone strategy for datetime parsing/serialisation. Default: `"naive"`. */
260
+ tz?: "naive" | "utc";
261
+ };
262
+ }
263
+
264
+ /** Descriptor for a colour picker field. */
265
+ export interface ColorDescriptor<
266
+ T = Record<string, unknown>,
267
+ > extends FieldBase<T> {
268
+ type: `${KnownFieldType.Color}`;
269
+ }
270
+
271
+ /** Descriptor for a file upload field. */
272
+ export interface FileDescriptor<
273
+ T = Record<string, unknown>,
274
+ > extends FieldBase<T> {
275
+ type: `${KnownFieldType.File}`;
276
+ attrs?: { accept?: string; maxSize?: number };
277
+ }
278
+
279
+ /** Descriptor for a Tiptap rich-text editor. */
280
+ export interface EditorDescriptor<
281
+ T = Record<string, unknown>,
282
+ > extends FieldBase<T> {
283
+ type: `${KnownFieldType.Editor}`;
284
+ attrs?: { extensions?: unknown[] };
285
+ }
286
+
287
+ /** Descriptor for the SEO preview field (title + description + live SERP preview). */
288
+ export interface SeoDescriptor<
289
+ T = Record<string, unknown>,
290
+ > extends FieldBase<T> {
291
+ type: `${KnownFieldType.Seo}`;
292
+ }
293
+
294
+ /** Descriptor for a Leaflet map field. Model value is `{ lat: number; lng: number }`. */
295
+ export interface MapDescriptor<
296
+ T = Record<string, unknown>,
297
+ > extends FieldBase<T> {
298
+ type: `${KnownFieldType.Map}`;
299
+ attrs?: {
300
+ defaultLat?: number;
301
+ defaultLng?: number;
302
+ zoom?: number;
303
+ };
304
+ }
305
+
306
+ /** Descriptor for a drag-and-drop repeater with nested field descriptors. */
307
+ export interface RepeaterDescriptor<
308
+ T = Record<string, unknown>,
309
+ > extends FieldBase<T> {
310
+ type: `${KnownFieldType.Repeater}`;
311
+ /**
312
+ * Field descriptors for each item inside the repeater.
313
+ * Optional when `attrs.templates` provides per-template fields.
314
+ */
315
+ fields?: AnyFieldDescriptor[];
316
+ attrs?: {
317
+ /** Multiple templates for heterogeneous items (`oneOf` discriminated union). */
318
+ templates?: Record<string, AnyFieldDescriptor[]>;
319
+ /** Label shown when an item is collapsed. Receives the item and its index. */
320
+ previewLabel?: (item: unknown, index: number) => string;
321
+ confirmDelete?: boolean;
322
+ allowDuplicate?: boolean;
323
+ /** Show a numeric position input alongside the reorder buttons. */
324
+ showPositionField?: boolean;
325
+ defaultExpanded?: boolean;
326
+ maxItems?: number;
327
+ minItems?: number;
328
+ /**
329
+ * Renders a compact preview card for inactive items.
330
+ * Activated when the number of items exceeds `compressThreshold`.
331
+ */
332
+ miniCard?: (
333
+ item: unknown,
334
+ index: number,
335
+ ) => {
336
+ title: string;
337
+ subtitle?: string;
338
+ /** URL or base64 string for a thumbnail image. */
339
+ thumbnail?: string;
340
+ statusColor?: "success" | "info" | "warning" | "error" | "neutral";
341
+ };
342
+ /** Minimum item count above which contextual scaling activates. Default: `3`. */
343
+ compressThreshold?: number;
344
+ };
345
+ }
346
+
347
+ /** Descriptor for media/file manager fields. */
348
+ export interface MediaDescriptor<
349
+ T = Record<string, unknown>,
350
+ > extends FieldBase<T> {
351
+ type:
352
+ | `${KnownFieldType.Media}`
353
+ | `${KnownFieldType.MediaM2m}`
354
+ | `${KnownFieldType.EnhancedMedia}`;
355
+ attrs?: { mime?: string; multiple?: boolean };
356
+ }
357
+
358
+ /**
359
+ * Escape-hatch descriptor for custom field types registered by the consumer.
360
+ * `attrs` stays fully open here: the wrapped component is user-defined, so its
361
+ * props cannot be known in advance.
362
+ */
363
+ export interface CustomDescriptor<
364
+ T = Record<string, unknown>,
365
+ > extends FieldBase<T> {
366
+ type: string & {};
367
+ attrs?: Record<string, unknown>;
368
+ }
369
+
370
+ /**
371
+ * Union of the built-in (known) field descriptor types.
372
+ * Use this as the type for a `fields` array passed to `<MapoForm>` or `useMapoForm()`.
373
+ *
374
+ * This union is **strict**: object literals get full excess-property checking
375
+ * on `attrs` (typos and wrong-type keys are compile errors). If the form mixes
376
+ * in custom field types registered via `defineFormField()` / the registry,
377
+ * annotate the array with {@link AnyFieldDescriptor} instead.
378
+ *
379
+ * @example
380
+ * const fields: FieldDescriptor<Article>[] = [
381
+ * { key: 'title', type: KnownFieldType.Text, label: 'Title', required: true },
382
+ * { key: 'body', type: KnownFieldType.Editor, label: 'Body', translatable: true },
383
+ * ]
384
+ */
385
+ export type FieldDescriptor<T = Record<string, unknown>> =
386
+ | TextDescriptor<T>
387
+ | NumberDescriptor<T>
388
+ | BooleanDescriptor<T>
389
+ | SelectDescriptor<T>
390
+ | FksDescriptor<T>
391
+ | DateDescriptor<T>
392
+ | ColorDescriptor<T>
393
+ | FileDescriptor<T>
394
+ | EditorDescriptor<T>
395
+ | SeoDescriptor<T>
396
+ | MapDescriptor<T>
397
+ | RepeaterDescriptor<T>
398
+ | MediaDescriptor<T>;
399
+
400
+ /**
401
+ * {@link FieldDescriptor} plus the {@link CustomDescriptor} escape hatch.
402
+ *
403
+ * This is what the form engine accepts everywhere (MapoForm, useMapoForm,
404
+ * MapoDetail / MapoList editFields…). Annotate your `fields` array with this
405
+ * type when it contains custom field types — note that the open
406
+ * `CustomDescriptor.attrs` disables excess-property checking on the union, so
407
+ * prefer the strict {@link FieldDescriptor} when no custom types are used.
408
+ */
409
+ export type AnyFieldDescriptor<T = Record<string, unknown>> =
410
+ | FieldDescriptor<T>
411
+ | CustomDescriptor<T>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Form engine type barrel.
3
+ * Re-exports all descriptor types, the {@link KnownFieldType} enum,
4
+ * and registry helpers.
5
+ */
6
+ export { KnownFieldType } from "./descriptor.js";
7
+ export type {
8
+ FieldType,
9
+ FieldAccessor,
10
+ FieldDescriptor,
11
+ AnyFieldDescriptor,
12
+ TextDescriptor,
13
+ NumberDescriptor,
14
+ BooleanDescriptor,
15
+ SelectDescriptor,
16
+ FksDescriptor,
17
+ DateDescriptor,
18
+ ColorDescriptor,
19
+ FileDescriptor,
20
+ EditorDescriptor,
21
+ SeoDescriptor,
22
+ MapDescriptor,
23
+ RepeaterDescriptor,
24
+ MediaDescriptor,
25
+ CustomDescriptor,
26
+ } from "./descriptor.js";
27
+
28
+ export type {
29
+ FieldComponentEntry,
30
+ FieldRegistry,
31
+ PartialFieldRegistry,
32
+ } from "./registry.js";
33
+
34
+ export {
35
+ resolveFieldComponent,
36
+ resolveFieldAttrs,
37
+ resolveFieldAccessor,
38
+ } from "./registry.js";
39
+
40
+ export type { MapoFormRuntimeConfig } from "./registry.js";
@@ -0,0 +1,22 @@
1
+ import type { AnyFieldDescriptor } from "./index.js";
2
+
3
+ /**
4
+ * A group of fields rendered inside a single group card.
5
+ * `subtabs` holds fields assigned to a sub-tab bar within the group.
6
+ *
7
+ * Shared by `MapoForm` and `MapoFormTabs` so the grouped tree is a single
8
+ * structural type (avoids "two unrelated types with the same name").
9
+ */
10
+ export interface GroupEntry {
11
+ label?: string;
12
+ fields: AnyFieldDescriptor[];
13
+ subtabs: Map<string, AnyFieldDescriptor[]>;
14
+ }
15
+
16
+ /** A tab panel; may nest further tabs through `children`. */
17
+ export interface TabEntry {
18
+ name: string;
19
+ label?: string;
20
+ groups: Map<string, GroupEntry>;
21
+ children: Map<string, TabEntry>;
22
+ }
@@ -0,0 +1,98 @@
1
+ import type { Component } from "vue";
2
+ import type { AnyFieldDescriptor, FieldAccessor } from "./descriptor.js";
3
+
4
+ /**
5
+ * Accepted values for a registry entry:
6
+ * - `string` — Nuxt UI component name, resolved at runtime via `resolveComponent()`
7
+ * - `Component` — synchronous Vue component object
8
+ * - `() => Promise<{ default: Component }>` — async lazy import
9
+ */
10
+ export type FieldComponentEntry =
11
+ | string
12
+ | Component
13
+ | (() => Promise<{ default: Component }>);
14
+
15
+ /** The global form field registry injected as `$mapoFormRegistry`. */
16
+ export interface FieldRegistry {
17
+ /** Maps a field `type` string to its component entry. */
18
+ mapping: Record<string, FieldComponentEntry>;
19
+ /**
20
+ * Default `attrs` merged into every field of the given type.
21
+ * The special key `'All'` is applied to all field types.
22
+ */
23
+ attrs: Record<string, Record<string, unknown>>;
24
+ /** Default `get`/`set` accessor applied per field type. */
25
+ accessor: Record<string, FieldAccessor>;
26
+ }
27
+
28
+ /**
29
+ * Partial version of {@link FieldRegistry} for consumer overrides
30
+ * (e.g. `mapoForm.fields` in `nuxt.config`).
31
+ */
32
+ export type PartialFieldRegistry = {
33
+ mapping?: Partial<FieldRegistry["mapping"]>;
34
+ attrs?: Partial<FieldRegistry["attrs"]>;
35
+ accessor?: Partial<FieldRegistry["accessor"]>;
36
+ };
37
+
38
+ /**
39
+ * Resolves the component entry for a given field descriptor.
40
+ * Returns the `descriptor.is` override if set, otherwise looks up `registry.mapping`.
41
+ */
42
+ // AnyFieldDescriptor<T> is contravariant in T through `validate: (ctx: { model: T })`.
43
+ // These functions only read `type`, `is`, `attrs`, and `accessor` — none depends on T —
44
+ // so `any` is the correct way to accept any AnyFieldDescriptor<T> without a type error.
45
+ export function resolveFieldComponent(
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
+ descriptor: AnyFieldDescriptor<any>,
48
+ registry: FieldRegistry,
49
+ ): FieldComponentEntry | null {
50
+ if (descriptor.is) return descriptor.is;
51
+ return registry.mapping[descriptor.type] ?? null;
52
+ }
53
+
54
+ /**
55
+ * Merges the registry-level default attrs for a field type with the descriptor's
56
+ * own typed `attrs` and its open `passthrough` props.
57
+ * Order: `registry.attrs['All']` → `registry.attrs[type]` → `descriptor.attrs`
58
+ * → `descriptor.passthrough` (most specific wins).
59
+ */
60
+ export function resolveFieldAttrs(
61
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
62
+ descriptor: AnyFieldDescriptor<any>,
63
+ registry: FieldRegistry,
64
+ ): Record<string, unknown> {
65
+ const allAttrs = registry.attrs["All"] ?? {};
66
+ const typeAttrs = registry.attrs[descriptor.type] ?? {};
67
+ // Not every descriptor in the union declares `attrs`; read it structurally.
68
+ const ownAttrs =
69
+ (descriptor as { attrs?: Record<string, unknown> }).attrs ?? {};
70
+ return {
71
+ ...allAttrs,
72
+ ...typeAttrs,
73
+ ...ownAttrs,
74
+ ...(descriptor.passthrough ?? {}),
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Merges the registry-level default accessor for a field type with the descriptor's own `accessor`.
80
+ */
81
+ export function resolveFieldAccessor(
82
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
83
+ descriptor: AnyFieldDescriptor<any>,
84
+ registry: FieldRegistry,
85
+ ): FieldAccessor {
86
+ const typeAccessor = registry.accessor[descriptor.type] ?? {};
87
+ return { ...typeAccessor, ...(descriptor.accessor ?? {}) };
88
+ }
89
+
90
+ /**
91
+ * Shape of `runtimeConfig.public.mapoForm` as written by the module setup.
92
+ * `groups` and `debounce` are guaranteed by the module `defaults`.
93
+ */
94
+ export interface MapoFormRuntimeConfig {
95
+ groups: { expanded: boolean };
96
+ debounce: number;
97
+ fields: { attrs: Record<string, Record<string, unknown>> };
98
+ }
@@ -0,0 +1,86 @@
1
+ import type { AnyFieldDescriptor } from "../types/index.js";
2
+
3
+ /**
4
+ * Hierarchical authoring structure. Lets you group field descriptors under a
5
+ * named group without repeating `group` on every field.
6
+ *
7
+ * Pass an array of these to {@link flattenFieldGroups} to get the flat
8
+ * `AnyFieldDescriptor<T>[]` expected by `<MapoForm>` / `<MapoDetail>`.
9
+ */
10
+ export interface FieldGroupDescriptor<T = Record<string, unknown>> {
11
+ /** Group name — propagated to every field in `fields`. */
12
+ group: string;
13
+ /** Optional tab — propagated to every field unless the field already has a `tab`. */
14
+ tab?: string | string[];
15
+ /** Fields belonging to this group. May contain nested `FieldGroupDescriptor` objects. */
16
+ fields: Array<AnyFieldDescriptor<T> | FieldGroupDescriptor<T>>;
17
+ }
18
+
19
+ /** Type guard: distinguishes a `FieldGroupDescriptor` from a `AnyFieldDescriptor`. */
20
+ function isGroupDescriptor<T>(
21
+ item: AnyFieldDescriptor<T> | FieldGroupDescriptor<T>,
22
+ ): item is FieldGroupDescriptor<T> {
23
+ return (
24
+ "fields" in item && Array.isArray((item as FieldGroupDescriptor<T>).fields)
25
+ );
26
+ }
27
+
28
+ /**
29
+ * Flattens a mixed array of `AnyFieldDescriptor` and `FieldGroupDescriptor` objects
30
+ * into a plain `AnyFieldDescriptor<T>[]` ready to be passed to `<MapoForm>` or `<MapoDetail>`.
31
+ *
32
+ * Each `FieldGroupDescriptor` propagates its `group` (and optionally `tab`) to
33
+ * every leaf field it contains. Fields that already declare a `group` or `tab`
34
+ * are left unchanged.
35
+ *
36
+ * Groups can be arbitrarily nested — the function recurses until every item is
37
+ * a leaf `AnyFieldDescriptor`.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * const fields = flattenFieldGroups<Article>([
42
+ * { key: 'title', type: 'text', label: 'Title', tab: 'content' },
43
+ * {
44
+ * group: 'seo',
45
+ * tab: 'seo',
46
+ * fields: [
47
+ * { key: 'seo_title', type: 'text', label: 'SEO Title' },
48
+ * { key: 'seo_description', type: 'textarea', label: 'SEO Description' },
49
+ * ],
50
+ * },
51
+ * ])
52
+ * // Result:
53
+ * // [
54
+ * // { key: 'title', type: 'text', label: 'Title', tab: 'content' },
55
+ * // { key: 'seo_title', type: 'text', label: 'SEO Title', group: 'seo', tab: 'seo' },
56
+ * // { key: 'seo_description', type: 'textarea', label: 'SEO Description', group: 'seo', tab: 'seo' },
57
+ * // ]
58
+ * ```
59
+ */
60
+ export function flattenFieldGroups<T = Record<string, unknown>>(
61
+ items: Array<AnyFieldDescriptor<T> | FieldGroupDescriptor<T>>,
62
+ _inheritedGroup?: string,
63
+ _inheritedTab?: string | string[],
64
+ ): AnyFieldDescriptor<T>[] {
65
+ const result: AnyFieldDescriptor<T>[] = [];
66
+
67
+ for (const item of items) {
68
+ if (isGroupDescriptor(item)) {
69
+ const group = item.group ?? _inheritedGroup;
70
+ const tab = item.tab ?? _inheritedTab;
71
+ result.push(...flattenFieldGroups(item.fields, group, tab));
72
+ } else {
73
+ const field = item as AnyFieldDescriptor<T>;
74
+ const merged: AnyFieldDescriptor<T> = { ...field };
75
+ if (_inheritedGroup && !merged.group) {
76
+ Object.assign(merged, { group: _inheritedGroup });
77
+ }
78
+ if (_inheritedTab && !merged.tab) {
79
+ Object.assign(merged, { tab: _inheritedTab });
80
+ }
81
+ result.push(merged);
82
+ }
83
+ }
84
+
85
+ return result;
86
+ }
@@ -0,0 +1,12 @@
1
+ declare module "*.vue" {
2
+ import type { DefineComponent } from "vue";
3
+ const component: DefineComponent;
4
+ export default component;
5
+ }
6
+
7
+ // Asset imports resolved by the bundler (Vite/Nuxt) at build time, not by TS.
8
+ declare module "*.css";
9
+ declare module "*.png?url" {
10
+ const url: string;
11
+ export default url;
12
+ }