@edc-motor/ui 0.4.6 → 0.4.8
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 +11 -0
- package/scss/_forms.scss +8 -2
- package/scss/_tokens.scss +3 -3
- package/scss/components/_base-button.scss +4 -1
- package/scss/components/_base-pagination.scss +41 -0
- package/scss/components/_filters-modal.scss +21 -0
- package/scss/components/_form-field.scss +1 -1
- package/scss/components/_index-toolbar.scss +103 -0
- package/scss/components/_index.scss +4 -0
- package/scss/components/_rich-content.scss +3 -3
- package/scss/components/_rich-text.scss +3 -3
- package/scss/components/_search-select.scss +4 -2
- package/scss/components/_sort-toggles.scss +35 -0
- package/src/components/BasePagination.vue +55 -0
- package/src/components/FiltersModal.vue +63 -0
- package/src/components/IndexToolbar.vue +87 -0
- package/src/components/SortToggles.vue +80 -0
- package/src/index.ts +4 -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.8",
|
|
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
|
@@ -44,3 +44,14 @@ input[type='submit']:not(:disabled),
|
|
|
44
44
|
input[type='file']:not(:disabled) {
|
|
45
45
|
cursor: pointer;
|
|
46
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
|
+
}
|
package/scss/_forms.scss
CHANGED
|
@@ -18,7 +18,8 @@ select,
|
|
|
18
18
|
textarea {
|
|
19
19
|
font: inherit;
|
|
20
20
|
width: 100%;
|
|
21
|
-
|
|
21
|
+
height: $input-height; // controles de una línea a 36px (como .form-field)
|
|
22
|
+
padding: 0 $input-padding-x;
|
|
22
23
|
background: $surface;
|
|
23
24
|
border: 1px solid $border;
|
|
24
25
|
border-radius: $radius-md;
|
|
@@ -26,7 +27,12 @@ textarea {
|
|
|
26
27
|
&:hover { border-color: $border-strong; }
|
|
27
28
|
&:focus { outline: none; border-color: $accent-500; }
|
|
28
29
|
}
|
|
29
|
-
textarea {
|
|
30
|
+
textarea {
|
|
31
|
+
height: auto;
|
|
32
|
+
min-height: $input-height;
|
|
33
|
+
padding: $input-padding-y $input-padding-x;
|
|
34
|
+
resize: vertical;
|
|
35
|
+
}
|
|
30
36
|
input[type="color"] { width: 44px; height: 36px; padding: 2px; cursor: pointer; }
|
|
31
37
|
|
|
32
38
|
.error { color: $danger; margin: 0; }
|
package/scss/_tokens.scss
CHANGED
|
@@ -101,9 +101,9 @@ $shadow-lg: var(--shadow-lg);
|
|
|
101
101
|
// Formularios (elementos estilo kontuan)
|
|
102
102
|
$focus-ring: 0 0 0 3px color-mix(in srgb, #{$accent-500} 25%, transparent);
|
|
103
103
|
$disabled-opacity: 0.45;
|
|
104
|
-
$input-height:
|
|
105
|
-
$input-padding-x:
|
|
106
|
-
$input-padding-y:
|
|
104
|
+
$input-height: 36px;
|
|
105
|
+
$input-padding-x: 10px;
|
|
106
|
+
$input-padding-y: 8px;
|
|
107
107
|
$input-radius: $radius-md;
|
|
108
108
|
$input-bg: $surface-2;
|
|
109
109
|
$input-text: $text-1;
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
align-items: center;
|
|
6
6
|
justify-content: center;
|
|
7
7
|
gap: $space-2;
|
|
8
|
-
|
|
8
|
+
min-height: $input-height; // misma altura que los inputs del motor (36px)
|
|
9
|
+
padding: $space-1 $space-4;
|
|
9
10
|
border: 1px solid transparent;
|
|
10
11
|
border-radius: $radius-md;
|
|
11
12
|
font: inherit;
|
|
@@ -74,6 +75,7 @@
|
|
|
74
75
|
.edc-button--text {
|
|
75
76
|
background: transparent;
|
|
76
77
|
border-color: transparent;
|
|
78
|
+
min-height: 0;
|
|
77
79
|
padding: 0;
|
|
78
80
|
color: $accent-500;
|
|
79
81
|
|
|
@@ -82,6 +84,7 @@
|
|
|
82
84
|
.edc-button--text-danger {
|
|
83
85
|
background: transparent;
|
|
84
86
|
border-color: transparent;
|
|
87
|
+
min-height: 0;
|
|
85
88
|
padding: 0;
|
|
86
89
|
color: $danger;
|
|
87
90
|
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
// 32px a propósito: control secundario, un punto por debajo de los 36px
|
|
15
|
+
// de los controles de formulario.
|
|
16
|
+
width: 32px;
|
|
17
|
+
height: 32px;
|
|
18
|
+
background: none;
|
|
19
|
+
border: 1px solid $border;
|
|
20
|
+
border-radius: $radius-sm;
|
|
21
|
+
color: $text-2;
|
|
22
|
+
transition: color 0.15s ease, border-color 0.15s ease;
|
|
23
|
+
|
|
24
|
+
&:not(:disabled):hover {
|
|
25
|
+
color: $accent-500;
|
|
26
|
+
border-color: $accent-500;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
&:disabled {
|
|
30
|
+
opacity: $disabled-opacity;
|
|
31
|
+
cursor: not-allowed;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
&__status {
|
|
36
|
+
font-size: $fs-13;
|
|
37
|
+
color: $text-2;
|
|
38
|
+
min-width: 56px;
|
|
39
|
+
text-align: center;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
@use "tokens" as *;
|
|
2
|
+
|
|
3
|
+
// Modal de filtros (FiltersModal.vue): el wrapper es contenedor y el grid
|
|
4
|
+
// del cuerpo suma columnas según el ancho REAL del modal (1 → 2 → 3).
|
|
5
|
+
.filters-modal {
|
|
6
|
+
container-type: inline-size;
|
|
7
|
+
|
|
8
|
+
&__grid {
|
|
9
|
+
display: grid;
|
|
10
|
+
grid-template-columns: 1fr;
|
|
11
|
+
gap: $space-4;
|
|
12
|
+
|
|
13
|
+
> * { min-width: 0; } // cada campo a ancho completo de su celda
|
|
14
|
+
|
|
15
|
+
@container (min-width: 460px) { grid-template-columns: repeat(2, 1fr); }
|
|
16
|
+
@container (min-width: 700px) { grid-template-columns: repeat(3, 1fr); }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// "Quitar filtros" a la izquierda del pie (el pie alinea a la derecha).
|
|
20
|
+
&__clear { margin-right: auto; }
|
|
21
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
@use "tokens" as *;
|
|
2
|
+
|
|
3
|
+
// Barra de los index (IndexToolbar.vue). El propio toolbar es el contenedor
|
|
4
|
+
// de las queries: por debajo de $bp-sm de ancho REAL de la barra, la búsqueda
|
|
5
|
+
// pasa a su fila y debajo toggles + botón se reparten el ancho.
|
|
6
|
+
.index-toolbar {
|
|
7
|
+
container-type: inline-size;
|
|
8
|
+
margin-bottom: $space-4;
|
|
9
|
+
|
|
10
|
+
&__inner {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: $space-3;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
&__search {
|
|
17
|
+
position: relative;
|
|
18
|
+
flex: 1;
|
|
19
|
+
min-width: 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
&__search-icon {
|
|
23
|
+
position: absolute;
|
|
24
|
+
right: $input-padding-x;
|
|
25
|
+
top: 50%;
|
|
26
|
+
transform: translateY(-50%);
|
|
27
|
+
color: $text-3;
|
|
28
|
+
pointer-events: none;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
&__search-input {
|
|
32
|
+
width: 100%;
|
|
33
|
+
height: $input-height;
|
|
34
|
+
padding: 0 34px 0 $input-padding-x; // hueco a la derecha para la lupa
|
|
35
|
+
background: $input-bg;
|
|
36
|
+
color: $input-text;
|
|
37
|
+
border: 1px solid $input-border;
|
|
38
|
+
border-radius: $input-radius;
|
|
39
|
+
font-family: $k-font-sans;
|
|
40
|
+
font-size: $fs-14;
|
|
41
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
42
|
+
|
|
43
|
+
&::placeholder { color: $input-placeholder; }
|
|
44
|
+
&:hover:not(:focus) { border-color: $input-border-hover; }
|
|
45
|
+
&:focus {
|
|
46
|
+
outline: none;
|
|
47
|
+
border-color: $input-border-focus;
|
|
48
|
+
box-shadow: $input-focus-ring;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
&__actions {
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
gap: $space-3;
|
|
56
|
+
flex-shrink: 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
&__filters {
|
|
60
|
+
display: inline-flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
justify-content: center;
|
|
63
|
+
gap: $space-2;
|
|
64
|
+
height: $input-height;
|
|
65
|
+
padding: 0 $space-3;
|
|
66
|
+
background: $input-bg;
|
|
67
|
+
border: 1px solid $input-border;
|
|
68
|
+
border-radius: $input-radius;
|
|
69
|
+
color: $text-2;
|
|
70
|
+
font-family: $k-font-sans;
|
|
71
|
+
font-size: $fs-14;
|
|
72
|
+
cursor: pointer;
|
|
73
|
+
transition: border-color 0.15s ease, color 0.15s ease;
|
|
74
|
+
|
|
75
|
+
&:hover { border-color: $input-border-hover; color: $text-1; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
&__badge {
|
|
79
|
+
display: inline-flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
justify-content: center;
|
|
82
|
+
min-width: 18px;
|
|
83
|
+
height: 18px;
|
|
84
|
+
padding: 0 5px;
|
|
85
|
+
border-radius: $radius-pill;
|
|
86
|
+
background: $accent-500;
|
|
87
|
+
color: #fff;
|
|
88
|
+
font-size: $fs-11;
|
|
89
|
+
font-weight: $k-fw-semibold;
|
|
90
|
+
line-height: 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Estrecho: la búsqueda ocupa su fila; debajo, toggles y botón se
|
|
94
|
+
// reparten el ancho proporcionalmente.
|
|
95
|
+
@container (max-width: #{$bp-sm - 1px}) {
|
|
96
|
+
&__inner { flex-wrap: wrap; }
|
|
97
|
+
&__search { flex: 1 1 100%; }
|
|
98
|
+
&__actions {
|
|
99
|
+
flex: 1 1 100%;
|
|
100
|
+
> * { flex: 1 1 auto; }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -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
|
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
border-radius: $input-radius;
|
|
17
17
|
color: $input-text;
|
|
18
18
|
cursor: pointer;
|
|
19
|
-
|
|
19
|
+
height: $input-height;
|
|
20
|
+
padding: 0 $input-padding-x;
|
|
20
21
|
text-align: left;
|
|
21
22
|
font: inherit;
|
|
22
23
|
|
|
@@ -65,7 +66,8 @@
|
|
|
65
66
|
border: 0;
|
|
66
67
|
border-bottom: 1px solid $border;
|
|
67
68
|
color: $input-text;
|
|
68
|
-
|
|
69
|
+
height: $input-height;
|
|
70
|
+
padding: 0 $input-padding-x;
|
|
69
71
|
font: inherit;
|
|
70
72
|
outline: none;
|
|
71
73
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
@use "tokens" as *;
|
|
2
|
+
|
|
3
|
+
// Toggles de ordenación (SortToggles.vue): grupo segmentado a la altura de
|
|
4
|
+
// los inputs (36px); el activo se tiñe de acento y su icono marca el sentido.
|
|
5
|
+
.sort-toggles {
|
|
6
|
+
display: inline-flex;
|
|
7
|
+
gap: 2px;
|
|
8
|
+
height: $input-height;
|
|
9
|
+
padding: 2px;
|
|
10
|
+
background: $input-bg;
|
|
11
|
+
border: 1px solid $input-border;
|
|
12
|
+
border-radius: $input-radius;
|
|
13
|
+
|
|
14
|
+
&__btn {
|
|
15
|
+
display: inline-flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: center;
|
|
18
|
+
flex: 1 1 auto;
|
|
19
|
+
min-width: 40px;
|
|
20
|
+
padding: 0 $space-2;
|
|
21
|
+
border: none;
|
|
22
|
+
border-radius: $radius-sm;
|
|
23
|
+
background: transparent;
|
|
24
|
+
color: $text-2;
|
|
25
|
+
cursor: pointer;
|
|
26
|
+
transition: background-color 0.15s ease, color 0.15s ease;
|
|
27
|
+
|
|
28
|
+
&:hover { color: $text-1; }
|
|
29
|
+
|
|
30
|
+
&.is-active {
|
|
31
|
+
background: color-mix(in srgb, $accent-500 14%, transparent);
|
|
32
|
+
color: $accent-500;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -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>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { FunnelX, X } from '@lucide/vue'
|
|
3
|
+
import BaseModal from './BaseModal.vue'
|
|
4
|
+
import BaseButton from './BaseButton.vue'
|
|
5
|
+
|
|
6
|
+
// Modal de filtros de los index: BaseModal (overlay, Escape, click fuera,
|
|
7
|
+
// aria-modal) SIN semántica de guardar — los campos del slot aplican en vivo
|
|
8
|
+
// (v-model directo desde el consumidor). El cuerpo es un grid que suma
|
|
9
|
+
// columnas según el ancho del propio modal (container queries). El pie trae
|
|
10
|
+
// "Quitar filtros" (emit `clear`, solo con activeCount > 0) y "Cerrar".
|
|
11
|
+
// Agnóstico de i18n (DC-29): textos por prop, defaults en castellano.
|
|
12
|
+
|
|
13
|
+
withDefaults(
|
|
14
|
+
defineProps<{
|
|
15
|
+
modelValue: boolean
|
|
16
|
+
title?: string
|
|
17
|
+
size?: 'sm' | 'md' | 'lg'
|
|
18
|
+
activeCount?: number
|
|
19
|
+
clearLabel?: string
|
|
20
|
+
closeLabel?: string
|
|
21
|
+
}>(),
|
|
22
|
+
{
|
|
23
|
+
title: 'Filtros',
|
|
24
|
+
size: 'md',
|
|
25
|
+
activeCount: 0,
|
|
26
|
+
clearLabel: 'Quitar filtros',
|
|
27
|
+
closeLabel: 'Cerrar',
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const emit = defineEmits<{ 'update:modelValue': [boolean]; clear: [] }>()
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<BaseModal
|
|
36
|
+
:model-value="modelValue"
|
|
37
|
+
:title="title"
|
|
38
|
+
:size="size"
|
|
39
|
+
:close-label="closeLabel"
|
|
40
|
+
@update:model-value="(v) => emit('update:modelValue', v)"
|
|
41
|
+
>
|
|
42
|
+
<div class="filters-modal">
|
|
43
|
+
<div class="filters-modal__grid"><slot /></div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<template #footer>
|
|
47
|
+
<BaseButton
|
|
48
|
+
v-if="activeCount > 0"
|
|
49
|
+
variant="secondary"
|
|
50
|
+
type="button"
|
|
51
|
+
class="filters-modal__clear"
|
|
52
|
+
@click="emit('clear')"
|
|
53
|
+
>
|
|
54
|
+
<template #icon><FunnelX :size="16" /></template>
|
|
55
|
+
{{ clearLabel }}
|
|
56
|
+
</BaseButton>
|
|
57
|
+
<BaseButton variant="primary" type="button" @click="emit('update:modelValue', false)">
|
|
58
|
+
<template #icon><X :size="16" /></template>
|
|
59
|
+
{{ closeLabel }}
|
|
60
|
+
</BaseButton>
|
|
61
|
+
</template>
|
|
62
|
+
</BaseModal>
|
|
63
|
+
</template>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Funnel, Search } from '@lucide/vue'
|
|
3
|
+
import SortToggles, { type SortValue } from './SortToggles.vue'
|
|
4
|
+
|
|
5
|
+
// Barra unificada de los index (admin y web pública): búsqueda (lupa a la
|
|
6
|
+
// derecha, como el FilterBar del admin-kit), toggles de ordenación y botón
|
|
7
|
+
// "Filtros" con badge de filtros activos (emite `open-filters`; el modal lo
|
|
8
|
+
// pone el consumidor). En ancho es una fila [búsqueda][toggles][botón]; en
|
|
9
|
+
// estrecho (container query propia) la búsqueda ocupa su fila y debajo se
|
|
10
|
+
// reparten el ancho los toggles y el botón.
|
|
11
|
+
// La búsqueda emite INMEDIATO (como FilterBar): el debounce va fuera.
|
|
12
|
+
// Agnóstico de i18n (DC-29): textos por prop, defaults en castellano.
|
|
13
|
+
|
|
14
|
+
withDefaults(
|
|
15
|
+
defineProps<{
|
|
16
|
+
modelValue: string
|
|
17
|
+
sort?: SortValue
|
|
18
|
+
searchPlaceholder?: string
|
|
19
|
+
filtersLabel?: string
|
|
20
|
+
activeCount?: number
|
|
21
|
+
showFilters?: boolean
|
|
22
|
+
showSort?: boolean
|
|
23
|
+
latestLabel?: string
|
|
24
|
+
oldestLabel?: string
|
|
25
|
+
nameLabel?: string
|
|
26
|
+
nameDescLabel?: string
|
|
27
|
+
}>(),
|
|
28
|
+
{
|
|
29
|
+
sort: 'latest',
|
|
30
|
+
searchPlaceholder: 'Buscar…',
|
|
31
|
+
filtersLabel: 'Filtros',
|
|
32
|
+
activeCount: 0,
|
|
33
|
+
showFilters: false,
|
|
34
|
+
showSort: true,
|
|
35
|
+
latestLabel: undefined,
|
|
36
|
+
oldestLabel: undefined,
|
|
37
|
+
nameLabel: undefined,
|
|
38
|
+
nameDescLabel: undefined,
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const emit = defineEmits<{
|
|
43
|
+
'update:modelValue': [value: string]
|
|
44
|
+
'update:sort': [value: SortValue]
|
|
45
|
+
'open-filters': []
|
|
46
|
+
}>()
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<div class="index-toolbar">
|
|
51
|
+
<div class="index-toolbar__inner">
|
|
52
|
+
<div class="index-toolbar__search">
|
|
53
|
+
<input
|
|
54
|
+
type="search"
|
|
55
|
+
:value="modelValue"
|
|
56
|
+
:placeholder="searchPlaceholder"
|
|
57
|
+
class="index-toolbar__search-input"
|
|
58
|
+
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
|
59
|
+
/>
|
|
60
|
+
<Search :size="16" class="index-toolbar__search-icon" />
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div class="index-toolbar__actions">
|
|
64
|
+
<SortToggles
|
|
65
|
+
v-if="showSort"
|
|
66
|
+
:model-value="sort"
|
|
67
|
+
class="index-toolbar__sort"
|
|
68
|
+
:latest-label="latestLabel"
|
|
69
|
+
:oldest-label="oldestLabel"
|
|
70
|
+
:name-label="nameLabel"
|
|
71
|
+
:name-desc-label="nameDescLabel"
|
|
72
|
+
@update:model-value="(v) => emit('update:sort', v)"
|
|
73
|
+
/>
|
|
74
|
+
<button
|
|
75
|
+
v-if="showFilters"
|
|
76
|
+
type="button"
|
|
77
|
+
class="index-toolbar__filters"
|
|
78
|
+
@click="emit('open-filters')"
|
|
79
|
+
>
|
|
80
|
+
<Funnel :size="16" />
|
|
81
|
+
<span class="index-toolbar__filters-text">{{ filtersLabel }}</span>
|
|
82
|
+
<span v-if="activeCount > 0" class="index-toolbar__badge">{{ activeCount }}</span>
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</template>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { ArrowDownAZ, ArrowDownZA, CalendarArrowDown, CalendarArrowUp } from '@lucide/vue'
|
|
4
|
+
|
|
5
|
+
// Par de toggles de ordenación de los index: fecha (latest ⇄ oldest) y
|
|
6
|
+
// alfabético (name ⇄ name_desc). Pulsar el inactivo lo activa en su primer
|
|
7
|
+
// estado; pulsar el activo invierte el sentido. Los valores casan con el
|
|
8
|
+
// ?sort del catálogo público del core.
|
|
9
|
+
// Agnóstico de i18n (DC-29): textos por prop, defaults en castellano.
|
|
10
|
+
|
|
11
|
+
export type SortValue = 'latest' | 'oldest' | 'name' | 'name_desc'
|
|
12
|
+
|
|
13
|
+
const props = withDefaults(
|
|
14
|
+
defineProps<{
|
|
15
|
+
modelValue?: SortValue
|
|
16
|
+
latestLabel?: string
|
|
17
|
+
oldestLabel?: string
|
|
18
|
+
nameLabel?: string
|
|
19
|
+
nameDescLabel?: string
|
|
20
|
+
}>(),
|
|
21
|
+
{
|
|
22
|
+
modelValue: 'latest',
|
|
23
|
+
latestLabel: 'Más recientes primero',
|
|
24
|
+
oldestLabel: 'Más antiguos primero',
|
|
25
|
+
nameLabel: 'Alfabético (A-Z)',
|
|
26
|
+
nameDescLabel: 'Alfabético (Z-A)',
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const emit = defineEmits<{ 'update:modelValue': [value: SortValue] }>()
|
|
31
|
+
|
|
32
|
+
const dateActive = computed(() => props.modelValue === 'latest' || props.modelValue === 'oldest')
|
|
33
|
+
const alphaActive = computed(() => props.modelValue === 'name' || props.modelValue === 'name_desc')
|
|
34
|
+
|
|
35
|
+
// Cada botón anuncia el estado que representa AHORA: el activo, su sentido
|
|
36
|
+
// actual; el inactivo, el primer estado al que llevaría el click.
|
|
37
|
+
const dateLabel = computed(() =>
|
|
38
|
+
props.modelValue === 'oldest' ? props.oldestLabel : props.latestLabel,
|
|
39
|
+
)
|
|
40
|
+
const alphaLabel = computed(() =>
|
|
41
|
+
props.modelValue === 'name_desc' ? props.nameDescLabel : props.nameLabel,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
function toggleDate() {
|
|
45
|
+
emit('update:modelValue', props.modelValue === 'latest' ? 'oldest' : 'latest')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toggleAlpha() {
|
|
49
|
+
emit('update:modelValue', props.modelValue === 'name' ? 'name_desc' : 'name')
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<div class="sort-toggles" role="group">
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
class="sort-toggles__btn"
|
|
58
|
+
:class="{ 'is-active': dateActive }"
|
|
59
|
+
:aria-pressed="dateActive"
|
|
60
|
+
:aria-label="dateLabel"
|
|
61
|
+
:title="dateLabel"
|
|
62
|
+
@click="toggleDate"
|
|
63
|
+
>
|
|
64
|
+
<CalendarArrowUp v-if="modelValue === 'oldest'" :size="16" />
|
|
65
|
+
<CalendarArrowDown v-else :size="16" />
|
|
66
|
+
</button>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
class="sort-toggles__btn"
|
|
70
|
+
:class="{ 'is-active': alphaActive }"
|
|
71
|
+
:aria-pressed="alphaActive"
|
|
72
|
+
:aria-label="alphaLabel"
|
|
73
|
+
:title="alphaLabel"
|
|
74
|
+
@click="toggleAlpha"
|
|
75
|
+
>
|
|
76
|
+
<ArrowDownZA v-if="modelValue === 'name_desc'" :size="16" />
|
|
77
|
+
<ArrowDownAZ v-else :size="16" />
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
</template>
|
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'
|
|
@@ -22,6 +23,9 @@ export { default as ImageUpload } from './components/ImageUpload.vue'
|
|
|
22
23
|
export { default as FontUpload } from './components/FontUpload.vue'
|
|
23
24
|
export { default as BaseModal } from './components/BaseModal.vue'
|
|
24
25
|
export { default as EditModal } from './components/EditModal.vue'
|
|
26
|
+
export { default as FiltersModal } from './components/FiltersModal.vue'
|
|
27
|
+
export { default as SortToggles, type SortValue } from './components/SortToggles.vue'
|
|
28
|
+
export { default as IndexToolbar } from './components/IndexToolbar.vue'
|
|
25
29
|
export { default as BaseTabs } from './components/BaseTabs.vue'
|
|
26
30
|
export { default as BaseToast } from './components/BaseToast.vue'
|
|
27
31
|
export { default as ToastContainer } from './components/ToastContainer.vue'
|