@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.
- package/README.md +155 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +34 -0
- package/dist/module.d.cts +2 -0
- package/dist/module.d.mts +2 -0
- package/dist/module.d.ts +2 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +18 -0
- package/dist/types.d.mts +13 -0
- package/package.json +78 -0
- package/src/__tests__/__mocks__/nuxt-imports.ts +5 -0
- package/src/__tests__/registry.test.ts +105 -0
- package/src/__tests__/useFormFromSchema.test.ts +415 -0
- package/src/__tests__/useMapoForm.test.ts +319 -0
- package/src/index.ts +49 -0
- package/src/module.ts +213 -0
- package/src/runtime/components/MapoFocusPortal.vue +154 -0
- package/src/runtime/components/MapoForm.vue +247 -0
- package/src/runtime/components/MapoFormField.vue +350 -0
- package/src/runtime/components/MapoFormFlatSection.vue +163 -0
- package/src/runtime/components/MapoFormGroup.vue +283 -0
- package/src/runtime/components/MapoFormTabs.vue +116 -0
- package/src/runtime/components/MapoUnknownField.vue +74 -0
- package/src/runtime/components/fields/MapoDateField.vue +98 -0
- package/src/runtime/components/fields/MapoDateTimeField.vue +192 -0
- package/src/runtime/components/fields/MapoFileField.vue +106 -0
- package/src/runtime/components/fields/MapoFksField.vue +151 -0
- package/src/runtime/components/fields/MapoMapField.vue +117 -0
- package/src/runtime/components/fields/MapoMapFieldClient.vue +92 -0
- package/src/runtime/components/fields/MapoRepeater.vue +553 -0
- package/src/runtime/components/fields/MapoRepeaterItem.vue +409 -0
- package/src/runtime/components/fields/MapoSeoPreview.vue +137 -0
- package/src/runtime/components/fields/MapoTimeField.vue +59 -0
- package/src/runtime/components/fields/MapoWygEditor.vue +256 -0
- package/src/runtime/components/fields/nui/NuiCheckbox.vue +8 -0
- package/src/runtime/components/fields/nui/NuiInput.vue +8 -0
- package/src/runtime/components/fields/nui/NuiSelectMenu.vue +27 -0
- package/src/runtime/components/fields/nui/NuiSlider.vue +8 -0
- package/src/runtime/components/fields/nui/NuiSwitch.vue +8 -0
- package/src/runtime/components/fields/nui/NuiTextarea.vue +8 -0
- package/src/runtime/components/fields/nui/useNuiFieldAttrs.ts +30 -0
- package/src/runtime/composables/defineFormField.ts +62 -0
- package/src/runtime/composables/useCurrentLang.ts +16 -0
- package/src/runtime/composables/useFocusMode.ts +86 -0
- package/src/runtime/composables/useFormDraft.ts +119 -0
- package/src/runtime/composables/useFormFromSchema.ts +253 -0
- package/src/runtime/composables/useMapoForm.ts +484 -0
- package/src/runtime/composables/useProgressiveDisclosure.ts +101 -0
- package/src/runtime/composables/useUnsavedChangesGuard.ts +58 -0
- package/src/runtime/devtools/DevtoolsFormsPage.vue +256 -0
- package/src/runtime/plugins/form-registry.ts +43 -0
- package/src/runtime/registry/defaults.ts +97 -0
- package/src/runtime/types/descriptor.ts +411 -0
- package/src/runtime/types/index.ts +40 -0
- package/src/runtime/types/layout.ts +22 -0
- package/src/runtime/types/registry.ts +98 -0
- package/src/runtime/utils/flattenFieldGroups.ts +86 -0
- 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>
|