@edc-motor/ui 0.4.33 → 0.4.34

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.33",
3
+ "version": "0.4.34",
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",
@@ -22,6 +22,7 @@
22
22
  @use "form-locale-switch";
23
23
  @use "image-upload";
24
24
  @use "number-input";
25
+ @use "multi-select";
25
26
  @use "font-upload";
26
27
  @use "toast";
27
28
  @use "toast-container";
@@ -0,0 +1,34 @@
1
+ @use "tokens" as *;
2
+
3
+ // Select múltiple (MultiSelect.vue): hereda toda la estética de .base-select;
4
+ // aquí solo la casilla de check de cada opción y el truncado del trigger.
5
+ .multi-select {
6
+ .base-select__value {
7
+ overflow: hidden;
8
+ text-overflow: ellipsis;
9
+ white-space: nowrap;
10
+ }
11
+
12
+ &__option {
13
+ display: flex;
14
+ align-items: center;
15
+ gap: $space-2;
16
+ }
17
+
18
+ &__box {
19
+ flex: none;
20
+ display: inline-flex;
21
+ align-items: center;
22
+ justify-content: center;
23
+ width: 16px;
24
+ height: 16px;
25
+ border: 1px solid $border-strong;
26
+ border-radius: $radius-sm;
27
+ color: #fff;
28
+
29
+ &.is-checked {
30
+ background: $accent-500;
31
+ border-color: $accent-500;
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,207 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
3
+ import { Check } from '@lucide/vue'
4
+ import { useDropdownPanel } from '../composables/useDropdownPanel'
5
+ import type { SelectOption } from './BaseSelect.vue'
6
+
7
+ // Select MÚLTIPLE de formulario: el hermano de BaseSelect para filtros y
8
+ // campos de varios valores. Mismo trigger (.form-field__select) y mismo
9
+ // panel en la top layer (useDropdownPanel), pero cada opción es un toggle
10
+ // con marca de check y el panel NO se cierra al marcar (se cierra con
11
+ // Escape, Tab o click fuera). El valor viaja como array de strings (mismo
12
+ // criterio String() que BaseSelect). El trigger pinta las etiquetas
13
+ // elegidas unidas por comas (el CSS las trunca con elipsis) — así no hace
14
+ // falta ningún texto "N seleccionadas" que traducir.
15
+ const props = withDefaults(
16
+ defineProps<{
17
+ modelValue?: (string | number)[]
18
+ label?: string
19
+ options: SelectOption[]
20
+ /** Texto en reposo (sin nada marcado), p. ej. "Todas". */
21
+ placeholder?: string
22
+ error?: string
23
+ hint?: string
24
+ disabled?: boolean
25
+ id?: string
26
+ }>(),
27
+ { modelValue: () => [], disabled: false },
28
+ )
29
+
30
+ const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
31
+
32
+ const selectId = props.id || `mselect-${Math.random().toString(36).slice(2, 9)}`
33
+ const panelId = `${selectId}-panel`
34
+
35
+ const open = ref(false)
36
+ const highlighted = ref(0)
37
+ const root = ref<HTMLElement | null>(null)
38
+ const panel = ref<HTMLElement | null>(null)
39
+
40
+ useDropdownPanel(root, panel, open)
41
+
42
+ const selectedValues = computed(() => new Set(props.modelValue.map((v) => String(v))))
43
+ const isSelected = (option: SelectOption) => selectedValues.value.has(String(option.value))
44
+ const triggerLabel = computed(() =>
45
+ props.options
46
+ .filter((o) => isSelected(o))
47
+ .map((o) => o.label)
48
+ .join(', '),
49
+ )
50
+
51
+ function optionId(index: number) {
52
+ return `${selectId}-opt-${index}`
53
+ }
54
+
55
+ async function openPanel() {
56
+ if (props.disabled || open.value) return
57
+ open.value = true
58
+ highlighted.value = 0
59
+ document.addEventListener('mousedown', onOutside)
60
+ await nextTick()
61
+ scrollToHighlighted()
62
+ }
63
+
64
+ function close() {
65
+ open.value = false
66
+ document.removeEventListener('mousedown', onOutside)
67
+ }
68
+
69
+ function toggle() {
70
+ if (open.value) close()
71
+ else void openPanel()
72
+ }
73
+
74
+ /** Marca/desmarca la opción SIN cerrar el panel (a diferencia del simple). */
75
+ function toggleOption(option: SelectOption) {
76
+ const value = String(option.value)
77
+ const next = props.modelValue.map((v) => String(v))
78
+ const at = next.indexOf(value)
79
+ if (at === -1) next.push(value)
80
+ else next.splice(at, 1)
81
+ emit('update:modelValue', next)
82
+ }
83
+
84
+ function highlight(index: number) {
85
+ if (!props.options.length) return
86
+ highlighted.value = (index + props.options.length) % props.options.length
87
+ scrollToHighlighted()
88
+ }
89
+
90
+ function scrollToHighlighted() {
91
+ document.getElementById(optionId(highlighted.value))?.scrollIntoView({ block: 'nearest' })
92
+ }
93
+
94
+ // El foco vive siempre en el trigger (patrón aria-activedescendant).
95
+ function onKeydown(e: KeyboardEvent) {
96
+ if (props.disabled) return
97
+
98
+ if (!open.value) {
99
+ if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
100
+ e.preventDefault()
101
+ void openPanel()
102
+ }
103
+ return
104
+ }
105
+
106
+ switch (e.key) {
107
+ case 'Escape':
108
+ // Escape con el panel abierto cierra SOLO el panel (que no llegue al
109
+ // listener global de un modal contenedor y lo cierre también).
110
+ e.preventDefault()
111
+ e.stopPropagation()
112
+ close()
113
+ break
114
+ case 'ArrowDown':
115
+ e.preventDefault()
116
+ highlight(highlighted.value + 1)
117
+ break
118
+ case 'ArrowUp':
119
+ e.preventDefault()
120
+ highlight(highlighted.value - 1)
121
+ break
122
+ case 'Home':
123
+ e.preventDefault()
124
+ highlight(0)
125
+ break
126
+ case 'End':
127
+ e.preventDefault()
128
+ highlight(props.options.length - 1)
129
+ break
130
+ case 'Enter':
131
+ case ' ': {
132
+ e.preventDefault()
133
+ const option = props.options[highlighted.value]
134
+ if (option) toggleOption(option)
135
+ break
136
+ }
137
+ case 'Tab':
138
+ close()
139
+ break
140
+ }
141
+ }
142
+
143
+ function onOutside(e: MouseEvent) {
144
+ const target = e.target as Node
145
+ // El panel vive en la top layer pero sigue dentro del árbol del root.
146
+ if (root.value && !root.value.contains(target)) close()
147
+ }
148
+
149
+ onBeforeUnmount(() => document.removeEventListener('mousedown', onOutside))
150
+ </script>
151
+
152
+ <template>
153
+ <div class="form-field" :class="{ 'form-field--error': error }">
154
+ <label v-if="label" :for="selectId" class="form-field__label">{{ label }}</label>
155
+ <div
156
+ ref="root"
157
+ class="form-field__select-wrapper base-select multi-select"
158
+ :class="{ 'is-open': open }"
159
+ >
160
+ <button
161
+ :id="selectId"
162
+ type="button"
163
+ class="form-field__select base-select__trigger"
164
+ :disabled="disabled"
165
+ aria-haspopup="listbox"
166
+ :aria-expanded="open"
167
+ :aria-controls="panelId"
168
+ :aria-activedescendant="open ? optionId(highlighted) : undefined"
169
+ @click="toggle"
170
+ @keydown="onKeydown"
171
+ >
172
+ <span class="base-select__value" :class="{ 'is-placeholder': !triggerLabel }">
173
+ {{ triggerLabel || placeholder }}
174
+ </span>
175
+ </button>
176
+
177
+ <ul
178
+ v-if="open"
179
+ :id="panelId"
180
+ ref="panel"
181
+ class="base-select__panel"
182
+ popover="manual"
183
+ role="listbox"
184
+ aria-multiselectable="true"
185
+ >
186
+ <li
187
+ v-for="(option, index) in options"
188
+ :id="optionId(index)"
189
+ :key="option.value"
190
+ role="option"
191
+ class="base-select__option multi-select__option"
192
+ :class="{ 'is-active': isSelected(option), 'is-highlighted': index === highlighted }"
193
+ :aria-selected="isSelected(option)"
194
+ @mousedown.prevent="toggleOption(option)"
195
+ @mouseenter="highlighted = index"
196
+ >
197
+ <span class="multi-select__box" :class="{ 'is-checked': isSelected(option) }">
198
+ <Check v-if="isSelected(option)" :size="12" />
199
+ </span>
200
+ {{ option.label }}
201
+ </li>
202
+ </ul>
203
+ </div>
204
+ <p v-if="error" class="form-field__error">{{ error }}</p>
205
+ <p v-else-if="hint" class="form-field__hint">{{ hint }}</p>
206
+ </div>
207
+ </template>
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export const RichTextInput = defineAsyncComponent(() => import('./components/Ric
32
32
  export type { RichIcon, RichTextLabels } from './components/RichTextInput.vue'
33
33
  export { default as ImageUpload } from './components/ImageUpload.vue'
34
34
  export { default as NumberInput } from './components/NumberInput.vue'
35
+ export { default as MultiSelect } from './components/MultiSelect.vue'
35
36
  export { default as FontUpload } from './components/FontUpload.vue'
36
37
  export { default as BaseModal } from './components/BaseModal.vue'
37
38
  export { default as EditModal } from './components/EditModal.vue'