@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,154 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * MapoFocusPortal — Renders full-page editing for a repeater item.
4
+ *
5
+ * Mount only one instance in layout/default or in MapoRootComponents.
6
+ * It activates automatically when `useFocusMode().enter()` is called.
7
+ *
8
+ * @example
9
+ * // In layouts/default.vue
10
+ * <MapoFocusPortal />
11
+ */
12
+ import { computed, ref, onMounted, onUnmounted } from "vue";
13
+ import { watch } from "vue";
14
+ import { useFocusMode } from "../composables/useFocusMode.js";
15
+ import MapoForm from "./MapoForm.vue";
16
+
17
+ const { focusTarget, exit } = useFocusMode();
18
+
19
+ const isOpen = computed(() => focusTarget.value !== null);
20
+ const canUseDocument = typeof document !== "undefined";
21
+
22
+ // Close on ESC.
23
+ function onKeyDown(e: KeyboardEvent) {
24
+ if (e.key === "Escape" && isOpen.value) exit();
25
+ }
26
+ onMounted(() => window.addEventListener("keydown", onKeyDown));
27
+ onUnmounted(() => window.removeEventListener("keydown", onKeyDown));
28
+
29
+ // Lock body scroll while the portal is open.
30
+ watch(isOpen, (open) => {
31
+ if (!canUseDocument) return;
32
+ document.body.style.overflow = open ? "hidden" : "";
33
+ });
34
+
35
+ // Local model for the sub-form, kept in two-way sync with the focus target.
36
+ const localModel = ref<Record<string, unknown>>({});
37
+
38
+ watch(
39
+ () => focusTarget.value?.model,
40
+ (val) => {
41
+ if (val) localModel.value = { ...val };
42
+ },
43
+ { immediate: true },
44
+ );
45
+
46
+ function onUpdate(val: Record<string, unknown>) {
47
+ localModel.value = val;
48
+ focusTarget.value?.onUpdate(val);
49
+ }
50
+ </script>
51
+
52
+ <template>
53
+ <Teleport to="body">
54
+ <Transition name="mapo-focus-portal">
55
+ <div
56
+ v-if="isOpen && focusTarget"
57
+ class="mapo-focus-portal fixed inset-0 z-[9999] flex flex-col bg-white"
58
+ role="dialog"
59
+ aria-modal="true"
60
+ >
61
+ <!-- ── Header ────────────────────────────────────────────────────── -->
62
+ <div
63
+ class="flex shrink-0 items-center gap-3 border-b border-gray-200 px-6 py-3"
64
+ >
65
+ <!-- Breadcrumb -->
66
+ <nav class="flex min-w-0 flex-1 items-center gap-1 text-sm">
67
+ <template v-for="(crumb, i) in focusTarget.breadcrumb" :key="i">
68
+ <span
69
+ :class="
70
+ i < focusTarget.breadcrumb.length - 1
71
+ ? 'text-gray-400'
72
+ : 'font-semibold text-gray-800'
73
+ "
74
+ class="truncate"
75
+ >
76
+ {{ crumb }}
77
+ </span>
78
+ <UIcon
79
+ v-if="i < focusTarget.breadcrumb.length - 1"
80
+ name="i-lucide-chevron-right"
81
+ class="h-3.5 w-3.5 shrink-0 text-gray-300"
82
+ />
83
+ </template>
84
+ </nav>
85
+
86
+ <!-- Close button -->
87
+ <UButton
88
+ variant="ghost"
89
+ color="neutral"
90
+ icon="i-lucide-x"
91
+ label="Close"
92
+ size="sm"
93
+ @click="exit"
94
+ />
95
+ </div>
96
+
97
+ <!-- ── Body: scrollable sub-form ────────────────────────────────── -->
98
+ <div class="min-h-0 flex-1 overflow-y-auto">
99
+ <div class="mx-auto max-w-3xl px-6 py-8">
100
+ <MapoForm
101
+ :model-value="localModel"
102
+ :fields="focusTarget.fields as any"
103
+ :errors="focusTarget.errors.value as any"
104
+ :languages="focusTarget.languages as any"
105
+ :current-lang="focusTarget.currentLang"
106
+ :registry="focusTarget.registry as any"
107
+ @update:model-value="onUpdate"
108
+ />
109
+ </div>
110
+ </div>
111
+
112
+ <!-- ── Footer ───────────────────────────────────────────────────── -->
113
+ <div
114
+ class="flex shrink-0 items-center justify-end gap-3 border-t border-gray-200 px-6 py-3"
115
+ >
116
+ <p class="mr-auto text-xs text-gray-400">
117
+ <UIcon name="i-lucide-keyboard" class="mr-1 inline h-3 w-3" />
118
+ Press
119
+ <kbd class="rounded bg-gray-100 px-1 py-0.5 font-mono text-xs"
120
+ >ESC</kbd
121
+ >
122
+ to return to the form
123
+ </p>
124
+ <UButton variant="ghost" color="neutral" @click="exit">
125
+ Close
126
+ </UButton>
127
+ </div>
128
+ </div>
129
+ </Transition>
130
+ </Teleport>
131
+ </template>
132
+
133
+ <style scoped>
134
+ .mapo-focus-portal-enter-active {
135
+ transition:
136
+ opacity 200ms ease,
137
+ transform 200ms ease;
138
+ }
139
+ .mapo-focus-portal-leave-active {
140
+ transition:
141
+ opacity 150ms ease,
142
+ transform 150ms ease;
143
+ }
144
+ .mapo-focus-portal-enter-from,
145
+ .mapo-focus-portal-leave-to {
146
+ opacity: 0;
147
+ transform: scale(0.98);
148
+ }
149
+ .mapo-focus-portal-enter-to,
150
+ .mapo-focus-portal-leave-from {
151
+ opacity: 1;
152
+ transform: scale(1);
153
+ }
154
+ </style>
@@ -0,0 +1,247 @@
1
+ <script setup lang="ts" generic="T extends object">
2
+ import { computed, useSlots, watchEffect, inject, onUnmounted } from "vue";
3
+ import type { Ref } from "vue";
4
+ import { useNuxtApp, useRuntimeConfig } from "#app";
5
+ import type {
6
+ AnyFieldDescriptor,
7
+ FieldRegistry,
8
+ MapoFormRuntimeConfig,
9
+ } from "../types/index.js";
10
+ import type { GroupEntry, TabEntry } from "../types/layout.js";
11
+ import { useMapoForm, injectMapoForm } from "../composables/useMapoForm.js";
12
+ import { provideCurrentLang } from "../composables/useCurrentLang.js";
13
+ import MapoFormGroup from "./MapoFormGroup.vue";
14
+ import MapoFormTabs from "./MapoFormTabs.vue";
15
+ import MapoFormFlatSection from "./MapoFormFlatSection.vue";
16
+
17
+ const props = withDefaults(
18
+ defineProps<{
19
+ modelValue: T;
20
+ fields: AnyFieldDescriptor<T>[];
21
+ errors?: Record<string, string[]>;
22
+ languages?: string[];
23
+ currentLang?: string;
24
+ readonly?: boolean;
25
+ immediate?: boolean;
26
+ /**
27
+ * Field component registry. When omitted, it is auto-injected from
28
+ * `$mapoFormRegistry` (provided by the `@mapomodule/form` plugin).
29
+ * Pass it explicitly only for headless forms or one-off registry swaps.
30
+ */
31
+ registry?: FieldRegistry;
32
+ }>(),
33
+ {
34
+ errors: () => ({}),
35
+ languages: () => [],
36
+ currentLang: "",
37
+ readonly: false,
38
+ immediate: false,
39
+ registry: undefined,
40
+ },
41
+ );
42
+
43
+ // Auto-inject the registry from the global plugin to remove the boilerplate
44
+ // `useNuxtApp().$mapoFormRegistry` + `:registry=…` from every page.
45
+ const injectedRegistry = computed<FieldRegistry>(() => {
46
+ if (props.registry) return props.registry;
47
+ const nuxt = useNuxtApp() as unknown as { $mapoFormRegistry?: FieldRegistry };
48
+ if (!nuxt.$mapoFormRegistry) {
49
+ throw new Error(
50
+ "[MapoForm] No registry available: pass the `registry` prop or " +
51
+ "make sure the @mapomodule/form module is installed.",
52
+ );
53
+ }
54
+ return nuxt.$mapoFormRegistry;
55
+ });
56
+
57
+ const emit = defineEmits<{ "update:modelValue": [value: T] }>();
58
+
59
+ // Slots are forwarded generically to inner layers; props vary per slot
60
+ // (field.* carry a descriptor, group.* carry none), so they are loosely typed.
61
+ // Slot names are dynamic (`field.{key}`, `field.{key}.label`, `group.{name}`…)
62
+ // and each kind carries different bindings, so the props stay loosely typed.
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
+ defineSlots<Record<string, (props: Record<string, any>) => unknown>>();
65
+
66
+ // ─── Setup form ──────────────────────────────────────────────────────────────
67
+
68
+ const model = computed({
69
+ get: () => props.modelValue,
70
+ set: (val) => emit("update:modelValue", val),
71
+ });
72
+
73
+ const currentLangRef = computed(() => props.currentLang);
74
+ const errorsRef = computed(() => props.errors);
75
+
76
+ const globalDebounce: number =
77
+ (useRuntimeConfig().public.mapoForm as MapoFormRuntimeConfig | undefined)
78
+ ?.debounce ?? 300;
79
+
80
+ const form = useMapoForm({
81
+ model: model as unknown as Ref<T>,
82
+ fields: computed(() => props.fields),
83
+ errors: errorsRef,
84
+ languages: props.languages,
85
+ currentLang: currentLangRef,
86
+ immediate: props.immediate,
87
+ debounce: globalDebounce,
88
+ registry: injectedRegistry.value,
89
+ });
90
+
91
+ // Inherit the parent form's submitted state (provided automatically by useMapoForm)
92
+ // so that form.submit() on the parent also shows errors in this nested MapoForm.
93
+ const parentCtx = injectMapoForm();
94
+ watchEffect(() => {
95
+ form.readonly.value = props.readonly;
96
+ if (parentCtx) form.ctx.submitted.value = parentCtx.submitted.value;
97
+ });
98
+
99
+ defineExpose({
100
+ submit: form.submit,
101
+ validateClient: form.validateClient,
102
+ resetDirty: form.resetDirty,
103
+ getPatch: form.getPatch,
104
+ isDirty: form.isDirty,
105
+ isLoading: form.isLoading,
106
+ });
107
+
108
+ type ValidateClientFn = () => {
109
+ valid: boolean;
110
+ errors: Record<string, string>;
111
+ };
112
+ type RegisterFn = (fn: ValidateClientFn) => () => void;
113
+ const registerValidator = inject<RegisterFn | null>(
114
+ "mapoDetailRegisterValidator",
115
+ null,
116
+ );
117
+ if (registerValidator) {
118
+ const unregister = registerValidator(form.validateClient);
119
+ onUnmounted(unregister);
120
+ }
121
+
122
+ // Forward only field-specific slots to inner layers, avoiding host-level slots
123
+ // such as header/footer/actions where they have no meaning.
124
+ const slots = useSlots();
125
+ const fieldSlotNames = computed(() =>
126
+ Object.keys(slots).filter(
127
+ (name) =>
128
+ name.startsWith("field.") ||
129
+ name.startsWith("group.") ||
130
+ name.startsWith("tab.") ||
131
+ name === "tab-bar-end",
132
+ ),
133
+ );
134
+ provideCurrentLang(currentLangRef);
135
+
136
+ // ─── Group fields by tab/group ───────────────────────────────────────────────
137
+
138
+ function getOrCreateTab(map: Map<string, TabEntry>, name: string): TabEntry {
139
+ if (!map.has(name)) {
140
+ map.set(name, { name, groups: new Map(), children: new Map() });
141
+ }
142
+ return map.get(name)!;
143
+ }
144
+
145
+ const grouped = computed<Map<string, TabEntry>>(() => {
146
+ const tabs = new Map<string, TabEntry>();
147
+
148
+ // The layout tree is type-agnostic: cast the generic descriptors to the bare
149
+ // AnyFieldDescriptor used by the (non-generic) MapoFormGroup/Tabs/FlatSection layers.
150
+ for (const field of props.fields as AnyFieldDescriptor[]) {
151
+ // Normalise: 'a/b', ['a','b'], or 'a' all become a string[]
152
+ const rawTab = field.tab;
153
+ const tabPath: string[] = !rawTab
154
+ ? ["__default__"]
155
+ : Array.isArray(rawTab)
156
+ ? rawTab
157
+ : rawTab.split("/");
158
+ const groupName = field.group ?? "__flat__";
159
+
160
+ // Walk/create the nested TabEntry tree
161
+ // tabPath always has at least one segment (split never returns []).
162
+ let currentMap = tabs;
163
+ for (let i = 0; i < tabPath.length - 1; i++) {
164
+ const parent = getOrCreateTab(currentMap, tabPath[i]!);
165
+ currentMap = parent.children;
166
+ }
167
+ const leaf = getOrCreateTab(currentMap, tabPath[tabPath.length - 1]!);
168
+ if (!leaf.groups.has(groupName)) {
169
+ leaf.groups.set(groupName, { fields: [], subtabs: new Map() });
170
+ }
171
+ const grp = leaf.groups.get(groupName)!;
172
+ if (field.subtab) {
173
+ if (!grp.subtabs.has(field.subtab)) {
174
+ grp.subtabs.set(field.subtab, []);
175
+ }
176
+ grp.subtabs.get(field.subtab)!.push(field);
177
+ } else {
178
+ grp.fields.push(field);
179
+ }
180
+ }
181
+
182
+ return tabs;
183
+ });
184
+
185
+ const hasTabs = computed(() => {
186
+ const keys = [...grouped.value.keys()];
187
+ return keys.length > 1 || (keys.length === 1 && keys[0] !== "__default__");
188
+ });
189
+
190
+ const defaultGroups = computed(
191
+ () =>
192
+ grouped.value.get("__default__")?.groups ?? new Map<string, GroupEntry>(),
193
+ );
194
+
195
+ const tabList = computed<TabEntry[]>(() => [...grouped.value.values()]);
196
+ </script>
197
+
198
+ <template>
199
+ <div class="mapo-form space-y-5">
200
+ <!-- All field slots (field.<key>, field.<key>.append, .prepend, .label, …)
201
+ are forwarded generically to inner layers (Tabs → Group → Field). -->
202
+ <MapoFormTabs v-if="hasTabs" :tabs="tabList">
203
+ <template
204
+ v-for="slotName in fieldSlotNames"
205
+ :key="slotName"
206
+ #[slotName]="slotProps"
207
+ >
208
+ <slot :name="slotName" v-bind="slotProps ?? {}" />
209
+ </template>
210
+ </MapoFormTabs>
211
+
212
+ <template v-else>
213
+ <template v-for="[groupName, group] of defaultGroups" :key="groupName">
214
+ <template v-if="groupName !== '__flat__'">
215
+ <slot :name="`group.${groupName}.before`" />
216
+ <MapoFormGroup
217
+ :name="groupName"
218
+ :fields="group.fields"
219
+ :subtabs="group.subtabs"
220
+ >
221
+ <template
222
+ v-for="slotName in fieldSlotNames"
223
+ :key="slotName"
224
+ #[slotName]="slotProps"
225
+ >
226
+ <slot :name="slotName" v-bind="slotProps ?? {}" />
227
+ </template>
228
+ </MapoFormGroup>
229
+ <slot :name="`group.${groupName}.after`" />
230
+ </template>
231
+
232
+ <MapoFormFlatSection
233
+ v-else
234
+ :fields="group.fields as AnyFieldDescriptor[]"
235
+ >
236
+ <template
237
+ v-for="slotName in fieldSlotNames"
238
+ :key="slotName"
239
+ #[slotName]="slotProps"
240
+ >
241
+ <slot :name="slotName" v-bind="slotProps ?? {}" />
242
+ </template>
243
+ </MapoFormFlatSection>
244
+ </template>
245
+ </template>
246
+ </div>
247
+ </template>