@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,192 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from "vue";
3
+ import {
4
+ parseDateTime,
5
+ CalendarDateTime,
6
+ today,
7
+ getLocalTimeZone,
8
+ } from "@internationalized/date";
9
+ import type { DateDescriptor } from "../../types/index.js";
10
+
11
+ /** Datetime field with configurable naive/UTC serialisation semantics. */
12
+ const props = defineProps<{
13
+ modelValue: unknown;
14
+ descriptor: DateDescriptor;
15
+ errors?: string[];
16
+ readonly?: boolean;
17
+ disabled?: boolean;
18
+ }>();
19
+
20
+ const emit = defineEmits<{ "update:modelValue": [value: string | null] }>();
21
+
22
+ const open = ref(false);
23
+
24
+ // Timezone strategy configurable through `descriptor.attrs.tz`:
25
+ // - `naive` (default): the input represents a wall-time with no timezone.
26
+ // The backend preserves the string as-is. Round-trip safe.
27
+ // - `utc`: the input is interpreted/emitted as ISO with a `Z` suffix.
28
+ // Explicit local ↔ UTC conversion; the backend stores UTC.
29
+ type TzMode = "naive" | "utc";
30
+ const tzMode = computed<TzMode>(() => props.descriptor.attrs?.tz ?? "naive");
31
+
32
+ // ISO string → CalendarDateTime
33
+ const calendarValue = computed<CalendarDateTime | undefined>(() => {
34
+ const v = props.modelValue as string | null | undefined;
35
+ if (!v) return undefined;
36
+ try {
37
+ if (tzMode.value === "utc") {
38
+ // Interpret the input as UTC and convert it to local wall-time for the UI.
39
+ const d = new Date(v);
40
+ if (Number.isNaN(d.getTime())) return undefined;
41
+ return new CalendarDateTime(
42
+ d.getFullYear(),
43
+ d.getMonth() + 1,
44
+ d.getDate(),
45
+ d.getHours(),
46
+ d.getMinutes(),
47
+ d.getSeconds(),
48
+ );
49
+ }
50
+ // `naive`: strip any stray Z/offset suffix and parse as wall-time.
51
+ // split() always returns at least one element.
52
+ const clean = v
53
+ .replace(/Z$/, "")
54
+ .replace(/[+-]\d{2}:\d{2}$/, "")
55
+ .split(".")[0]!;
56
+ return parseDateTime(clean);
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ });
61
+
62
+ function emitFromCalendar(v: CalendarDateTime) {
63
+ if (tzMode.value === "utc") {
64
+ // Local wall-time → UTC ISO instant.
65
+ const d = new Date(v.year, v.month - 1, v.day, v.hour, v.minute, 0, 0);
66
+ emit("update:modelValue", d.toISOString());
67
+ return;
68
+ }
69
+ emit("update:modelValue", v.toString());
70
+ }
71
+
72
+ // Displayed label in the trigger button
73
+ const displayLabel = computed(() => {
74
+ const v = calendarValue.value;
75
+ if (!v) return "Select date and time";
76
+ const date = `${String(v.day).padStart(2, "0")}/${String(v.month).padStart(2, "0")}/${v.year}`;
77
+ const time = `${String(v.hour).padStart(2, "0")}:${String(v.minute).padStart(2, "0")}`;
78
+ return `${date} ${time}`;
79
+ });
80
+
81
+ // Separate refs for the time picker
82
+ const timeHour = computed(() => calendarValue.value?.hour ?? 0);
83
+ const timeMinute = computed(() => calendarValue.value?.minute ?? 0);
84
+
85
+ function onDateSelect(date: unknown) {
86
+ const d = date as CalendarDateTime | null;
87
+ if (!d) {
88
+ emit("update:modelValue", null);
89
+ return;
90
+ }
91
+ // Preserve existing time when changing date
92
+ const h = calendarValue.value?.hour ?? 0;
93
+ const m = calendarValue.value?.minute ?? 0;
94
+ const merged = new CalendarDateTime(d.year, d.month, d.day, h, m);
95
+ emitFromCalendar(merged);
96
+ }
97
+
98
+ function onTimeChange(part: "hour" | "minute", val: string) {
99
+ const n = Math.max(
100
+ 0,
101
+ Math.min(part === "hour" ? 23 : 59, parseInt(val) || 0),
102
+ );
103
+ const base =
104
+ calendarValue.value ??
105
+ (() => {
106
+ const t = today(getLocalTimeZone());
107
+ return new CalendarDateTime(t.year, t.month, t.day, 0, 0);
108
+ })();
109
+ const merged =
110
+ part === "hour"
111
+ ? new CalendarDateTime(base.year, base.month, base.day, n, base.minute)
112
+ : new CalendarDateTime(base.year, base.month, base.day, base.hour, n);
113
+ emitFromCalendar(merged);
114
+ }
115
+
116
+ function clear() {
117
+ emit("update:modelValue", null);
118
+ open.value = false;
119
+ }
120
+ </script>
121
+
122
+ <template>
123
+ <UPopover v-model:open="open" :disabled="readonly || disabled">
124
+ <UButton
125
+ variant="outline"
126
+ color="neutral"
127
+ icon="i-lucide-calendar"
128
+ trailing
129
+ class="w-full justify-between font-normal"
130
+ :class="{
131
+ 'opacity-50 cursor-not-allowed': readonly || disabled,
132
+ 'ring-2 ring-red-500': errors?.length,
133
+ }"
134
+ :disabled="readonly || disabled"
135
+ >
136
+ <span
137
+ :class="
138
+ calendarValue ? 'text-gray-900 dark:text-white' : 'text-gray-400'
139
+ "
140
+ >
141
+ {{ displayLabel }}
142
+ </span>
143
+ </UButton>
144
+
145
+ <template #content>
146
+ <div class="p-3 space-y-3">
147
+ <UCalendar
148
+ :model-value="calendarValue"
149
+ @update:model-value="onDateSelect"
150
+ />
151
+
152
+ <!-- Time picker -->
153
+ <div
154
+ class="flex items-center gap-2 border-t border-gray-200 dark:border-gray-700 pt-3"
155
+ >
156
+ <UIcon name="i-lucide-clock" class="text-gray-400 shrink-0" />
157
+ <div class="flex items-center gap-1">
158
+ <UInput
159
+ type="number"
160
+ :model-value="timeHour"
161
+ min="0"
162
+ max="23"
163
+ class="w-14 text-center"
164
+ @update:model-value="
165
+ (v: unknown) => onTimeChange('hour', String(v))
166
+ "
167
+ />
168
+ <span class="text-gray-500 font-medium">:</span>
169
+ <UInput
170
+ type="number"
171
+ :model-value="timeMinute"
172
+ min="0"
173
+ max="59"
174
+ class="w-14 text-center"
175
+ @update:model-value="
176
+ (v: unknown) => onTimeChange('minute', String(v))
177
+ "
178
+ />
179
+ </div>
180
+ <UButton
181
+ variant="ghost"
182
+ color="neutral"
183
+ size="xs"
184
+ icon="i-lucide-x"
185
+ class="ml-auto"
186
+ @click="clear"
187
+ />
188
+ </div>
189
+ </div>
190
+ </template>
191
+ </UPopover>
192
+ </template>
@@ -0,0 +1,106 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from "vue";
3
+ import type { FileDescriptor } from "../../types/index.js";
4
+
5
+ const props = defineProps<{
6
+ modelValue: unknown;
7
+ descriptor: FileDescriptor;
8
+ errors?: string[];
9
+ readonly?: boolean;
10
+ disabled?: boolean;
11
+ }>();
12
+
13
+ const emit = defineEmits<{ "update:modelValue": [value: File | null] }>();
14
+
15
+ const inputRef = ref<HTMLInputElement | null>(null);
16
+
17
+ const currentFileName = computed<string | null>(() => {
18
+ if (props.modelValue instanceof File) return props.modelValue.name;
19
+ if (typeof props.modelValue === "string" && props.modelValue)
20
+ return props.modelValue.split("/").pop() ?? props.modelValue;
21
+ return null;
22
+ });
23
+
24
+ const previewUrl = computed<string | null>(() => {
25
+ if (props.modelValue instanceof File) {
26
+ if (props.modelValue.type.startsWith("image/"))
27
+ return URL.createObjectURL(props.modelValue);
28
+ }
29
+ if (typeof props.modelValue === "string" && props.modelValue) {
30
+ const ext = props.modelValue.split(".").pop()?.toLowerCase() ?? "";
31
+ if (["jpg", "jpeg", "png", "gif", "webp", "svg", "avif"].includes(ext))
32
+ return props.modelValue;
33
+ }
34
+ return null;
35
+ });
36
+
37
+ function onFileChange(e: Event) {
38
+ const file = (e.target as HTMLInputElement).files?.[0] ?? null;
39
+ emit("update:modelValue", file);
40
+ }
41
+
42
+ function clear() {
43
+ emit("update:modelValue", null);
44
+ if (inputRef.value) inputRef.value.value = "";
45
+ }
46
+
47
+ const hasError = computed(() => (props.errors?.length ?? 0) > 0);
48
+ const accept = computed(
49
+ () => (props.descriptor.attrs?.accept as string) ?? undefined,
50
+ );
51
+ </script>
52
+
53
+ <template>
54
+ <div class="mapo-file-field space-y-2">
55
+ <!-- Current file preview -->
56
+ <div
57
+ v-if="currentFileName"
58
+ class="flex items-center gap-2 rounded border px-3 py-2 text-sm"
59
+ :class="
60
+ hasError ? 'border-red-300 bg-red-50' : 'border-gray-200 bg-gray-50'
61
+ "
62
+ >
63
+ <img
64
+ v-if="previewUrl"
65
+ :src="previewUrl"
66
+ class="h-8 w-8 rounded object-cover shrink-0"
67
+ alt=""
68
+ />
69
+ <UIcon
70
+ v-else
71
+ name="i-lucide-file"
72
+ class="size-4 shrink-0 text-gray-400"
73
+ />
74
+ <span class="flex-1 truncate text-gray-700">{{ currentFileName }}</span>
75
+ <UButton
76
+ v-if="!readonly && !disabled"
77
+ size="xs"
78
+ variant="ghost"
79
+ color="neutral"
80
+ icon="i-lucide-x"
81
+ aria-label="Remove file"
82
+ @click="clear"
83
+ />
84
+ </div>
85
+
86
+ <!-- File input -->
87
+ <UInput
88
+ v-if="!readonly && !disabled"
89
+ ref="inputRef"
90
+ type="file"
91
+ :accept="accept"
92
+ :color="hasError ? 'error' : undefined"
93
+ :aria-invalid="hasError ? 'true' : undefined"
94
+ @change="onFileChange"
95
+ />
96
+
97
+ <p
98
+ v-for="err in errors"
99
+ :key="err"
100
+ class="text-sm text-red-500"
101
+ role="alert"
102
+ >
103
+ {{ err }}
104
+ </p>
105
+ </div>
106
+ </template>
@@ -0,0 +1,151 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, onMounted } from "vue";
3
+ import { useNuxtApp } from "#app";
4
+ import { debounce } from "@mapomodule/utils";
5
+ import type { FksDescriptor } from "../../types/index.js";
6
+
7
+ const props = defineProps<{
8
+ modelValue: unknown;
9
+ descriptor: FksDescriptor;
10
+ errors?: string[];
11
+ readonly?: boolean;
12
+ disabled?: boolean;
13
+ }>();
14
+
15
+ const emit = defineEmits<{ "update:modelValue": [value: unknown] }>();
16
+
17
+ const attrs = computed(() => props.descriptor.attrs);
18
+ const isMultiple = computed(
19
+ () => props.descriptor.type === "m2m" || !!attrs.value.multiple,
20
+ );
21
+ const itemText = computed(() => attrs.value.itemText ?? "name");
22
+ const itemValue = computed(() => attrs.value.itemValue ?? "id");
23
+
24
+ // ─── Remote search ───────────────────────────────────────────────────────────
25
+
26
+ const items = ref<Record<string, unknown>[]>([]);
27
+ const isLoading = ref(false);
28
+ const searchTerm = ref("");
29
+
30
+ const $mapoFetch = (useNuxtApp() as any).$mapoFetch;
31
+
32
+ async function fetchItems(query: string) {
33
+ isLoading.value = true;
34
+ try {
35
+ const rawEndpoint = attrs.value.endpoint as string;
36
+ const endpoint =
37
+ rawEndpoint.startsWith("/") || rawEndpoint.startsWith("http")
38
+ ? rawEndpoint
39
+ : `/${rawEndpoint}`;
40
+ const url = query
41
+ ? `${endpoint}${endpoint.includes("?") ? "&" : "?"}search=${encodeURIComponent(query)}`
42
+ : endpoint;
43
+ const data = await ($mapoFetch ?? $fetch)(url);
44
+ // Supports both a plain array and a paginated DRF response { results: [] }.
45
+ const raw = Array.isArray(data)
46
+ ? data
47
+ : ((data as { results?: unknown[] }).results ?? []);
48
+ items.value = raw as Record<string, unknown>[];
49
+ } catch {
50
+ items.value = [];
51
+ } finally {
52
+ isLoading.value = false;
53
+ }
54
+ }
55
+
56
+ const debouncedFetch = debounce(fetchItems, 300);
57
+
58
+ // onMounted: fetching is client-only (the select is interactive, so SSR fetch does not add value).
59
+ onMounted(() => fetchItems(""));
60
+ watch(searchTerm, (q) => debouncedFetch(q));
61
+
62
+ // ─── Value ───────────────────────────────────────────────────────────────────
63
+
64
+ // returnObject=true (default): modelValue is an object or array of objects.
65
+ // returnObject=false: modelValue is an id or array of ids.
66
+ const internalValue = computed({
67
+ get: () => props.modelValue,
68
+ set: (val) => emit("update:modelValue", val),
69
+ });
70
+
71
+ // Placeholder
72
+ const placeholder = computed(() =>
73
+ isMultiple.value ? "Search and select..." : "Search...",
74
+ );
75
+
76
+ // Chip text shown in the trigger.
77
+ // Handles both returnObject=true (item is a full object) and returnObject=false (item is a raw ID).
78
+ function getLabel(item: unknown): string {
79
+ if (item !== null && item !== undefined && typeof item !== "object") {
80
+ // Raw ID — look it up in the loaded items array.
81
+ const found = items.value.find((i) => i[itemValue.value] === item);
82
+ return found ? String(found[itemText.value] ?? item) : String(item);
83
+ }
84
+ if (!item || typeof item !== "object") return "";
85
+ return String((item as Record<string, unknown>)[itemText.value] ?? "");
86
+ }
87
+
88
+ // Extracts the comparable key from an item, regardless of whether it's an object or a raw ID.
89
+ function getItemKey(item: unknown): unknown {
90
+ if (item !== null && item !== undefined && typeof item !== "object")
91
+ return item;
92
+ return (item as Record<string, unknown>)?.[itemValue.value];
93
+ }
94
+ </script>
95
+
96
+ <template>
97
+ <USelectMenu
98
+ v-model="internalValue"
99
+ v-model:search-term="searchTerm"
100
+ :items="items"
101
+ :label-key="itemText"
102
+ :value-key="attrs.returnObject === false ? itemValue : undefined"
103
+ :by="attrs.returnObject !== false ? (itemValue as string) : undefined"
104
+ :multiple="isMultiple"
105
+ :loading="isLoading"
106
+ :ignore-filter="true"
107
+ :placeholder="placeholder"
108
+ :readonly="readonly"
109
+ :disabled="disabled"
110
+ :highlight="!!errors?.length"
111
+ clear
112
+ >
113
+ <!-- Chips for multi-selection -->
114
+ <template
115
+ v-if="isMultiple && Array.isArray(internalValue) && internalValue.length"
116
+ #default
117
+ >
118
+ <div class="flex flex-wrap gap-1 py-0.5">
119
+ <UBadge
120
+ v-for="item in internalValue as unknown[]"
121
+ :key="String(getItemKey(item))"
122
+ variant="soft"
123
+ color="primary"
124
+ class="cursor-default"
125
+ >
126
+ {{ getLabel(item) }}
127
+ <UButton
128
+ v-if="!readonly && !disabled"
129
+ size="xs"
130
+ variant="ghost"
131
+ color="primary"
132
+ icon="i-lucide-x"
133
+ class="-mr-1 ml-0.5 h-3.5 w-3.5"
134
+ @click.stop="
135
+ internalValue = (internalValue as unknown[]).filter(
136
+ (i) => getItemKey(i) !== getItemKey(item),
137
+ )
138
+ "
139
+ />
140
+ </UBadge>
141
+ </div>
142
+ </template>
143
+
144
+ <!-- Empty state -->
145
+ <template #empty>
146
+ <span class="text-sm text-gray-500">
147
+ {{ searchTerm ? "No results found" : "No items available" }}
148
+ </span>
149
+ </template>
150
+ </USelectMenu>
151
+ </template>
@@ -0,0 +1,117 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, defineAsyncComponent } from "vue";
3
+ import type { MapDescriptor } from "../../types/index.js";
4
+
5
+ // Leaflet is loaded only on the client because it is not SSR-safe.
6
+ const LeafletMap = defineAsyncComponent(
7
+ () => import("./MapoMapFieldClient.vue"),
8
+ );
9
+
10
+ /** Geographic coordinates stored by the map field. */
11
+ interface LatLng {
12
+ lat: number;
13
+ lng: number;
14
+ }
15
+
16
+ /** Map field with manual lat/lng inputs plus a client-only Leaflet picker. */
17
+ const props = defineProps<{
18
+ modelValue: unknown;
19
+ descriptor: MapDescriptor;
20
+ errors?: string[];
21
+ readonly?: boolean;
22
+ disabled?: boolean;
23
+ }>();
24
+
25
+ const emit = defineEmits<{ "update:modelValue": [value: LatLng | null] }>();
26
+
27
+ const attrs = computed(() => props.descriptor.attrs ?? {});
28
+
29
+ const value = computed<LatLng | null>(() => {
30
+ const v = props.modelValue;
31
+ if (!v || typeof v !== "object") return null;
32
+ const { lat, lng } = v as LatLng;
33
+ return typeof lat === "number" && typeof lng === "number"
34
+ ? { lat, lng }
35
+ : null;
36
+ });
37
+
38
+ const latInput = ref(value.value?.lat?.toString() ?? "");
39
+ const lngInput = ref(value.value?.lng?.toString() ?? "");
40
+
41
+ function onMapClick(coords: LatLng) {
42
+ latInput.value = coords.lat.toFixed(6);
43
+ lngInput.value = coords.lng.toFixed(6);
44
+ emit("update:modelValue", coords);
45
+ }
46
+
47
+ function onManualInput() {
48
+ const lat = parseFloat(latInput.value);
49
+ const lng = parseFloat(lngInput.value);
50
+ if (!isNaN(lat) && !isNaN(lng)) emit("update:modelValue", { lat, lng });
51
+ }
52
+
53
+ function clear() {
54
+ latInput.value = "";
55
+ lngInput.value = "";
56
+ emit("update:modelValue", null);
57
+ }
58
+ </script>
59
+
60
+ <template>
61
+ <div class="mapo-map-field space-y-2">
62
+ <!-- Manual coordinate inputs -->
63
+ <div class="flex gap-2">
64
+ <UInput
65
+ v-model="latInput"
66
+ placeholder="Latitude"
67
+ class="flex-1"
68
+ :readonly="readonly"
69
+ :disabled="disabled"
70
+ @blur="onManualInput"
71
+ />
72
+ <UInput
73
+ v-model="lngInput"
74
+ placeholder="Longitude"
75
+ class="flex-1"
76
+ :readonly="readonly"
77
+ :disabled="disabled"
78
+ @blur="onManualInput"
79
+ />
80
+ <UButton
81
+ v-if="value && !readonly && !disabled"
82
+ variant="ghost"
83
+ color="neutral"
84
+ icon="i-lucide-x"
85
+ @click="clear"
86
+ />
87
+ </div>
88
+
89
+ <!-- Leaflet map (client only) -->
90
+ <ClientOnly>
91
+ <LeafletMap
92
+ :value="value"
93
+ :default-lat="attrs.defaultLat ?? 41.9"
94
+ :default-lng="attrs.defaultLng ?? 12.5"
95
+ :zoom="attrs.zoom ?? 6"
96
+ :readonly="readonly ?? false"
97
+ @click="onMapClick"
98
+ />
99
+ <template #fallback>
100
+ <div
101
+ class="flex h-48 items-center justify-center rounded border border-gray-200 bg-gray-50 text-sm text-gray-400"
102
+ >
103
+ Loading map...
104
+ </div>
105
+ </template>
106
+ </ClientOnly>
107
+
108
+ <p
109
+ v-for="err in errors"
110
+ :key="err"
111
+ class="text-sm text-red-500"
112
+ role="alert"
113
+ >
114
+ {{ err }}
115
+ </p>
116
+ </div>
117
+ </template>
@@ -0,0 +1,92 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, onMounted, onBeforeUnmount } from "vue";
3
+
4
+ /** Geographic coordinates emitted by the interactive Leaflet client. */
5
+ interface LatLng {
6
+ lat: number;
7
+ lng: number;
8
+ }
9
+
10
+ const props = defineProps<{
11
+ value: LatLng | null;
12
+ defaultLat: number;
13
+ defaultLng: number;
14
+ zoom: number;
15
+ readonly: boolean;
16
+ }>();
17
+
18
+ const emit = defineEmits<{ click: [coords: LatLng] }>();
19
+
20
+ const mapEl = ref<HTMLElement | null>(null);
21
+ let L: typeof import("leaflet") | null = null;
22
+ let map: import("leaflet").Map | null = null;
23
+ let marker: import("leaflet").Marker | null = null;
24
+
25
+ onMounted(async () => {
26
+ // Dynamic import: Leaflet is not SSR-safe.
27
+ L = (await import("leaflet")).default;
28
+ await import("leaflet/dist/leaflet.css");
29
+
30
+ // Bundle marker icons directly from the npm package (no external CDN):
31
+ // this avoids exposing the user's IP, CSP issues, and extra runtime dependencies.
32
+ // Vite resolves `?url` to bundled asset URLs at build time.
33
+ const [iconRetinaUrl, iconUrl, shadowUrl] = await Promise.all([
34
+ import("leaflet/dist/images/marker-icon-2x.png?url").then((m) => m.default),
35
+ import("leaflet/dist/images/marker-icon.png?url").then((m) => m.default),
36
+ import("leaflet/dist/images/marker-shadow.png?url").then((m) => m.default),
37
+ ]);
38
+
39
+ // @ts-expect-error — _getIconUrl is not part of the public typings
40
+ delete L.Icon.Default.prototype._getIconUrl;
41
+ L.Icon.Default.mergeOptions({ iconRetinaUrl, iconUrl, shadowUrl });
42
+
43
+ if (!mapEl.value) return;
44
+
45
+ map = L.map(mapEl.value).setView(
46
+ [
47
+ props.value?.lat ?? props.defaultLat,
48
+ props.value?.lng ?? props.defaultLng,
49
+ ],
50
+ props.zoom,
51
+ );
52
+
53
+ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
54
+ attribution:
55
+ '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
56
+ }).addTo(map);
57
+
58
+ if (props.value) {
59
+ marker = L.marker([props.value.lat, props.value.lng]).addTo(map);
60
+ }
61
+
62
+ if (!props.readonly) {
63
+ map.on("click", (e: import("leaflet").LeafletMouseEvent) => {
64
+ const { lat, lng } = e.latlng;
65
+ if (marker) marker.setLatLng([lat, lng]);
66
+ else if (L && map) marker = L.marker([lat, lng]).addTo(map);
67
+ emit("click", { lat, lng });
68
+ });
69
+ }
70
+ });
71
+
72
+ watch(
73
+ () => props.value,
74
+ (val) => {
75
+ if (!map || !L) return;
76
+ if (val) {
77
+ if (marker) marker.setLatLng([val.lat, val.lng]);
78
+ else marker = L.marker([val.lat, val.lng]).addTo(map);
79
+ map.panTo([val.lat, val.lng]);
80
+ } else {
81
+ marker?.remove();
82
+ marker = null;
83
+ }
84
+ },
85
+ );
86
+
87
+ onBeforeUnmount(() => map?.remove());
88
+ </script>
89
+
90
+ <template>
91
+ <div ref="mapEl" class="h-64 w-full rounded border border-gray-200" />
92
+ </template>