@edc-motor/ui 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +45 -0
  3. package/package.json +47 -0
  4. package/scss/_base.scss +33 -0
  5. package/scss/_forms.scss +33 -0
  6. package/scss/_shared-components.scss +1 -0
  7. package/scss/_theme.scss +74 -0
  8. package/scss/_tokens.scss +114 -0
  9. package/scss/components/_base-button.scss +113 -0
  10. package/scss/components/_blocks.scss +211 -0
  11. package/scss/components/_breadcrumbs.scss +31 -0
  12. package/scss/components/_checkbox.scss +42 -0
  13. package/scss/components/_chip.scss +23 -0
  14. package/scss/components/_confirm.scss +3 -0
  15. package/scss/components/_edit-modal.scss +11 -0
  16. package/scss/components/_font-upload.scss +91 -0
  17. package/scss/components/_form-field.scss +85 -0
  18. package/scss/components/_icon-button.scss +23 -0
  19. package/scss/components/_image-upload.scss +85 -0
  20. package/scss/components/_index.scss +24 -0
  21. package/scss/components/_locale-selector.scss +89 -0
  22. package/scss/components/_modal.scss +68 -0
  23. package/scss/components/_motor-badge.scss +21 -0
  24. package/scss/components/_numeric-input.scss +53 -0
  25. package/scss/components/_page-background.scss +22 -0
  26. package/scss/components/_palette-color-picker.scss +69 -0
  27. package/scss/components/_rich-text.scss +144 -0
  28. package/scss/components/_search-select.scss +138 -0
  29. package/scss/components/_tabs.scss +120 -0
  30. package/scss/components/_theme-selector.scss +34 -0
  31. package/scss/components/_toast-container.scss +16 -0
  32. package/scss/components/_toast.scss +25 -0
  33. package/scss/components/_translatable-input.scss +73 -0
  34. package/src/blocks/BlockCta.vue +32 -0
  35. package/src/blocks/BlockFaq.vue +25 -0
  36. package/src/blocks/BlockHeader.vue +13 -0
  37. package/src/blocks/BlockIndex.vue +20 -0
  38. package/src/blocks/BlockQuote.vue +16 -0
  39. package/src/blocks/BlockShell.vue +30 -0
  40. package/src/blocks/BlockText.vue +20 -0
  41. package/src/blocks/BlockTextCard.vue +23 -0
  42. package/src/blocks/PageBackground.vue +15 -0
  43. package/src/blocks/index.ts +25 -0
  44. package/src/components/AppBreadcrumbs.vue +44 -0
  45. package/src/components/BaseButton.vue +24 -0
  46. package/src/components/BaseCheckbox.vue +37 -0
  47. package/src/components/BaseInput.vue +53 -0
  48. package/src/components/BaseModal.vue +98 -0
  49. package/src/components/BaseSelect.vue +53 -0
  50. package/src/components/BaseTabs.vue +30 -0
  51. package/src/components/BaseTextarea.vue +45 -0
  52. package/src/components/BaseToast.vue +31 -0
  53. package/src/components/ConfirmDialog.vue +35 -0
  54. package/src/components/EditModal.vue +57 -0
  55. package/src/components/FontUpload.vue +123 -0
  56. package/src/components/IconButton.vue +24 -0
  57. package/src/components/ImageUpload.vue +142 -0
  58. package/src/components/LocaleSelector.vue +59 -0
  59. package/src/components/MotorBadge.vue +17 -0
  60. package/src/components/NumericInput.vue +115 -0
  61. package/src/components/PaletteColorPicker.vue +94 -0
  62. package/src/components/RichTextInput.vue +273 -0
  63. package/src/components/SearchSelect.vue +172 -0
  64. package/src/components/ThemeSelector.vue +28 -0
  65. package/src/components/ToastContainer.vue +23 -0
  66. package/src/components/TranslatableImage.vue +120 -0
  67. package/src/components/TranslatableInput.vue +135 -0
  68. package/src/composables/useConfirm.ts +49 -0
  69. package/src/composables/useHead.ts +54 -0
  70. package/src/composables/useTheme.ts +50 -0
  71. package/src/composables/useToast.ts +26 -0
  72. package/src/index.ts +39 -0
  73. package/src/lib/createApi.ts +42 -0
@@ -0,0 +1,273 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue'
3
+ import { useEditor, EditorContent } from '@tiptap/vue-3'
4
+ import StarterKit from '@tiptap/starter-kit'
5
+ import Image from '@tiptap/extension-image'
6
+ import {
7
+ Bold,
8
+ Italic,
9
+ Strikethrough,
10
+ Heading2,
11
+ List,
12
+ ListOrdered,
13
+ Undo2,
14
+ Redo2,
15
+ Smile,
16
+ Code,
17
+ } from '@lucide/vue'
18
+
19
+ // Editor de texto enriquecido (WYSIWYG) basado en TipTap (DC-09).
20
+ // v-model = HTML. Agnóstico de i18n. Si se pasan `icons`, muestra un selector
21
+ // para insertar iconos del juego (como en CDL): se insertan como <img.rt-icon>.
22
+ // Incluye un toggle para editar en modo visual o directamente el HTML.
23
+ export interface RichIcon {
24
+ name: string
25
+ url: string
26
+ }
27
+
28
+ // Textos de la barra de herramientas, sobreescribibles por la app (DC-29).
29
+ export interface RichTextLabels {
30
+ bold: string
31
+ italic: string
32
+ strike: string
33
+ heading: string
34
+ bulletList: string
35
+ orderedList: string
36
+ undo: string
37
+ redo: string
38
+ insertIcon: string
39
+ editHtml: string
40
+ }
41
+
42
+ const defaultLabels: RichTextLabels = {
43
+ bold: 'Negrita',
44
+ italic: 'Cursiva',
45
+ strike: 'Tachado',
46
+ heading: 'Título',
47
+ bulletList: 'Lista',
48
+ orderedList: 'Lista numerada',
49
+ undo: 'Deshacer',
50
+ redo: 'Rehacer',
51
+ insertIcon: 'Insertar icono',
52
+ editHtml: 'Editar HTML',
53
+ }
54
+
55
+ const props = withDefaults(
56
+ defineProps<{
57
+ modelValue?: string
58
+ placeholder?: string
59
+ disabled?: boolean
60
+ icons?: RichIcon[]
61
+ labels?: Partial<RichTextLabels>
62
+ }>(),
63
+ { modelValue: '', disabled: false, icons: () => [], labels: () => ({}) },
64
+ )
65
+
66
+ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
67
+
68
+ const t = computed<RichTextLabels>(() => ({ ...defaultLabels, ...props.labels }))
69
+
70
+ // Declarado antes del watch de modelValue, que lo lee.
71
+ const source = ref(false)
72
+
73
+ const editor = useEditor({
74
+ content: props.modelValue,
75
+ editable: !props.disabled,
76
+ extensions: [
77
+ StarterKit,
78
+ // Los iconos son la única imagen que se inserta: en línea y con clase fija.
79
+ Image.configure({ inline: true, HTMLAttributes: { class: 'rt-icon' } }),
80
+ ],
81
+ onUpdate: ({ editor }) => {
82
+ const html = editor.isEmpty ? '' : editor.getHTML()
83
+ emit('update:modelValue', html)
84
+ },
85
+ })
86
+
87
+ watch(
88
+ () => props.modelValue,
89
+ (value) => {
90
+ if (!editor.value || source.value) return
91
+ if (value !== (editor.value.isEmpty ? '' : editor.value.getHTML())) {
92
+ editor.value.commands.setContent(value || '', false)
93
+ }
94
+ },
95
+ )
96
+ watch(
97
+ () => props.disabled,
98
+ (d) => editor.value?.setEditable(!d),
99
+ )
100
+
101
+ const tools = computed(() => [
102
+ {
103
+ key: 'bold',
104
+ icon: Bold,
105
+ title: t.value.bold,
106
+ run: () => editor.value?.chain().focus().toggleBold().run(),
107
+ active: () => editor.value?.isActive('bold'),
108
+ },
109
+ {
110
+ key: 'italic',
111
+ icon: Italic,
112
+ title: t.value.italic,
113
+ run: () => editor.value?.chain().focus().toggleItalic().run(),
114
+ active: () => editor.value?.isActive('italic'),
115
+ },
116
+ {
117
+ key: 'strike',
118
+ icon: Strikethrough,
119
+ title: t.value.strike,
120
+ run: () => editor.value?.chain().focus().toggleStrike().run(),
121
+ active: () => editor.value?.isActive('strike'),
122
+ },
123
+ {
124
+ key: 'h2',
125
+ icon: Heading2,
126
+ title: t.value.heading,
127
+ run: () => editor.value?.chain().focus().toggleHeading({ level: 2 }).run(),
128
+ active: () => editor.value?.isActive('heading', { level: 2 }),
129
+ },
130
+ {
131
+ key: 'ul',
132
+ icon: List,
133
+ title: t.value.bulletList,
134
+ run: () => editor.value?.chain().focus().toggleBulletList().run(),
135
+ active: () => editor.value?.isActive('bulletList'),
136
+ },
137
+ {
138
+ key: 'ol',
139
+ icon: ListOrdered,
140
+ title: t.value.orderedList,
141
+ run: () => editor.value?.chain().focus().toggleOrderedList().run(),
142
+ active: () => editor.value?.isActive('orderedList'),
143
+ },
144
+ ])
145
+
146
+ // --- Selector de iconos ---
147
+ const pickerOpen = ref(false)
148
+ const pickerRef = ref<HTMLElement>()
149
+
150
+ function insertIcon(icon: RichIcon) {
151
+ editor.value?.chain().focus().setImage({ src: icon.url, alt: icon.name, title: icon.name }).run()
152
+ pickerOpen.value = false
153
+ }
154
+ function onClickOutside(e: MouseEvent) {
155
+ if (pickerRef.value && !pickerRef.value.contains(e.target as Node)) pickerOpen.value = false
156
+ }
157
+ onMounted(() => document.addEventListener('click', onClickOutside))
158
+ onBeforeUnmount(() => {
159
+ document.removeEventListener('click', onClickOutside)
160
+ editor.value?.destroy()
161
+ })
162
+
163
+ // --- Toggle modo visual / HTML (source declarado arriba, antes del watch) ---
164
+ const sourceHtml = ref('')
165
+
166
+ function toggleSource() {
167
+ if (!source.value) {
168
+ // Entrar a modo HTML: cargar el HTML actual.
169
+ sourceHtml.value = editor.value?.isEmpty ? '' : (editor.value?.getHTML() ?? '')
170
+ } else {
171
+ // Volver a visual: aplicar el HTML editado al editor.
172
+ editor.value?.commands.setContent(sourceHtml.value || '', false)
173
+ }
174
+ source.value = !source.value
175
+ pickerOpen.value = false
176
+ }
177
+
178
+ function onSourceInput(value: string) {
179
+ sourceHtml.value = value
180
+ emit('update:modelValue', value)
181
+ }
182
+ </script>
183
+
184
+ <template>
185
+ <div class="rich-text" :class="{ 'rich-text--disabled': disabled }">
186
+ <div v-if="editor" class="rich-text__toolbar">
187
+ <template v-if="!source">
188
+ <button
189
+ v-for="tool in tools"
190
+ :key="tool.key"
191
+ type="button"
192
+ class="rich-text__tool"
193
+ :class="{ 'rich-text__tool--active': tool.active() }"
194
+ :title="tool.title"
195
+ :disabled="disabled"
196
+ @click="tool.run()"
197
+ >
198
+ <component :is="tool.icon" :size="16" />
199
+ </button>
200
+ <span class="rich-text__sep" />
201
+ <button
202
+ type="button"
203
+ class="rich-text__tool"
204
+ :title="t.undo"
205
+ :disabled="disabled"
206
+ @click="editor?.chain().focus().undo().run()"
207
+ >
208
+ <Undo2 :size="16" />
209
+ </button>
210
+ <button
211
+ type="button"
212
+ class="rich-text__tool"
213
+ :title="t.redo"
214
+ :disabled="disabled"
215
+ @click="editor?.chain().focus().redo().run()"
216
+ >
217
+ <Redo2 :size="16" />
218
+ </button>
219
+
220
+ <!-- Selector de iconos del juego -->
221
+ <template v-if="icons.length">
222
+ <span class="rich-text__sep" />
223
+ <div ref="pickerRef" class="rich-text__picker">
224
+ <button
225
+ type="button"
226
+ class="rich-text__tool"
227
+ :class="{ 'rich-text__tool--active': pickerOpen }"
228
+ :title="t.insertIcon"
229
+ :disabled="disabled"
230
+ @click="pickerOpen = !pickerOpen"
231
+ >
232
+ <Smile :size="16" />
233
+ </button>
234
+ <div v-if="pickerOpen" class="rich-text__icons">
235
+ <button
236
+ v-for="icon in icons"
237
+ :key="icon.url"
238
+ type="button"
239
+ class="rich-text__icon"
240
+ :title="icon.name"
241
+ @click="insertIcon(icon)"
242
+ >
243
+ <img :src="icon.url" :alt="icon.name" />
244
+ </button>
245
+ </div>
246
+ </div>
247
+ </template>
248
+ </template>
249
+
250
+ <!-- Toggle visual / HTML (siempre a la derecha) -->
251
+ <button
252
+ type="button"
253
+ class="rich-text__tool rich-text__tool--source"
254
+ :class="{ 'rich-text__tool--active': source }"
255
+ :title="t.editHtml"
256
+ :disabled="disabled"
257
+ @click="toggleSource"
258
+ >
259
+ <Code :size="16" />
260
+ </button>
261
+ </div>
262
+
263
+ <textarea
264
+ v-if="source"
265
+ class="rich-text__source"
266
+ :value="sourceHtml"
267
+ :disabled="disabled"
268
+ spellcheck="false"
269
+ @input="onSourceInput(($event.target as HTMLTextAreaElement).value)"
270
+ />
271
+ <EditorContent v-show="!source" class="rich-text__content" :editor="editor" />
272
+ </div>
273
+ </template>
@@ -0,0 +1,172 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
3
+ import { Check, ChevronDown } from '@lucide/vue'
4
+
5
+ // Select con buscador (combobox de selección ÚNICA): cerrado ocupa una
6
+ // línea; al desplegar muestra un input de búsqueda y las opciones filtradas
7
+ // (lista larga => scroll interno del desplegable). Emparentado con el
8
+ // TagCombobox de kontuan (multi-etiquetas), del que hereda el teclado
9
+ // (flechas + Enter + Escape) y el cierre por mousedown exterior. El filtrado
10
+ // lo hace el PADRE (evento `search`, con debounce aquí): en cliente o contra
11
+ // el servidor, y con `canLoadMore` pagina.
12
+ // Agnóstico de i18n (DC-29): textos por prop, defaults en castellano.
13
+
14
+ export interface SearchSelectOption {
15
+ id: number | string
16
+ label: string
17
+ }
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ modelValue?: number | string | null
22
+ options: SearchSelectOption[]
23
+ placeholder?: string
24
+ searchPlaceholder?: string
25
+ noResults?: string
26
+ loadMoreLabel?: string
27
+ canLoadMore?: boolean
28
+ }>(),
29
+ {
30
+ modelValue: null,
31
+ placeholder: 'Elige…',
32
+ searchPlaceholder: 'Buscar…',
33
+ noResults: 'Sin resultados.',
34
+ loadMoreLabel: 'Cargar más',
35
+ canLoadMore: false,
36
+ },
37
+ )
38
+
39
+ const emit = defineEmits<{
40
+ 'update:modelValue': [id: number | string]
41
+ search: [query: string]
42
+ loadMore: []
43
+ }>()
44
+
45
+ const open = ref(false)
46
+ const query = ref('')
47
+ const highlighted = ref(0)
48
+ const root = ref<HTMLElement | null>(null)
49
+ const input = ref<HTMLInputElement | null>(null)
50
+
51
+ const selectedLabel = computed(
52
+ () => props.options.find((o) => o.id === props.modelValue)?.label ?? '',
53
+ )
54
+
55
+ async function toggle() {
56
+ open.value = !open.value
57
+ if (open.value) {
58
+ // Al abrir se resetea la búsqueda (lista completa) y el foco va al input.
59
+ if (query.value !== '') {
60
+ query.value = ''
61
+ emit('search', '')
62
+ }
63
+ highlighted.value = 0
64
+ document.addEventListener('mousedown', onOutside)
65
+ await nextTick()
66
+ input.value?.focus()
67
+ } else {
68
+ close()
69
+ }
70
+ }
71
+
72
+ function close() {
73
+ open.value = false
74
+ document.removeEventListener('mousedown', onOutside)
75
+ }
76
+
77
+ function select(option: SearchSelectOption) {
78
+ emit('update:modelValue', option.id)
79
+ close()
80
+ }
81
+
82
+ // Búsqueda con debounce: el padre filtra (cliente o servidor).
83
+ let timer: ReturnType<typeof setTimeout> | null = null
84
+ function onInput() {
85
+ if (timer) clearTimeout(timer)
86
+ timer = setTimeout(() => emit('search', query.value), 300)
87
+ }
88
+
89
+ watch(
90
+ () => props.options,
91
+ () => {
92
+ highlighted.value = 0
93
+ },
94
+ )
95
+
96
+ function onKeydown(e: KeyboardEvent) {
97
+ if (e.key === 'Escape') {
98
+ close()
99
+ return
100
+ }
101
+ if (e.key === 'ArrowDown') {
102
+ e.preventDefault()
103
+ if (props.options.length) highlighted.value = (highlighted.value + 1) % props.options.length
104
+ return
105
+ }
106
+ if (e.key === 'ArrowUp') {
107
+ e.preventDefault()
108
+ if (props.options.length) {
109
+ highlighted.value = (highlighted.value - 1 + props.options.length) % props.options.length
110
+ }
111
+ return
112
+ }
113
+ if (e.key === 'Enter') {
114
+ e.preventDefault()
115
+ const option = props.options[highlighted.value]
116
+ if (option) select(option)
117
+ }
118
+ }
119
+
120
+ function onOutside(e: MouseEvent) {
121
+ if (root.value && !root.value.contains(e.target as Node)) close()
122
+ }
123
+
124
+ onBeforeUnmount(() => {
125
+ document.removeEventListener('mousedown', onOutside)
126
+ if (timer) clearTimeout(timer)
127
+ })
128
+ </script>
129
+
130
+ <template>
131
+ <div ref="root" class="search-select" :class="{ 'is-open': open }">
132
+ <button type="button" class="search-select__trigger" @click="toggle">
133
+ <span class="search-select__value" :class="{ 'is-placeholder': !selectedLabel }">
134
+ {{ selectedLabel || placeholder }}
135
+ </span>
136
+ <ChevronDown :size="16" class="search-select__chevron" />
137
+ </button>
138
+
139
+ <div v-if="open" class="search-select__dropdown">
140
+ <input
141
+ ref="input"
142
+ v-model="query"
143
+ type="text"
144
+ class="search-select__input"
145
+ :placeholder="searchPlaceholder"
146
+ @input="onInput"
147
+ @keydown="onKeydown"
148
+ />
149
+ <ul class="search-select__options">
150
+ <li v-for="(option, index) in options" :key="option.id">
151
+ <button
152
+ type="button"
153
+ class="search-select__option"
154
+ :class="{
155
+ 'is-active': option.id === modelValue,
156
+ 'is-highlighted': index === highlighted,
157
+ }"
158
+ @mousedown.prevent="select(option)"
159
+ @mouseenter="highlighted = index"
160
+ >
161
+ <span class="search-select__label">{{ option.label }}</span>
162
+ <Check v-if="option.id === modelValue" :size="14" />
163
+ </button>
164
+ </li>
165
+ <li v-if="!options.length" class="search-select__empty">{{ noResults }}</li>
166
+ <li v-if="canLoadMore" class="search-select__more">
167
+ <button type="button" @mousedown.prevent="emit('loadMore')">{{ loadMoreLabel }}</button>
168
+ </li>
169
+ </ul>
170
+ </div>
171
+ </div>
172
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ import { type Component } from 'vue'
3
+ import { Sun, Moon, Monitor } from '@lucide/vue'
4
+ import { useTheme, type ThemeMode } from '../composables/useTheme'
5
+
6
+ const { themeMode, setTheme } = useTheme()
7
+
8
+ const options: { value: ThemeMode; icon: Component }[] = [
9
+ { value: 'light', icon: Sun },
10
+ { value: 'dark', icon: Moon },
11
+ { value: 'system', icon: Monitor },
12
+ ]
13
+ </script>
14
+
15
+ <template>
16
+ <div class="theme-selector">
17
+ <button
18
+ v-for="opt in options"
19
+ :key="opt.value"
20
+ type="button"
21
+ :class="['theme-btn', { active: themeMode === opt.value }]"
22
+ :title="opt.value"
23
+ @click="setTheme(opt.value)"
24
+ >
25
+ <component :is="opt.icon" :size="14" />
26
+ </button>
27
+ </div>
28
+ </template>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ import { useToast } from '../composables/useToast'
3
+ import BaseToast from './BaseToast.vue'
4
+
5
+ const { toasts, remove } = useToast()
6
+ </script>
7
+
8
+ <template>
9
+ <Teleport to="body">
10
+ <div class="toast-container">
11
+ <TransitionGroup name="toast">
12
+ <BaseToast
13
+ v-for="toast in toasts"
14
+ :key="toast.id"
15
+ :variant="toast.variant"
16
+ :message="toast.message"
17
+ :duration="toast.duration"
18
+ @close="remove(toast.id)"
19
+ />
20
+ </TransitionGroup>
21
+ </div>
22
+ </Teleport>
23
+ </template>
@@ -0,0 +1,120 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
3
+ import { ChevronDown } from '@lucide/vue'
4
+ import ImageUpload from './ImageUpload.vue'
5
+
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).
11
+ interface Locale {
12
+ code: string
13
+ name: string
14
+ }
15
+
16
+ const props = withDefaults(
17
+ defineProps<{
18
+ modelValue?: Record<string, string>
19
+ locales: Locale[]
20
+ label?: string
21
+ required?: boolean
22
+ /** Sube el fichero y devuelve la URL pública que se guarda. */
23
+ upload: (file: File) => Promise<string>
24
+ error?: string
25
+ }>(),
26
+ { modelValue: () => ({}), required: false },
27
+ )
28
+
29
+ const emit = defineEmits<{ 'update:modelValue': [Record<string, string>] }>()
30
+
31
+ const codes = computed(() => props.locales.map((l) => l.code))
32
+ const active = ref(codes.value[0] ?? 'es')
33
+ const open = ref(false)
34
+ const dropdownRef = ref<HTMLElement>()
35
+ const uploading = ref(false)
36
+
37
+ const currentUrl = computed(() => props.modelValue?.[active.value] || null)
38
+ const filledCount = computed(() => codes.value.filter((c) => !!props.modelValue?.[c]).length)
39
+ const hasContent = (code: string) => !!props.modelValue?.[code]
40
+
41
+ function selectLocale(code: string) {
42
+ active.value = code
43
+ open.value = false
44
+ }
45
+
46
+ function setUrl(url: string | null) {
47
+ const next = { ...props.modelValue }
48
+ if (url) next[active.value] = url
49
+ else delete next[active.value]
50
+ emit('update:modelValue', next)
51
+ }
52
+
53
+ async function onFile(file: File | null) {
54
+ if (!file) {
55
+ setUrl(null)
56
+ return
57
+ }
58
+ uploading.value = true
59
+ try {
60
+ setUrl(await props.upload(file))
61
+ } finally {
62
+ uploading.value = false
63
+ }
64
+ }
65
+
66
+ function onClickOutside(e: MouseEvent) {
67
+ if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) open.value = false
68
+ }
69
+ onMounted(() => document.addEventListener('click', onClickOutside))
70
+ onBeforeUnmount(() => document.removeEventListener('click', onClickOutside))
71
+ </script>
72
+
73
+ <template>
74
+ <div class="form-field" :class="{ 'form-field--error': error }">
75
+ <div class="translatable-header">
76
+ <span v-if="label" class="form-field__label">
77
+ {{ label }}<span v-if="required" class="form-field__required">*</span>
78
+ </span>
79
+ <div ref="dropdownRef" class="translatable-selector">
80
+ <button type="button" class="translatable-trigger" @click="open = !open">
81
+ <span class="translatable-trigger__code">{{ active.toUpperCase() }}</span>
82
+ <span class="translatable-trigger__count">{{ filledCount }}/{{ codes.length }}</span>
83
+ <ChevronDown class="translatable-trigger__chevron" :size="12" />
84
+ </button>
85
+ <Transition name="dropdown">
86
+ <div v-if="open" class="translatable-dropdown">
87
+ <button
88
+ v-for="loc in locales"
89
+ :key="loc.code"
90
+ type="button"
91
+ :class="[
92
+ 'translatable-option',
93
+ {
94
+ 'translatable-option--active': loc.code === active,
95
+ 'translatable-option--empty': !hasContent(loc.code),
96
+ },
97
+ ]"
98
+ @click="selectLocale(loc.code)"
99
+ >
100
+ <span class="translatable-option__code">{{ loc.code.toUpperCase() }}</span>
101
+ <span class="translatable-option__label">{{ loc.name }}</span>
102
+ <span v-if="hasContent(loc.code)" class="translatable-option__filled" />
103
+ </button>
104
+ </div>
105
+ </Transition>
106
+ </div>
107
+ </div>
108
+
109
+ <ImageUpload
110
+ :key="active"
111
+ :model-value="null"
112
+ :current-url="currentUrl"
113
+ :error="uploading ? undefined : error"
114
+ @update:model-value="onFile"
115
+ @remove="setUrl(null)"
116
+ />
117
+
118
+ <p v-if="error" class="form-field__error">{{ error }}</p>
119
+ </div>
120
+ </template>