@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 +1 -1
- package/scss/_base.scss +24 -0
- package/scss/components/_base-pagination.scss +39 -0
- package/scss/components/_base-select.scss +65 -0
- package/scss/components/_index.scss +2 -0
- package/scss/components/_rich-content.scss +3 -3
- package/scss/components/_rich-text.scss +3 -3
- package/src/components/BasePagination.vue +55 -0
- package/src/components/BaseSelect.vue +148 -13
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edc-motor/ui",
|
|
3
|
-
"version": "0.4.
|
|
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
|
+
}
|
|
@@ -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.
|
|
49
|
-
height: 1.
|
|
50
|
-
vertical-align: -0.
|
|
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.
|
|
130
|
-
height: 1.
|
|
131
|
-
vertical-align: -0.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
<
|
|
150
|
+
<div ref="root" class="form-field__select-wrapper base-select" :class="{ 'is-open': open }">
|
|
151
|
+
<button
|
|
37
152
|
:id="selectId"
|
|
38
|
-
|
|
153
|
+
type="button"
|
|
154
|
+
class="form-field__select base-select__trigger"
|
|
39
155
|
:disabled="disabled"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
<
|
|
45
|
-
|
|
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
|
-
</
|
|
48
|
-
</
|
|
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'
|