@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,409 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, watch, watchEffect } from "vue";
|
|
3
|
+
import type {
|
|
4
|
+
AnyFieldDescriptor,
|
|
5
|
+
FieldRegistry,
|
|
6
|
+
RepeaterDescriptor,
|
|
7
|
+
} from "../../types/index.js";
|
|
8
|
+
import { useMapoForm, injectMapoForm } from "../../composables/useMapoForm.js";
|
|
9
|
+
import { useFocusMode } from "../../composables/useFocusMode.js";
|
|
10
|
+
import MapoFormField from "../MapoFormField.vue";
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
/** Stable identity-based UID assigned by the parent repeater. */
|
|
14
|
+
uid?: string;
|
|
15
|
+
item: Record<string, unknown>;
|
|
16
|
+
fields: AnyFieldDescriptor[];
|
|
17
|
+
index: number;
|
|
18
|
+
errorPrefix: string;
|
|
19
|
+
parentErrors: Record<string, string[]>;
|
|
20
|
+
languages: string[];
|
|
21
|
+
currentLang: string;
|
|
22
|
+
readonly: boolean;
|
|
23
|
+
registry: FieldRegistry;
|
|
24
|
+
previewLabel?: (item: unknown, index: number) => string;
|
|
25
|
+
defaultExpanded: boolean;
|
|
26
|
+
allowDuplicate?: boolean;
|
|
27
|
+
/** Descriptor of the parent repeater (for miniCard and other attrs). */
|
|
28
|
+
repeaterDescriptor?: RepeaterDescriptor;
|
|
29
|
+
/** Total number of items in the repeater (used to compute contextual scaling). */
|
|
30
|
+
totalItems?: number;
|
|
31
|
+
/** true = this item is selected in bulk mode. */
|
|
32
|
+
selected?: boolean;
|
|
33
|
+
/** true = bulk selection mode is active in the parent repeater. */
|
|
34
|
+
selectionMode?: boolean;
|
|
35
|
+
}>();
|
|
36
|
+
|
|
37
|
+
const emit = defineEmits<{
|
|
38
|
+
"update:item": [value: Record<string, unknown>];
|
|
39
|
+
delete: [];
|
|
40
|
+
duplicate: [];
|
|
41
|
+
"move-up": [];
|
|
42
|
+
"move-down": [];
|
|
43
|
+
"move-to": [index: number];
|
|
44
|
+
/** Emits `true` when the user held Shift to request a range selection. */
|
|
45
|
+
"toggle-select": [withShift: boolean];
|
|
46
|
+
}>();
|
|
47
|
+
|
|
48
|
+
/** Toggle selection from a click event, propagating the shift modifier. */
|
|
49
|
+
function toggleSelectFromEvent(e: MouseEvent) {
|
|
50
|
+
emit("toggle-select", e.shiftKey === true);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Local state ─────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
const expanded = ref(props.defaultExpanded);
|
|
56
|
+
|
|
57
|
+
const localModel = ref<Record<string, unknown>>({ ...props.item });
|
|
58
|
+
|
|
59
|
+
// Re-sync the local model when the parent replaces the item object
|
|
60
|
+
// (reorder, undo, external splice). Without this watcher, after a reorder the
|
|
61
|
+
// reused instance would keep the previous model, causing ghost data on the wrong item.
|
|
62
|
+
watch(
|
|
63
|
+
() => props.item,
|
|
64
|
+
(next) => {
|
|
65
|
+
if (next === localModel.value) return;
|
|
66
|
+
localModel.value = { ...next };
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// MapoFormField mutates model.value in-place (setNestedValueMutating) rather than
|
|
71
|
+
// calling emit. Without this watcher the parent repeater never sees those mutations,
|
|
72
|
+
// so its items array stays stale — causing data loss when the key changes on
|
|
73
|
+
// collapse/expand (remount) or when addItem reads the stale array.
|
|
74
|
+
watch(
|
|
75
|
+
localModel,
|
|
76
|
+
(val) => {
|
|
77
|
+
emit("update:item", val);
|
|
78
|
+
},
|
|
79
|
+
{ deep: true },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Filter parent errors down to this item: "blocks.0.title" → "title".
|
|
83
|
+
const itemErrors = computed<Record<string, string[]>>(() => {
|
|
84
|
+
const result: Record<string, string[]> = {};
|
|
85
|
+
const prefix = `${props.errorPrefix}.`;
|
|
86
|
+
for (const [k, v] of Object.entries(props.parentErrors)) {
|
|
87
|
+
if (k.startsWith(prefix)) result[k.slice(prefix.length)] = v;
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const hasErrors = computed(() => Object.keys(itemErrors.value).length > 0);
|
|
93
|
+
|
|
94
|
+
// ─── Sub-form context for child fields ───────────────────────────────────────
|
|
95
|
+
// Reuse the full form engine (useMapoForm) instead of a hand-rolled context, so the
|
|
96
|
+
// item's fields get correct nested-path / translatable / synci18n / accessor / onChange
|
|
97
|
+
// handling and real client-side validation — not a stripped-down reimplementation.
|
|
98
|
+
|
|
99
|
+
const currentLangRef = computed(() => props.currentLang);
|
|
100
|
+
const readonly = computed(() => props.readonly);
|
|
101
|
+
|
|
102
|
+
// Capture the parent context BEFORE useMapoForm provides a new one for this subtree.
|
|
103
|
+
const parentCtx = injectMapoForm<Record<string, unknown>>();
|
|
104
|
+
|
|
105
|
+
// `notifyErrors: false` so each item does not duplicate the root form's error toast.
|
|
106
|
+
const form = useMapoForm<Record<string, unknown>>({
|
|
107
|
+
model: localModel,
|
|
108
|
+
fields: computed(() => props.fields),
|
|
109
|
+
errors: itemErrors,
|
|
110
|
+
languages: props.languages,
|
|
111
|
+
currentLang: currentLangRef,
|
|
112
|
+
immediate: parentCtx?.immediate ?? false,
|
|
113
|
+
debounce: parentCtx?.debounce ?? 300,
|
|
114
|
+
notifyErrors: false,
|
|
115
|
+
registry: props.registry,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Inherit readonly + submitted from the root form so its submit() reveals item-level
|
|
119
|
+
// validation errors (mirrors MapoForm's nested-form behavior).
|
|
120
|
+
watchEffect(() => {
|
|
121
|
+
form.readonly.value = props.readonly;
|
|
122
|
+
if (parentCtx) form.ctx.submitted.value = parentCtx.submitted.value;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ─── Preview label ────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
const label = computed(
|
|
128
|
+
() =>
|
|
129
|
+
props.previewLabel?.(localModel.value, props.index) ??
|
|
130
|
+
`Item ${props.index + 1}`,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// ─── Contextual Scaling (miniCard) ───────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
const miniCardAttrs = computed(() => props.repeaterDescriptor?.attrs);
|
|
136
|
+
|
|
137
|
+
const isCompressed = computed(() => {
|
|
138
|
+
if (expanded.value) return false;
|
|
139
|
+
const threshold = miniCardAttrs.value?.compressThreshold ?? 3;
|
|
140
|
+
const hasMiniCard = !!miniCardAttrs.value?.miniCard;
|
|
141
|
+
return hasMiniCard && (props.totalItems ?? 0) >= threshold;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const miniCard = computed(() =>
|
|
145
|
+
miniCardAttrs.value?.miniCard?.(localModel.value, props.index),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const statusColorClass = computed(
|
|
149
|
+
() =>
|
|
150
|
+
({
|
|
151
|
+
success: "bg-green-500",
|
|
152
|
+
info: "bg-blue-500",
|
|
153
|
+
warning: "bg-yellow-500",
|
|
154
|
+
error: "bg-red-500",
|
|
155
|
+
neutral: "bg-gray-400",
|
|
156
|
+
})[miniCard.value?.statusColor ?? "neutral"],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// ─── Focus Mode ──────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
const focusMode = useFocusMode();
|
|
162
|
+
|
|
163
|
+
function enterFocusMode() {
|
|
164
|
+
focusMode.enter({
|
|
165
|
+
descriptor: props.repeaterDescriptor as AnyFieldDescriptor,
|
|
166
|
+
fields: props.fields,
|
|
167
|
+
model: { ...localModel.value },
|
|
168
|
+
errors: itemErrors,
|
|
169
|
+
onUpdate: (val) => {
|
|
170
|
+
localModel.value = { ...val };
|
|
171
|
+
emit("update:item", localModel.value);
|
|
172
|
+
},
|
|
173
|
+
registry: props.registry,
|
|
174
|
+
languages: props.languages,
|
|
175
|
+
currentLang: props.currentLang,
|
|
176
|
+
breadcrumb: [props.repeaterDescriptor?.label ?? "Repeater", label.value],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
</script>
|
|
180
|
+
|
|
181
|
+
<template>
|
|
182
|
+
<div
|
|
183
|
+
class="mapo-repeater-item rounded border transition-all"
|
|
184
|
+
:class="[
|
|
185
|
+
hasErrors ? 'border-red-300' : 'border-gray-200',
|
|
186
|
+
selected && selectionMode ? 'ring-2 ring-primary-500 ring-offset-1' : '',
|
|
187
|
+
]"
|
|
188
|
+
>
|
|
189
|
+
<!-- ── Mini-card (contextual scaling) ──────────────────────────────────── -->
|
|
190
|
+
<div
|
|
191
|
+
v-if="isCompressed && miniCard"
|
|
192
|
+
class="flex cursor-pointer items-center gap-3 px-3 py-2 transition-colors hover:bg-gray-50"
|
|
193
|
+
@click="selectionMode ? toggleSelectFromEvent($event) : (expanded = true)"
|
|
194
|
+
>
|
|
195
|
+
<!-- Bulk selection checkbox -->
|
|
196
|
+
<UCheckbox
|
|
197
|
+
v-if="selectionMode"
|
|
198
|
+
:model-value="selected"
|
|
199
|
+
@update:model-value="emit('toggle-select', false)"
|
|
200
|
+
@click.stop
|
|
201
|
+
/>
|
|
202
|
+
|
|
203
|
+
<!-- Optional thumbnail -->
|
|
204
|
+
<img
|
|
205
|
+
v-if="miniCard.thumbnail"
|
|
206
|
+
:src="miniCard.thumbnail"
|
|
207
|
+
class="h-9 w-9 rounded object-cover"
|
|
208
|
+
alt=""
|
|
209
|
+
/>
|
|
210
|
+
|
|
211
|
+
<!-- Status dot -->
|
|
212
|
+
<span
|
|
213
|
+
v-if="miniCard.statusColor"
|
|
214
|
+
class="h-2 w-2 shrink-0 rounded-full"
|
|
215
|
+
:class="statusColorClass"
|
|
216
|
+
/>
|
|
217
|
+
|
|
218
|
+
<!-- Drag handle (mousedown.stop prevents the parent click handler from firing on drag start) -->
|
|
219
|
+
<UIcon
|
|
220
|
+
name="i-lucide-grip-vertical"
|
|
221
|
+
class="drag-handle h-4 w-4 shrink-0 cursor-grab text-gray-300 active:cursor-grabbing"
|
|
222
|
+
@mousedown.stop
|
|
223
|
+
@click.stop
|
|
224
|
+
/>
|
|
225
|
+
|
|
226
|
+
<!-- Contenuto testuale -->
|
|
227
|
+
<div class="min-w-0 flex-1">
|
|
228
|
+
<p class="truncate text-sm font-medium text-gray-700">
|
|
229
|
+
{{ miniCard.title }}
|
|
230
|
+
</p>
|
|
231
|
+
<p v-if="miniCard.subtitle" class="truncate text-xs text-gray-400">
|
|
232
|
+
{{ miniCard.subtitle }}
|
|
233
|
+
</p>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<!-- Error badge -->
|
|
237
|
+
<UBadge v-if="hasErrors" color="error" variant="soft" size="xs">
|
|
238
|
+
errors
|
|
239
|
+
</UBadge>
|
|
240
|
+
|
|
241
|
+
<!-- Quick actions (delete only in mini mode) -->
|
|
242
|
+
<div class="flex items-center gap-1">
|
|
243
|
+
<UButton
|
|
244
|
+
size="xs"
|
|
245
|
+
variant="ghost"
|
|
246
|
+
color="error"
|
|
247
|
+
icon="i-lucide-trash-2"
|
|
248
|
+
:disabled="readonly"
|
|
249
|
+
title="Delete"
|
|
250
|
+
@click.stop="$emit('delete')"
|
|
251
|
+
/>
|
|
252
|
+
<UIcon name="i-lucide-chevron-down" class="h-4 w-4 text-gray-400" />
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<!-- ── Item header (expanded mode) ─────────────────────────────────────── -->
|
|
257
|
+
<div
|
|
258
|
+
v-else
|
|
259
|
+
class="flex items-center gap-2 bg-gray-50 px-3 py-2"
|
|
260
|
+
:class="selectionMode ? 'cursor-pointer hover:bg-gray-100' : ''"
|
|
261
|
+
@click="selectionMode && toggleSelectFromEvent($event)"
|
|
262
|
+
>
|
|
263
|
+
<!-- Bulk selection checkbox -->
|
|
264
|
+
<UCheckbox
|
|
265
|
+
v-if="selectionMode"
|
|
266
|
+
:model-value="selected"
|
|
267
|
+
@update:model-value="emit('toggle-select', false)"
|
|
268
|
+
@click.stop
|
|
269
|
+
/>
|
|
270
|
+
|
|
271
|
+
<!-- Drag handle (mousedown.stop avoids triggering row-click selection on drag start) -->
|
|
272
|
+
<UIcon
|
|
273
|
+
name="i-lucide-grip-vertical"
|
|
274
|
+
class="drag-handle h-4 w-4 cursor-grab text-gray-400 active:cursor-grabbing"
|
|
275
|
+
@mousedown.stop
|
|
276
|
+
@click.stop
|
|
277
|
+
/>
|
|
278
|
+
|
|
279
|
+
<!-- Collapsed label / error badge. In selection mode the row click handles selection,
|
|
280
|
+
so this button only toggles expansion when not selecting. -->
|
|
281
|
+
<button
|
|
282
|
+
type="button"
|
|
283
|
+
class="flex flex-1 items-center gap-2 text-left text-sm font-medium text-gray-700"
|
|
284
|
+
@click.stop="
|
|
285
|
+
selectionMode ? toggleSelectFromEvent($event) : (expanded = !expanded)
|
|
286
|
+
"
|
|
287
|
+
>
|
|
288
|
+
<span class="truncate">{{ label }}</span>
|
|
289
|
+
<UBadge v-if="hasErrors" color="error" variant="soft" size="xs">
|
|
290
|
+
errors
|
|
291
|
+
</UBadge>
|
|
292
|
+
</button>
|
|
293
|
+
|
|
294
|
+
<!-- Action buttons. All buttons stop propagation so the row-click selection handler
|
|
295
|
+
does not also toggle selection when the user clicks an action. -->
|
|
296
|
+
<div class="flex items-center gap-1" @click.stop>
|
|
297
|
+
<slot name="actions" />
|
|
298
|
+
|
|
299
|
+
<!-- Numeric position field (enabled via repeaterDescriptor.attrs.showPositionField) -->
|
|
300
|
+
<template
|
|
301
|
+
v-if="repeaterDescriptor?.attrs?.showPositionField && !readonly"
|
|
302
|
+
>
|
|
303
|
+
<UInput
|
|
304
|
+
:model-value="index + 1"
|
|
305
|
+
type="number"
|
|
306
|
+
:min="1"
|
|
307
|
+
:max="totalItems"
|
|
308
|
+
size="xs"
|
|
309
|
+
class="w-14 text-center"
|
|
310
|
+
title="Position"
|
|
311
|
+
@change="
|
|
312
|
+
$emit(
|
|
313
|
+
'move-to',
|
|
314
|
+
Math.max(
|
|
315
|
+
0,
|
|
316
|
+
Math.min(
|
|
317
|
+
totalItems! - 1,
|
|
318
|
+
Number(($event.target as HTMLInputElement).value) - 1,
|
|
319
|
+
),
|
|
320
|
+
),
|
|
321
|
+
)
|
|
322
|
+
"
|
|
323
|
+
/>
|
|
324
|
+
</template>
|
|
325
|
+
|
|
326
|
+
<!-- Focus mode -->
|
|
327
|
+
<UButton
|
|
328
|
+
size="xs"
|
|
329
|
+
variant="ghost"
|
|
330
|
+
color="neutral"
|
|
331
|
+
icon="i-lucide-maximize-2"
|
|
332
|
+
title="Edit full screen"
|
|
333
|
+
@click="enterFocusMode"
|
|
334
|
+
/>
|
|
335
|
+
|
|
336
|
+
<UButton
|
|
337
|
+
size="xs"
|
|
338
|
+
variant="ghost"
|
|
339
|
+
color="neutral"
|
|
340
|
+
icon="i-lucide-chevron-up"
|
|
341
|
+
:disabled="readonly"
|
|
342
|
+
title="Move up"
|
|
343
|
+
@click="$emit('move-up')"
|
|
344
|
+
/>
|
|
345
|
+
<UButton
|
|
346
|
+
size="xs"
|
|
347
|
+
variant="ghost"
|
|
348
|
+
color="neutral"
|
|
349
|
+
icon="i-lucide-chevron-down"
|
|
350
|
+
:disabled="readonly"
|
|
351
|
+
title="Move down"
|
|
352
|
+
@click="$emit('move-down')"
|
|
353
|
+
/>
|
|
354
|
+
|
|
355
|
+
<UButton
|
|
356
|
+
v-if="allowDuplicate"
|
|
357
|
+
size="xs"
|
|
358
|
+
variant="ghost"
|
|
359
|
+
color="neutral"
|
|
360
|
+
icon="i-lucide-copy"
|
|
361
|
+
:disabled="readonly"
|
|
362
|
+
title="Duplicate"
|
|
363
|
+
@click="$emit('duplicate')"
|
|
364
|
+
/>
|
|
365
|
+
|
|
366
|
+
<UButton
|
|
367
|
+
size="xs"
|
|
368
|
+
variant="ghost"
|
|
369
|
+
color="error"
|
|
370
|
+
icon="i-lucide-trash-2"
|
|
371
|
+
:disabled="readonly"
|
|
372
|
+
title="Delete"
|
|
373
|
+
@click="$emit('delete')"
|
|
374
|
+
/>
|
|
375
|
+
|
|
376
|
+
<UIcon
|
|
377
|
+
:name="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
378
|
+
class="h-4 w-4 text-gray-400"
|
|
379
|
+
@click="expanded = !expanded"
|
|
380
|
+
/>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
<!-- ── Item body (fields) ──────────────────────────────────────────────── -->
|
|
385
|
+
<!-- COL_SPAN: Tailwind v4 requires static classes — do not use dynamic template literals. -->
|
|
386
|
+
<div v-if="expanded && !isCompressed" class="grid grid-cols-12 gap-5 p-4">
|
|
387
|
+
<div
|
|
388
|
+
v-for="field in fields"
|
|
389
|
+
:key="field.key as string"
|
|
390
|
+
:class="{
|
|
391
|
+
'col-span-1': field.cols === 1,
|
|
392
|
+
'col-span-2': field.cols === 2,
|
|
393
|
+
'col-span-3': field.cols === 3,
|
|
394
|
+
'col-span-4': field.cols === 4,
|
|
395
|
+
'col-span-5': field.cols === 5,
|
|
396
|
+
'col-span-6': field.cols === 6,
|
|
397
|
+
'col-span-7': field.cols === 7,
|
|
398
|
+
'col-span-8': field.cols === 8,
|
|
399
|
+
'col-span-9': field.cols === 9,
|
|
400
|
+
'col-span-10': field.cols === 10,
|
|
401
|
+
'col-span-11': field.cols === 11,
|
|
402
|
+
'col-span-12': !field.cols || field.cols === 12,
|
|
403
|
+
}"
|
|
404
|
+
>
|
|
405
|
+
<MapoFormField :descriptor="field" />
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
</template>
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import { useRequestURL } from "#app";
|
|
4
|
+
import type { SeoDescriptor } from "../../types/index.js";
|
|
5
|
+
|
|
6
|
+
interface SeoValue {
|
|
7
|
+
title?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
/** Matches v1 key. `url` is accepted as alias via the registry accessor. */
|
|
10
|
+
permalink?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
modelValue: unknown;
|
|
15
|
+
descriptor: SeoDescriptor;
|
|
16
|
+
errors?: string[];
|
|
17
|
+
readonly?: boolean;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
}>();
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits<{ "update:modelValue": [value: SeoValue] }>();
|
|
22
|
+
|
|
23
|
+
const value = computed<SeoValue>(() => {
|
|
24
|
+
const v = props.modelValue;
|
|
25
|
+
if (!v || typeof v !== "object")
|
|
26
|
+
return { title: "", description: "", permalink: "" };
|
|
27
|
+
return v as SeoValue;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function update(field: keyof SeoValue, val: string) {
|
|
31
|
+
emit("update:modelValue", { ...value.value, [field]: val });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// SERP preview: Google mostra ~60 char per title, ~155 per description
|
|
35
|
+
const titleLen = computed(() => (value.value.title ?? "").length);
|
|
36
|
+
const descLen = computed(() => (value.value.description ?? "").length);
|
|
37
|
+
|
|
38
|
+
const titleColor = computed(() => {
|
|
39
|
+
if (titleLen.value > 60) return "text-red-500";
|
|
40
|
+
if (titleLen.value > 50) return "text-amber-500";
|
|
41
|
+
return "text-green-600";
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const descColor = computed(() => {
|
|
45
|
+
if (descLen.value > 155) return "text-red-500";
|
|
46
|
+
if (descLen.value > 130) return "text-amber-500";
|
|
47
|
+
return "text-green-600";
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// useRequestURL() works both in SSR and client-side environments (unlike window.location).
|
|
51
|
+
const requestUrl = useRequestURL();
|
|
52
|
+
const displayUrl = computed(() => value.value.permalink || requestUrl.host);
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<div class="mapo-seo-preview space-y-4">
|
|
57
|
+
<!-- Input fields -->
|
|
58
|
+
<div class="space-y-3">
|
|
59
|
+
<div>
|
|
60
|
+
<div class="mb-1 flex items-center justify-between">
|
|
61
|
+
<label class="text-sm font-medium text-gray-700">URL</label>
|
|
62
|
+
</div>
|
|
63
|
+
<UInput
|
|
64
|
+
:model-value="value.permalink"
|
|
65
|
+
:readonly="readonly"
|
|
66
|
+
:disabled="disabled"
|
|
67
|
+
placeholder="https://example.com/slug"
|
|
68
|
+
class="w-full"
|
|
69
|
+
@update:model-value="update('permalink', $event as string)"
|
|
70
|
+
/>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div>
|
|
74
|
+
<div class="mb-1 flex items-center justify-between">
|
|
75
|
+
<label class="text-sm font-medium text-gray-700">SEO title</label>
|
|
76
|
+
<span class="text-xs" :class="titleColor">{{ titleLen }} / 60</span>
|
|
77
|
+
</div>
|
|
78
|
+
<UInput
|
|
79
|
+
:model-value="value.title"
|
|
80
|
+
:readonly="readonly"
|
|
81
|
+
:disabled="disabled"
|
|
82
|
+
placeholder="Page title..."
|
|
83
|
+
class="w-full"
|
|
84
|
+
@update:model-value="update('title', $event as string)"
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div>
|
|
89
|
+
<div class="mb-1 flex items-center justify-between">
|
|
90
|
+
<label class="text-sm font-medium text-gray-700"
|
|
91
|
+
>Meta description</label
|
|
92
|
+
>
|
|
93
|
+
<span class="text-xs" :class="descColor">{{ descLen }} / 155</span>
|
|
94
|
+
</div>
|
|
95
|
+
<UTextarea
|
|
96
|
+
:model-value="value.description"
|
|
97
|
+
:rows="3"
|
|
98
|
+
:readonly="readonly"
|
|
99
|
+
:disabled="disabled"
|
|
100
|
+
placeholder="Page description..."
|
|
101
|
+
class="w-full"
|
|
102
|
+
@update:model-value="update('description', $event as string)"
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<!-- SERP preview -->
|
|
108
|
+
<div class="rounded border border-gray-200 bg-gray-50 p-4">
|
|
109
|
+
<p class="mb-1 text-xs font-medium uppercase tracking-wide text-gray-400">
|
|
110
|
+
Google preview
|
|
111
|
+
</p>
|
|
112
|
+
<div class="max-w-lg">
|
|
113
|
+
<p class="truncate text-sm text-green-700">
|
|
114
|
+
{{ displayUrl }}
|
|
115
|
+
</p>
|
|
116
|
+
<p class="truncate text-lg text-blue-700 underline">
|
|
117
|
+
{{ value.title || "Page title" }}
|
|
118
|
+
</p>
|
|
119
|
+
<p class="mt-1 line-clamp-2 text-sm text-gray-600">
|
|
120
|
+
{{
|
|
121
|
+
value.description ||
|
|
122
|
+
"Page description that will appear in Google search results."
|
|
123
|
+
}}
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<p
|
|
129
|
+
v-for="err in errors"
|
|
130
|
+
:key="err"
|
|
131
|
+
class="text-sm text-red-500"
|
|
132
|
+
role="alert"
|
|
133
|
+
>
|
|
134
|
+
{{ err }}
|
|
135
|
+
</p>
|
|
136
|
+
</div>
|
|
137
|
+
</template>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import { parseDateTime, CalendarDateTime } from "@internationalized/date";
|
|
4
|
+
import type { DateDescriptor } from "../../types/index.js";
|
|
5
|
+
|
|
6
|
+
/** Time-only field backed by `UInputDate` with minute granularity. */
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
modelValue: unknown;
|
|
9
|
+
descriptor: DateDescriptor;
|
|
10
|
+
errors?: string[];
|
|
11
|
+
readonly?: boolean;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
}>();
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits<{ "update:modelValue": [value: string | null] }>();
|
|
16
|
+
|
|
17
|
+
// UInputDate with `granularity='minute'` shows only hours and minutes.
|
|
18
|
+
// The value is normalised to `1970-01-01THH:MM:00` for parsing.
|
|
19
|
+
const calendarValue = computed<CalendarDateTime | undefined>(() => {
|
|
20
|
+
const v = props.modelValue as string | null | undefined;
|
|
21
|
+
if (!v) return undefined;
|
|
22
|
+
const normalized = v.includes("T") ? v : `1970-01-01T${v}`;
|
|
23
|
+
try {
|
|
24
|
+
return parseDateTime(normalized);
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// UInputDate emits a broad model union (date / datetime / zoned); with
|
|
31
|
+
// `granularity='minute'` the value carries hour and minute, so narrow on those.
|
|
32
|
+
function onUpdate(val: unknown) {
|
|
33
|
+
// Emit only `HH:MM`.
|
|
34
|
+
const time = val as { hour?: number; minute?: number } | null | undefined;
|
|
35
|
+
if (
|
|
36
|
+
!time ||
|
|
37
|
+
typeof time.hour !== "number" ||
|
|
38
|
+
typeof time.minute !== "number"
|
|
39
|
+
) {
|
|
40
|
+
emit("update:modelValue", null);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const h = String(time.hour).padStart(2, "0");
|
|
44
|
+
const m = String(time.minute).padStart(2, "0");
|
|
45
|
+
emit("update:modelValue", `${h}:${m}`);
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<UInputDate
|
|
51
|
+
:model-value="calendarValue"
|
|
52
|
+
granularity="minute"
|
|
53
|
+
hide-time-zone
|
|
54
|
+
:readonly="readonly"
|
|
55
|
+
:disabled="disabled"
|
|
56
|
+
:highlight="!!errors?.length"
|
|
57
|
+
@update:model-value="onUpdate"
|
|
58
|
+
/>
|
|
59
|
+
</template>
|