@edc-motor/ui 0.4.4 → 0.4.6

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.4",
3
+ "version": "0.4.6",
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",
package/scss/_base.scss CHANGED
@@ -31,3 +31,16 @@ h2 { font-size: $fs-24; }
31
31
  h3 { font-size: $fs-20; }
32
32
 
33
33
  a { color: $accent-500; text-decoration: none; &:hover { color: $accent-600; } }
34
+
35
+ // Todo lo clickable señala que lo es; los estados deshabilitados lo revierten
36
+ // (cada componente pone su not-allowed si toca).
37
+ button:not(:disabled),
38
+ [role='button']:not([aria-disabled='true']),
39
+ select:not(:disabled),
40
+ summary,
41
+ input[type='checkbox']:not(:disabled),
42
+ input[type='radio']:not(:disabled),
43
+ input[type='submit']:not(:disabled),
44
+ input[type='file']:not(:disabled) {
45
+ cursor: pointer;
46
+ }
@@ -0,0 +1,65 @@
1
+ @use "tokens" as *;
2
+
3
+ // Dropdown personalizado del BaseSelect: el trigger hereda altura/borde de
4
+ // .form-field__select (comparte clase) y el wrapper aporta la flecha ::after;
5
+ // aquí solo el estado abierto y el panel flotante (estética del search-select).
6
+ .base-select {
7
+ &.is-open::after {
8
+ transform: translateY(-50%) rotate(180deg);
9
+ }
10
+
11
+ &.is-open > &__trigger {
12
+ border-color: $input-border-focus;
13
+ }
14
+
15
+ &__trigger {
16
+ text-align: left;
17
+ }
18
+
19
+ &__value {
20
+ display: block;
21
+ overflow: hidden;
22
+ text-overflow: ellipsis;
23
+ white-space: nowrap;
24
+
25
+ &.is-placeholder { color: $input-placeholder; }
26
+ }
27
+
28
+ &__panel {
29
+ position: absolute;
30
+ top: calc(100% + #{$space-1});
31
+ left: 0;
32
+ right: 0;
33
+ z-index: 50;
34
+ background: $surface;
35
+ border: 1px solid $border-strong;
36
+ border-radius: $radius-md;
37
+ box-shadow: $shadow-md;
38
+ list-style: none;
39
+ margin: 0;
40
+ padding: $space-1;
41
+ display: grid;
42
+ gap: 1px;
43
+ max-height: 240px;
44
+ overflow-y: auto;
45
+ }
46
+
47
+ &__option {
48
+ border-radius: $radius-sm;
49
+ color: $text-1;
50
+ cursor: pointer;
51
+ font-size: $fs-13;
52
+ overflow: hidden;
53
+ padding: $space-2 $space-2;
54
+ text-overflow: ellipsis;
55
+ white-space: nowrap;
56
+
57
+ &.is-highlighted { background: $surface-2; }
58
+
59
+ &.is-active {
60
+ background: color-mix(in srgb, $accent-500 14%, transparent);
61
+ color: $accent-500;
62
+ font-weight: $k-fw-semibold;
63
+ }
64
+ }
65
+ }
@@ -3,6 +3,7 @@
3
3
  @use "icon-button";
4
4
  @use "motor-badge";
5
5
  @use "form-field";
6
+ @use "base-select";
6
7
  @use "search-select";
7
8
  @use "blocks";
8
9
  @use "block-related";
@@ -1,5 +1,20 @@
1
1
  <script setup lang="ts">
2
- // Select de formulario (portado de kontuan).
2
+ import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
3
+
4
+ // Select de formulario (portado de kontuan). Dropdown personalizado (botón
5
+ // trigger + panel de opciones) con la MISMA API que el <select> nativo al que
6
+ // sustituye: el trigger reutiliza el aspecto de .form-field__select y el
7
+ // panel, la estética del SearchSelect. Teclado completo (flechas, Enter,
8
+ // Escape, Home/End) y cierre por mousedown exterior.
9
+ //
10
+ // Compatibilidad con el nativo:
11
+ // - El valor viaja como en el DOM: se emite String(option.value) y la opción
12
+ // activa se compara con String() por ambos lados (un modelValue numérico
13
+ // casa con su opción, como hacía el <select>).
14
+ // - El placeholder se pinta en el trigger cuando no hay valor; la antigua
15
+ // <option value="" disabled> no se lista en el panel (nunca era
16
+ // seleccionable: solo servía de texto en reposo, y de eso ya se encarga
17
+ // el trigger).
3
18
  export interface SelectOption {
4
19
  value: string | number
5
20
  label: string
@@ -20,11 +35,111 @@ const props = withDefaults(
20
35
  { modelValue: '', disabled: false, required: false },
21
36
  )
22
37
 
23
- // El DOM siempre entrega string: no se declara `number` para no mentir al
24
- // consumidor (que convierta él si guarda ids numéricos).
38
+ // Se emite siempre string, como entregaba el DOM del <select> nativo.
25
39
  const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
26
40
 
27
41
  const selectId = props.id || `select-${Math.random().toString(36).slice(2, 9)}`
42
+ const panelId = `${selectId}-panel`
43
+
44
+ const open = ref(false)
45
+ const highlighted = ref(0)
46
+ const root = ref<HTMLElement | null>(null)
47
+
48
+ const selectedIndex = computed(() =>
49
+ props.options.findIndex((o) => String(o.value) === String(props.modelValue)),
50
+ )
51
+ const selectedLabel = computed(() => props.options[selectedIndex.value]?.label ?? '')
52
+
53
+ function optionId(index: number) {
54
+ return `${selectId}-opt-${index}`
55
+ }
56
+
57
+ async function openPanel() {
58
+ if (props.disabled || open.value) return
59
+ open.value = true
60
+ // El resaltado arranca en la opción activa (o la primera).
61
+ highlighted.value = Math.max(selectedIndex.value, 0)
62
+ document.addEventListener('mousedown', onOutside)
63
+ await nextTick()
64
+ scrollToHighlighted()
65
+ }
66
+
67
+ function close() {
68
+ open.value = false
69
+ document.removeEventListener('mousedown', onOutside)
70
+ }
71
+
72
+ function toggle() {
73
+ if (open.value) close()
74
+ else void openPanel()
75
+ }
76
+
77
+ function select(option: SelectOption) {
78
+ emit('update:modelValue', String(option.value))
79
+ close()
80
+ }
81
+
82
+ function highlight(index: number) {
83
+ if (!props.options.length) return
84
+ highlighted.value = (index + props.options.length) % props.options.length
85
+ scrollToHighlighted()
86
+ }
87
+
88
+ function scrollToHighlighted() {
89
+ document.getElementById(optionId(highlighted.value))?.scrollIntoView({ block: 'nearest' })
90
+ }
91
+
92
+ // El foco vive siempre en el trigger (patrón aria-activedescendant).
93
+ function onKeydown(e: KeyboardEvent) {
94
+ if (props.disabled) return
95
+
96
+ if (!open.value) {
97
+ if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
98
+ e.preventDefault()
99
+ void openPanel()
100
+ }
101
+ return
102
+ }
103
+
104
+ switch (e.key) {
105
+ case 'Escape':
106
+ e.preventDefault()
107
+ close()
108
+ break
109
+ case 'ArrowDown':
110
+ e.preventDefault()
111
+ highlight(highlighted.value + 1)
112
+ break
113
+ case 'ArrowUp':
114
+ e.preventDefault()
115
+ highlight(highlighted.value - 1)
116
+ break
117
+ case 'Home':
118
+ e.preventDefault()
119
+ highlight(0)
120
+ break
121
+ case 'End':
122
+ e.preventDefault()
123
+ highlight(props.options.length - 1)
124
+ break
125
+ case 'Enter':
126
+ case ' ': {
127
+ e.preventDefault()
128
+ const option = props.options[highlighted.value]
129
+ if (option) select(option)
130
+ break
131
+ }
132
+ case 'Tab':
133
+ close()
134
+ break
135
+ }
136
+ }
137
+
138
+ function onOutside(e: MouseEvent) {
139
+ if (root.value && !root.value.contains(e.target as Node)) close()
140
+ }
141
+
142
+ onBeforeUnmount(() => document.removeEventListener('mousedown', onOutside))
28
143
  </script>
29
144
 
30
145
  <template>
@@ -32,20 +147,40 @@ const selectId = props.id || `select-${Math.random().toString(36).slice(2, 9)}`
32
147
  <label v-if="label" :for="selectId" class="form-field__label">
33
148
  {{ label }}<span v-if="required" class="form-field__required">*</span>
34
149
  </label>
35
- <div class="form-field__select-wrapper">
36
- <select
150
+ <div ref="root" class="form-field__select-wrapper base-select" :class="{ 'is-open': open }">
151
+ <button
37
152
  :id="selectId"
38
- :value="modelValue"
153
+ type="button"
154
+ class="form-field__select base-select__trigger"
39
155
  :disabled="disabled"
40
- :required="required"
41
- class="form-field__select"
42
- @change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
156
+ aria-haspopup="listbox"
157
+ :aria-expanded="open"
158
+ :aria-controls="panelId"
159
+ :aria-required="required || undefined"
160
+ :aria-activedescendant="open ? optionId(highlighted) : undefined"
161
+ @click="toggle"
162
+ @keydown="onKeydown"
43
163
  >
44
- <option v-if="placeholder" value="" disabled>{{ placeholder }}</option>
45
- <option v-for="option in options" :key="option.value" :value="option.value">
164
+ <span class="base-select__value" :class="{ 'is-placeholder': !selectedLabel }">
165
+ {{ selectedLabel || placeholder }}
166
+ </span>
167
+ </button>
168
+
169
+ <ul v-if="open" :id="panelId" class="base-select__panel" role="listbox">
170
+ <li
171
+ v-for="(option, index) in options"
172
+ :id="optionId(index)"
173
+ :key="option.value"
174
+ role="option"
175
+ class="base-select__option"
176
+ :class="{ 'is-active': index === selectedIndex, 'is-highlighted': index === highlighted }"
177
+ :aria-selected="index === selectedIndex"
178
+ @mousedown.prevent="select(option)"
179
+ @mouseenter="highlighted = index"
180
+ >
46
181
  {{ option.label }}
47
- </option>
48
- </select>
182
+ </li>
183
+ </ul>
49
184
  </div>
50
185
  <p v-if="error" class="form-field__error">{{ error }}</p>
51
186
  <p v-else-if="hint" class="form-field__hint">{{ hint }}</p>