@edc-motor/ui 0.4.15 → 0.4.17

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edc-motor/ui",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "description": "EdC Motor — componentes públicos Vue 3 + tokens SCSS para webs de juegos de mesa (paquete fuente: lo compila el consumidor con Vite)",
5
5
  "license": "GPL-3.0-only",
6
6
  "type": "module",
@@ -3,6 +3,22 @@
3
3
  .edit-modal {
4
4
  &__form { display: contents; }
5
5
 
6
+ // Cabecera propia: título + selector de locale global de los campos
7
+ // traducibles (solo aparece si el formulario contiene alguno).
8
+ &__header {
9
+ display: flex;
10
+ align-items: center;
11
+ gap: $space-3;
12
+ min-width: 0;
13
+ flex: 1;
14
+
15
+ .modal__title {
16
+ overflow: hidden;
17
+ text-overflow: ellipsis;
18
+ white-space: nowrap;
19
+ }
20
+ }
21
+
6
22
  &__body {
7
23
  display: flex;
8
24
  flex-direction: column;
@@ -0,0 +1,38 @@
1
+ @use "tokens" as *;
2
+
3
+ // Selector compacto de locale GLOBAL de un formulario (FormLocaleSwitch):
4
+ // segmentado de códigos, mismo lenguaje visual que el trigger de locale de
5
+ // los campos traducibles (_translatable-input.scss). Vive en la cabecera del
6
+ // EditModal (solo si el formulario contiene campos traducibles).
7
+ .form-locale-switch {
8
+ display: inline-flex;
9
+ align-items: stretch;
10
+ background: $surface-2;
11
+ border: 1px solid $border;
12
+ border-radius: $radius-sm;
13
+ overflow: hidden;
14
+ flex-shrink: 0;
15
+
16
+ &__option {
17
+ padding: 2px $space-2;
18
+ border: none;
19
+ background: transparent;
20
+ color: $text-3;
21
+ font-size: $fs-11;
22
+ font-weight: $k-fw-bold;
23
+ letter-spacing: $k-track-wide;
24
+ cursor: pointer;
25
+ transition: background-color 0.15s, color 0.15s;
26
+
27
+ & + & { border-left: 1px solid $border; }
28
+
29
+ &:hover { background: $surface-3; color: $text-1; }
30
+
31
+ // Resalta la ÚLTIMA elección global (los tabs individuales pueden haberse
32
+ // movido después: esto es un mando, no un espejo).
33
+ &.is-active {
34
+ background: $accent-500;
35
+ color: $text-1;
36
+ }
37
+ }
38
+ }
@@ -80,6 +80,17 @@
80
80
 
81
81
  &__icon { width: 32px; height: 32px; color: $text-3; }
82
82
  &__text { font-size: $fs-12; color: $text-2; }
83
+
84
+ // Nombre del fichero (pendiente o guardado) bajo la miniatura.
85
+ &__name {
86
+ max-width: 160px;
87
+ overflow: hidden;
88
+ font-size: $fs-12;
89
+ color: $text-3;
90
+ text-overflow: ellipsis;
91
+ white-space: nowrap;
92
+ }
93
+
83
94
  &__hint { font-size: $fs-12; color: $text-3; }
84
95
  &__error { font-size: $fs-12; color: $danger; }
85
96
  }
@@ -21,17 +21,20 @@
21
21
 
22
22
  &__search-icon {
23
23
  position: absolute;
24
- right: $input-padding-x;
24
+ left: $input-padding-x;
25
25
  top: 50%;
26
26
  transform: translateY(-50%);
27
27
  color: $text-3;
28
28
  pointer-events: none;
29
29
  }
30
30
 
31
- &__search-input {
31
+ // Doble clase: gana al input[type="search"] genérico de _forms.scss
32
+ // (0,1,1) sin depender del orden de carga — si no, el padding de la lupa
33
+ // no se aplica y el texto se monta con el icono.
34
+ &__search #{&}__search-input {
32
35
  width: 100%;
33
36
  height: $input-height;
34
- padding: 0 34px 0 $input-padding-x; // hueco a la derecha para la lupa
37
+ padding: 0 $input-padding-x 0 34px; // hueco a la izquierda para la lupa
35
38
  background: $input-bg;
36
39
  color: $input-text;
37
40
  border: 1px solid $input-border;
@@ -19,6 +19,7 @@
19
19
  @use "rich-text";
20
20
  @use "rich-content";
21
21
  @use "translatable-input";
22
+ @use "form-locale-switch";
22
23
  @use "image-upload";
23
24
  @use "font-upload";
24
25
  @use "toast";
@@ -2,6 +2,8 @@
2
2
  import BaseModal from './BaseModal.vue'
3
3
  import { Save, X } from '@lucide/vue'
4
4
  import BaseButton from './BaseButton.vue'
5
+ import FormLocaleSwitch from './FormLocaleSwitch.vue'
6
+ import { provideFormLocale } from '../composables/useFormLocale'
5
7
 
6
8
  // Modal de formulario (portado de kontuan): BaseModal + pie con Cancelar/Guardar.
7
9
  // Agnóstico de i18n: las etiquetas se pasan por props (la app las traduce).
@@ -14,6 +16,8 @@ const props = withDefaults(
14
16
  submitLabel?: string
15
17
  cancelLabel?: string
16
18
  submitVariant?: 'primary' | 'secondary' | 'danger' | 'success'
19
+ /** Texto accesible del selector de locale global (DC-29). */
20
+ localeSwitchLabel?: string
17
21
  }>(),
18
22
  {
19
23
  size: 'md',
@@ -21,11 +25,17 @@ const props = withDefaults(
21
25
  submitLabel: 'Guardar',
22
26
  cancelLabel: 'Cancelar',
23
27
  submitVariant: 'primary',
28
+ localeSwitchLabel: undefined,
24
29
  },
25
30
  )
26
31
 
27
32
  const emit = defineEmits<{ 'update:modelValue': [boolean]; submit: [] }>()
28
33
 
34
+ // Locale global del formulario: los campos traducibles del slot (Translatable*
35
+ // del ui, también dentro de SchemaFields/PageBlocks) se suscriben SOLOS por
36
+ // inject; el selector de la cabecera solo se pinta si hay alguno.
37
+ provideFormLocale()
38
+
29
39
  function close() {
30
40
  if (props.loading) return
31
41
  emit('update:modelValue', false)
@@ -39,6 +49,15 @@ function close() {
39
49
  :size="size"
40
50
  @update:model-value="(v) => !loading && emit('update:modelValue', v)"
41
51
  >
52
+ <!-- Cabecera propia: título + selector de locale global (si hay campos
53
+ traducibles); el botón de cerrar lo sigue poniendo BaseModal. -->
54
+ <template #header>
55
+ <div class="edit-modal__header">
56
+ <h3 class="modal__title">{{ title }}</h3>
57
+ <FormLocaleSwitch :title="localeSwitchLabel" />
58
+ </div>
59
+ </template>
60
+
42
61
  <form class="edit-modal__form" @submit.prevent="emit('submit')">
43
62
  <div class="edit-modal__body"><slot /></div>
44
63
  </form>
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ import { inject, ref, watch } from 'vue'
3
+ import { FormLocaleKey } from '../composables/useFormLocale'
4
+
5
+ // Selector COMPACTO de locale global de un formulario: botones segmentados
6
+ // con los códigos (unión de los locales de los campos traducibles suscritos
7
+ // vía provide/inject, ver useFormLocale). Al pulsar uno cambia el tab activo
8
+ // de TODOS los campos a la vez; los tabs individuales siguen funcionando por
9
+ // su cuenta (por eso el resaltado marca la ÚLTIMA elección global, no un
10
+ // estado que los campos puedan desmentir). Solo se pinta si el formulario
11
+ // contiene campos traducibles. Agnóstico de i18n (DC-29): texto por prop.
12
+
13
+ withDefaults(defineProps<{ title?: string }>(), { title: 'Idioma de todos los campos' })
14
+
15
+ const context = inject(FormLocaleKey, null)
16
+
17
+ // Última elección global (para el resaltado); se reinicia si cambia el set
18
+ // de locales (formularios que se rellenan en diferido).
19
+ const current = ref<string | null>(null)
20
+ if (context) {
21
+ watch(context.locales, () => {
22
+ if (current.value && !context.locales.value.some((l) => l.code === current.value)) {
23
+ current.value = null
24
+ }
25
+ })
26
+ }
27
+
28
+ function setAll(code: string) {
29
+ if (!context) return
30
+ current.value = code
31
+ context.setAll(code)
32
+ }
33
+ </script>
34
+
35
+ <template>
36
+ <div
37
+ v-if="context && context.hasFields.value && context.locales.value.length > 1"
38
+ class="form-locale-switch"
39
+ role="group"
40
+ :title="title"
41
+ :aria-label="title"
42
+ >
43
+ <button
44
+ v-for="locale in context.locales.value"
45
+ :key="locale.code"
46
+ type="button"
47
+ class="form-locale-switch__option"
48
+ :class="{ 'is-active': locale.code === current }"
49
+ :title="locale.name"
50
+ @click="setAll(locale.code)"
51
+ >
52
+ {{ locale.code.toUpperCase() }}
53
+ </button>
54
+ </div>
55
+ </template>
@@ -1,8 +1,11 @@
1
1
  <script setup lang="ts">
2
- import { computed, onBeforeUnmount, ref } from 'vue'
2
+ import { computed, onBeforeUnmount, ref, watch } from 'vue'
3
3
  import { Trash2 } from '@lucide/vue'
4
4
 
5
5
  // Subida de imagen con arrastrar-y-soltar o clic (portado de kontuan).
6
+ // DIFERIDO: elegir fichero NO sube nada — el File queda en el v-model (con
7
+ // object URL para la vista previa) y quien lo usa lo envía al GUARDAR. La
8
+ // imagen ya guardada se muestra con `currentUrl` (miniatura + nombre).
6
9
  // Agnóstico de i18n: los textos van por props.
7
10
  const props = withDefaults(
8
11
  defineProps<{
@@ -41,6 +44,40 @@ const displayUrl = computed(() => (removed.value ? null : previewUrl.value || pr
41
44
  // El error externo (validación del servidor) manda sobre el interno.
42
45
  const shownError = computed(() => props.error || localError.value)
43
46
 
47
+ // Nombre del fichero bajo la miniatura: el del File pendiente o, si se
48
+ // muestra la imagen guardada, el nombre extraído de su URL.
49
+ const displayName = computed(() => {
50
+ if (removed.value) return null
51
+ if (props.modelValue) return props.modelValue.name
52
+ if (!previewUrl.value && props.currentUrl) {
53
+ try {
54
+ const path = decodeURIComponent(props.currentUrl.split('?')[0].split('#')[0])
55
+ return path.split('/').pop() || null
56
+ } catch {
57
+ return null
58
+ }
59
+ }
60
+ return null
61
+ })
62
+
63
+ // La vista previa se deriva del v-model (controlado): si el padre repone un
64
+ // File (p. ej. TranslatableImage al volver a un idioma con imagen pendiente)
65
+ // la miniatura reaparece sin re-elegir el fichero.
66
+ watch(
67
+ () => props.modelValue,
68
+ (file) => {
69
+ if (previewUrl.value) {
70
+ URL.revokeObjectURL(previewUrl.value)
71
+ previewUrl.value = null
72
+ }
73
+ if (file) {
74
+ previewUrl.value = URL.createObjectURL(file)
75
+ removed.value = false
76
+ }
77
+ },
78
+ { immediate: true },
79
+ )
80
+
44
81
  function handleFile(file: File) {
45
82
  // Validación en cliente: tamaño y tipo, con feedback (no se ignora en silencio).
46
83
  if (file.size > props.maxSize * 1024 * 1024) {
@@ -52,9 +89,6 @@ function handleFile(file: File) {
52
89
  return
53
90
  }
54
91
  localError.value = null
55
- removed.value = false
56
- if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
57
- previewUrl.value = URL.createObjectURL(file)
58
92
  emit('update:modelValue', file)
59
93
  }
60
94
 
@@ -137,6 +171,7 @@ onBeforeUnmount(() => {
137
171
  </div>
138
172
  </template>
139
173
  </div>
174
+ <p v-if="displayName" class="image-upload__name">{{ displayName }}</p>
140
175
  <p v-if="shownError" class="image-upload__error">{{ shownError }}</p>
141
176
  </div>
142
177
  </template>
@@ -3,7 +3,7 @@ import { Search } from '@lucide/vue'
3
3
  import SortToggles, { type SortValue } from './SortToggles.vue'
4
4
 
5
5
  // Barra unificada de los index (admin y web pública): búsqueda (lupa a la
6
- // derecha, como el FilterBar del admin-kit) y toggles de ordenación. Los
6
+ // izquierda, como el FilterBar del admin-kit) y toggles de ordenación. Los
7
7
  // filtros del listado viven en la barra derecha (RightSidebar del admin /
8
8
  // AppRightSidebar de la web), no aquí. En ancho es una fila
9
9
  // [búsqueda][toggles]; en estrecho (container query propia) la búsqueda
@@ -43,6 +43,7 @@ const emit = defineEmits<{
43
43
  <div class="index-toolbar">
44
44
  <div class="index-toolbar__inner">
45
45
  <div class="index-toolbar__search">
46
+ <Search :size="16" class="index-toolbar__search-icon" />
46
47
  <input
47
48
  type="search"
48
49
  :value="modelValue"
@@ -50,7 +51,6 @@ const emit = defineEmits<{
50
51
  class="index-toolbar__search-input"
51
52
  @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
52
53
  />
53
- <Search :size="16" class="index-toolbar__search-icon" />
54
54
  </div>
55
55
 
56
56
  <div v-if="showSort" class="index-toolbar__actions">
@@ -16,19 +16,19 @@ const props = withDefaults(
16
16
 
17
17
  const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
18
18
 
19
- // Paleta base (mismos tonos que kontuan).
19
+ // Paleta base: espectro cálido → frío, con el gris al final (el tono "Slate"
20
+ // heredado de kontuan). Los valores viajan como HEX, no como clave.
20
21
  const PALETTE = [
21
- { name: 'Slate', hex: '#64748B' },
22
- { name: 'Red', hex: '#EF4444' },
23
- { name: 'Orange', hex: '#F97316' },
24
- { name: 'Amber', hex: '#F59E0B' },
25
- { name: 'Yellow', hex: '#EAB308' },
26
- { name: 'Green', hex: '#22C55E' },
27
- { name: 'Teal', hex: '#14B8A6' },
28
- { name: 'Blue', hex: '#3B82F6' },
29
- { name: 'Indigo', hex: '#6366F1' },
30
- { name: 'Violet', hex: '#8B5CF6' },
31
- { name: 'Pink', hex: '#EC4899' },
22
+ { name: 'Rojo', hex: '#f15959' },
23
+ { name: 'Naranja', hex: '#f1753a' },
24
+ { name: 'Lima', hex: '#88b033' },
25
+ { name: 'Verde', hex: '#29ab5f' },
26
+ { name: 'Esmeralda', hex: '#31a28e' },
27
+ { name: 'Cian', hex: '#3999cd' },
28
+ { name: 'Azul', hex: '#408cfd' },
29
+ { name: 'Violeta', hex: '#7a64c8' },
30
+ { name: 'Magenta', hex: '#a75da5' },
31
+ { name: 'Gris', hex: '#64748B' },
32
32
  ]
33
33
 
34
34
  const norm = (v: string | null) => (v ?? '').toLowerCase()
@@ -2,12 +2,15 @@
2
2
  import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
3
3
  import { ChevronDown } from '@lucide/vue'
4
4
  import ImageUpload from './ImageUpload.vue'
5
+ import { useFormLocaleField } from '../composables/useFormLocale'
5
6
 
6
- // Imagen traducible (una URL por locale): mismo selector desplegable de
7
- // locale que TranslatableInput, con un ImageUpload para el idioma activo.
8
- // La subida la pone quien lo usa (prop `upload`): este componente solo
9
- // gestiona el mapa locale => URL. En el render, el motor localiza el valor
10
- // con fallback al locale por defecto (localizeSettings).
7
+ // Imagen traducible (una por locale): mismo selector desplegable de locale
8
+ // que TranslatableInput, con un ImageUpload para el idioma activo. DIFERIDO:
9
+ // este componente NO sube nada el mapa del v-model lleva la URL guardada
10
+ // (string) o el File pendiente por locale, y quien lo usa sube los File al
11
+ // GUARDAR. Quitar la imagen de un locale borra su clave del mapa (también
12
+ // diferido). En el render, el motor localiza el valor con fallback al locale
13
+ // por defecto (localizeSettings).
11
14
  interface Locale {
12
15
  code: string
13
16
  name: string
@@ -15,29 +18,35 @@ interface Locale {
15
18
 
16
19
  const props = withDefaults(
17
20
  defineProps<{
18
- modelValue?: Record<string, string>
21
+ modelValue?: Record<string, string | File>
19
22
  locales: Locale[]
20
23
  label?: string
21
24
  required?: boolean
22
- /** Sube el fichero (con la URL a la que sustituye, para que el backend
23
- * borre la anterior) y devuelve la URL pública que se guarda. */
24
- upload: (file: File, replaces?: string | null) => Promise<string>
25
- /** Borra el fichero al pulsar "quitar" (opcional). */
26
- removeFile?: (url: string) => void | Promise<void>
27
25
  error?: string
28
26
  }>(),
29
27
  { modelValue: () => ({}), required: false },
30
28
  )
31
29
 
32
- const emit = defineEmits<{ 'update:modelValue': [Record<string, string>] }>()
30
+ const emit = defineEmits<{ 'update:modelValue': [Record<string, string | File>] }>()
33
31
 
34
32
  const codes = computed(() => props.locales.map((l) => l.code))
35
33
  const active = ref(codes.value[0] ?? 'es')
36
34
  const open = ref(false)
37
35
  const dropdownRef = ref<HTMLElement>()
38
- const uploading = ref(false)
39
36
 
40
- const currentUrl = computed(() => props.modelValue?.[active.value] || null)
37
+ // Locale global del formulario (si el contenedor lo provee, p. ej. EditModal):
38
+ // una difusión cambia el tab activo; el selector propio sigue siendo local.
39
+ useFormLocaleField(
40
+ computed(() => props.locales),
41
+ (code) => {
42
+ active.value = code
43
+ },
44
+ )
45
+
46
+ const current = computed(() => props.modelValue?.[active.value] ?? null)
47
+ // La URL guardada y el File pendiente del locale activo, para el ImageUpload.
48
+ const currentFile = computed(() => (current.value instanceof File ? current.value : null))
49
+ const currentUrl = computed(() => (typeof current.value === 'string' ? current.value : null))
41
50
  const filledCount = computed(() => codes.value.filter((c) => !!props.modelValue?.[c]).length)
42
51
  const hasContent = (code: string) => !!props.modelValue?.[code]
43
52
 
@@ -46,30 +55,20 @@ function selectLocale(code: string) {
46
55
  open.value = false
47
56
  }
48
57
 
49
- function setUrl(url: string | null) {
58
+ /** Deja el mapa en su estado final deseado: File pendiente, o sin clave. */
59
+ function setValue(value: string | File | null) {
50
60
  const next = { ...props.modelValue }
51
- if (url) next[active.value] = url
61
+ if (value) next[active.value] = value
52
62
  else delete next[active.value]
53
63
  emit('update:modelValue', next)
54
64
  }
55
65
 
56
- async function onFile(file: File | null) {
57
- if (!file) {
58
- onRemove()
59
- return
60
- }
61
- uploading.value = true
62
- try {
63
- setUrl(await props.upload(file, currentUrl.value))
64
- } finally {
65
- uploading.value = false
66
- }
66
+ function onFile(file: File | null) {
67
+ setValue(file)
67
68
  }
68
69
 
69
70
  function onRemove() {
70
- const url = currentUrl.value
71
- if (url && props.removeFile) Promise.resolve(props.removeFile(url)).catch(() => {})
72
- setUrl(null)
71
+ setValue(null)
73
72
  }
74
73
 
75
74
  function onClickOutside(e: MouseEvent) {
@@ -117,9 +116,9 @@ onBeforeUnmount(() => document.removeEventListener('click', onClickOutside))
117
116
 
118
117
  <ImageUpload
119
118
  :key="active"
120
- :model-value="null"
119
+ :model-value="currentFile"
121
120
  :current-url="currentUrl"
122
- :error="uploading ? undefined : error"
121
+ :error="error"
123
122
  @update:model-value="onFile"
124
123
  @remove="onRemove"
125
124
  />
@@ -2,6 +2,7 @@
2
2
  import { ref, computed, onMounted, onBeforeUnmount, defineAsyncComponent } from 'vue'
3
3
  import { ChevronDown } from '@lucide/vue'
4
4
  import type { RichTextLabels } from './RichTextInput.vue'
5
+ import { useFormLocaleField } from '../composables/useFormLocale'
5
6
 
6
7
  // Editor WYSIWYG cargado en diferido: TipTap solo se descarga si algún campo
7
8
  // traducible usa type="wysiwyg".
@@ -45,6 +46,15 @@ const emit = defineEmits<{ 'update:modelValue': [Record<string, string>] }>()
45
46
  const codes = computed(() => props.locales.map((l) => l.code))
46
47
  const active = ref(codes.value[0] ?? 'es')
47
48
  const open = ref(false)
49
+
50
+ // Locale global del formulario (si el contenedor lo provee, p. ej. EditModal):
51
+ // una difusión cambia el tab activo; el selector propio sigue siendo local.
52
+ useFormLocaleField(
53
+ computed(() => props.locales),
54
+ (code) => {
55
+ active.value = code
56
+ },
57
+ )
48
58
  const dropdownRef = ref<HTMLElement>()
49
59
  const inputId = props.id || `translatable-${Math.random().toString(36).slice(2, 9)}`
50
60
 
@@ -0,0 +1,106 @@
1
+ import {
2
+ computed,
3
+ inject,
4
+ onScopeDispose,
5
+ provide,
6
+ reactive,
7
+ ref,
8
+ watch,
9
+ type ComputedRef,
10
+ type InjectionKey,
11
+ type Ref,
12
+ } from 'vue'
13
+
14
+ /**
15
+ * Locale global de un FORMULARIO con campos traducibles.
16
+ *
17
+ * Cada campo traducible (TranslatableInput/TranslatableImage) conserva sus
18
+ * tabs de locale propias, pero además se SUSCRIBE (inject) al contexto que
19
+ * provee su formulario contenedor (EditModal lo hace solo): un selector
20
+ * compacto en la cabecera cambia el tab activo de TODOS los campos a la vez,
21
+ * y tocar el tab de un campo individual sigue afectando solo a ese campo.
22
+ *
23
+ * Los juegos no tocan nada: los campos del motor se registran/suscriben
24
+ * solos, y el selector solo se pinta si el formulario contiene alguno.
25
+ */
26
+
27
+ export interface FormLocale {
28
+ code: string
29
+ name: string
30
+ }
31
+
32
+ interface FormLocaleBroadcast {
33
+ code: string
34
+ /** Contador: re-emite aunque se repita el mismo código. */
35
+ tick: number
36
+ }
37
+
38
+ export interface FormLocaleContext {
39
+ /** Unión (sin duplicados) de los locales de los campos registrados. */
40
+ locales: ComputedRef<FormLocale[]>
41
+ /** ¿Hay campos traducibles suscritos? (el selector solo se pinta si sí). */
42
+ hasFields: ComputedRef<boolean>
43
+ /** Última difusión global (la observan los campos). */
44
+ broadcast: Ref<FormLocaleBroadcast | null>
45
+ /** Cambia el locale activo de TODOS los campos suscritos. */
46
+ setAll: (code: string) => void
47
+ /** Alta de un campo; devuelve la baja (la llama useFormLocaleField). */
48
+ register: (locales: ComputedRef<FormLocale[]> | Ref<FormLocale[]>) => () => void
49
+ }
50
+
51
+ export const FormLocaleKey: InjectionKey<FormLocaleContext> = Symbol('edc-form-locale')
52
+
53
+ /** Lo llama el contenedor del formulario (EditModal ya lo hace). */
54
+ export function provideFormLocale(): FormLocaleContext {
55
+ // Mapa id => locales del campo (reactive para que los computed reaccionen).
56
+ const fields = reactive(new Map<number, ComputedRef<FormLocale[]> | Ref<FormLocale[]>>())
57
+ let nextId = 0
58
+
59
+ const locales = computed<FormLocale[]>(() => {
60
+ const seen = new Map<string, FormLocale>()
61
+ for (const fieldLocales of fields.values()) {
62
+ for (const locale of fieldLocales.value) {
63
+ if (!seen.has(locale.code)) seen.set(locale.code, locale)
64
+ }
65
+ }
66
+ return [...seen.values()]
67
+ })
68
+
69
+ const hasFields = computed(() => fields.size > 0)
70
+ const broadcast = ref<FormLocaleBroadcast | null>(null)
71
+
72
+ function setAll(code: string) {
73
+ broadcast.value = { code, tick: (broadcast.value?.tick ?? 0) + 1 }
74
+ }
75
+
76
+ function register(fieldLocales: ComputedRef<FormLocale[]> | Ref<FormLocale[]>) {
77
+ const id = nextId++
78
+ fields.set(id, fieldLocales)
79
+ return () => {
80
+ fields.delete(id)
81
+ }
82
+ }
83
+
84
+ const context: FormLocaleContext = { locales, hasFields, broadcast, setAll, register }
85
+ provide(FormLocaleKey, context)
86
+ return context
87
+ }
88
+
89
+ /**
90
+ * Lo llaman los campos traducibles en su setup: si hay proveedor por encima,
91
+ * se registran (alimentan el selector) y aplican cada difusión global que
92
+ * incluya alguno de sus locales. Sin proveedor, no hace nada.
93
+ */
94
+ export function useFormLocaleField(
95
+ locales: ComputedRef<FormLocale[]> | Ref<FormLocale[]>,
96
+ apply: (code: string) => void,
97
+ ): void {
98
+ const context = inject(FormLocaleKey, null)
99
+ if (!context) return
100
+
101
+ onScopeDispose(context.register(locales))
102
+
103
+ watch(context.broadcast, (b) => {
104
+ if (b && locales.value.some((locale) => locale.code === b.code)) apply(b.code)
105
+ })
106
+ }
package/src/index.ts CHANGED
@@ -15,6 +15,17 @@ export { default as NumericInput } from './components/NumericInput.vue'
15
15
  export { default as PaletteColorPicker } from './components/PaletteColorPicker.vue'
16
16
  export { default as TranslatableInput } from './components/TranslatableInput.vue'
17
17
  export { default as TranslatableImage } from './components/TranslatableImage.vue'
18
+ // Locale global de un formulario: EditModal lo provee solo (selector compacto
19
+ // en su cabecera) y los campos traducibles se suscriben solos; cualquier otro
20
+ // contenedor puede montar lo mismo con provideFormLocale() + FormLocaleSwitch.
21
+ export { default as FormLocaleSwitch } from './components/FormLocaleSwitch.vue'
22
+ export {
23
+ provideFormLocale,
24
+ useFormLocaleField,
25
+ FormLocaleKey,
26
+ type FormLocale,
27
+ type FormLocaleContext,
28
+ } from './composables/useFormLocale'
18
29
  // El WYSIWYG carga DIFERIDO (TipTap pesa ~450 KB): así la web pública no lo
19
30
  // arrastra a su bundle y el admin lo trocea en su propio chunk.
20
31
  export const RichTextInput = defineAsyncComponent(() => import('./components/RichTextInput.vue'))