@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,553 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, watchEffect } from "vue";
|
|
3
|
+
import { VueDraggable } from "vue-draggable-plus";
|
|
4
|
+
import { useConfirmStore } from "@mapomodule/store/runtime/stores/confirm";
|
|
5
|
+
import type { RepeaterDescriptor } from "../../types/index.js";
|
|
6
|
+
import { injectMapoForm } from "../../composables/useMapoForm.js";
|
|
7
|
+
import MapoRepeaterItem from "./MapoRepeaterItem.vue";
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
modelValue: unknown;
|
|
11
|
+
descriptor: RepeaterDescriptor;
|
|
12
|
+
errors?: string[];
|
|
13
|
+
readonly?: boolean;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
}>();
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits<{ "update:modelValue": [value: unknown[]] }>();
|
|
18
|
+
|
|
19
|
+
const form = injectMapoForm()!;
|
|
20
|
+
|
|
21
|
+
const attrs = computed(() => props.descriptor.attrs ?? {});
|
|
22
|
+
|
|
23
|
+
// ─── Item list (reactive local copy) ────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const items = computed<Record<string, unknown>[]>({
|
|
26
|
+
get: () =>
|
|
27
|
+
(Array.isArray(props.modelValue) ? props.modelValue : []) as Record<
|
|
28
|
+
string,
|
|
29
|
+
unknown
|
|
30
|
+
>[],
|
|
31
|
+
set: (val) => emit("update:modelValue", val),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function emitItems(newItems: Record<string, unknown>[]) {
|
|
35
|
+
emit("update:modelValue", [...newItems]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Stable per-item UID: guarantees persistent :key values across reorder/duplicate
|
|
39
|
+
// AND identity-based selection that survives reordering and deletions.
|
|
40
|
+
const itemUids = new WeakMap<object, string>();
|
|
41
|
+
let _uidSeq = 0;
|
|
42
|
+
function uidFor(item: Record<string, unknown>): string {
|
|
43
|
+
let id = itemUids.get(item);
|
|
44
|
+
if (!id) {
|
|
45
|
+
id = `it_${++_uidSeq}`;
|
|
46
|
+
itemUids.set(item, id);
|
|
47
|
+
}
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Undo stack ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
const undoStack = ref<Record<string, unknown>[][]>([]);
|
|
54
|
+
|
|
55
|
+
function pushUndo() {
|
|
56
|
+
undoStack.value.push(items.value.map((i) => ({ ...i })));
|
|
57
|
+
if (undoStack.value.length > 20) undoStack.value.shift();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function undo() {
|
|
61
|
+
const prev = undoStack.value.pop();
|
|
62
|
+
if (prev) emit("update:modelValue", prev);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Template selection ──────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const templates = computed(() => attrs.value.templates);
|
|
68
|
+
const templateKeys = computed(() =>
|
|
69
|
+
templates.value ? Object.keys(templates.value) : [],
|
|
70
|
+
);
|
|
71
|
+
const hasTemplates = computed(() => templateKeys.value.length > 0);
|
|
72
|
+
const selectedTemplate = ref<string>("");
|
|
73
|
+
|
|
74
|
+
// Async-safe: when templates load after mount, auto-pick the first one.
|
|
75
|
+
watchEffect(() => {
|
|
76
|
+
if (!selectedTemplate.value && templateKeys.value[0]) {
|
|
77
|
+
selectedTemplate.value = templateKeys.value[0];
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function fieldsForItem(item: Record<string, unknown>) {
|
|
82
|
+
if (!templates.value) return props.descriptor.fields ?? [];
|
|
83
|
+
const key = (item._template as string) ?? selectedTemplate.value;
|
|
84
|
+
return templates.value[key] ?? props.descriptor.fields ?? [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Item CRUD ───────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
const canAdd = computed(
|
|
90
|
+
() =>
|
|
91
|
+
!props.readonly &&
|
|
92
|
+
(!attrs.value.maxItems || items.value.length < attrs.value.maxItems),
|
|
93
|
+
);
|
|
94
|
+
const canDelete = computed(
|
|
95
|
+
() =>
|
|
96
|
+
!props.readonly &&
|
|
97
|
+
(!attrs.value.minItems || items.value.length > attrs.value.minItems),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Helper: how many additional items can still be added.
|
|
101
|
+
function remainingSlots(): number {
|
|
102
|
+
if (!attrs.value.maxItems) return Number.POSITIVE_INFINITY;
|
|
103
|
+
return Math.max(0, attrs.value.maxItems - items.value.length);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Helper: the lower bound on items.length given minItems.
|
|
107
|
+
function minBound(): number {
|
|
108
|
+
return attrs.value.minItems ?? 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function askConfirm(
|
|
112
|
+
message: string,
|
|
113
|
+
title = "Confirm",
|
|
114
|
+
): Promise<boolean> {
|
|
115
|
+
try {
|
|
116
|
+
// Falls back to native confirm when the Pinia store is not registered.
|
|
117
|
+
return await useConfirmStore().ask({ title, message });
|
|
118
|
+
} catch {
|
|
119
|
+
return typeof globalThis.confirm === "function"
|
|
120
|
+
? globalThis.confirm(message)
|
|
121
|
+
: false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function addItem() {
|
|
126
|
+
if (!canAdd.value) return;
|
|
127
|
+
pushUndo();
|
|
128
|
+
const newItem: Record<string, unknown> = hasTemplates.value
|
|
129
|
+
? { _template: selectedTemplate.value }
|
|
130
|
+
: {};
|
|
131
|
+
emitItems([...items.value, newItem]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function deleteItem(index: number) {
|
|
135
|
+
if (!canDelete.value) return;
|
|
136
|
+
if (attrs.value.confirmDelete !== false) {
|
|
137
|
+
const confirmed = await askConfirm(
|
|
138
|
+
"Are you sure you want to delete this item?",
|
|
139
|
+
"Delete item",
|
|
140
|
+
);
|
|
141
|
+
if (!confirmed) return;
|
|
142
|
+
}
|
|
143
|
+
pushUndo();
|
|
144
|
+
const next = [...items.value];
|
|
145
|
+
next.splice(index, 1);
|
|
146
|
+
emitItems(next);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function duplicateItem(index: number) {
|
|
150
|
+
if (remainingSlots() < 1) return;
|
|
151
|
+
const source = items.value[index];
|
|
152
|
+
if (!source) return;
|
|
153
|
+
pushUndo();
|
|
154
|
+
const copy = { ...source };
|
|
155
|
+
const next = [...items.value];
|
|
156
|
+
next.splice(index + 1, 0, copy);
|
|
157
|
+
emitItems(next);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function updateItem(index: number, val: Record<string, unknown>) {
|
|
161
|
+
const oldItem = items.value[index];
|
|
162
|
+
if (oldItem && oldItem !== val) {
|
|
163
|
+
const existingUid = itemUids.get(oldItem);
|
|
164
|
+
if (existingUid) itemUids.set(val, existingUid);
|
|
165
|
+
}
|
|
166
|
+
const next = [...items.value];
|
|
167
|
+
next[index] = val;
|
|
168
|
+
emitItems(next);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function moveUp(index: number) {
|
|
172
|
+
moveTo(index, index - 1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function moveDown(index: number) {
|
|
176
|
+
moveTo(index, index + 1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function moveTo(from: number, to: number) {
|
|
180
|
+
if (to < 0 || to >= items.value.length || from === to) return;
|
|
181
|
+
pushUndo();
|
|
182
|
+
const next = [...items.value];
|
|
183
|
+
const [item] = next.splice(from, 1);
|
|
184
|
+
if (item === undefined) return;
|
|
185
|
+
next.splice(to, 0, item);
|
|
186
|
+
emitItems(next);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Global collapse/expand ──────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
const collapseVersion = ref(0);
|
|
192
|
+
const globalExpanded = ref(attrs.value.defaultExpanded ?? false);
|
|
193
|
+
|
|
194
|
+
function collapseAll() {
|
|
195
|
+
globalExpanded.value = false;
|
|
196
|
+
collapseVersion.value++;
|
|
197
|
+
}
|
|
198
|
+
function expandAll() {
|
|
199
|
+
globalExpanded.value = true;
|
|
200
|
+
collapseVersion.value++;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── Drag & drop (SSR-safe via ClientOnly) ───────────────────────────────────
|
|
204
|
+
|
|
205
|
+
function onDragStart() {
|
|
206
|
+
pushUndo();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── Bulk actions ────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
const selectionMode = ref(false);
|
|
212
|
+
// Identity-based selection: survives reorder, delete, undo.
|
|
213
|
+
const selectedUids = ref<Set<string>>(new Set());
|
|
214
|
+
const anchorUid = ref<string | null>(null);
|
|
215
|
+
|
|
216
|
+
function toggleSelectionMode() {
|
|
217
|
+
selectionMode.value = !selectionMode.value;
|
|
218
|
+
if (!selectionMode.value) {
|
|
219
|
+
selectedUids.value = new Set();
|
|
220
|
+
anchorUid.value = null;
|
|
221
|
+
// Auto-collapse all when leaving selection mode for tidiness.
|
|
222
|
+
collapseVersion.value++;
|
|
223
|
+
} else {
|
|
224
|
+
// Auto-collapse all when entering selection mode so the user sees the whole list.
|
|
225
|
+
globalExpanded.value = false;
|
|
226
|
+
collapseVersion.value++;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isSelected(uid: string): boolean {
|
|
231
|
+
return selectedUids.value.has(uid);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function toggleSelect(uid: string, withShift = false) {
|
|
235
|
+
if (withShift && anchorUid.value && anchorUid.value !== uid) {
|
|
236
|
+
// Range select between anchor and current.
|
|
237
|
+
const uids = items.value.map(uidFor);
|
|
238
|
+
const a = uids.indexOf(anchorUid.value);
|
|
239
|
+
const b = uids.indexOf(uid);
|
|
240
|
+
if (a !== -1 && b !== -1) {
|
|
241
|
+
const [lo, hi] = a < b ? [a, b] : [b, a];
|
|
242
|
+
const next = new Set(selectedUids.value);
|
|
243
|
+
for (let i = lo; i <= hi; i++) {
|
|
244
|
+
const uid = uids[i];
|
|
245
|
+
if (uid) next.add(uid);
|
|
246
|
+
}
|
|
247
|
+
selectedUids.value = next;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const next = new Set(selectedUids.value);
|
|
252
|
+
if (next.has(uid)) next.delete(uid);
|
|
253
|
+
else next.add(uid);
|
|
254
|
+
selectedUids.value = next;
|
|
255
|
+
anchorUid.value = uid;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function selectAll() {
|
|
259
|
+
selectedUids.value = new Set(items.value.map(uidFor));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function clearSelection() {
|
|
263
|
+
selectedUids.value = new Set();
|
|
264
|
+
anchorUid.value = null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Resolve current selection to the array of items, in current order.
|
|
268
|
+
function selectedItems(): Record<string, unknown>[] {
|
|
269
|
+
return items.value.filter((i) => selectedUids.value.has(uidFor(i)));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function bulkDelete() {
|
|
273
|
+
const count = selectedUids.value.size;
|
|
274
|
+
if (count === 0) return;
|
|
275
|
+
// Enforce minItems: would deletion drop below the lower bound?
|
|
276
|
+
if (items.value.length - count < minBound()) {
|
|
277
|
+
await askConfirm(
|
|
278
|
+
`Cannot delete: at least ${minBound()} items must remain.`,
|
|
279
|
+
"Action blocked",
|
|
280
|
+
);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const confirmed = await askConfirm(
|
|
284
|
+
`Delete ${count} selected items?`,
|
|
285
|
+
"Delete items",
|
|
286
|
+
);
|
|
287
|
+
if (!confirmed) return;
|
|
288
|
+
pushUndo();
|
|
289
|
+
emitItems(items.value.filter((i) => !selectedUids.value.has(uidFor(i))));
|
|
290
|
+
clearSelection();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function bulkDuplicate() {
|
|
294
|
+
const count = selectedUids.value.size;
|
|
295
|
+
if (count === 0) return;
|
|
296
|
+
// Cap by maxItems.
|
|
297
|
+
const slots = remainingSlots();
|
|
298
|
+
if (slots <= 0) return;
|
|
299
|
+
const toDup = selectedItems().slice(0, slots);
|
|
300
|
+
pushUndo();
|
|
301
|
+
// Insert each copy right after its original. Walk from the end so indices stay valid.
|
|
302
|
+
const next = [...items.value];
|
|
303
|
+
const dupSet = new Set(toDup.map(uidFor));
|
|
304
|
+
for (let i = next.length - 1; i >= 0; i--) {
|
|
305
|
+
const item = next[i];
|
|
306
|
+
if (item && dupSet.has(uidFor(item))) {
|
|
307
|
+
next.splice(i + 1, 0, { ...item });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
emitItems(next);
|
|
311
|
+
clearSelection();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function bulkMoveToTop() {
|
|
315
|
+
if (selectedUids.value.size === 0) return;
|
|
316
|
+
pushUndo();
|
|
317
|
+
const selected = items.value.filter((i) => selectedUids.value.has(uidFor(i)));
|
|
318
|
+
const rest = items.value.filter((i) => !selectedUids.value.has(uidFor(i)));
|
|
319
|
+
emitItems([...selected, ...rest]);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const hasSelection = computed(() => selectedUids.value.size > 0);
|
|
323
|
+
const selectionCount = computed(() => selectedUids.value.size);
|
|
324
|
+
|
|
325
|
+
// ─── Errors ──────────────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
const parentErrors = computed(() => form.errors.value);
|
|
328
|
+
const errorPrefix = (index: number) =>
|
|
329
|
+
`${props.descriptor.key as string}.${index}`;
|
|
330
|
+
</script>
|
|
331
|
+
|
|
332
|
+
<template>
|
|
333
|
+
<div class="mapo-repeater space-y-2">
|
|
334
|
+
<!-- ── Main toolbar ────────────────────────────────────────────────────── -->
|
|
335
|
+
<div
|
|
336
|
+
class="flex flex-wrap items-center gap-2 rounded border border-gray-200 bg-gray-50 px-3 py-2"
|
|
337
|
+
>
|
|
338
|
+
<span class="text-sm font-medium text-gray-700">
|
|
339
|
+
{{ items.length }} {{ items.length === 1 ? "item" : "items" }}
|
|
340
|
+
<span v-if="attrs.maxItems" class="text-gray-400"
|
|
341
|
+
>/ {{ attrs.maxItems }}</span
|
|
342
|
+
>
|
|
343
|
+
</span>
|
|
344
|
+
|
|
345
|
+
<div class="flex flex-1 flex-wrap items-center justify-end gap-2">
|
|
346
|
+
<UButton size="xs" variant="ghost" color="neutral" @click="collapseAll">
|
|
347
|
+
Collapse all
|
|
348
|
+
</UButton>
|
|
349
|
+
<UButton size="xs" variant="ghost" color="neutral" @click="expandAll">
|
|
350
|
+
Expand all
|
|
351
|
+
</UButton>
|
|
352
|
+
<UButton
|
|
353
|
+
v-if="undoStack.length"
|
|
354
|
+
size="xs"
|
|
355
|
+
variant="ghost"
|
|
356
|
+
color="neutral"
|
|
357
|
+
icon="i-lucide-undo-2"
|
|
358
|
+
title="Undo last action"
|
|
359
|
+
@click="undo"
|
|
360
|
+
/>
|
|
361
|
+
|
|
362
|
+
<!-- Bulk selection mode -->
|
|
363
|
+
<UButton
|
|
364
|
+
size="xs"
|
|
365
|
+
:variant="selectionMode ? 'solid' : 'ghost'"
|
|
366
|
+
color="neutral"
|
|
367
|
+
icon="i-lucide-check-square"
|
|
368
|
+
title="Bulk selection"
|
|
369
|
+
:label="selectionMode ? 'Exit selection' : 'Select'"
|
|
370
|
+
@click="toggleSelectionMode"
|
|
371
|
+
/>
|
|
372
|
+
|
|
373
|
+
<!-- Template selector -->
|
|
374
|
+
<USelectMenu
|
|
375
|
+
v-if="hasTemplates"
|
|
376
|
+
v-model="selectedTemplate"
|
|
377
|
+
:items="templateKeys"
|
|
378
|
+
size="xs"
|
|
379
|
+
class="w-32"
|
|
380
|
+
/>
|
|
381
|
+
|
|
382
|
+
<UButton
|
|
383
|
+
size="sm"
|
|
384
|
+
color="primary"
|
|
385
|
+
icon="i-lucide-plus"
|
|
386
|
+
:disabled="!canAdd"
|
|
387
|
+
@click="addItem"
|
|
388
|
+
>
|
|
389
|
+
Add
|
|
390
|
+
</UButton>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
<!-- ── Bulk actions bar (visible when there is a selection) ───────────── -->
|
|
395
|
+
<Transition name="slide-down">
|
|
396
|
+
<div
|
|
397
|
+
v-if="selectionMode && hasSelection"
|
|
398
|
+
class="flex items-center gap-2 rounded border border-primary-200 bg-primary-50 px-3 py-2"
|
|
399
|
+
>
|
|
400
|
+
<span class="text-sm font-medium text-primary-700">
|
|
401
|
+
{{ selectionCount }} selected
|
|
402
|
+
</span>
|
|
403
|
+
<div class="flex flex-1 flex-wrap items-center gap-2">
|
|
404
|
+
<UButton size="xs" variant="ghost" color="neutral" @click="selectAll">
|
|
405
|
+
All
|
|
406
|
+
</UButton>
|
|
407
|
+
<UButton
|
|
408
|
+
size="xs"
|
|
409
|
+
variant="ghost"
|
|
410
|
+
color="neutral"
|
|
411
|
+
@click="clearSelection"
|
|
412
|
+
>
|
|
413
|
+
Clear
|
|
414
|
+
</UButton>
|
|
415
|
+
<UButton
|
|
416
|
+
size="xs"
|
|
417
|
+
variant="outline"
|
|
418
|
+
color="neutral"
|
|
419
|
+
icon="i-lucide-copy"
|
|
420
|
+
:disabled="remainingSlots() <= 0"
|
|
421
|
+
:title="
|
|
422
|
+
remainingSlots() < selectionCount
|
|
423
|
+
? `Only ${remainingSlots()} of ${selectionCount} can be duplicated (maxItems reached)`
|
|
424
|
+
: 'Duplicate selected'
|
|
425
|
+
"
|
|
426
|
+
@click="bulkDuplicate"
|
|
427
|
+
>
|
|
428
|
+
Duplicate ({{ Math.min(selectionCount, remainingSlots()) }})
|
|
429
|
+
</UButton>
|
|
430
|
+
<UButton
|
|
431
|
+
size="xs"
|
|
432
|
+
variant="outline"
|
|
433
|
+
color="neutral"
|
|
434
|
+
icon="i-lucide-arrow-up"
|
|
435
|
+
@click="bulkMoveToTop"
|
|
436
|
+
>
|
|
437
|
+
Move to top
|
|
438
|
+
</UButton>
|
|
439
|
+
<UButton
|
|
440
|
+
size="xs"
|
|
441
|
+
variant="outline"
|
|
442
|
+
color="error"
|
|
443
|
+
icon="i-lucide-trash-2"
|
|
444
|
+
:disabled="!canDelete"
|
|
445
|
+
@click="bulkDelete"
|
|
446
|
+
>
|
|
447
|
+
Delete ({{ selectionCount }})
|
|
448
|
+
</UButton>
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
</Transition>
|
|
452
|
+
|
|
453
|
+
<!-- ── Item list with drag&drop (ClientOnly for SSR) ───────────────────── -->
|
|
454
|
+
<ClientOnly>
|
|
455
|
+
<VueDraggable
|
|
456
|
+
v-model="items"
|
|
457
|
+
handle=".drag-handle"
|
|
458
|
+
:animation="150"
|
|
459
|
+
:disabled="readonly || selectionMode"
|
|
460
|
+
@start="onDragStart"
|
|
461
|
+
>
|
|
462
|
+
<MapoRepeaterItem
|
|
463
|
+
v-for="(item, index) in items"
|
|
464
|
+
:key="`${uidFor(item)}-${collapseVersion}`"
|
|
465
|
+
:uid="uidFor(item)"
|
|
466
|
+
:item="item"
|
|
467
|
+
:fields="fieldsForItem(item)"
|
|
468
|
+
:index="index"
|
|
469
|
+
:error-prefix="errorPrefix(index)"
|
|
470
|
+
:parent-errors="parentErrors"
|
|
471
|
+
:languages="form.languages"
|
|
472
|
+
:current-lang="form.currentLang.value"
|
|
473
|
+
:readonly="readonly ?? false"
|
|
474
|
+
:registry="form.registry"
|
|
475
|
+
:preview-label="attrs.previewLabel"
|
|
476
|
+
:default-expanded="globalExpanded"
|
|
477
|
+
:allow-duplicate="attrs.allowDuplicate"
|
|
478
|
+
:repeater-descriptor="descriptor"
|
|
479
|
+
:total-items="items.length"
|
|
480
|
+
:selected="isSelected(uidFor(item))"
|
|
481
|
+
:selection-mode="selectionMode"
|
|
482
|
+
@update:item="updateItem(index, $event)"
|
|
483
|
+
@delete="deleteItem(index)"
|
|
484
|
+
@duplicate="duplicateItem(index)"
|
|
485
|
+
@move-up="moveUp(index)"
|
|
486
|
+
@move-down="moveDown(index)"
|
|
487
|
+
@move-to="moveTo(index, $event)"
|
|
488
|
+
@toggle-select="toggleSelect(uidFor(item), $event)"
|
|
489
|
+
/>
|
|
490
|
+
</VueDraggable>
|
|
491
|
+
|
|
492
|
+
<!-- SSR fallback: list without drag -->
|
|
493
|
+
<template #fallback>
|
|
494
|
+
<MapoRepeaterItem
|
|
495
|
+
v-for="(item, index) in items"
|
|
496
|
+
:key="`${uidFor(item)}-${collapseVersion}`"
|
|
497
|
+
:uid="uidFor(item)"
|
|
498
|
+
:item="item"
|
|
499
|
+
:fields="fieldsForItem(item)"
|
|
500
|
+
:index="index"
|
|
501
|
+
:error-prefix="errorPrefix(index)"
|
|
502
|
+
:parent-errors="parentErrors"
|
|
503
|
+
:languages="form.languages"
|
|
504
|
+
:current-lang="form.currentLang.value"
|
|
505
|
+
:readonly="readonly ?? false"
|
|
506
|
+
:registry="form.registry"
|
|
507
|
+
:preview-label="attrs.previewLabel"
|
|
508
|
+
:default-expanded="globalExpanded"
|
|
509
|
+
:allow-duplicate="attrs.allowDuplicate"
|
|
510
|
+
:repeater-descriptor="descriptor"
|
|
511
|
+
:total-items="items.length"
|
|
512
|
+
:selected="isSelected(uidFor(item))"
|
|
513
|
+
:selection-mode="selectionMode"
|
|
514
|
+
@update:item="updateItem(index, $event)"
|
|
515
|
+
@delete="deleteItem(index)"
|
|
516
|
+
@duplicate="duplicateItem(index)"
|
|
517
|
+
@move-up="moveUp(index)"
|
|
518
|
+
@move-down="moveDown(index)"
|
|
519
|
+
@move-to="moveTo(index, $event)"
|
|
520
|
+
@toggle-select="toggleSelect(uidFor(item), $event)"
|
|
521
|
+
/>
|
|
522
|
+
</template>
|
|
523
|
+
</ClientOnly>
|
|
524
|
+
|
|
525
|
+
<!-- ── Empty state ─────────────────────────────────────────────────────── -->
|
|
526
|
+
<div
|
|
527
|
+
v-if="items.length === 0"
|
|
528
|
+
class="rounded border border-dashed border-gray-300 py-8 text-center text-sm text-gray-400"
|
|
529
|
+
>
|
|
530
|
+
No items. Click "Add" to start.
|
|
531
|
+
</div>
|
|
532
|
+
</div>
|
|
533
|
+
</template>
|
|
534
|
+
|
|
535
|
+
<style scoped>
|
|
536
|
+
.slide-down-enter-active,
|
|
537
|
+
.slide-down-leave-active {
|
|
538
|
+
transition:
|
|
539
|
+
grid-template-rows 250ms ease,
|
|
540
|
+
opacity 200ms ease;
|
|
541
|
+
display: grid;
|
|
542
|
+
}
|
|
543
|
+
.slide-down-enter-from,
|
|
544
|
+
.slide-down-leave-to {
|
|
545
|
+
grid-template-rows: 0fr;
|
|
546
|
+
opacity: 0;
|
|
547
|
+
}
|
|
548
|
+
.slide-down-enter-to,
|
|
549
|
+
.slide-down-leave-from {
|
|
550
|
+
grid-template-rows: 1fr;
|
|
551
|
+
opacity: 1;
|
|
552
|
+
}
|
|
553
|
+
</style>
|