@edc-motor/ui 0.4.33 → 0.4.35

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