@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,283 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ ref,
4
+ computed,
5
+ provide,
6
+ watchEffect,
7
+ onMounted,
8
+ onUnmounted,
9
+ } from "vue";
10
+ import { useRuntimeConfig } from "#app";
11
+ import type {
12
+ AnyFieldDescriptor,
13
+ MapoFormRuntimeConfig,
14
+ } from "../types/index.js";
15
+ import { injectMapoForm } from "../composables/useMapoForm.js";
16
+ import MapoFormField from "./MapoFormField.vue";
17
+
18
+ const props = defineProps<{
19
+ name: string;
20
+ label?: string;
21
+ fields: AnyFieldDescriptor[];
22
+ subtabs?: Map<string, AnyFieldDescriptor[]>;
23
+ initialExpanded?: boolean;
24
+ }>();
25
+
26
+ const globalExpanded: boolean =
27
+ (useRuntimeConfig().public.mapoForm as MapoFormRuntimeConfig | undefined)
28
+ ?.groups?.expanded ?? true;
29
+ const expanded = ref(props.initialExpanded ?? globalExpanded);
30
+
31
+ // ─── Sub-tabs inside the group ────────────────────────────────────────────────
32
+
33
+ const activeSubtab = ref<string>("");
34
+ // Initialise to first subtab when subtabs are provided.
35
+ watchEffect(() => {
36
+ if (props.subtabs && props.subtabs.size > 0 && !activeSubtab.value) {
37
+ activeSubtab.value = [...props.subtabs.keys()][0] ?? "";
38
+ }
39
+ });
40
+
41
+ // ─── Field expand ────────────────────────────────────────────────────────────
42
+
43
+ const expandedFieldKey = ref<string | null>(null);
44
+
45
+ function expandField(key: string) {
46
+ expandedFieldKey.value = key;
47
+ document.body.style.overflow = "hidden";
48
+ }
49
+
50
+ function collapseField() {
51
+ expandedFieldKey.value = null;
52
+ document.body.style.overflow = "";
53
+ }
54
+
55
+ function onKeydown(e: KeyboardEvent) {
56
+ if (e.key === "Escape" && expandedFieldKey.value) collapseField();
57
+ }
58
+
59
+ onMounted(() => document.addEventListener("keydown", onKeydown));
60
+ onUnmounted(() => {
61
+ document.removeEventListener("keydown", onKeydown);
62
+ document.body.style.overflow = "";
63
+ });
64
+
65
+ provide("mapoGroupExpand", { expandField, collapseField, expandedFieldKey });
66
+
67
+ const form = injectMapoForm();
68
+ const model = computed(() => form?.model.value);
69
+ const currentLang = computed(() => form?.currentLang.value ?? "");
70
+
71
+ // ─── Col-span lookup ─────────────────────────────────────────────────────────
72
+
73
+ const COL_SPAN: Record<number, string> = {
74
+ 1: "col-span-1",
75
+ 2: "col-span-2",
76
+ 3: "col-span-3",
77
+ 4: "col-span-4",
78
+ 5: "col-span-5",
79
+ 6: "col-span-6",
80
+ 7: "col-span-7",
81
+ 8: "col-span-8",
82
+ 9: "col-span-9",
83
+ 10: "col-span-10",
84
+ 11: "col-span-11",
85
+ 12: "col-span-12",
86
+ };
87
+ function colClass(cols: AnyFieldDescriptor["cols"]): string {
88
+ if (!cols || typeof cols !== "number") return "col-span-12";
89
+ return COL_SPAN[cols] ?? "col-span-12";
90
+ }
91
+ </script>
92
+
93
+ <template>
94
+ <!-- Expanded panel: teleported to body to avoid parent overflow/transform interference. -->
95
+ <Teleport to="body">
96
+ <Transition name="mapo-expand">
97
+ <div
98
+ v-if="expandedFieldKey"
99
+ class="fixed inset-0 z-50 flex flex-col"
100
+ style="padding: 1rem"
101
+ >
102
+ <!-- Clickable backdrop -->
103
+ <div
104
+ class="absolute inset-0 bg-black/50 backdrop-blur-sm"
105
+ @click="collapseField"
106
+ />
107
+
108
+ <!-- Panel -->
109
+ <div
110
+ class="relative z-10 flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xl"
111
+ >
112
+ <!-- Header -->
113
+ <div
114
+ class="flex shrink-0 items-center justify-between border-b border-gray-100 px-5 py-3.5"
115
+ >
116
+ <span class="text-sm font-semibold tracking-wide text-gray-700">{{
117
+ label ?? name
118
+ }}</span>
119
+ <button
120
+ type="button"
121
+ class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
122
+ @click="collapseField"
123
+ >
124
+ <UIcon name="i-lucide-minimize-2" class="h-4 w-4" />
125
+ </button>
126
+ </div>
127
+
128
+ <!-- Expanded field -->
129
+ <div
130
+ class="mapo-group-expand-body flex min-h-0 flex-1 flex-col p-5"
131
+ style="overflow: hidden"
132
+ >
133
+ <template v-for="field in fields" :key="field.key as string">
134
+ <div
135
+ v-if="expandedFieldKey === (field.key as string)"
136
+ style="
137
+ display: flex;
138
+ flex-direction: column;
139
+ flex: 1;
140
+ min-height: 0;
141
+ height: 100%;
142
+ "
143
+ >
144
+ <slot
145
+ :name="`field.${field.key as string}`"
146
+ :field="field"
147
+ :model="model"
148
+ :current-lang="currentLang"
149
+ >
150
+ <MapoFormField
151
+ :descriptor="field"
152
+ style="flex: 1; min-height: 0; height: 100%"
153
+ >
154
+ <template
155
+ v-for="(_, slotName) in $slots"
156
+ #[slotName]="slotProps"
157
+ >
158
+ <slot :name="slotName" v-bind="slotProps ?? {}" />
159
+ </template>
160
+ </MapoFormField>
161
+ </slot>
162
+ </div>
163
+ </template>
164
+ </div>
165
+ </div>
166
+ </div>
167
+ </Transition>
168
+ </Teleport>
169
+
170
+ <!-- Regular group (kept in the DOM so the provide stays active) -->
171
+ <div
172
+ class="mapo-form-group relative overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm"
173
+ >
174
+ <!-- Collapsible heading -->
175
+ <button
176
+ type="button"
177
+ class="flex w-full items-center justify-between px-5 py-3.5 text-left transition-colors hover:bg-gray-50"
178
+ @click="expanded = !expanded"
179
+ >
180
+ <span class="text-sm font-semibold tracking-wide text-gray-700">{{
181
+ label ?? name
182
+ }}</span>
183
+ <UIcon
184
+ :name="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
185
+ class="h-4 w-4 shrink-0 text-gray-400"
186
+ />
187
+ </button>
188
+
189
+ <!-- Body -->
190
+ <div v-if="expanded">
191
+ <!-- Flat fields (no subtab) -->
192
+ <div
193
+ v-if="fields.length"
194
+ class="grid grid-cols-12 gap-5 px-5 pb-5 pt-4"
195
+ :class="{ 'pb-0': subtabs && subtabs.size > 0 }"
196
+ >
197
+ <div
198
+ v-for="field in fields"
199
+ :key="field.key as string"
200
+ :class="colClass(field.cols)"
201
+ >
202
+ <slot
203
+ :name="`field.${field.key as string}`"
204
+ :field="field"
205
+ :model="model"
206
+ :current-lang="currentLang"
207
+ >
208
+ <MapoFormField :descriptor="field">
209
+ <template v-for="(_, slotName) in $slots" #[slotName]="slotProps">
210
+ <slot :name="slotName" v-bind="slotProps ?? {}" />
211
+ </template>
212
+ </MapoFormField>
213
+ </slot>
214
+ </div>
215
+ </div>
216
+
217
+ <!-- Sub-tab bar + content (when subtabs are present) -->
218
+ <div v-if="subtabs && subtabs.size > 0" class="px-5 pb-5 pt-4">
219
+ <!-- Sub-tab bar -->
220
+ <div class="flex gap-1 border-b border-gray-200 mb-4">
221
+ <button
222
+ v-for="[subtabName] of subtabs"
223
+ :key="subtabName"
224
+ type="button"
225
+ class="relative px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none"
226
+ :class="[
227
+ activeSubtab === subtabName
228
+ ? 'text-primary-600 border-b-2 border-primary-600 -mb-px'
229
+ : 'text-gray-500 hover:text-gray-700',
230
+ ]"
231
+ @click="activeSubtab = subtabName"
232
+ >
233
+ {{ subtabName }}
234
+ </button>
235
+ </div>
236
+
237
+ <!-- Sub-tab fields -->
238
+ <div
239
+ v-for="[subtabName, subtabFields] of subtabs"
240
+ v-show="activeSubtab === subtabName"
241
+ :key="subtabName"
242
+ class="grid grid-cols-12 gap-5"
243
+ >
244
+ <div
245
+ v-for="field in subtabFields"
246
+ :key="field.key as string"
247
+ :class="colClass(field.cols)"
248
+ >
249
+ <slot
250
+ :name="`field.${field.key as string}`"
251
+ :field="field"
252
+ :model="model"
253
+ :current-lang="currentLang"
254
+ >
255
+ <MapoFormField :descriptor="field">
256
+ <template
257
+ v-for="(_, slotName) in $slots"
258
+ #[slotName]="slotProps"
259
+ >
260
+ <slot :name="slotName" v-bind="slotProps ?? {}" />
261
+ </template>
262
+ </MapoFormField>
263
+ </slot>
264
+ </div>
265
+ </div>
266
+ </div>
267
+ </div>
268
+ </div>
269
+ </template>
270
+
271
+ <style>
272
+ .mapo-expand-enter-active,
273
+ .mapo-expand-leave-active {
274
+ transition:
275
+ opacity 0.2s ease,
276
+ transform 0.2s ease;
277
+ }
278
+ .mapo-expand-enter-from,
279
+ .mapo-expand-leave-to {
280
+ opacity: 0;
281
+ transform: scale(0.97);
282
+ }
283
+ </style>
@@ -0,0 +1,116 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from "vue";
3
+ import type { TabEntry } from "../types/layout.js";
4
+ import { injectMapoForm } from "../composables/useMapoForm.js";
5
+ import MapoFormGroup from "./MapoFormGroup.vue";
6
+ import MapoFormFlatSection from "./MapoFormFlatSection.vue";
7
+
8
+ const props = defineProps<{ tabs: TabEntry[] }>();
9
+
10
+ defineSlots<Record<string, unknown>>();
11
+
12
+ const activeTab = ref(props.tabs[0]?.name ?? "");
13
+
14
+ // Recursively check if a tab (or any of its descendants) contains errors.
15
+ function tabHasErrors(tab: TabEntry, errorKeys: Set<string>): boolean {
16
+ const directError = [...tab.groups.values()].some((g) =>
17
+ g.fields.some((f) => errorKeys.has(f.key as string)),
18
+ );
19
+ if (directError) return true;
20
+ return [...tab.children.values()].some((child) =>
21
+ tabHasErrors(child, errorKeys),
22
+ );
23
+ }
24
+
25
+ // Highlight tabs containing errors.
26
+ const form = injectMapoForm()!;
27
+ const tabsWithErrors = computed(() => {
28
+ const errorKeys = new Set(Object.keys(form.errors.value));
29
+ return new Set(
30
+ props.tabs.filter((tab) => tabHasErrors(tab, errorKeys)).map((t) => t.name),
31
+ );
32
+ });
33
+ </script>
34
+
35
+ <template>
36
+ <div class="mapo-form-tabs">
37
+ <!-- Tab bar -->
38
+ <div class="flex gap-1 border-b border-gray-200 pb-px">
39
+ <button
40
+ v-for="tab in tabs"
41
+ :key="tab.name"
42
+ type="button"
43
+ class="relative flex items-center gap-1.5 rounded-t px-4 py-2 text-sm font-medium transition-colors"
44
+ :class="[
45
+ activeTab === tab.name
46
+ ? 'border-b-2 border-primary-500 text-primary-600'
47
+ : 'text-gray-500 hover:bg-gray-50 hover:text-gray-700',
48
+ ]"
49
+ @click="activeTab = tab.name"
50
+ >
51
+ <slot :name="`tab.${tab.name}.label`" :tab="tab">
52
+ {{ tab.label ?? tab.name }}
53
+ </slot>
54
+ <span
55
+ v-if="tabsWithErrors.has(tab.name)"
56
+ class="inline-block h-1.5 w-1.5 rounded-full bg-red-500"
57
+ aria-label="This tab contains errors"
58
+ />
59
+ </button>
60
+ <!-- Extra content appended to the tab bar (actions, badges, …). -->
61
+ <slot name="tab-bar-end" />
62
+ </div>
63
+
64
+ <!-- Tab content -->
65
+ <div
66
+ v-for="tab in tabs"
67
+ v-show="activeTab === tab.name"
68
+ :key="tab.name"
69
+ class="space-y-5 pt-5"
70
+ >
71
+ <!-- Full panel override: `tab.{name}` replaces this tab's content entirely. -->
72
+ <slot
73
+ v-if="$slots[`tab.${tab.name}`]"
74
+ :name="`tab.${tab.name}`"
75
+ :tab="tab"
76
+ />
77
+ <template v-else>
78
+ <slot :name="`tab.${tab.name}.before`" :tab="tab" />
79
+ <!-- Direct groups at this level -->
80
+ <template v-for="[groupName, group] of tab.groups" :key="groupName">
81
+ <template v-if="groupName !== '__flat__'">
82
+ <slot :name="`group.${groupName}.before`" />
83
+ <MapoFormGroup
84
+ :name="groupName"
85
+ :label="group.label"
86
+ :fields="group.fields"
87
+ :subtabs="group.subtabs"
88
+ >
89
+ <template v-for="(_, slotName) in $slots" #[slotName]="slotProps">
90
+ <slot :name="slotName" v-bind="slotProps ?? {}" />
91
+ </template>
92
+ </MapoFormGroup>
93
+ <slot :name="`group.${groupName}.after`" />
94
+ </template>
95
+
96
+ <MapoFormFlatSection v-else :fields="group.fields">
97
+ <template v-for="(_, slotName) in $slots" #[slotName]="slotProps">
98
+ <slot :name="slotName" v-bind="slotProps ?? {}" />
99
+ </template>
100
+ </MapoFormFlatSection>
101
+ </template>
102
+
103
+ <!-- Nested sub-tabs (recursive) -->
104
+ <MapoFormTabs
105
+ v-if="tab.children.size > 0"
106
+ :tabs="[...tab.children.values()]"
107
+ >
108
+ <template v-for="(_, slotName) in $slots" #[slotName]="slotProps">
109
+ <slot :name="slotName" v-bind="slotProps ?? {}" />
110
+ </template>
111
+ </MapoFormTabs>
112
+ <slot :name="`tab.${tab.name}.after`" :tab="tab" />
113
+ </template>
114
+ </div>
115
+ </div>
116
+ </template>
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from "vue";
3
+ import type { AnyFieldDescriptor } from "../types/index.js";
4
+
5
+ const props = defineProps<{
6
+ modelValue: unknown;
7
+ descriptor: AnyFieldDescriptor;
8
+ errors?: string[];
9
+ readonly?: boolean;
10
+ disabled?: boolean;
11
+ }>();
12
+
13
+ const emit = defineEmits<{ "update:modelValue": [value: unknown] }>();
14
+
15
+ // Inspect-friendly representation of the value. Never emits type-changing updates:
16
+ // if the user edits the JSON, emit only after a successful parse.
17
+ const jsonValue = computed(() => {
18
+ try {
19
+ return JSON.stringify(props.modelValue, null, 2);
20
+ } catch {
21
+ return String(props.modelValue);
22
+ }
23
+ });
24
+
25
+ const draft = ref(jsonValue.value);
26
+ const parseError = ref<string | null>(null);
27
+
28
+ watch(jsonValue, (val) => {
29
+ draft.value = val;
30
+ parseError.value = null;
31
+ });
32
+
33
+ function onInput(val: string | number) {
34
+ draft.value = String(val);
35
+ try {
36
+ const parsed = JSON.parse(draft.value);
37
+ parseError.value = null;
38
+ emit("update:modelValue", parsed);
39
+ } catch (e) {
40
+ // Do not emit until the JSON is valid: this avoids silently turning
41
+ // `42` (number) into `"42"` (string) while the user is typing.
42
+ parseError.value = (e as Error).message;
43
+ }
44
+ }
45
+
46
+ if (import.meta.dev) {
47
+ console.warn(
48
+ `[Mapo] Field type "${props.descriptor.type}" (key: "${props.descriptor.key as string}") is not in the registry. Rendered as MapoUnknownField.`,
49
+ );
50
+ }
51
+ </script>
52
+
53
+ <template>
54
+ <div class="rounded border border-yellow-400 bg-yellow-50 p-3 text-sm">
55
+ <p class="font-mono font-semibold text-yellow-700">
56
+ Unknown field: <code>{{ descriptor.key }}</code>
57
+ <span class="ml-2 text-yellow-500">(type: {{ descriptor.type }})</span>
58
+ </p>
59
+ <UTextarea
60
+ class="mt-2 font-mono text-xs"
61
+ :model-value="draft"
62
+ :rows="4"
63
+ :readonly="readonly || disabled"
64
+ :disabled="disabled"
65
+ @update:model-value="onInput"
66
+ />
67
+ <p v-if="parseError" class="mt-1 text-xs text-yellow-700">
68
+ Invalid JSON — changes are not applied until the value becomes parseable.
69
+ </p>
70
+ <p v-for="err in errors" :key="err" class="mt-1 text-red-500">
71
+ {{ err }}
72
+ </p>
73
+ </div>
74
+ </template>
@@ -0,0 +1,98 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from "vue";
3
+ import { parseDate, CalendarDate } from "@internationalized/date";
4
+ import type { DateDescriptor } from "../../types/index.js";
5
+
6
+ /** Date-only field backed by `UCalendar` and serialised as `YYYY-MM-DD`. */
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
+ const open = ref(false);
18
+
19
+ /** Parsed calendar value derived from the current ISO-like model string. */
20
+ const calendarValue = computed<CalendarDate | undefined>(() => {
21
+ const v = props.modelValue as string | null | undefined;
22
+ if (!v) return undefined;
23
+ try {
24
+ // split() always returns at least one element.
25
+ return parseDate(String(v).split("T")[0]!);
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ });
30
+
31
+ const displayLabel = computed(() => {
32
+ const v = calendarValue.value;
33
+ if (!v) return "Select date";
34
+ return `${String(v.day).padStart(2, "0")}/${String(v.month).padStart(2, "0")}/${v.year}`;
35
+ });
36
+
37
+ const attrs = computed(() => props.descriptor.attrs ?? {});
38
+
39
+ function onDateSelect(date: unknown) {
40
+ const d = date as CalendarDate | null;
41
+ open.value = false;
42
+ emit("update:modelValue", d ? d.toString() : null);
43
+ }
44
+
45
+ function clear() {
46
+ emit("update:modelValue", null);
47
+ open.value = false;
48
+ }
49
+ </script>
50
+
51
+ <template>
52
+ <UPopover v-model:open="open" :disabled="readonly || disabled">
53
+ <UButton
54
+ variant="outline"
55
+ color="neutral"
56
+ icon="i-lucide-calendar"
57
+ trailing
58
+ class="w-full justify-between font-normal"
59
+ :class="{
60
+ 'opacity-50 cursor-not-allowed': readonly || disabled,
61
+ 'ring-2 ring-red-500': errors?.length,
62
+ }"
63
+ :disabled="readonly || disabled"
64
+ >
65
+ <span
66
+ :class="
67
+ calendarValue ? 'text-gray-900 dark:text-white' : 'text-gray-400'
68
+ "
69
+ >
70
+ {{ displayLabel }}
71
+ </span>
72
+ </UButton>
73
+
74
+ <template #content>
75
+ <div class="p-3 space-y-2">
76
+ <UCalendar
77
+ :model-value="calendarValue"
78
+ :min-value="attrs.min ? parseDate(attrs.min) : undefined"
79
+ :max-value="attrs.max ? parseDate(attrs.max) : undefined"
80
+ @update:model-value="onDateSelect"
81
+ />
82
+ <div
83
+ class="flex justify-end border-t border-gray-200 dark:border-gray-700 pt-2"
84
+ >
85
+ <UButton
86
+ variant="ghost"
87
+ color="neutral"
88
+ size="xs"
89
+ icon="i-lucide-x"
90
+ @click="clear"
91
+ >
92
+ Clear
93
+ </UButton>
94
+ </div>
95
+ </div>
96
+ </template>
97
+ </UPopover>
98
+ </template>