@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,350 @@
1
+ <script setup lang="ts">
2
+ import { computed, defineAsyncComponent, inject, ref, watch } from "vue";
3
+ import type { Ref } from "vue";
4
+ import type { AnyFieldDescriptor } from "../types/index.js";
5
+ import {
6
+ resolveFieldComponent,
7
+ resolveFieldAttrs,
8
+ resolveFieldAccessor,
9
+ } from "../types/index.js";
10
+ import { injectMapoForm } from "../composables/useMapoForm.js";
11
+ import { getNestedValue, debounce } from "@mapomodule/utils";
12
+ import MapoUnknownField from "./MapoUnknownField.vue";
13
+
14
+ // `any` accepts descriptors typed against any model (e.g. AnyFieldDescriptor<Article>)
15
+ // in headless usage; the renderer itself only reads model-agnostic metadata.
16
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
+ const props = defineProps<{ descriptor: AnyFieldDescriptor<any> }>();
18
+
19
+ const form = injectMapoForm<Record<string, unknown>>()!;
20
+
21
+ const registry = form.registry;
22
+ const currentLang = form.currentLang;
23
+ const model = form.model;
24
+
25
+ // ─── Visibility ──────────────────────────────────────────────────────────────
26
+
27
+ const isVisible = computed(() => {
28
+ if (
29
+ props.descriptor.visible &&
30
+ !props.descriptor.visible({ model: model.value })
31
+ )
32
+ return false;
33
+ return true;
34
+ });
35
+
36
+ const showField = computed(() => {
37
+ if (props.descriptor.show && !props.descriptor.show({ model: model.value }))
38
+ return false;
39
+ return true;
40
+ });
41
+
42
+ // ─── Value ───────────────────────────────────────────────────────────────────
43
+
44
+ const path = computed(() => {
45
+ const lang = currentLang.value;
46
+ if (props.descriptor.translatable && lang) {
47
+ return `translations.${lang}.${props.descriptor.key as string}`;
48
+ }
49
+ return props.descriptor.key as string;
50
+ });
51
+
52
+ const accessor = computed(() =>
53
+ resolveFieldAccessor(props.descriptor, registry),
54
+ );
55
+
56
+ const modelValue = computed(() => {
57
+ const raw = getNestedValue(model.value, path.value);
58
+ return accessor.value.get
59
+ ? accessor.value.get({
60
+ model: model.value,
61
+ val: raw,
62
+ lang: currentLang.value,
63
+ })
64
+ : raw;
65
+ });
66
+
67
+ function doUpdate(val: unknown) {
68
+ // Delegate to the context so that dirtyKeys tracking, clientError clearing,
69
+ // initLang, and onChange are all handled in one place (useMapoForm.setFieldValue).
70
+ form.setFieldValue(props.descriptor.key as string, val);
71
+ }
72
+
73
+ // Per-field debounce: descriptor.debounce → form.debounce → 0 when immediate.
74
+ const debounceMs = form.immediate
75
+ ? 0
76
+ : (props.descriptor.debounce ?? form.debounce);
77
+ const onUpdate = debounceMs > 0 ? debounce(doUpdate, debounceMs) : doUpdate;
78
+
79
+ // ─── Validazione asincrona ────────────────────────────────────────────────────
80
+
81
+ type AsyncState = "idle" | "pending" | "valid" | "invalid";
82
+ const asyncState = ref<AsyncState>("idle");
83
+ const asyncError = ref<string | null>(null);
84
+ const asyncCache = new Map<string, string | null>();
85
+
86
+ const asyncValidationDebounceMs = props.descriptor.validateAsync
87
+ ? (props.descriptor.validateAsyncDebounce ?? 600)
88
+ : 0;
89
+
90
+ const runAsyncValidation = props.descriptor.validateAsync
91
+ ? debounce(async (val: unknown) => {
92
+ const cacheKey = JSON.stringify(val);
93
+ if (asyncCache.has(cacheKey)) {
94
+ asyncError.value = asyncCache.get(cacheKey)!;
95
+ asyncState.value = asyncError.value ? "invalid" : "valid";
96
+ return;
97
+ }
98
+ asyncState.value = "pending";
99
+ try {
100
+ const result = await props.descriptor.validateAsync!(val, {
101
+ model: model.value,
102
+ });
103
+ asyncCache.set(cacheKey, result);
104
+ asyncError.value = result;
105
+ asyncState.value = result ? "invalid" : "valid";
106
+ } catch {
107
+ asyncState.value = "idle";
108
+ }
109
+ }, asyncValidationDebounceMs)
110
+ : null;
111
+
112
+ if (runAsyncValidation) {
113
+ // No `{ immediate: true }`: async validation starts only after the user edits
114
+ // the value, avoiding error icons on first render.
115
+ watch(modelValue, (val, old) => {
116
+ if (val === old) return;
117
+ runAsyncValidation(val);
118
+ });
119
+ }
120
+
121
+ function onBlur() {
122
+ form.markTouched(props.descriptor.key as string);
123
+ }
124
+
125
+ const asyncTrailingIcon = computed(
126
+ () =>
127
+ ({
128
+ idle: undefined,
129
+ pending: "i-lucide-loader-2",
130
+ valid: "i-lucide-check-circle",
131
+ invalid: "i-lucide-x-circle",
132
+ })[asyncState.value],
133
+ );
134
+
135
+ const asyncTrailingIconClass = computed(
136
+ () =>
137
+ ({
138
+ idle: "",
139
+ pending: "text-gray-400 animate-spin",
140
+ valid: "text-green-500",
141
+ invalid: "text-red-500",
142
+ })[asyncState.value],
143
+ );
144
+
145
+ // ─── Component resolution ────────────────────────────────────────────────────
146
+
147
+ const fieldComponent = computed(() => {
148
+ const entry = resolveFieldComponent(props.descriptor, registry);
149
+ if (!entry) return MapoUnknownField;
150
+ if (typeof entry === "function")
151
+ return defineAsyncComponent(entry as () => Promise<{ default: unknown }>);
152
+ return entry;
153
+ });
154
+
155
+ const fieldAttrs = computed(() =>
156
+ resolveFieldAttrs(props.descriptor, registry),
157
+ );
158
+
159
+ // ─── Errors ──────────────────────────────────────────────────────────────────
160
+
161
+ function resolveErrors(
162
+ errorsMap: Record<string, string[]>,
163
+ dotPath: string,
164
+ ): string[] {
165
+ const segments = dotPath.split(".");
166
+ const result: string[] = [];
167
+ let current = "";
168
+ for (const seg of segments) {
169
+ current = current ? `${current}.${seg}` : seg;
170
+ const errs = errorsMap[current];
171
+ if (errs) result.push(...errs);
172
+ }
173
+ return result;
174
+ }
175
+
176
+ const errors = computed(() => resolveErrors(form.errors.value, path.value));
177
+
178
+ const clientError = computed(() =>
179
+ form.getClientError(props.descriptor.key as string),
180
+ );
181
+
182
+ const allErrors = computed(() => {
183
+ const errs = [...errors.value];
184
+ if (clientError.value) errs.unshift(clientError.value);
185
+ if (asyncError.value && asyncState.value === "invalid")
186
+ errs.push(asyncError.value);
187
+ return errs;
188
+ });
189
+
190
+ const hasError = computed(() => allErrors.value.length > 0);
191
+
192
+ // Pass the error color into Nuxt UI components (UInput, UTextarea, USelectMenu, ...)
193
+ // so they display their built-in red border without relying on external CSS.
194
+ const errorColor = computed(() => (hasError.value ? "error" : undefined));
195
+
196
+ // ─── Label ───────────────────────────────────────────────────────────────────
197
+
198
+ function autoLabel(key: string): string {
199
+ const stripped = key.replace(/^translations\.[^.]+\./, "");
200
+ return stripped
201
+ .replace(/[_.-]/g, " ")
202
+ .replace(/\b\w/g, (c) => c.toUpperCase());
203
+ }
204
+
205
+ const label = computed(
206
+ () => props.descriptor.label ?? autoLabel(props.descriptor.key as string),
207
+ );
208
+ const fieldId = computed(() => `field-${props.descriptor.key as string}`);
209
+
210
+ // ─── Group expand ────────────────────────────────────────────────────────────
211
+
212
+ const groupExpand = inject<{
213
+ expandField: (key: string) => void;
214
+ collapseField: () => void;
215
+ expandedFieldKey: Ref<string | null>;
216
+ } | null>("mapoGroupExpand", null);
217
+
218
+ const isExpanded = computed(() =>
219
+ groupExpand
220
+ ? groupExpand.expandedFieldKey.value === (props.descriptor.key as string)
221
+ : false,
222
+ );
223
+
224
+ function toggleExpand() {
225
+ if (!groupExpand) return;
226
+ if (isExpanded.value) groupExpand.collapseField();
227
+ else groupExpand.expandField(props.descriptor.key as string);
228
+ }
229
+ </script>
230
+
231
+ <template>
232
+ <div
233
+ v-if="isVisible"
234
+ v-show="showField"
235
+ class="mapo-form-field group flex flex-col gap-1"
236
+ :class="[
237
+ descriptor.class,
238
+ hasError && 'mapo-form-field--error',
239
+ isExpanded && 'flex-1 min-h-0',
240
+ ]"
241
+ >
242
+ <!-- Label -->
243
+ <slot
244
+ :name="`field.${descriptor.key as string}.label`"
245
+ :descriptor="descriptor"
246
+ :label="label"
247
+ >
248
+ <label
249
+ :for="fieldId"
250
+ class="flex shrink-0 items-center gap-1 text-sm font-medium text-gray-700"
251
+ >
252
+ <span>{{ label }}</span>
253
+ <span v-if="descriptor.required" class="text-red-500" aria-hidden="true"
254
+ >*</span
255
+ >
256
+ <UIcon
257
+ v-if="asyncTrailingIcon"
258
+ :name="asyncTrailingIcon"
259
+ class="ml-auto h-3.5 w-3.5"
260
+ :class="asyncTrailingIconClass"
261
+ />
262
+ <!-- Expand button — visible on hover or while expanded -->
263
+ <button
264
+ v-if="descriptor.expandable && groupExpand"
265
+ type="button"
266
+ :class="
267
+ isExpanded
268
+ ? 'ml-auto text-primary-500'
269
+ : 'ml-auto text-gray-300 opacity-0 group-hover:opacity-100 focus:opacity-100'
270
+ "
271
+ class="rounded p-0.5 transition-all hover:bg-gray-100 hover:text-gray-600"
272
+ :title="isExpanded ? 'Collapse field' : 'Expand field'"
273
+ @click.prevent="toggleExpand"
274
+ >
275
+ <UIcon
276
+ :name="isExpanded ? 'i-lucide-minimize-2' : 'i-lucide-expand'"
277
+ class="h-3.5 w-3.5"
278
+ />
279
+ </button>
280
+ </label>
281
+ </slot>
282
+
283
+ <!-- Slot before -->
284
+ <slot
285
+ :name="`field.${descriptor.key as string}.before`"
286
+ :descriptor="descriptor"
287
+ :model="model"
288
+ />
289
+
290
+ <div
291
+ class="flex items-start gap-2"
292
+ :class="isExpanded && 'flex-1 min-h-0'"
293
+ :style="isExpanded ? 'height: 100%; overflow: hidden;' : ''"
294
+ >
295
+ <!-- Slot prepend -->
296
+ <slot
297
+ :name="`field.${descriptor.key as string}.prepend`"
298
+ :descriptor="descriptor"
299
+ :model="model"
300
+ />
301
+
302
+ <component
303
+ :is="fieldComponent"
304
+ :id="fieldId"
305
+ v-bind="fieldAttrs"
306
+ :class="isExpanded ? 'flex-1 min-h-0' : 'flex-1'"
307
+ :style="isExpanded ? 'height: 100%;' : ''"
308
+ :model-value="modelValue"
309
+ :readonly="descriptor.readonly || form.readonly.value"
310
+ :disabled="descriptor.readonly || form.readonly.value"
311
+ :descriptor="descriptor"
312
+ :errors="allErrors"
313
+ :color="errorColor"
314
+ @update:model-value="onUpdate"
315
+ @blur="onBlur"
316
+ />
317
+
318
+ <!-- Slot append -->
319
+ <slot
320
+ :name="`field.${descriptor.key as string}.append`"
321
+ :descriptor="descriptor"
322
+ :model="model"
323
+ />
324
+ </div>
325
+
326
+ <!-- Slot hint -->
327
+ <slot
328
+ :name="`field.${descriptor.key as string}.hint`"
329
+ :descriptor="descriptor"
330
+ :model="model"
331
+ />
332
+
333
+ <!-- Errori -->
334
+ <p
335
+ v-for="err in allErrors"
336
+ :key="err"
337
+ class="text-xs text-red-500"
338
+ role="alert"
339
+ >
340
+ {{ err }}
341
+ </p>
342
+
343
+ <!-- Slot after -->
344
+ <slot
345
+ :name="`field.${descriptor.key as string}.after`"
346
+ :descriptor="descriptor"
347
+ :model="model"
348
+ />
349
+ </div>
350
+ </template>
@@ -0,0 +1,163 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, provide, onMounted, onUnmounted } from "vue";
3
+ import type { AnyFieldDescriptor } from "../types/index.js";
4
+ import { injectMapoForm } from "../composables/useMapoForm.js";
5
+ import MapoFormField from "./MapoFormField.vue";
6
+
7
+ const props = defineProps<{ fields: AnyFieldDescriptor[] }>();
8
+
9
+ defineSlots<Record<string, unknown>>();
10
+
11
+ const form = injectMapoForm();
12
+ const model = computed(() => form?.model.value);
13
+ const currentLang = computed(() => form?.currentLang.value ?? "");
14
+
15
+ // ─── Field expand ────────────────────────────────────────────────────────────
16
+
17
+ const expandedFieldKey = ref<string | null>(null);
18
+
19
+ const expandedFieldLabel = computed(() => {
20
+ if (!expandedFieldKey.value) return "";
21
+ const field = props.fields.find(
22
+ (f) => (f.key as string) === expandedFieldKey.value,
23
+ );
24
+ return field?.label ?? expandedFieldKey.value;
25
+ });
26
+
27
+ function expandField(key: string) {
28
+ expandedFieldKey.value = key;
29
+ document.body.style.overflow = "hidden";
30
+ }
31
+
32
+ function collapseField() {
33
+ expandedFieldKey.value = null;
34
+ document.body.style.overflow = "";
35
+ }
36
+
37
+ function onKeydown(e: KeyboardEvent) {
38
+ if (e.key === "Escape" && expandedFieldKey.value) collapseField();
39
+ }
40
+
41
+ onMounted(() => document.addEventListener("keydown", onKeydown));
42
+ onUnmounted(() => {
43
+ document.removeEventListener("keydown", onKeydown);
44
+ document.body.style.overflow = "";
45
+ });
46
+
47
+ provide("mapoGroupExpand", { expandField, collapseField, expandedFieldKey });
48
+
49
+ // ─── Col-span lookup ─────────────────────────────────────────────────────────
50
+
51
+ const COL_SPAN: Record<number, string> = {
52
+ 1: "col-span-1",
53
+ 2: "col-span-2",
54
+ 3: "col-span-3",
55
+ 4: "col-span-4",
56
+ 5: "col-span-5",
57
+ 6: "col-span-6",
58
+ 7: "col-span-7",
59
+ 8: "col-span-8",
60
+ 9: "col-span-9",
61
+ 10: "col-span-10",
62
+ 11: "col-span-11",
63
+ 12: "col-span-12",
64
+ };
65
+ function colClass(cols: AnyFieldDescriptor["cols"]): string {
66
+ if (!cols || typeof cols !== "number") return "col-span-12";
67
+ return COL_SPAN[cols] ?? "col-span-12";
68
+ }
69
+ </script>
70
+
71
+ <template>
72
+ <!-- Expanded panel: teleported to body -->
73
+ <Teleport to="body">
74
+ <Transition name="mapo-expand">
75
+ <div
76
+ v-if="expandedFieldKey"
77
+ class="fixed inset-0 z-50 flex flex-col"
78
+ style="padding: 1rem"
79
+ >
80
+ <div
81
+ class="absolute inset-0 bg-black/50 backdrop-blur-sm"
82
+ @click="collapseField"
83
+ />
84
+ <div
85
+ class="relative z-10 flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xl"
86
+ >
87
+ <div
88
+ class="flex shrink-0 items-center justify-between border-b border-gray-100 px-5 py-3.5"
89
+ >
90
+ <span class="text-sm font-semibold tracking-wide text-gray-700">{{
91
+ expandedFieldLabel
92
+ }}</span>
93
+ <button
94
+ type="button"
95
+ class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
96
+ @click="collapseField"
97
+ >
98
+ <UIcon name="i-lucide-minimize-2" class="h-4 w-4" />
99
+ </button>
100
+ </div>
101
+ <div
102
+ class="mapo-group-expand-body flex min-h-0 flex-1 flex-col p-5"
103
+ style="overflow: hidden"
104
+ >
105
+ <template v-for="field in fields" :key="field.key as string">
106
+ <div
107
+ v-if="expandedFieldKey === (field.key as string)"
108
+ style="
109
+ display: flex;
110
+ flex-direction: column;
111
+ flex: 1;
112
+ min-height: 0;
113
+ height: 100%;
114
+ "
115
+ >
116
+ <slot
117
+ :name="`field.${field.key as string}`"
118
+ :field="field"
119
+ :model="model"
120
+ :current-lang="currentLang"
121
+ >
122
+ <MapoFormField
123
+ :descriptor="field"
124
+ style="flex: 1; min-height: 0; height: 100%"
125
+ >
126
+ <template
127
+ v-for="(_, slotName) in $slots"
128
+ #[slotName]="slotProps"
129
+ >
130
+ <slot :name="slotName" v-bind="slotProps ?? {}" />
131
+ </template>
132
+ </MapoFormField>
133
+ </slot>
134
+ </div>
135
+ </template>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ </Transition>
140
+ </Teleport>
141
+
142
+ <!-- Flat grid -->
143
+ <div class="grid grid-cols-12 gap-5">
144
+ <div
145
+ v-for="field in fields"
146
+ :key="field.key as string"
147
+ :class="colClass(field.cols)"
148
+ >
149
+ <slot
150
+ :name="`field.${field.key as string}`"
151
+ :field="field"
152
+ :model="model"
153
+ :current-lang="currentLang"
154
+ >
155
+ <MapoFormField :descriptor="field">
156
+ <template v-for="(_, slotName) in $slots" #[slotName]="slotProps">
157
+ <slot :name="slotName" v-bind="slotProps ?? {}" />
158
+ </template>
159
+ </MapoFormField>
160
+ </slot>
161
+ </div>
162
+ </div>
163
+ </template>