@mapomodule/form 2.0.0-beta.0 → 2.0.0-beta.10

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 (194) hide show
  1. package/README.md +2 -1
  2. package/dist/__tests__/__mocks__/nuxt-imports.d.ts +4 -0
  3. package/dist/__tests__/__mocks__/nuxt-imports.d.ts.map +1 -0
  4. package/dist/__tests__/camomillaRoundTrip.test.d.ts +2 -0
  5. package/dist/__tests__/camomillaRoundTrip.test.d.ts.map +1 -0
  6. package/dist/__tests__/registry.test.d.ts +2 -0
  7. package/dist/__tests__/registry.test.d.ts.map +1 -0
  8. package/dist/__tests__/useFormFromSchema.test.d.ts +2 -0
  9. package/dist/__tests__/useFormFromSchema.test.d.ts.map +1 -0
  10. package/dist/__tests__/useMapoForm.test.d.ts +2 -0
  11. package/dist/__tests__/useMapoForm.test.d.ts.map +1 -0
  12. package/dist/index.d.mts +9 -1
  13. package/dist/index.d.ts +15 -1
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.mjs +7 -34
  16. package/dist/module.d.mts +20 -2
  17. package/dist/module.d.ts +12 -2
  18. package/dist/module.d.ts.map +1 -0
  19. package/dist/module.json +1 -1
  20. package/dist/module.mjs +187 -13
  21. package/dist/runtime/components/MapoFocusPortal.vue +115 -0
  22. package/dist/runtime/components/MapoFocusPortal.vue.d.ts +4 -0
  23. package/dist/runtime/components/MapoFocusPortal.vue.d.ts.map +1 -0
  24. package/dist/runtime/components/MapoForm.vue +168 -0
  25. package/dist/runtime/components/MapoForm.vue.d.ts +47 -0
  26. package/dist/runtime/components/MapoForm.vue.d.ts.map +1 -0
  27. package/dist/runtime/components/MapoFormField.vue +269 -0
  28. package/dist/runtime/components/MapoFormField.vue.d.ts +46 -0
  29. package/dist/runtime/components/MapoFormField.vue.d.ts.map +1 -0
  30. package/dist/runtime/components/MapoFormFlatSection.vue +149 -0
  31. package/dist/runtime/components/MapoFormFlatSection.vue.d.ts +15 -0
  32. package/dist/runtime/components/MapoFormFlatSection.vue.d.ts.map +1 -0
  33. package/dist/runtime/components/MapoFormGroup.vue +247 -0
  34. package/dist/runtime/components/MapoFormGroup.vue.d.ts +44 -0
  35. package/dist/runtime/components/MapoFormGroup.vue.d.ts.map +1 -0
  36. package/dist/runtime/components/MapoFormTabs.vue +110 -0
  37. package/dist/runtime/components/MapoFormTabs.vue.d.ts +15 -0
  38. package/dist/runtime/components/MapoFormTabs.vue.d.ts.map +1 -0
  39. package/dist/runtime/components/MapoUnknownField.vue +62 -0
  40. package/dist/runtime/components/MapoUnknownField.vue.d.ts +16 -0
  41. package/dist/runtime/components/MapoUnknownField.vue.d.ts.map +1 -0
  42. package/dist/runtime/components/fields/MapoDateField.vue +88 -0
  43. package/dist/runtime/components/fields/MapoDateField.vue.d.ts +17 -0
  44. package/dist/runtime/components/fields/MapoDateField.vue.d.ts.map +1 -0
  45. package/dist/runtime/components/fields/MapoDateTimeField.vue +159 -0
  46. package/dist/runtime/components/fields/MapoDateTimeField.vue.d.ts +17 -0
  47. package/dist/runtime/components/fields/MapoDateTimeField.vue.d.ts.map +1 -0
  48. package/dist/runtime/components/fields/MapoEnhancedMediaField.vue +130 -0
  49. package/dist/runtime/components/fields/MapoEnhancedMediaField.vue.d.ts +16 -0
  50. package/dist/runtime/components/fields/MapoEnhancedMediaField.vue.d.ts.map +1 -0
  51. package/dist/runtime/components/fields/MapoFileField.vue +99 -0
  52. package/dist/runtime/components/fields/MapoFileField.vue.d.ts +16 -0
  53. package/dist/runtime/components/fields/MapoFileField.vue.d.ts.map +1 -0
  54. package/dist/runtime/components/fields/MapoFksField.vue +122 -0
  55. package/dist/runtime/components/fields/MapoFksField.vue.d.ts +16 -0
  56. package/dist/runtime/components/fields/MapoFksField.vue.d.ts.map +1 -0
  57. package/dist/runtime/components/fields/MapoMapField.vue +99 -0
  58. package/dist/runtime/components/fields/MapoMapField.vue.d.ts +22 -0
  59. package/dist/runtime/components/fields/MapoMapField.vue.d.ts.map +1 -0
  60. package/dist/runtime/components/fields/MapoMapFieldClient.vue +67 -0
  61. package/dist/runtime/components/fields/MapoMapFieldClient.vue.d.ts +20 -0
  62. package/dist/runtime/components/fields/MapoMapFieldClient.vue.d.ts.map +1 -0
  63. package/dist/runtime/components/fields/MapoMediaField.vue +91 -0
  64. package/dist/runtime/components/fields/MapoMediaField.vue.d.ts +16 -0
  65. package/dist/runtime/components/fields/MapoMediaField.vue.d.ts.map +1 -0
  66. package/dist/runtime/components/fields/MapoMediaM2mField.vue +110 -0
  67. package/dist/runtime/components/fields/MapoMediaM2mField.vue.d.ts +16 -0
  68. package/dist/runtime/components/fields/MapoMediaM2mField.vue.d.ts.map +1 -0
  69. package/dist/runtime/components/fields/MapoMediaUnavailable.vue +15 -0
  70. package/dist/runtime/components/fields/MapoMediaUnavailable.vue.d.ts +4 -0
  71. package/dist/runtime/components/fields/MapoMediaUnavailable.vue.d.ts.map +1 -0
  72. package/dist/runtime/components/fields/MapoRepeater.vue +459 -0
  73. package/dist/runtime/components/fields/MapoRepeater.vue.d.ts +16 -0
  74. package/dist/runtime/components/fields/MapoRepeater.vue.d.ts.map +1 -0
  75. package/dist/runtime/components/fields/MapoRepeaterItem.vue +346 -0
  76. package/dist/runtime/components/fields/MapoRepeaterItem.vue.d.ts +55 -0
  77. package/dist/runtime/components/fields/MapoRepeaterItem.vue.d.ts.map +1 -0
  78. package/dist/runtime/components/fields/MapoSeoPreview.vue +122 -0
  79. package/dist/runtime/components/fields/MapoSeoPreview.vue.d.ts +22 -0
  80. package/dist/runtime/components/fields/MapoSeoPreview.vue.d.ts.map +1 -0
  81. package/dist/runtime/components/fields/MapoTimeField.vue +44 -0
  82. package/dist/runtime/components/fields/MapoTimeField.vue.d.ts +17 -0
  83. package/dist/runtime/components/fields/MapoTimeField.vue.d.ts.map +1 -0
  84. package/dist/runtime/components/fields/MapoWygEditor.vue +252 -0
  85. package/dist/runtime/components/fields/MapoWygEditor.vue.d.ts +16 -0
  86. package/dist/runtime/components/fields/MapoWygEditor.vue.d.ts.map +1 -0
  87. package/dist/runtime/components/fields/nui/NuiCheckbox.vue +9 -0
  88. package/dist/runtime/components/fields/nui/NuiCheckbox.vue.d.ts +4 -0
  89. package/dist/runtime/components/fields/nui/NuiCheckbox.vue.d.ts.map +1 -0
  90. package/dist/runtime/components/fields/nui/NuiInput.vue +9 -0
  91. package/dist/runtime/components/fields/nui/NuiInput.vue.d.ts +4 -0
  92. package/dist/runtime/components/fields/nui/NuiInput.vue.d.ts.map +1 -0
  93. package/dist/runtime/components/fields/nui/NuiSelectMenu.vue +18 -0
  94. package/dist/runtime/components/fields/nui/NuiSelectMenu.vue.d.ts +4 -0
  95. package/dist/runtime/components/fields/nui/NuiSelectMenu.vue.d.ts.map +1 -0
  96. package/dist/runtime/components/fields/nui/NuiSlider.vue +9 -0
  97. package/dist/runtime/components/fields/nui/NuiSlider.vue.d.ts +4 -0
  98. package/dist/runtime/components/fields/nui/NuiSlider.vue.d.ts.map +1 -0
  99. package/dist/runtime/components/fields/nui/NuiSwitch.vue +9 -0
  100. package/dist/runtime/components/fields/nui/NuiSwitch.vue.d.ts +4 -0
  101. package/dist/runtime/components/fields/nui/NuiSwitch.vue.d.ts.map +1 -0
  102. package/dist/runtime/components/fields/nui/NuiTextarea.vue +9 -0
  103. package/dist/runtime/components/fields/nui/NuiTextarea.vue.d.ts +4 -0
  104. package/dist/runtime/components/fields/nui/NuiTextarea.vue.d.ts.map +1 -0
  105. package/dist/runtime/components/fields/nui/useNuiFieldAttrs.d.ts +13 -0
  106. package/dist/runtime/components/fields/nui/useNuiFieldAttrs.d.ts.map +1 -0
  107. package/dist/runtime/components/fields/nui/useNuiFieldAttrs.js +19 -0
  108. package/dist/runtime/composables/defineFormField.d.ts +25 -0
  109. package/dist/runtime/composables/defineFormField.d.ts.map +1 -0
  110. package/dist/runtime/composables/defineFormField.js +27 -0
  111. package/dist/runtime/composables/useCurrentLang.d.ts +6 -0
  112. package/dist/runtime/composables/useCurrentLang.d.ts.map +1 -0
  113. package/dist/runtime/composables/useCurrentLang.js +9 -0
  114. package/dist/runtime/composables/useFocusMode.d.ts +40 -0
  115. package/dist/runtime/composables/useFocusMode.d.ts.map +1 -0
  116. package/dist/runtime/composables/useFocusMode.js +31 -0
  117. package/dist/runtime/composables/useFormDraft.d.ts +60 -0
  118. package/dist/runtime/composables/useFormDraft.d.ts.map +1 -0
  119. package/dist/runtime/composables/useFormDraft.js +52 -0
  120. package/dist/runtime/composables/useFormFromSchema.d.ts +21 -0
  121. package/dist/runtime/composables/useFormFromSchema.d.ts.map +1 -0
  122. package/dist/runtime/composables/useFormFromSchema.js +145 -0
  123. package/dist/runtime/composables/useMapoForm.d.ts +96 -0
  124. package/dist/runtime/composables/useMapoForm.d.ts.map +1 -0
  125. package/dist/runtime/composables/useMapoForm.js +325 -0
  126. package/dist/runtime/composables/useMediaManager.d.ts +17 -0
  127. package/dist/runtime/composables/useMediaManager.d.ts.map +1 -0
  128. package/dist/runtime/composables/useMediaManager.js +6 -0
  129. package/dist/runtime/composables/useProgressiveDisclosure.d.ts +44 -0
  130. package/dist/runtime/composables/useProgressiveDisclosure.d.ts.map +1 -0
  131. package/dist/runtime/composables/useProgressiveDisclosure.js +23 -0
  132. package/dist/runtime/composables/useUnsavedChangesGuard.d.ts +24 -0
  133. package/dist/runtime/composables/useUnsavedChangesGuard.d.ts.map +1 -0
  134. package/dist/runtime/composables/useUnsavedChangesGuard.js +28 -0
  135. package/dist/runtime/devtools/DevtoolsFormsPage.vue +235 -0
  136. package/dist/runtime/devtools/DevtoolsFormsPage.vue.d.ts +4 -0
  137. package/dist/runtime/devtools/DevtoolsFormsPage.vue.d.ts.map +1 -0
  138. package/dist/runtime/plugins/form-registry.d.ts +18 -0
  139. package/dist/runtime/plugins/form-registry.d.ts.map +1 -0
  140. package/dist/runtime/plugins/form-registry.js +19 -0
  141. package/dist/runtime/registry/defaults.d.ts +3 -0
  142. package/dist/runtime/registry/defaults.d.ts.map +1 -0
  143. package/dist/runtime/registry/defaults.js +93 -0
  144. package/dist/runtime/types/descriptor.d.ts +373 -0
  145. package/dist/runtime/types/descriptor.d.ts.map +1 -0
  146. package/dist/runtime/types/descriptor.js +26 -0
  147. package/dist/runtime/types/index.d.ts +12 -0
  148. package/dist/runtime/types/index.d.ts.map +1 -0
  149. package/dist/runtime/types/index.js +6 -0
  150. package/dist/runtime/types/layout.d.ts +21 -0
  151. package/dist/runtime/types/layout.d.ts.map +1 -0
  152. package/dist/runtime/types/layout.js +0 -0
  153. package/dist/runtime/types/media.d.ts +43 -0
  154. package/dist/runtime/types/media.d.ts.map +1 -0
  155. package/dist/runtime/types/media.js +0 -0
  156. package/dist/runtime/types/registry.d.ts +62 -0
  157. package/dist/runtime/types/registry.d.ts.map +1 -0
  158. package/dist/runtime/types/registry.js +19 -0
  159. package/dist/runtime/utils/flattenFieldGroups.d.ts +50 -0
  160. package/dist/runtime/utils/flattenFieldGroups.d.ts.map +1 -0
  161. package/dist/runtime/utils/flattenFieldGroups.js +24 -0
  162. package/dist/types.d.mts +6 -10
  163. package/package.json +10 -8
  164. package/src/__tests__/article.schema.json +193 -0
  165. package/src/__tests__/camomillaRoundTrip.test.ts +73 -0
  166. package/src/__tests__/useFormFromSchema.test.ts +24 -0
  167. package/src/__tests__/useMapoForm.test.ts +5 -0
  168. package/src/index.ts +5 -0
  169. package/src/module.ts +34 -0
  170. package/src/runtime/components/MapoFocusPortal.vue +3 -1
  171. package/src/runtime/components/MapoFormTabs.vue +4 -1
  172. package/src/runtime/components/fields/MapoDateField.vue +3 -1
  173. package/src/runtime/components/fields/MapoDateTimeField.vue +3 -1
  174. package/src/runtime/components/fields/MapoEnhancedMediaField.vue +153 -0
  175. package/src/runtime/components/fields/MapoFileField.vue +3 -1
  176. package/src/runtime/components/fields/MapoFksField.vue +7 -2
  177. package/src/runtime/components/fields/MapoMapField.vue +5 -2
  178. package/src/runtime/components/fields/MapoMediaField.vue +104 -0
  179. package/src/runtime/components/fields/MapoMediaM2mField.vue +130 -0
  180. package/src/runtime/components/fields/MapoMediaUnavailable.vue +19 -0
  181. package/src/runtime/components/fields/MapoRepeater.vue +33 -26
  182. package/src/runtime/components/fields/MapoRepeaterItem.vue +14 -8
  183. package/src/runtime/components/fields/MapoSeoPreview.vue +17 -13
  184. package/src/runtime/components/fields/MapoWygEditor.vue +47 -5
  185. package/src/runtime/composables/useFormFromSchema.ts +4 -1
  186. package/src/runtime/composables/useMapoForm.ts +4 -2
  187. package/src/runtime/composables/useMediaManager.ts +21 -0
  188. package/src/runtime/composables/useUnsavedChangesGuard.ts +3 -3
  189. package/src/runtime/registry/defaults.ts +9 -1
  190. package/src/runtime/types/descriptor.ts +22 -7
  191. package/src/runtime/types/index.ts +4 -0
  192. package/src/runtime/types/media.ts +44 -0
  193. package/dist/index.d.cts +0 -1
  194. package/dist/module.d.cts +0 -2
@@ -0,0 +1,153 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from "vue";
3
+ import { useI18n } from "vue-i18n";
4
+ import type {
5
+ EnhancedMediaDescriptor,
6
+ EnhancedMediaValue,
7
+ MediaItem,
8
+ } from "../../types/index.js";
9
+ import { useMediaManager } from "../../composables/useMediaManager.js";
10
+
11
+ const props = defineProps<{
12
+ modelValue: EnhancedMediaValue | null | undefined;
13
+ descriptor: EnhancedMediaDescriptor;
14
+ errors?: string[];
15
+ readonly?: boolean;
16
+ disabled?: boolean;
17
+ }>();
18
+
19
+ const emit = defineEmits<{
20
+ "update:modelValue": [value: EnhancedMediaValue | null];
21
+ }>();
22
+
23
+ const { t } = useI18n();
24
+
25
+ const dialogOpen = ref(false);
26
+
27
+ // Picker dialog comes from @mapomodule/uikit, resolved at runtime.
28
+ const { MediaDialog, available } = useMediaManager();
29
+
30
+ const current = computed<MediaItem | null>(
31
+ () => props.modelValue?.media ?? null,
32
+ );
33
+
34
+ const altValue = computed({
35
+ get: () => props.modelValue?.alt ?? "",
36
+ set: (v: string) =>
37
+ emit("update:modelValue", {
38
+ ...(props.modelValue ?? { media: null }),
39
+ alt: v,
40
+ }),
41
+ });
42
+
43
+ const captionValue = computed({
44
+ get: () => props.modelValue?.caption ?? "",
45
+ set: (v: string) =>
46
+ emit("update:modelValue", {
47
+ ...(props.modelValue ?? { media: null }),
48
+ caption: v,
49
+ }),
50
+ });
51
+
52
+ const mime = computed(() => props.descriptor.attrs?.mime as string | undefined);
53
+
54
+ function onConfirm(selection: MediaItem | MediaItem[]) {
55
+ const media = Array.isArray(selection) ? (selection[0] ?? null) : selection;
56
+ emit("update:modelValue", {
57
+ ...(props.modelValue ?? {}),
58
+ media,
59
+ alt: props.modelValue?.alt ?? media?.alt_text ?? "",
60
+ caption: props.modelValue?.caption ?? "",
61
+ });
62
+ }
63
+
64
+ function clear() {
65
+ emit("update:modelValue", null);
66
+ }
67
+ </script>
68
+
69
+ <template>
70
+ <div class="mapo-enhanced-media-field space-y-3">
71
+ <MapoMediaUnavailable v-if="!available" />
72
+
73
+ <template v-else>
74
+ <div class="flex gap-3">
75
+ <!-- Preview -->
76
+ <div class="shrink-0">
77
+ <div
78
+ class="flex size-24 cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 border-dashed transition-colors"
79
+ :class="
80
+ current
81
+ ? 'border-default'
82
+ : 'border-accented hover:border-primary'
83
+ "
84
+ @click="!readonly && !disabled && (dialogOpen = true)"
85
+ >
86
+ <MapoMediaPreview v-if="current" :media="current" size="md" />
87
+ <div v-else class="flex flex-col items-center gap-1 text-dimmed">
88
+ <UIcon name="i-lucide-image-plus" class="size-7" />
89
+ <span class="text-[10px]">{{ t("mapo.select") }}</span>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <!-- Metadata fields -->
95
+ <div class="flex-1 space-y-2">
96
+ <div v-if="current" class="flex items-start justify-between gap-2">
97
+ <p class="truncate text-xs text-muted">
98
+ {{ current.title || current.file?.split("/").pop() }}
99
+ </p>
100
+ <div class="flex gap-1">
101
+ <UButton
102
+ v-if="!readonly && !disabled"
103
+ icon="i-lucide-refresh-cw"
104
+ size="xs"
105
+ variant="ghost"
106
+ color="neutral"
107
+ :title="t('mapo.change')"
108
+ @click="dialogOpen = true"
109
+ />
110
+ <UButton
111
+ v-if="!readonly && !disabled"
112
+ icon="i-lucide-x"
113
+ size="xs"
114
+ variant="ghost"
115
+ color="neutral"
116
+ :title="t('mapo.remove')"
117
+ @click="clear"
118
+ />
119
+ </div>
120
+ </div>
121
+
122
+ <UInput
123
+ v-model="altValue"
124
+ size="sm"
125
+ :placeholder="t('mapo.enhancedMediaField.altText')"
126
+ :disabled="readonly || disabled || !current"
127
+ />
128
+ <UInput
129
+ v-model="captionValue"
130
+ size="sm"
131
+ :placeholder="t('mapo.enhancedMediaField.caption')"
132
+ :disabled="readonly || disabled || !current"
133
+ />
134
+ </div>
135
+ </div>
136
+
137
+ <!-- Dialog -->
138
+ <component
139
+ :is="MediaDialog"
140
+ v-model="dialogOpen"
141
+ selection-mode="single"
142
+ :mime="mime"
143
+ :selected="current"
144
+ @confirm="onConfirm"
145
+ />
146
+ </template>
147
+
148
+ <!-- Errors -->
149
+ <p v-for="err in errors" :key="err" class="text-xs text-error" role="alert">
150
+ {{ err }}
151
+ </p>
152
+ </div>
153
+ </template>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, computed } from "vue";
3
+ import { useI18n } from "vue-i18n";
3
4
  import type { FileDescriptor } from "../../types/index.js";
4
5
 
5
6
  const props = defineProps<{
@@ -12,6 +13,7 @@ const props = defineProps<{
12
13
 
13
14
  const emit = defineEmits<{ "update:modelValue": [value: File | null] }>();
14
15
 
16
+ const { t } = useI18n();
15
17
  const inputRef = ref<HTMLInputElement | null>(null);
16
18
 
17
19
  const currentFileName = computed<string | null>(() => {
@@ -78,7 +80,7 @@ const accept = computed(
78
80
  variant="ghost"
79
81
  color="neutral"
80
82
  icon="i-lucide-x"
81
- aria-label="Remove file"
83
+ :aria-label="t('mapo.fileField.removeFile')"
82
84
  @click="clear"
83
85
  />
84
86
  </div>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, computed, watch, onMounted } from "vue";
3
+ import { useI18n } from "vue-i18n";
3
4
  import { useNuxtApp } from "#app";
4
5
  import { debounce } from "@mapomodule/utils";
5
6
  import type { FksDescriptor } from "../../types/index.js";
@@ -14,6 +15,8 @@ const props = defineProps<{
14
15
 
15
16
  const emit = defineEmits<{ "update:modelValue": [value: unknown] }>();
16
17
 
18
+ const { t } = useI18n();
19
+
17
20
  const attrs = computed(() => props.descriptor.attrs);
18
21
  const isMultiple = computed(
19
22
  () => props.descriptor.type === "m2m" || !!attrs.value.multiple,
@@ -70,7 +73,7 @@ const internalValue = computed({
70
73
 
71
74
  // Placeholder
72
75
  const placeholder = computed(() =>
73
- isMultiple.value ? "Search and select..." : "Search...",
76
+ isMultiple.value ? t("mapo.fksField.searchSelect") : t("mapo.search"),
74
77
  );
75
78
 
76
79
  // Chip text shown in the trigger.
@@ -144,7 +147,9 @@ function getItemKey(item: unknown): unknown {
144
147
  <!-- Empty state -->
145
148
  <template #empty>
146
149
  <span class="text-sm text-gray-500">
147
- {{ searchTerm ? "No results found" : "No items available" }}
150
+ {{
151
+ searchTerm ? t("mapo.fksField.noResults") : t("mapo.fksField.noItems")
152
+ }}
148
153
  </span>
149
154
  </template>
150
155
  </USelectMenu>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, computed, defineAsyncComponent } from "vue";
3
+ import { useI18n } from "vue-i18n";
3
4
  import type { MapDescriptor } from "../../types/index.js";
4
5
 
5
6
  // Leaflet is loaded only on the client because it is not SSR-safe.
@@ -7,6 +8,8 @@ const LeafletMap = defineAsyncComponent(
7
8
  () => import("./MapoMapFieldClient.vue"),
8
9
  );
9
10
 
11
+ const { t } = useI18n();
12
+
10
13
  /** Geographic coordinates stored by the map field. */
11
14
  interface LatLng {
12
15
  lat: number;
@@ -63,7 +66,7 @@ function clear() {
63
66
  <div class="flex gap-2">
64
67
  <UInput
65
68
  v-model="latInput"
66
- placeholder="Latitude"
69
+ :placeholder="t('mapo.latitude')"
67
70
  class="flex-1"
68
71
  :readonly="readonly"
69
72
  :disabled="disabled"
@@ -71,7 +74,7 @@ function clear() {
71
74
  />
72
75
  <UInput
73
76
  v-model="lngInput"
74
- placeholder="Longitude"
77
+ :placeholder="t('mapo.longitude')"
75
78
  class="flex-1"
76
79
  :readonly="readonly"
77
80
  :disabled="disabled"
@@ -0,0 +1,104 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from "vue";
3
+ import { useI18n } from "vue-i18n";
4
+ import type { MediaDescriptor, MediaItem } from "../../types/index.js";
5
+ import { useMediaManager } from "../../composables/useMediaManager.js";
6
+
7
+ const props = defineProps<{
8
+ modelValue: MediaItem | number | null | undefined;
9
+ descriptor: MediaDescriptor;
10
+ errors?: string[];
11
+ readonly?: boolean;
12
+ disabled?: boolean;
13
+ }>();
14
+
15
+ const emit = defineEmits<{
16
+ "update:modelValue": [value: MediaItem | null];
17
+ }>();
18
+
19
+ const { t } = useI18n();
20
+
21
+ const dialogOpen = ref(false);
22
+
23
+ // Picker dialog comes from @mapomodule/uikit, resolved at runtime.
24
+ const { MediaDialog, available } = useMediaManager();
25
+
26
+ const current = computed<MediaItem | null>(() => {
27
+ if (!props.modelValue) return null;
28
+ if (typeof props.modelValue === "object") return props.modelValue;
29
+ return null;
30
+ });
31
+
32
+ const mime = computed(() => props.descriptor.attrs?.mime as string | undefined);
33
+
34
+ function onConfirm(selection: MediaItem | MediaItem[]) {
35
+ emit(
36
+ "update:modelValue",
37
+ Array.isArray(selection) ? (selection[0] ?? null) : selection,
38
+ );
39
+ }
40
+
41
+ function clear() {
42
+ emit("update:modelValue", null);
43
+ }
44
+ </script>
45
+
46
+ <template>
47
+ <div class="mapo-media-field space-y-2">
48
+ <MapoMediaUnavailable v-if="!available" />
49
+
50
+ <template v-else>
51
+ <!-- Selected media preview -->
52
+ <div
53
+ v-if="current"
54
+ class="flex items-center gap-3 rounded-lg border border-default p-2"
55
+ >
56
+ <MapoMediaPreview :media="current" size="sm" />
57
+ <div class="flex-1 min-w-0">
58
+ <p class="truncate text-xs font-medium">
59
+ {{ current.title || current.file?.split("/").pop() }}
60
+ </p>
61
+ <p class="text-xs text-dimmed">
62
+ {{ current.mime_type }}
63
+ </p>
64
+ </div>
65
+ <UButton
66
+ v-if="!readonly && !disabled"
67
+ icon="i-lucide-x"
68
+ size="xs"
69
+ variant="ghost"
70
+ color="neutral"
71
+ @click="clear"
72
+ />
73
+ </div>
74
+
75
+ <!-- Trigger button -->
76
+ <UButton
77
+ v-if="!readonly && !disabled"
78
+ :icon="current ? 'i-lucide-refresh-cw' : 'i-lucide-image-plus'"
79
+ size="sm"
80
+ variant="soft"
81
+ color="neutral"
82
+ :block="!current"
83
+ @click="dialogOpen = true"
84
+ >
85
+ {{ current ? t("mapo.change") : t("mapo.mediaField.selectMedia") }}
86
+ </UButton>
87
+
88
+ <!-- Dialog -->
89
+ <component
90
+ :is="MediaDialog"
91
+ v-model="dialogOpen"
92
+ selection-mode="single"
93
+ :mime="mime"
94
+ :selected="current"
95
+ @confirm="onConfirm"
96
+ />
97
+ </template>
98
+
99
+ <!-- Errors -->
100
+ <p v-for="err in errors" :key="err" class="text-xs text-error" role="alert">
101
+ {{ err }}
102
+ </p>
103
+ </div>
104
+ </template>
@@ -0,0 +1,130 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from "vue";
3
+ import { useI18n } from "vue-i18n";
4
+ import type { MediaM2mDescriptor, MediaItem } from "../../types/index.js";
5
+ import { useMediaManager } from "../../composables/useMediaManager.js";
6
+
7
+ const props = defineProps<{
8
+ modelValue: MediaItem[] | number[] | null | undefined;
9
+ descriptor: MediaM2mDescriptor;
10
+ errors?: string[];
11
+ readonly?: boolean;
12
+ disabled?: boolean;
13
+ }>();
14
+
15
+ const emit = defineEmits<{
16
+ "update:modelValue": [value: MediaItem[]];
17
+ }>();
18
+
19
+ const { t } = useI18n();
20
+
21
+ const dialogOpen = ref(false);
22
+
23
+ // Picker dialog comes from @mapomodule/uikit, resolved at runtime.
24
+ const { MediaDialog, available } = useMediaManager();
25
+
26
+ const current = computed<MediaItem[]>(() => {
27
+ if (!props.modelValue) return [];
28
+ return props.modelValue.filter((v): v is MediaItem => typeof v === "object");
29
+ });
30
+
31
+ const mime = computed(() => props.descriptor.attrs?.mime as string | undefined);
32
+
33
+ function onConfirm(selection: MediaItem | MediaItem[]) {
34
+ emit("update:modelValue", Array.isArray(selection) ? selection : [selection]);
35
+ }
36
+
37
+ function removeItem(id: number) {
38
+ emit(
39
+ "update:modelValue",
40
+ current.value.filter((m) => m.id !== id),
41
+ );
42
+ }
43
+
44
+ // HTML5 drag reorder — order matters for m2m relations (legacy parity).
45
+ const dragIndex = ref<number | null>(null);
46
+
47
+ function onDragStart(index: number) {
48
+ dragIndex.value = index;
49
+ }
50
+
51
+ function onDragOverItem(index: number) {
52
+ if (dragIndex.value === null || dragIndex.value === index) return;
53
+ const items = [...current.value];
54
+ const [moved] = items.splice(dragIndex.value, 1);
55
+ if (!moved) return;
56
+ items.splice(index, 0, moved);
57
+ dragIndex.value = index;
58
+ emit("update:modelValue", items);
59
+ }
60
+
61
+ function onDragEnd() {
62
+ dragIndex.value = null;
63
+ }
64
+ </script>
65
+
66
+ <template>
67
+ <div class="mapo-media-m2m-field space-y-2">
68
+ <MapoMediaUnavailable v-if="!available" />
69
+
70
+ <template v-else>
71
+ <!-- Selected items grid -->
72
+ <div v-if="current.length > 0" class="flex flex-wrap gap-2">
73
+ <div
74
+ v-for="(media, i) in current"
75
+ :key="media.id"
76
+ class="group relative"
77
+ :class="{
78
+ 'opacity-50': dragIndex === i,
79
+ 'cursor-grab': !readonly && !disabled,
80
+ }"
81
+ :draggable="!readonly && !disabled"
82
+ @dragstart="onDragStart(i)"
83
+ @dragover.prevent="onDragOverItem(i)"
84
+ @dragend="onDragEnd"
85
+ >
86
+ <MapoMediaPreview :media="media" size="sm" />
87
+ <button
88
+ v-if="!readonly && !disabled"
89
+ class="absolute -right-1.5 -top-1.5 hidden size-4 items-center justify-center rounded-full bg-error text-inverted group-hover:flex"
90
+ type="button"
91
+ @click="removeItem(media.id)"
92
+ >
93
+ <UIcon name="i-lucide-x" class="size-2.5" />
94
+ </button>
95
+ </div>
96
+ </div>
97
+
98
+ <!-- Add button -->
99
+ <UButton
100
+ v-if="!readonly && !disabled"
101
+ icon="i-lucide-plus"
102
+ size="sm"
103
+ variant="soft"
104
+ color="neutral"
105
+ @click="dialogOpen = true"
106
+ >
107
+ {{
108
+ current.length > 0
109
+ ? t("mapo.mediaM2MField.addMedia")
110
+ : t("mapo.mediaField.selectMedia")
111
+ }}
112
+ </UButton>
113
+
114
+ <!-- Dialog -->
115
+ <component
116
+ :is="MediaDialog"
117
+ v-model="dialogOpen"
118
+ selection-mode="multi"
119
+ :mime="mime"
120
+ :selected="current"
121
+ @confirm="onConfirm"
122
+ />
123
+ </template>
124
+
125
+ <!-- Errors -->
126
+ <p v-for="err in errors" :key="err" class="text-xs text-error" role="alert">
127
+ {{ err }}
128
+ </p>
129
+ </div>
130
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Placeholder rendered by the media fields when the Media Manager is not
4
+ * registered — i.e. `@mapomodule/form` is installed without `@mapomodule/uikit`.
5
+ * Better than a silently broken picker: it names the missing package.
6
+ */
7
+ </script>
8
+
9
+ <template>
10
+ <div
11
+ class="flex items-center gap-2 rounded-lg border border-dashed border-default px-3 py-2 text-xs text-muted"
12
+ >
13
+ <UIcon
14
+ name="i-lucide-triangle-alert"
15
+ class="size-4 shrink-0 text-warning"
16
+ />
17
+ <span>Media fields require <code>@mapomodule/uikit</code>.</span>
18
+ </div>
19
+ </template>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, computed, watchEffect } from "vue";
3
+ import { useI18n } from "vue-i18n";
3
4
  import { VueDraggable } from "vue-draggable-plus";
4
5
  import { useConfirmStore } from "@mapomodule/store/runtime/stores/confirm";
5
6
  import type { RepeaterDescriptor } from "../../types/index.js";
@@ -17,6 +18,7 @@ const props = defineProps<{
17
18
  const emit = defineEmits<{ "update:modelValue": [value: unknown[]] }>();
18
19
 
19
20
  const form = injectMapoForm()!;
21
+ const { t } = useI18n();
20
22
 
21
23
  const attrs = computed(() => props.descriptor.attrs ?? {});
22
24
 
@@ -108,10 +110,8 @@ function minBound(): number {
108
110
  return attrs.value.minItems ?? 0;
109
111
  }
110
112
 
111
- async function askConfirm(
112
- message: string,
113
- title = "Confirm",
114
- ): Promise<boolean> {
113
+ async function askConfirm(message: string, title?: string): Promise<boolean> {
114
+ title ??= t("mapo.confirm");
115
115
  try {
116
116
  // Falls back to native confirm when the Pinia store is not registered.
117
117
  return await useConfirmStore().ask({ title, message });
@@ -135,8 +135,8 @@ async function deleteItem(index: number) {
135
135
  if (!canDelete.value) return;
136
136
  if (attrs.value.confirmDelete !== false) {
137
137
  const confirmed = await askConfirm(
138
- "Are you sure you want to delete this item?",
139
- "Delete item",
138
+ t("mapo.confirmDelete"),
139
+ t("mapo.repeater.deleteItem"),
140
140
  );
141
141
  if (!confirmed) return;
142
142
  }
@@ -275,14 +275,14 @@ async function bulkDelete() {
275
275
  // Enforce minItems: would deletion drop below the lower bound?
276
276
  if (items.value.length - count < minBound()) {
277
277
  await askConfirm(
278
- `Cannot delete: at least ${minBound()} items must remain.`,
279
- "Action blocked",
278
+ t("mapo.repeater.minItemsBlock", { min: minBound() }),
279
+ t("mapo.repeater.actionBlocked"),
280
280
  );
281
281
  return;
282
282
  }
283
283
  const confirmed = await askConfirm(
284
- `Delete ${count} selected items?`,
285
- "Delete items",
284
+ t("mapo.repeater.confirmBulkDelete", { count }),
285
+ t("mapo.repeater.deleteItems"),
286
286
  );
287
287
  if (!confirmed) return;
288
288
  pushUndo();
@@ -336,7 +336,7 @@ const errorPrefix = (index: number) =>
336
336
  class="flex flex-wrap items-center gap-2 rounded border border-gray-200 bg-gray-50 px-3 py-2"
337
337
  >
338
338
  <span class="text-sm font-medium text-gray-700">
339
- {{ items.length }} {{ items.length === 1 ? "item" : "items" }}
339
+ {{ t("mapo.repeater.nItems", { n: items.length }, items.length) }}
340
340
  <span v-if="attrs.maxItems" class="text-gray-400"
341
341
  >/ {{ attrs.maxItems }}</span
342
342
  >
@@ -344,10 +344,10 @@ const errorPrefix = (index: number) =>
344
344
 
345
345
  <div class="flex flex-1 flex-wrap items-center justify-end gap-2">
346
346
  <UButton size="xs" variant="ghost" color="neutral" @click="collapseAll">
347
- Collapse all
347
+ {{ t("mapo.repeater.collapseAll") }}
348
348
  </UButton>
349
349
  <UButton size="xs" variant="ghost" color="neutral" @click="expandAll">
350
- Expand all
350
+ {{ t("mapo.repeater.expandAll") }}
351
351
  </UButton>
352
352
  <UButton
353
353
  v-if="undoStack.length"
@@ -355,7 +355,7 @@ const errorPrefix = (index: number) =>
355
355
  variant="ghost"
356
356
  color="neutral"
357
357
  icon="i-lucide-undo-2"
358
- title="Undo last action"
358
+ :title="t('mapo.repeater.undoLastAction')"
359
359
  @click="undo"
360
360
  />
361
361
 
@@ -365,8 +365,10 @@ const errorPrefix = (index: number) =>
365
365
  :variant="selectionMode ? 'solid' : 'ghost'"
366
366
  color="neutral"
367
367
  icon="i-lucide-check-square"
368
- title="Bulk selection"
369
- :label="selectionMode ? 'Exit selection' : 'Select'"
368
+ :title="t('mapo.repeater.bulkSelection')"
369
+ :label="
370
+ selectionMode ? t('mapo.repeater.exitSelection') : t('mapo.select')
371
+ "
370
372
  @click="toggleSelectionMode"
371
373
  />
372
374
 
@@ -386,7 +388,7 @@ const errorPrefix = (index: number) =>
386
388
  :disabled="!canAdd"
387
389
  @click="addItem"
388
390
  >
389
- Add
391
+ {{ t("mapo.add") }}
390
392
  </UButton>
391
393
  </div>
392
394
  </div>
@@ -398,11 +400,11 @@ const errorPrefix = (index: number) =>
398
400
  class="flex items-center gap-2 rounded border border-primary-200 bg-primary-50 px-3 py-2"
399
401
  >
400
402
  <span class="text-sm font-medium text-primary-700">
401
- {{ selectionCount }} selected
403
+ {{ t("mapo.repeater.nSelected", { n: selectionCount }) }}
402
404
  </span>
403
405
  <div class="flex flex-1 flex-wrap items-center gap-2">
404
406
  <UButton size="xs" variant="ghost" color="neutral" @click="selectAll">
405
- All
407
+ {{ t("mapo.repeater.all") }}
406
408
  </UButton>
407
409
  <UButton
408
410
  size="xs"
@@ -410,7 +412,7 @@ const errorPrefix = (index: number) =>
410
412
  color="neutral"
411
413
  @click="clearSelection"
412
414
  >
413
- Clear
415
+ {{ t("mapo.clear") }}
414
416
  </UButton>
415
417
  <UButton
416
418
  size="xs"
@@ -420,12 +422,17 @@ const errorPrefix = (index: number) =>
420
422
  :disabled="remainingSlots() <= 0"
421
423
  :title="
422
424
  remainingSlots() < selectionCount
423
- ? `Only ${remainingSlots()} of ${selectionCount} can be duplicated (maxItems reached)`
424
- : 'Duplicate selected'
425
+ ? t('mapo.repeater.duplicateLimited', {
426
+ slots: remainingSlots(),
427
+ count: selectionCount,
428
+ })
429
+ : t('mapo.repeater.duplicateSelected')
425
430
  "
426
431
  @click="bulkDuplicate"
427
432
  >
428
- Duplicate ({{ Math.min(selectionCount, remainingSlots()) }})
433
+ {{ t("mapo.duplicate") }} ({{
434
+ Math.min(selectionCount, remainingSlots())
435
+ }})
429
436
  </UButton>
430
437
  <UButton
431
438
  size="xs"
@@ -434,7 +441,7 @@ const errorPrefix = (index: number) =>
434
441
  icon="i-lucide-arrow-up"
435
442
  @click="bulkMoveToTop"
436
443
  >
437
- Move to top
444
+ {{ t("mapo.repeater.moveToTop") }}
438
445
  </UButton>
439
446
  <UButton
440
447
  size="xs"
@@ -444,7 +451,7 @@ const errorPrefix = (index: number) =>
444
451
  :disabled="!canDelete"
445
452
  @click="bulkDelete"
446
453
  >
447
- Delete ({{ selectionCount }})
454
+ {{ t("mapo.delete") }} ({{ selectionCount }})
448
455
  </UButton>
449
456
  </div>
450
457
  </div>
@@ -527,7 +534,7 @@ const errorPrefix = (index: number) =>
527
534
  v-if="items.length === 0"
528
535
  class="rounded border border-dashed border-gray-300 py-8 text-center text-sm text-gray-400"
529
536
  >
530
- No items. Click "Add" to start.
537
+ {{ t("mapo.repeater.empty") }}
531
538
  </div>
532
539
  </div>
533
540
  </template>