@mapomodule/form 2.0.0-beta.1 → 2.0.0-beta.11

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 (202) hide show
  1. package/README.md +37 -118
  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__/tabVisibility.test.d.ts +2 -0
  9. package/dist/__tests__/tabVisibility.test.d.ts.map +1 -0
  10. package/dist/__tests__/useFormFromSchema.test.d.ts +2 -0
  11. package/dist/__tests__/useFormFromSchema.test.d.ts.map +1 -0
  12. package/dist/__tests__/useMapoForm.test.d.ts +2 -0
  13. package/dist/__tests__/useMapoForm.test.d.ts.map +1 -0
  14. package/dist/index.d.mts +9 -1
  15. package/dist/index.d.ts +15 -1
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.mjs +7 -34
  18. package/dist/module.d.mts +20 -2
  19. package/dist/module.d.ts +12 -2
  20. package/dist/module.d.ts.map +1 -0
  21. package/dist/module.json +1 -1
  22. package/dist/module.mjs +187 -13
  23. package/dist/runtime/components/MapoFocusPortal.vue +115 -0
  24. package/dist/runtime/components/MapoFocusPortal.vue.d.ts +4 -0
  25. package/dist/runtime/components/MapoFocusPortal.vue.d.ts.map +1 -0
  26. package/dist/runtime/components/MapoForm.vue +177 -0
  27. package/dist/runtime/components/MapoForm.vue.d.ts +47 -0
  28. package/dist/runtime/components/MapoForm.vue.d.ts.map +1 -0
  29. package/dist/runtime/components/MapoFormField.vue +269 -0
  30. package/dist/runtime/components/MapoFormField.vue.d.ts +46 -0
  31. package/dist/runtime/components/MapoFormField.vue.d.ts.map +1 -0
  32. package/dist/runtime/components/MapoFormFlatSection.vue +149 -0
  33. package/dist/runtime/components/MapoFormFlatSection.vue.d.ts +15 -0
  34. package/dist/runtime/components/MapoFormFlatSection.vue.d.ts.map +1 -0
  35. package/dist/runtime/components/MapoFormGroup.vue +247 -0
  36. package/dist/runtime/components/MapoFormGroup.vue.d.ts +44 -0
  37. package/dist/runtime/components/MapoFormGroup.vue.d.ts.map +1 -0
  38. package/dist/runtime/components/MapoFormTabs.vue +122 -0
  39. package/dist/runtime/components/MapoFormTabs.vue.d.ts +15 -0
  40. package/dist/runtime/components/MapoFormTabs.vue.d.ts.map +1 -0
  41. package/dist/runtime/components/MapoUnknownField.vue +62 -0
  42. package/dist/runtime/components/MapoUnknownField.vue.d.ts +16 -0
  43. package/dist/runtime/components/MapoUnknownField.vue.d.ts.map +1 -0
  44. package/dist/runtime/components/fields/MapoDateField.vue +88 -0
  45. package/dist/runtime/components/fields/MapoDateField.vue.d.ts +17 -0
  46. package/dist/runtime/components/fields/MapoDateField.vue.d.ts.map +1 -0
  47. package/dist/runtime/components/fields/MapoDateTimeField.vue +159 -0
  48. package/dist/runtime/components/fields/MapoDateTimeField.vue.d.ts +17 -0
  49. package/dist/runtime/components/fields/MapoDateTimeField.vue.d.ts.map +1 -0
  50. package/dist/runtime/components/fields/MapoEnhancedMediaField.vue +130 -0
  51. package/dist/runtime/components/fields/MapoEnhancedMediaField.vue.d.ts +16 -0
  52. package/dist/runtime/components/fields/MapoEnhancedMediaField.vue.d.ts.map +1 -0
  53. package/dist/runtime/components/fields/MapoFileField.vue +99 -0
  54. package/dist/runtime/components/fields/MapoFileField.vue.d.ts +16 -0
  55. package/dist/runtime/components/fields/MapoFileField.vue.d.ts.map +1 -0
  56. package/dist/runtime/components/fields/MapoFksField.vue +122 -0
  57. package/dist/runtime/components/fields/MapoFksField.vue.d.ts +16 -0
  58. package/dist/runtime/components/fields/MapoFksField.vue.d.ts.map +1 -0
  59. package/dist/runtime/components/fields/MapoMapField.vue +99 -0
  60. package/dist/runtime/components/fields/MapoMapField.vue.d.ts +22 -0
  61. package/dist/runtime/components/fields/MapoMapField.vue.d.ts.map +1 -0
  62. package/dist/runtime/components/fields/MapoMapFieldClient.vue +67 -0
  63. package/dist/runtime/components/fields/MapoMapFieldClient.vue.d.ts +20 -0
  64. package/dist/runtime/components/fields/MapoMapFieldClient.vue.d.ts.map +1 -0
  65. package/dist/runtime/components/fields/MapoMediaField.vue +93 -0
  66. package/dist/runtime/components/fields/MapoMediaField.vue.d.ts +16 -0
  67. package/dist/runtime/components/fields/MapoMediaField.vue.d.ts.map +1 -0
  68. package/dist/runtime/components/fields/MapoMediaM2mField.vue +111 -0
  69. package/dist/runtime/components/fields/MapoMediaM2mField.vue.d.ts +16 -0
  70. package/dist/runtime/components/fields/MapoMediaM2mField.vue.d.ts.map +1 -0
  71. package/dist/runtime/components/fields/MapoMediaUnavailable.vue +15 -0
  72. package/dist/runtime/components/fields/MapoMediaUnavailable.vue.d.ts +4 -0
  73. package/dist/runtime/components/fields/MapoMediaUnavailable.vue.d.ts.map +1 -0
  74. package/dist/runtime/components/fields/MapoRepeater.vue +459 -0
  75. package/dist/runtime/components/fields/MapoRepeater.vue.d.ts +16 -0
  76. package/dist/runtime/components/fields/MapoRepeater.vue.d.ts.map +1 -0
  77. package/dist/runtime/components/fields/MapoRepeaterItem.vue +346 -0
  78. package/dist/runtime/components/fields/MapoRepeaterItem.vue.d.ts +55 -0
  79. package/dist/runtime/components/fields/MapoRepeaterItem.vue.d.ts.map +1 -0
  80. package/dist/runtime/components/fields/MapoSeoPreview.vue +122 -0
  81. package/dist/runtime/components/fields/MapoSeoPreview.vue.d.ts +22 -0
  82. package/dist/runtime/components/fields/MapoSeoPreview.vue.d.ts.map +1 -0
  83. package/dist/runtime/components/fields/MapoTimeField.vue +44 -0
  84. package/dist/runtime/components/fields/MapoTimeField.vue.d.ts +17 -0
  85. package/dist/runtime/components/fields/MapoTimeField.vue.d.ts.map +1 -0
  86. package/dist/runtime/components/fields/MapoWygEditor.vue +258 -0
  87. package/dist/runtime/components/fields/MapoWygEditor.vue.d.ts +16 -0
  88. package/dist/runtime/components/fields/MapoWygEditor.vue.d.ts.map +1 -0
  89. package/dist/runtime/components/fields/nui/NuiCheckbox.vue +9 -0
  90. package/dist/runtime/components/fields/nui/NuiCheckbox.vue.d.ts +4 -0
  91. package/dist/runtime/components/fields/nui/NuiCheckbox.vue.d.ts.map +1 -0
  92. package/dist/runtime/components/fields/nui/NuiInput.vue +9 -0
  93. package/dist/runtime/components/fields/nui/NuiInput.vue.d.ts +4 -0
  94. package/dist/runtime/components/fields/nui/NuiInput.vue.d.ts.map +1 -0
  95. package/dist/runtime/components/fields/nui/NuiSelectMenu.vue +18 -0
  96. package/dist/runtime/components/fields/nui/NuiSelectMenu.vue.d.ts +4 -0
  97. package/dist/runtime/components/fields/nui/NuiSelectMenu.vue.d.ts.map +1 -0
  98. package/dist/runtime/components/fields/nui/NuiSlider.vue +9 -0
  99. package/dist/runtime/components/fields/nui/NuiSlider.vue.d.ts +4 -0
  100. package/dist/runtime/components/fields/nui/NuiSlider.vue.d.ts.map +1 -0
  101. package/dist/runtime/components/fields/nui/NuiSwitch.vue +9 -0
  102. package/dist/runtime/components/fields/nui/NuiSwitch.vue.d.ts +4 -0
  103. package/dist/runtime/components/fields/nui/NuiSwitch.vue.d.ts.map +1 -0
  104. package/dist/runtime/components/fields/nui/NuiTextarea.vue +9 -0
  105. package/dist/runtime/components/fields/nui/NuiTextarea.vue.d.ts +4 -0
  106. package/dist/runtime/components/fields/nui/NuiTextarea.vue.d.ts.map +1 -0
  107. package/dist/runtime/components/fields/nui/useNuiFieldAttrs.d.ts +13 -0
  108. package/dist/runtime/components/fields/nui/useNuiFieldAttrs.d.ts.map +1 -0
  109. package/dist/runtime/components/fields/nui/useNuiFieldAttrs.js +19 -0
  110. package/dist/runtime/composables/defineFormField.d.ts +25 -0
  111. package/dist/runtime/composables/defineFormField.d.ts.map +1 -0
  112. package/dist/runtime/composables/defineFormField.js +27 -0
  113. package/dist/runtime/composables/useCurrentLang.d.ts +6 -0
  114. package/dist/runtime/composables/useCurrentLang.d.ts.map +1 -0
  115. package/dist/runtime/composables/useCurrentLang.js +9 -0
  116. package/dist/runtime/composables/useFocusMode.d.ts +40 -0
  117. package/dist/runtime/composables/useFocusMode.d.ts.map +1 -0
  118. package/dist/runtime/composables/useFocusMode.js +31 -0
  119. package/dist/runtime/composables/useFormDraft.d.ts +60 -0
  120. package/dist/runtime/composables/useFormDraft.d.ts.map +1 -0
  121. package/dist/runtime/composables/useFormDraft.js +52 -0
  122. package/dist/runtime/composables/useFormFromSchema.d.ts +21 -0
  123. package/dist/runtime/composables/useFormFromSchema.d.ts.map +1 -0
  124. package/dist/runtime/composables/useFormFromSchema.js +145 -0
  125. package/dist/runtime/composables/useMapoForm.d.ts +96 -0
  126. package/dist/runtime/composables/useMapoForm.d.ts.map +1 -0
  127. package/dist/runtime/composables/useMapoForm.js +325 -0
  128. package/dist/runtime/composables/useMediaManager.d.ts +17 -0
  129. package/dist/runtime/composables/useMediaManager.d.ts.map +1 -0
  130. package/dist/runtime/composables/useMediaManager.js +6 -0
  131. package/dist/runtime/composables/useProgressiveDisclosure.d.ts +44 -0
  132. package/dist/runtime/composables/useProgressiveDisclosure.d.ts.map +1 -0
  133. package/dist/runtime/composables/useProgressiveDisclosure.js +23 -0
  134. package/dist/runtime/composables/useUnsavedChangesGuard.d.ts +24 -0
  135. package/dist/runtime/composables/useUnsavedChangesGuard.d.ts.map +1 -0
  136. package/dist/runtime/composables/useUnsavedChangesGuard.js +28 -0
  137. package/dist/runtime/devtools/DevtoolsFormsPage.vue +235 -0
  138. package/dist/runtime/devtools/DevtoolsFormsPage.vue.d.ts +4 -0
  139. package/dist/runtime/devtools/DevtoolsFormsPage.vue.d.ts.map +1 -0
  140. package/dist/runtime/plugins/form-registry.d.ts +18 -0
  141. package/dist/runtime/plugins/form-registry.d.ts.map +1 -0
  142. package/dist/runtime/plugins/form-registry.js +19 -0
  143. package/dist/runtime/registry/defaults.d.ts +3 -0
  144. package/dist/runtime/registry/defaults.d.ts.map +1 -0
  145. package/dist/runtime/registry/defaults.js +93 -0
  146. package/dist/runtime/types/descriptor.d.ts +373 -0
  147. package/dist/runtime/types/descriptor.d.ts.map +1 -0
  148. package/dist/runtime/types/descriptor.js +26 -0
  149. package/dist/runtime/types/index.d.ts +12 -0
  150. package/dist/runtime/types/index.d.ts.map +1 -0
  151. package/dist/runtime/types/index.js +6 -0
  152. package/dist/runtime/types/layout.d.ts +21 -0
  153. package/dist/runtime/types/layout.d.ts.map +1 -0
  154. package/dist/runtime/types/layout.js +0 -0
  155. package/dist/runtime/types/media.d.ts +43 -0
  156. package/dist/runtime/types/media.d.ts.map +1 -0
  157. package/dist/runtime/types/media.js +0 -0
  158. package/dist/runtime/types/registry.d.ts +62 -0
  159. package/dist/runtime/types/registry.d.ts.map +1 -0
  160. package/dist/runtime/types/registry.js +19 -0
  161. package/dist/runtime/utils/flattenFieldGroups.d.ts +50 -0
  162. package/dist/runtime/utils/flattenFieldGroups.d.ts.map +1 -0
  163. package/dist/runtime/utils/flattenFieldGroups.js +24 -0
  164. package/dist/runtime/utils/tabVisibility.d.ts +18 -0
  165. package/dist/runtime/utils/tabVisibility.d.ts.map +1 -0
  166. package/dist/runtime/utils/tabVisibility.js +18 -0
  167. package/dist/types.d.mts +6 -10
  168. package/package.json +10 -8
  169. package/src/__tests__/article.schema.json +193 -0
  170. package/src/__tests__/camomillaRoundTrip.test.ts +73 -0
  171. package/src/__tests__/tabVisibility.test.ts +102 -0
  172. package/src/__tests__/useFormFromSchema.test.ts +24 -0
  173. package/src/__tests__/useMapoForm.test.ts +5 -0
  174. package/src/index.ts +5 -0
  175. package/src/module.ts +34 -0
  176. package/src/runtime/components/MapoFocusPortal.vue +3 -1
  177. package/src/runtime/components/MapoForm.vue +17 -4
  178. package/src/runtime/components/MapoFormTabs.vue +29 -6
  179. package/src/runtime/components/fields/MapoDateField.vue +3 -1
  180. package/src/runtime/components/fields/MapoDateTimeField.vue +3 -1
  181. package/src/runtime/components/fields/MapoEnhancedMediaField.vue +153 -0
  182. package/src/runtime/components/fields/MapoFileField.vue +3 -1
  183. package/src/runtime/components/fields/MapoFksField.vue +7 -2
  184. package/src/runtime/components/fields/MapoMapField.vue +5 -2
  185. package/src/runtime/components/fields/MapoMediaField.vue +106 -0
  186. package/src/runtime/components/fields/MapoMediaM2mField.vue +131 -0
  187. package/src/runtime/components/fields/MapoMediaUnavailable.vue +19 -0
  188. package/src/runtime/components/fields/MapoRepeater.vue +33 -26
  189. package/src/runtime/components/fields/MapoRepeaterItem.vue +14 -8
  190. package/src/runtime/components/fields/MapoSeoPreview.vue +17 -13
  191. package/src/runtime/components/fields/MapoWygEditor.vue +55 -7
  192. package/src/runtime/composables/useFormFromSchema.ts +4 -1
  193. package/src/runtime/composables/useMapoForm.ts +4 -2
  194. package/src/runtime/composables/useMediaManager.ts +21 -0
  195. package/src/runtime/composables/useUnsavedChangesGuard.ts +3 -3
  196. package/src/runtime/registry/defaults.ts +9 -1
  197. package/src/runtime/types/descriptor.ts +22 -7
  198. package/src/runtime/types/index.ts +4 -0
  199. package/src/runtime/types/media.ts +44 -0
  200. package/src/runtime/utils/tabVisibility.ts +44 -0
  201. package/dist/index.d.cts +0 -1
  202. package/dist/module.d.cts +0 -2
@@ -2,6 +2,7 @@
2
2
  import { computed, useSlots, watchEffect, inject, onUnmounted } from "vue";
3
3
  import type { Ref } from "vue";
4
4
  import { useNuxtApp, useRuntimeConfig } from "#app";
5
+ import { useI18n } from "vue-i18n";
5
6
  import type {
6
7
  AnyFieldDescriptor,
7
8
  FieldRegistry,
@@ -135,6 +136,12 @@ provideCurrentLang(currentLangRef);
135
136
 
136
137
  // ─── Group fields by tab/group ───────────────────────────────────────────────
137
138
 
139
+ const { t } = useI18n();
140
+
141
+ // Fields without a `tab` go here. It becomes a tab of its own only when other
142
+ // fields have one, and then it shows as "General" (`mapo.form.defaultTab`).
143
+ const DEFAULT_TAB = "__default__";
144
+
138
145
  function getOrCreateTab(map: Map<string, TabEntry>, name: string): TabEntry {
139
146
  if (!map.has(name)) {
140
147
  map.set(name, { name, groups: new Map(), children: new Map() });
@@ -151,7 +158,7 @@ const grouped = computed<Map<string, TabEntry>>(() => {
151
158
  // Normalise: 'a/b', ['a','b'], or 'a' all become a string[]
152
159
  const rawTab = field.tab;
153
160
  const tabPath: string[] = !rawTab
154
- ? ["__default__"]
161
+ ? [DEFAULT_TAB]
155
162
  : Array.isArray(rawTab)
156
163
  ? rawTab
157
164
  : rawTab.split("/");
@@ -179,17 +186,23 @@ const grouped = computed<Map<string, TabEntry>>(() => {
179
186
  }
180
187
  }
181
188
 
189
+ const defaultTab = tabs.get(DEFAULT_TAB);
190
+ if (defaultTab) defaultTab.label = t("mapo.form.defaultTab");
191
+
182
192
  return tabs;
183
193
  });
184
194
 
185
195
  const hasTabs = computed(() => {
186
196
  const keys = [...grouped.value.keys()];
187
- return keys.length > 1 || (keys.length === 1 && keys[0] !== "__default__");
197
+ const hasFields = keys.length > 0;
198
+ if (!hasFields) return false;
199
+ const hasSeveralTabs = keys.length > 1;
200
+ const hasNamedTab = keys[0] !== DEFAULT_TAB;
201
+ return hasSeveralTabs || hasNamedTab;
188
202
  });
189
203
 
190
204
  const defaultGroups = computed(
191
- () =>
192
- grouped.value.get("__default__")?.groups ?? new Map<string, GroupEntry>(),
205
+ () => grouped.value.get(DEFAULT_TAB)?.groups ?? new Map<string, GroupEntry>(),
193
206
  );
194
207
 
195
208
  const tabList = computed<TabEntry[]>(() => [...grouped.value.values()]);
@@ -1,15 +1,39 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, computed } from "vue";
3
+ import { useI18n } from "vue-i18n";
3
4
  import type { TabEntry } from "../types/layout.js";
4
5
  import { injectMapoForm } from "../composables/useMapoForm.js";
6
+ import { pickActiveTab, tabHasShownFields } from "../utils/tabVisibility.js";
5
7
  import MapoFormGroup from "./MapoFormGroup.vue";
6
8
  import MapoFormFlatSection from "./MapoFormFlatSection.vue";
7
9
 
10
+ const { t } = useI18n();
11
+
8
12
  const props = defineProps<{ tabs: TabEntry[] }>();
9
13
 
10
- defineSlots<Record<string, unknown>>();
14
+ const slots = defineSlots<Record<string, unknown>>();
15
+
16
+ const form = injectMapoForm<Record<string, unknown>>()!;
17
+
18
+ // A tab whose fields are all hidden by `visible` or `show` would open on an
19
+ // empty panel, so it leaves the tab bar until one of them shows. Content passed
20
+ // through the tab's own slots counts as something to show.
21
+ function tabHasContent(tab: TabEntry): boolean {
22
+ const hasPanelSlot = Boolean(slots[`tab.${tab.name}`]);
23
+ const hasBeforeSlot = Boolean(slots[`tab.${tab.name}.before`]);
24
+ const hasAfterSlot = Boolean(slots[`tab.${tab.name}.after`]);
25
+ if (hasPanelSlot || hasBeforeSlot || hasAfterSlot) return true;
26
+ return tabHasShownFields(tab, form.model.value);
27
+ }
28
+
29
+ const shownTabs = computed(() => props.tabs.filter(tabHasContent));
11
30
 
12
- const activeTab = ref(props.tabs[0]?.name ?? "");
31
+ // The tab the user clicked; until then, or while it is hidden, the first shown
32
+ // tab is open.
33
+ const pickedTab = ref<string | null>(null);
34
+ const activeTab = computed(() =>
35
+ pickActiveTab(shownTabs.value, pickedTab.value),
36
+ );
13
37
 
14
38
  // Recursively check if a tab (or any of its descendants) contains errors.
15
39
  function tabHasErrors(tab: TabEntry, errorKeys: Set<string>): boolean {
@@ -23,7 +47,6 @@ function tabHasErrors(tab: TabEntry, errorKeys: Set<string>): boolean {
23
47
  }
24
48
 
25
49
  // Highlight tabs containing errors.
26
- const form = injectMapoForm()!;
27
50
  const tabsWithErrors = computed(() => {
28
51
  const errorKeys = new Set(Object.keys(form.errors.value));
29
52
  return new Set(
@@ -37,7 +60,7 @@ const tabsWithErrors = computed(() => {
37
60
  <!-- Tab bar -->
38
61
  <div class="flex gap-1 border-b border-gray-200 pb-px">
39
62
  <button
40
- v-for="tab in tabs"
63
+ v-for="tab in shownTabs"
41
64
  :key="tab.name"
42
65
  type="button"
43
66
  class="relative flex items-center gap-1.5 rounded-t px-4 py-2 text-sm font-medium transition-colors"
@@ -46,7 +69,7 @@ const tabsWithErrors = computed(() => {
46
69
  ? 'border-b-2 border-primary-500 text-primary-600'
47
70
  : 'text-gray-500 hover:bg-gray-50 hover:text-gray-700',
48
71
  ]"
49
- @click="activeTab = tab.name"
72
+ @click="pickedTab = tab.name"
50
73
  >
51
74
  <slot :name="`tab.${tab.name}.label`" :tab="tab">
52
75
  {{ tab.label ?? tab.name }}
@@ -54,7 +77,7 @@ const tabsWithErrors = computed(() => {
54
77
  <span
55
78
  v-if="tabsWithErrors.has(tab.name)"
56
79
  class="inline-block h-1.5 w-1.5 rounded-full bg-red-500"
57
- aria-label="This tab contains errors"
80
+ :aria-label="t('mapo.form.tabErrors')"
58
81
  />
59
82
  </button>
60
83
  <!-- Extra content appended to the tab bar (actions, badges, …). -->
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref } from "vue";
3
+ import { useI18n } from "vue-i18n";
3
4
  import { parseDate, CalendarDate } from "@internationalized/date";
4
5
  import type { DateDescriptor } from "../../types/index.js";
5
6
 
@@ -14,6 +15,7 @@ const props = defineProps<{
14
15
 
15
16
  const emit = defineEmits<{ "update:modelValue": [value: string | null] }>();
16
17
 
18
+ const { t } = useI18n();
17
19
  const open = ref(false);
18
20
 
19
21
  /** Parsed calendar value derived from the current ISO-like model string. */
@@ -30,7 +32,7 @@ const calendarValue = computed<CalendarDate | undefined>(() => {
30
32
 
31
33
  const displayLabel = computed(() => {
32
34
  const v = calendarValue.value;
33
- if (!v) return "Select date";
35
+ if (!v) return t("mapo.dateField.selectDate");
34
36
  return `${String(v.day).padStart(2, "0")}/${String(v.month).padStart(2, "0")}/${v.year}`;
35
37
  });
36
38
 
@@ -6,6 +6,7 @@ import {
6
6
  today,
7
7
  getLocalTimeZone,
8
8
  } from "@internationalized/date";
9
+ import { useI18n } from "vue-i18n";
9
10
  import type { DateDescriptor } from "../../types/index.js";
10
11
 
11
12
  /** Datetime field with configurable naive/UTC serialisation semantics. */
@@ -19,6 +20,7 @@ const props = defineProps<{
19
20
 
20
21
  const emit = defineEmits<{ "update:modelValue": [value: string | null] }>();
21
22
 
23
+ const { t } = useI18n();
22
24
  const open = ref(false);
23
25
 
24
26
  // Timezone strategy configurable through `descriptor.attrs.tz`:
@@ -72,7 +74,7 @@ function emitFromCalendar(v: CalendarDateTime) {
72
74
  // Displayed label in the trigger button
73
75
  const displayLabel = computed(() => {
74
76
  const v = calendarValue.value;
75
- if (!v) return "Select date and time";
77
+ if (!v) return t("mapo.dateField.selectDateTime");
76
78
  const date = `${String(v.day).padStart(2, "0")}/${String(v.month).padStart(2, "0")}/${v.year}`;
77
79
  const time = `${String(v.hour).padStart(2, "0")}:${String(v.minute).padStart(2, "0")}`;
78
80
  return `${date} ${time}`;
@@ -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,106 @@
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. It and the trigger below paint their own
52
+ surface, like an input: `soft` buttons and transparent cards take
53
+ the page background and vanish on a tinted page. -->
54
+ <div
55
+ v-if="current"
56
+ class="flex items-center gap-3 rounded-lg border border-default bg-default p-2"
57
+ >
58
+ <MapoMediaPreview :media="current" size="sm" />
59
+ <div class="flex-1 min-w-0">
60
+ <p class="truncate text-xs font-medium">
61
+ {{ current.title || current.file?.split("/").pop() }}
62
+ </p>
63
+ <p class="text-xs text-dimmed">
64
+ {{ current.mime_type }}
65
+ </p>
66
+ </div>
67
+ <UButton
68
+ v-if="!readonly && !disabled"
69
+ icon="i-lucide-x"
70
+ size="xs"
71
+ variant="ghost"
72
+ color="neutral"
73
+ @click="clear"
74
+ />
75
+ </div>
76
+
77
+ <!-- Trigger button -->
78
+ <UButton
79
+ v-if="!readonly && !disabled"
80
+ :icon="current ? 'i-lucide-refresh-cw' : 'i-lucide-image-plus'"
81
+ size="sm"
82
+ variant="outline"
83
+ color="neutral"
84
+ :block="!current"
85
+ @click="dialogOpen = true"
86
+ >
87
+ {{ current ? t("mapo.change") : t("mapo.mediaField.selectMedia") }}
88
+ </UButton>
89
+
90
+ <!-- Dialog -->
91
+ <component
92
+ :is="MediaDialog"
93
+ v-model="dialogOpen"
94
+ selection-mode="single"
95
+ :mime="mime"
96
+ :selected="current"
97
+ @confirm="onConfirm"
98
+ />
99
+ </template>
100
+
101
+ <!-- Errors -->
102
+ <p v-for="err in errors" :key="err" class="text-xs text-error" role="alert">
103
+ {{ err }}
104
+ </p>
105
+ </div>
106
+ </template>
@@ -0,0 +1,131 @@
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: outlined like MapoMediaField's trigger, so it keeps its
99
+ own surface on a tinted page. -->
100
+ <UButton
101
+ v-if="!readonly && !disabled"
102
+ icon="i-lucide-plus"
103
+ size="sm"
104
+ variant="outline"
105
+ color="neutral"
106
+ @click="dialogOpen = true"
107
+ >
108
+ {{
109
+ current.length > 0
110
+ ? t("mapo.mediaM2MField.addMedia")
111
+ : t("mapo.mediaField.selectMedia")
112
+ }}
113
+ </UButton>
114
+
115
+ <!-- Dialog -->
116
+ <component
117
+ :is="MediaDialog"
118
+ v-model="dialogOpen"
119
+ selection-mode="multi"
120
+ :mime="mime"
121
+ :selected="current"
122
+ @confirm="onConfirm"
123
+ />
124
+ </template>
125
+
126
+ <!-- Errors -->
127
+ <p v-for="err in errors" :key="err" class="text-xs text-error" role="alert">
128
+ {{ err }}
129
+ </p>
130
+ </div>
131
+ </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>