@edc-motor/ui 0.4.5 → 0.4.7

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.5",
3
+ "version": "0.4.7",
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,27 @@ 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
+ }
47
+
48
+ // Iconos del juego insertados por el wysiwyg (img.rt-icon): SIEMPRE al
49
+ // tamaño del texto que los rodea (1.2x), se rendericen donde se rendericen
50
+ // (con o sin .rich-content alrededor).
51
+ img.rt-icon {
52
+ display: inline-block;
53
+ width: 1.2em;
54
+ height: 1.2em;
55
+ vertical-align: -0.24em;
56
+ margin: 0 1px;
57
+ }
@@ -0,0 +1,39 @@
1
+ @use "../tokens" as *;
2
+
3
+ // Paginación compacta de listados (BasePagination.vue).
4
+ .base-pagination {
5
+ display: flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ gap: $space-2;
9
+
10
+ &__button {
11
+ display: inline-flex;
12
+ align-items: center;
13
+ justify-content: center;
14
+ width: 32px;
15
+ height: 32px;
16
+ background: none;
17
+ border: 1px solid $border;
18
+ border-radius: $radius-sm;
19
+ color: $text-2;
20
+ transition: color 0.15s ease, border-color 0.15s ease;
21
+
22
+ &:not(:disabled):hover {
23
+ color: $accent-500;
24
+ border-color: $accent-500;
25
+ }
26
+
27
+ &:disabled {
28
+ opacity: $disabled-opacity;
29
+ cursor: not-allowed;
30
+ }
31
+ }
32
+
33
+ &__status {
34
+ font-size: $fs-13;
35
+ color: $text-2;
36
+ min-width: 56px;
37
+ text-align: center;
38
+ }
39
+ }
@@ -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,8 @@
3
3
  @use "icon-button";
4
4
  @use "motor-badge";
5
5
  @use "form-field";
6
+ @use "base-select";
7
+ @use "base-pagination";
6
8
  @use "search-select";
7
9
  @use "blocks";
8
10
  @use "block-related";
@@ -45,9 +45,9 @@
45
45
  // Iconos del juego insertados en línea por el editor (<img class="rt-icon">).
46
46
  img.rt-icon {
47
47
  display: inline-block;
48
- width: 1.25em;
49
- height: 1.25em;
50
- vertical-align: -0.25em;
48
+ width: 1.2em;
49
+ height: 1.2em;
50
+ vertical-align: -0.24em;
51
51
  margin: 0 1px;
52
52
  }
53
53
  }
@@ -126,9 +126,9 @@
126
126
  // Iconos insertados: en línea con el texto.
127
127
  img.rt-icon {
128
128
  display: inline-block;
129
- width: 1.25em;
130
- height: 1.25em;
131
- vertical-align: -0.25em;
129
+ width: 1.2em;
130
+ height: 1.2em;
131
+ vertical-align: -0.24em;
132
132
  margin: 0 1px;
133
133
  }
134
134
 
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ import { ChevronLeft, ChevronRight } from '@lucide/vue'
3
+
4
+ // Paginación compacta para listados (admin y web): anterior / "x de y" /
5
+ // siguiente. Con una sola página no pinta nada. Agnóstica de i18n (DC-29):
6
+ // textos por prop, defaults en castellano.
7
+ const props = withDefaults(
8
+ defineProps<{
9
+ page: number
10
+ pages: number
11
+ prevLabel?: string
12
+ nextLabel?: string
13
+ /** Texto accesible del estado, con {page} y {pages}. */
14
+ ofLabel?: string
15
+ }>(),
16
+ { prevLabel: 'Anterior', nextLabel: 'Siguiente', ofLabel: '{page} de {pages}' },
17
+ )
18
+
19
+ const emit = defineEmits<{ 'update:page': [page: number] }>()
20
+
21
+ function go(page: number) {
22
+ if (page < 1 || page > props.pages || page === props.page) return
23
+ emit('update:page', page)
24
+ }
25
+
26
+ function status(): string {
27
+ return props.ofLabel.replace('{page}', String(props.page)).replace('{pages}', String(props.pages))
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <nav v-if="pages > 1" class="base-pagination" :aria-label="status()">
33
+ <button
34
+ type="button"
35
+ class="base-pagination__button"
36
+ :disabled="page <= 1"
37
+ :aria-label="prevLabel"
38
+ :title="prevLabel"
39
+ @click="go(page - 1)"
40
+ >
41
+ <ChevronLeft :size="16" />
42
+ </button>
43
+ <span class="base-pagination__status">{{ status() }}</span>
44
+ <button
45
+ type="button"
46
+ class="base-pagination__button"
47
+ :disabled="page >= pages"
48
+ :aria-label="nextLabel"
49
+ :title="nextLabel"
50
+ @click="go(page + 1)"
51
+ >
52
+ <ChevronRight :size="16" />
53
+ </button>
54
+ </nav>
55
+ </template>
@@ -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>
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export { default as IconButton } from './components/IconButton.vue'
7
7
  export { default as MotorBadge } from './components/MotorBadge.vue'
8
8
  export { default as BaseInput } from './components/BaseInput.vue'
9
9
  export { default as BaseTextarea } from './components/BaseTextarea.vue'
10
+ export { default as BasePagination } from './components/BasePagination.vue'
10
11
  export { default as BaseSelect, type SelectOption } from './components/BaseSelect.vue'
11
12
  export { default as SearchSelect, type SearchSelectOption } from './components/SearchSelect.vue'
12
13
  export { default as BaseCheckbox } from './components/BaseCheckbox.vue'