@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.
|
|
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",
|
|
@@ -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'
|