@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,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>