@edc-motor/ui 0.4.8 → 0.4.10
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/components/_app-right-sidebar.scss +112 -0
- package/scss/components/_base-select.scss +3 -0
- package/scss/components/_index-toolbar.scss +3 -37
- package/scss/components/_index.scss +1 -1
- package/scss/components/_search-select.scss +3 -0
- package/src/components/AppRightSidebar.vue +97 -0
- package/src/components/BaseSelect.vue +18 -2
- package/src/components/IndexToolbar.vue +7 -25
- package/src/components/SearchSelect.vue +11 -2
- package/src/composables/useAppRightSidebar.ts +101 -0
- package/src/composables/useDropdownPanel.ts +66 -0
- package/src/index.ts +5 -1
- package/scss/components/_filters-modal.scss +0 -21
- package/src/components/FiltersModal.vue +0 -63
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edc-motor/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.10",
|
|
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,112 @@
|
|
|
1
|
+
@use "tokens" as *;
|
|
2
|
+
|
|
3
|
+
// Barra lateral derecha contextual de la web pública (AppRightSidebar.vue):
|
|
4
|
+
// cada vista la registra con useAppRightSidebar() y App.vue la monta una vez
|
|
5
|
+
// dentro de .site-main (flex row con .site-content). El cascarón fija
|
|
6
|
+
// --app-right-sidebar-top a la altura de su cabecera fija; sin fijarla, la
|
|
7
|
+
// barra arranca en el borde superior.
|
|
8
|
+
|
|
9
|
+
.app-right-sidebar {
|
|
10
|
+
position: sticky;
|
|
11
|
+
top: 0;
|
|
12
|
+
align-self: flex-start;
|
|
13
|
+
height: 100dvh;
|
|
14
|
+
width: 320px;
|
|
15
|
+
flex-shrink: 0;
|
|
16
|
+
padding-top: var(--app-right-sidebar-top, 0px);
|
|
17
|
+
background: $surface;
|
|
18
|
+
border-left: 1px solid $border;
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-direction: column;
|
|
21
|
+
overflow: hidden;
|
|
22
|
+
transition: width 0.2s ease, transform 0.25s ease;
|
|
23
|
+
|
|
24
|
+
&--collapsed {
|
|
25
|
+
width: 0;
|
|
26
|
+
border-left: 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
&__header {
|
|
30
|
+
display: flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
gap: $space-2;
|
|
33
|
+
height: 48px;
|
|
34
|
+
padding: 0 $space-2 0 $space-4;
|
|
35
|
+
border-bottom: 1px solid $border;
|
|
36
|
+
flex-shrink: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
&__title {
|
|
40
|
+
flex: 1;
|
|
41
|
+
font-size: $fs-14;
|
|
42
|
+
font-weight: $k-fw-semibold;
|
|
43
|
+
color: $text-1;
|
|
44
|
+
overflow: hidden;
|
|
45
|
+
text-overflow: ellipsis;
|
|
46
|
+
white-space: nowrap;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Cerrar como el action-button del modal: sin caja, acento al hover.
|
|
50
|
+
&__close {
|
|
51
|
+
display: inline-flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
justify-content: center;
|
|
54
|
+
width: 32px;
|
|
55
|
+
height: 32px;
|
|
56
|
+
border: none;
|
|
57
|
+
background: none;
|
|
58
|
+
color: $text-2;
|
|
59
|
+
cursor: pointer;
|
|
60
|
+
border-radius: $radius-sm;
|
|
61
|
+
transition: background-color 0.15s ease, color 0.15s ease;
|
|
62
|
+
|
|
63
|
+
&:hover {
|
|
64
|
+
color: $accent-500;
|
|
65
|
+
background: color-mix(in srgb, $accent-500 12%, transparent);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
&__body {
|
|
70
|
+
flex: 1;
|
|
71
|
+
overflow-y: auto;
|
|
72
|
+
padding: $space-4;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// El contenido registrado (selects de filtros…) se apila a una columna.
|
|
76
|
+
#app-right-sidebar-target {
|
|
77
|
+
display: grid;
|
|
78
|
+
gap: $space-3;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Modo drawer (por debajo del umbral del componente): superpuesto al
|
|
82
|
+
// contenido, bajo la cabecera, con telón y cierre por click fuera/Escape.
|
|
83
|
+
&--drawer {
|
|
84
|
+
position: fixed;
|
|
85
|
+
top: var(--app-right-sidebar-top, 0px);
|
|
86
|
+
right: 0;
|
|
87
|
+
bottom: 0;
|
|
88
|
+
// El align-self: flex-start del modo columna aplica aquí como alineación
|
|
89
|
+
// de caja posicionada y encogería el alto al contenido en vez de
|
|
90
|
+
// estirarlo hasta bottom: 0.
|
|
91
|
+
align-self: auto;
|
|
92
|
+
height: auto;
|
|
93
|
+
padding-top: 0;
|
|
94
|
+
z-index: 60; // por encima de la cabecera fija (50)
|
|
95
|
+
width: min(320px, 100vw);
|
|
96
|
+
transform: translateX(100%);
|
|
97
|
+
|
|
98
|
+
&.app-right-sidebar--drawer-open {
|
|
99
|
+
transform: translateX(0);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.app-right-sidebar-overlay {
|
|
105
|
+
position: fixed;
|
|
106
|
+
top: var(--app-right-sidebar-top, 0px);
|
|
107
|
+
left: 0;
|
|
108
|
+
right: 0;
|
|
109
|
+
bottom: 0;
|
|
110
|
+
background: rgb(0 0 0 / 40%);
|
|
111
|
+
z-index: 55;
|
|
112
|
+
}
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
&.is-placeholder { color: $input-placeholder; }
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Posición base (fallback sin Popover API): absoluto bajo el trigger. Al
|
|
29
|
+
// abrir, useDropdownPanel lo promociona a la top layer (popover) y lo ancla
|
|
30
|
+
// por coordenadas fijas: dentro de modales no lo recorta ningún overflow.
|
|
28
31
|
&__panel {
|
|
29
32
|
position: absolute;
|
|
30
33
|
top: calc(100% + #{$space-1});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// Barra de los index (IndexToolbar.vue). El propio toolbar es el contenedor
|
|
4
4
|
// de las queries: por debajo de $bp-sm de ancho REAL de la barra, la búsqueda
|
|
5
|
-
// pasa a su fila y
|
|
5
|
+
// pasa a su fila y los toggles se reparten el ancho debajo.
|
|
6
6
|
.index-toolbar {
|
|
7
7
|
container-type: inline-size;
|
|
8
8
|
margin-bottom: $space-4;
|
|
@@ -56,42 +56,8 @@
|
|
|
56
56
|
flex-shrink: 0;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
59
|
+
// Estrecho: la búsqueda ocupa su fila; debajo, los toggles se reparten
|
|
60
|
+
// el ancho proporcionalmente.
|
|
95
61
|
@container (max-width: #{$bp-sm - 1px}) {
|
|
96
62
|
&__inner { flex-wrap: wrap; }
|
|
97
63
|
&__search { flex: 1 1 100%; }
|
|
@@ -46,6 +46,9 @@
|
|
|
46
46
|
|
|
47
47
|
&.is-open &__chevron { transform: rotate(180deg); }
|
|
48
48
|
|
|
49
|
+
// Posición base (fallback sin Popover API): absoluto bajo el trigger. Al
|
|
50
|
+
// abrir, useDropdownPanel lo promociona a la top layer (popover) y lo ancla
|
|
51
|
+
// por coordenadas fijas: dentro de modales no lo recorta ningún overflow.
|
|
49
52
|
&__dropdown {
|
|
50
53
|
position: absolute;
|
|
51
54
|
top: calc(100% + #{$space-1});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
|
3
|
+
import { X } from '@lucide/vue'
|
|
4
|
+
import { useAppRightSidebar } from '../composables/useAppRightSidebar'
|
|
5
|
+
|
|
6
|
+
// Barra lateral derecha contextual de la web pública (misma mecánica que la
|
|
7
|
+
// RightSidebar del admin-kit): cada vista la activa con
|
|
8
|
+
// useAppRightSidebar().useRegister(titulo) y teletransporta su contenido
|
|
9
|
+
// (típicamente sus selects de filtros) a #app-right-sidebar-target. En ancho
|
|
10
|
+
// es una columna pegajosa junto al contenido; por debajo de
|
|
11
|
+
// OVERLAY_BREAKPOINT pasa a drawer superpuesto con cierre por click fuera y
|
|
12
|
+
// Escape. Se abre/cierra con el botón (Funnel) del header, que llama a
|
|
13
|
+
// useAppRightSidebar().toggle(). La monta App.vue una vez, dentro de
|
|
14
|
+
// .site-main; el cascarón fija --app-right-sidebar-top a la altura de su
|
|
15
|
+
// cabecera fija.
|
|
16
|
+
// Agnóstica de i18n (DC-29): textos por prop, defaults en castellano.
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(
|
|
19
|
+
defineProps<{
|
|
20
|
+
closeLabel?: string
|
|
21
|
+
/** Título por defecto si la vista registra sin título. */
|
|
22
|
+
fallbackTitle?: string
|
|
23
|
+
}>(),
|
|
24
|
+
{ closeLabel: 'Cerrar el panel', fallbackTitle: 'Filtros' },
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const { hasContent, collapsed, mobileOpen, overlay, title, isOpen, toggle, closeMobile } =
|
|
28
|
+
useAppRightSidebar()
|
|
29
|
+
|
|
30
|
+
// Por debajo de este ancho la barra se superpone (drawer) en vez de ocupar
|
|
31
|
+
// columna: coincide con el corte móvil del header público (900px).
|
|
32
|
+
const OVERLAY_BREAKPOINT = 900
|
|
33
|
+
|
|
34
|
+
function checkOverlay() {
|
|
35
|
+
overlay.value = window.innerWidth < OVERLAY_BREAKPOINT
|
|
36
|
+
if (!overlay.value) mobileOpen.value = false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// En el drawer, Escape cierra (el click fuera lo cubre el telón).
|
|
40
|
+
function onKeydown(e: KeyboardEvent) {
|
|
41
|
+
if (e.key === 'Escape' && overlay.value && mobileOpen.value) closeMobile()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
onMounted(() => {
|
|
45
|
+
checkOverlay()
|
|
46
|
+
window.addEventListener('resize', checkOverlay)
|
|
47
|
+
window.addEventListener('keydown', onKeydown)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
onBeforeUnmount(() => {
|
|
51
|
+
window.removeEventListener('resize', checkOverlay)
|
|
52
|
+
window.removeEventListener('keydown', onKeydown)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const panelTitle = computed(() => title.value || props.fallbackTitle)
|
|
56
|
+
|
|
57
|
+
function handleClose(e: MouseEvent) {
|
|
58
|
+
// Evita dejar el foco dentro de un subárbol inert.
|
|
59
|
+
;(e.currentTarget as HTMLElement | null)?.blur()
|
|
60
|
+
toggle()
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<div
|
|
66
|
+
v-if="overlay && hasContent && mobileOpen"
|
|
67
|
+
class="app-right-sidebar-overlay"
|
|
68
|
+
@click="closeMobile"
|
|
69
|
+
/>
|
|
70
|
+
|
|
71
|
+
<aside
|
|
72
|
+
v-show="hasContent"
|
|
73
|
+
class="app-right-sidebar"
|
|
74
|
+
:class="{
|
|
75
|
+
'app-right-sidebar--collapsed': collapsed && !overlay,
|
|
76
|
+
'app-right-sidebar--drawer': overlay,
|
|
77
|
+
'app-right-sidebar--drawer-open': overlay && mobileOpen,
|
|
78
|
+
}"
|
|
79
|
+
:inert="!isOpen"
|
|
80
|
+
>
|
|
81
|
+
<div class="app-right-sidebar__header">
|
|
82
|
+
<span class="app-right-sidebar__title">{{ panelTitle }}</span>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
class="app-right-sidebar__close"
|
|
86
|
+
:aria-label="closeLabel"
|
|
87
|
+
@click="handleClose"
|
|
88
|
+
>
|
|
89
|
+
<X :size="18" />
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<div class="app-right-sidebar__body">
|
|
94
|
+
<div id="app-right-sidebar-target" />
|
|
95
|
+
</div>
|
|
96
|
+
</aside>
|
|
97
|
+
</template>
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
|
3
|
+
import { useDropdownPanel } from '../composables/useDropdownPanel'
|
|
3
4
|
|
|
4
5
|
// Select de formulario (portado de kontuan). Dropdown personalizado (botón
|
|
5
6
|
// trigger + panel de opciones) con la MISMA API que el <select> nativo al que
|
|
6
7
|
// sustituye: el trigger reutiliza el aspecto de .form-field__select y el
|
|
7
8
|
// panel, la estética del SearchSelect. Teclado completo (flechas, Enter,
|
|
8
|
-
// Escape, Home/End) y cierre por mousedown exterior.
|
|
9
|
+
// Escape, Home/End) y cierre por mousedown exterior. El panel abierto vuela
|
|
10
|
+
// a la top layer (useDropdownPanel): dentro de un modal se superpone sin
|
|
11
|
+
// recortarse ni deformar el modal.
|
|
9
12
|
//
|
|
10
13
|
// Compatibilidad con el nativo:
|
|
11
14
|
// - El valor viaja como en el DOM: se emite String(option.value) y la opción
|
|
@@ -44,6 +47,9 @@ const panelId = `${selectId}-panel`
|
|
|
44
47
|
const open = ref(false)
|
|
45
48
|
const highlighted = ref(0)
|
|
46
49
|
const root = ref<HTMLElement | null>(null)
|
|
50
|
+
const panel = ref<HTMLElement | null>(null)
|
|
51
|
+
|
|
52
|
+
useDropdownPanel(root, panel, open)
|
|
47
53
|
|
|
48
54
|
const selectedIndex = computed(() =>
|
|
49
55
|
props.options.findIndex((o) => String(o.value) === String(props.modelValue)),
|
|
@@ -103,7 +109,10 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
103
109
|
|
|
104
110
|
switch (e.key) {
|
|
105
111
|
case 'Escape':
|
|
112
|
+
// Escape con el panel abierto cierra SOLO el panel (que no llegue al
|
|
113
|
+
// listener global de un modal contenedor y lo cierre también).
|
|
106
114
|
e.preventDefault()
|
|
115
|
+
e.stopPropagation()
|
|
107
116
|
close()
|
|
108
117
|
break
|
|
109
118
|
case 'ArrowDown':
|
|
@@ -166,7 +175,14 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onOutside))
|
|
|
166
175
|
</span>
|
|
167
176
|
</button>
|
|
168
177
|
|
|
169
|
-
<ul
|
|
178
|
+
<ul
|
|
179
|
+
v-if="open"
|
|
180
|
+
:id="panelId"
|
|
181
|
+
ref="panel"
|
|
182
|
+
class="base-select__panel"
|
|
183
|
+
popover="manual"
|
|
184
|
+
role="listbox"
|
|
185
|
+
>
|
|
170
186
|
<li
|
|
171
187
|
v-for="(option, index) in options"
|
|
172
188
|
:id="optionId(index)"
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { Search } from '@lucide/vue'
|
|
3
3
|
import SortToggles, { type SortValue } from './SortToggles.vue'
|
|
4
4
|
|
|
5
5
|
// Barra unificada de los index (admin y web pública): búsqueda (lupa a la
|
|
6
|
-
// derecha, como el FilterBar del admin-kit)
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// estrecho (container query propia) la búsqueda
|
|
10
|
-
//
|
|
6
|
+
// derecha, como el FilterBar del admin-kit) y toggles de ordenación. Los
|
|
7
|
+
// filtros del listado viven en la barra derecha (RightSidebar del admin /
|
|
8
|
+
// AppRightSidebar de la web), no aquí. En ancho es una fila
|
|
9
|
+
// [búsqueda][toggles]; en estrecho (container query propia) la búsqueda
|
|
10
|
+
// ocupa su fila y los toggles pasan debajo.
|
|
11
11
|
// La búsqueda emite INMEDIATO (como FilterBar): el debounce va fuera.
|
|
12
12
|
// Agnóstico de i18n (DC-29): textos por prop, defaults en castellano.
|
|
13
13
|
|
|
@@ -16,9 +16,6 @@ withDefaults(
|
|
|
16
16
|
modelValue: string
|
|
17
17
|
sort?: SortValue
|
|
18
18
|
searchPlaceholder?: string
|
|
19
|
-
filtersLabel?: string
|
|
20
|
-
activeCount?: number
|
|
21
|
-
showFilters?: boolean
|
|
22
19
|
showSort?: boolean
|
|
23
20
|
latestLabel?: string
|
|
24
21
|
oldestLabel?: string
|
|
@@ -28,9 +25,6 @@ withDefaults(
|
|
|
28
25
|
{
|
|
29
26
|
sort: 'latest',
|
|
30
27
|
searchPlaceholder: 'Buscar…',
|
|
31
|
-
filtersLabel: 'Filtros',
|
|
32
|
-
activeCount: 0,
|
|
33
|
-
showFilters: false,
|
|
34
28
|
showSort: true,
|
|
35
29
|
latestLabel: undefined,
|
|
36
30
|
oldestLabel: undefined,
|
|
@@ -42,7 +36,6 @@ withDefaults(
|
|
|
42
36
|
const emit = defineEmits<{
|
|
43
37
|
'update:modelValue': [value: string]
|
|
44
38
|
'update:sort': [value: SortValue]
|
|
45
|
-
'open-filters': []
|
|
46
39
|
}>()
|
|
47
40
|
</script>
|
|
48
41
|
|
|
@@ -60,9 +53,8 @@ const emit = defineEmits<{
|
|
|
60
53
|
<Search :size="16" class="index-toolbar__search-icon" />
|
|
61
54
|
</div>
|
|
62
55
|
|
|
63
|
-
<div class="index-toolbar__actions">
|
|
56
|
+
<div v-if="showSort" class="index-toolbar__actions">
|
|
64
57
|
<SortToggles
|
|
65
|
-
v-if="showSort"
|
|
66
58
|
:model-value="sort"
|
|
67
59
|
class="index-toolbar__sort"
|
|
68
60
|
:latest-label="latestLabel"
|
|
@@ -71,16 +63,6 @@ const emit = defineEmits<{
|
|
|
71
63
|
:name-desc-label="nameDescLabel"
|
|
72
64
|
@update:model-value="(v) => emit('update:sort', v)"
|
|
73
65
|
/>
|
|
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
66
|
</div>
|
|
85
67
|
</div>
|
|
86
68
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
3
3
|
import { Check, ChevronDown } from '@lucide/vue'
|
|
4
|
+
import { useDropdownPanel } from '../composables/useDropdownPanel'
|
|
4
5
|
|
|
5
6
|
// Select con buscador (combobox de selección ÚNICA): cerrado ocupa una
|
|
6
7
|
// línea; al desplegar muestra un input de búsqueda y las opciones filtradas
|
|
@@ -8,7 +9,9 @@ import { Check, ChevronDown } from '@lucide/vue'
|
|
|
8
9
|
// TagCombobox de kontuan (multi-etiquetas), del que hereda el teclado
|
|
9
10
|
// (flechas + Enter + Escape) y el cierre por mousedown exterior. El filtrado
|
|
10
11
|
// lo hace el PADRE (evento `search`, con debounce aquí): en cliente o contra
|
|
11
|
-
// el servidor, y con `canLoadMore` pagina.
|
|
12
|
+
// el servidor, y con `canLoadMore` pagina. El desplegable abierto vuela a la
|
|
13
|
+
// top layer (useDropdownPanel): dentro de un modal se superpone sin
|
|
14
|
+
// recortarse ni deformar el modal.
|
|
12
15
|
// Agnóstico de i18n (DC-29): textos por prop, defaults en castellano.
|
|
13
16
|
|
|
14
17
|
export interface SearchSelectOption {
|
|
@@ -47,6 +50,9 @@ const query = ref('')
|
|
|
47
50
|
const highlighted = ref(0)
|
|
48
51
|
const root = ref<HTMLElement | null>(null)
|
|
49
52
|
const input = ref<HTMLInputElement | null>(null)
|
|
53
|
+
const dropdown = ref<HTMLElement | null>(null)
|
|
54
|
+
|
|
55
|
+
useDropdownPanel(root, dropdown, open)
|
|
50
56
|
|
|
51
57
|
const selectedLabel = computed(
|
|
52
58
|
() => props.options.find((o) => o.id === props.modelValue)?.label ?? '',
|
|
@@ -95,6 +101,9 @@ watch(
|
|
|
95
101
|
|
|
96
102
|
function onKeydown(e: KeyboardEvent) {
|
|
97
103
|
if (e.key === 'Escape') {
|
|
104
|
+
// Cierra SOLO el desplegable (que no llegue al listener global de un
|
|
105
|
+
// modal contenedor y lo cierre también).
|
|
106
|
+
e.stopPropagation()
|
|
98
107
|
close()
|
|
99
108
|
return
|
|
100
109
|
}
|
|
@@ -136,7 +145,7 @@ onBeforeUnmount(() => {
|
|
|
136
145
|
<ChevronDown :size="16" class="search-select__chevron" />
|
|
137
146
|
</button>
|
|
138
147
|
|
|
139
|
-
<div v-if="open" class="search-select__dropdown">
|
|
148
|
+
<div v-if="open" ref="dropdown" class="search-select__dropdown" popover="manual">
|
|
140
149
|
<input
|
|
141
150
|
ref="input"
|
|
142
151
|
v-model="query"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { computed, onBeforeUnmount, ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Barra lateral derecha contextual de la web pública, por vista (misma
|
|
5
|
+
* mecánica que el useRightSidebar del admin-kit, patrón kontuan).
|
|
6
|
+
*
|
|
7
|
+
* Estado singleton a nivel de módulo (como useToast/useConfirm: los juegos
|
|
8
|
+
* consumen el paquete como fuente con `optimizeDeps.exclude` para que solo
|
|
9
|
+
* exista una copia):
|
|
10
|
+
* - escritorio: columna junto al contenido cuando la vista actual registra
|
|
11
|
+
* contenido; se puede plegar con `collapsed`.
|
|
12
|
+
* - estrecho: oculta; entra como drawer superpuesto cuando `mobileOpen`.
|
|
13
|
+
*
|
|
14
|
+
* El botón del header (Funnel) llama a `toggle()`, que despliega/pliega según
|
|
15
|
+
* el modo; `overlay` lo mantiene el componente AppRightSidebar con el ancho.
|
|
16
|
+
*
|
|
17
|
+
* La propiedad se rastrea con un token opaco para que el `unregister` de una
|
|
18
|
+
* vista saliente nunca borre el contenido cuando otra vista ya ha registrado
|
|
19
|
+
* el suyo (en un cambio de ruta, el setup nuevo corre antes que el
|
|
20
|
+
* onUnmounted viejo).
|
|
21
|
+
*/
|
|
22
|
+
const hasContent = ref(false)
|
|
23
|
+
const collapsed = ref(false)
|
|
24
|
+
const mobileOpen = ref(false)
|
|
25
|
+
const overlay = ref(false)
|
|
26
|
+
const title = ref<string>('')
|
|
27
|
+
|
|
28
|
+
let ownerToken: symbol | null = null
|
|
29
|
+
|
|
30
|
+
export type AppRightSidebarToken = symbol
|
|
31
|
+
|
|
32
|
+
export function useAppRightSidebar() {
|
|
33
|
+
/** Visible ahora mismo (columna desplegada o drawer abierto). */
|
|
34
|
+
const isOpen = computed(() => (overlay.value ? mobileOpen.value : !collapsed.value))
|
|
35
|
+
|
|
36
|
+
function toggleCollapsed() {
|
|
37
|
+
collapsed.value = !collapsed.value
|
|
38
|
+
}
|
|
39
|
+
function openMobile() {
|
|
40
|
+
mobileOpen.value = true
|
|
41
|
+
}
|
|
42
|
+
function closeMobile() {
|
|
43
|
+
mobileOpen.value = false
|
|
44
|
+
}
|
|
45
|
+
function toggleMobile() {
|
|
46
|
+
mobileOpen.value = !mobileOpen.value
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Abre/cierra según el modo: el botón del header no distingue anchos. */
|
|
50
|
+
function toggle() {
|
|
51
|
+
if (overlay.value) toggleMobile()
|
|
52
|
+
else toggleCollapsed()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Muestra la barra esté donde esté (despliega en ancho, abre en estrecho). */
|
|
56
|
+
function reveal() {
|
|
57
|
+
collapsed.value = false
|
|
58
|
+
mobileOpen.value = true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function register(panelTitle = '', token?: AppRightSidebarToken): AppRightSidebarToken {
|
|
62
|
+
const owned = token ?? Symbol('app-right-sidebar-owner')
|
|
63
|
+
ownerToken = owned
|
|
64
|
+
hasContent.value = true
|
|
65
|
+
title.value = panelTitle
|
|
66
|
+
return owned
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Limpia el contenido solo si quien llama sigue siendo el dueño. */
|
|
70
|
+
function unregister(token?: AppRightSidebarToken) {
|
|
71
|
+
if (token !== undefined && token !== ownerToken) return
|
|
72
|
+
ownerToken = null
|
|
73
|
+
hasContent.value = false
|
|
74
|
+
title.value = ''
|
|
75
|
+
mobileOpen.value = false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function useRegister(panelTitle = ''): AppRightSidebarToken {
|
|
79
|
+
const token = register(panelTitle)
|
|
80
|
+
onBeforeUnmount(() => unregister(token))
|
|
81
|
+
return token
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
hasContent,
|
|
86
|
+
collapsed,
|
|
87
|
+
mobileOpen,
|
|
88
|
+
overlay,
|
|
89
|
+
title,
|
|
90
|
+
isOpen,
|
|
91
|
+
toggleCollapsed,
|
|
92
|
+
openMobile,
|
|
93
|
+
closeMobile,
|
|
94
|
+
toggleMobile,
|
|
95
|
+
toggle,
|
|
96
|
+
reveal,
|
|
97
|
+
register,
|
|
98
|
+
unregister,
|
|
99
|
+
useRegister,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { onBeforeUnmount, watch, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Panel flotante de los dropdowns (BaseSelect, SearchSelect).
|
|
5
|
+
*
|
|
6
|
+
* Con `position: absolute`, dentro de un modal el panel queda recortado por
|
|
7
|
+
* el `overflow` del cuerpo (.modal__body) y además le añade scroll fantasma:
|
|
8
|
+
* el scrollbar cambia el ancho interior y dispara reflows (con container
|
|
9
|
+
* queries, hasta cambia la rejilla). El arreglo: al abrir, el panel se
|
|
10
|
+
* promociona a la TOP LAYER del navegador con el atributo `popover` — se
|
|
11
|
+
* superpone a cualquier overflow/z-index sin tocar el layout — y, como la
|
|
12
|
+
* top layer posiciona respecto al viewport, se ancla por coordenadas fijas
|
|
13
|
+
* al trigger y se reancla en cada scroll/resize (sigue pegado al trigger
|
|
14
|
+
* también al scrollear la página o el propio modal).
|
|
15
|
+
*
|
|
16
|
+
* Sin soporte de popover no hace nada: sigue actuando el CSS `absolute` de
|
|
17
|
+
* siempre (correcto fuera de modales).
|
|
18
|
+
*/
|
|
19
|
+
export function useDropdownPanel(
|
|
20
|
+
anchor: Ref<HTMLElement | null>,
|
|
21
|
+
panel: Ref<HTMLElement | null>,
|
|
22
|
+
open: Ref<boolean>,
|
|
23
|
+
) {
|
|
24
|
+
const GAP = 4 // $space-1: aire entre trigger y panel, como el CSS absolute
|
|
25
|
+
|
|
26
|
+
/** Ancla el panel bajo el trigger (o encima, si abajo no cabe y arriba sí). */
|
|
27
|
+
function place() {
|
|
28
|
+
const a = anchor.value
|
|
29
|
+
const p = panel.value
|
|
30
|
+
if (!a || !p) return
|
|
31
|
+
const r = a.getBoundingClientRect()
|
|
32
|
+
p.style.width = `${r.width}px`
|
|
33
|
+
p.style.left = `${r.left}px`
|
|
34
|
+
const height = p.offsetHeight
|
|
35
|
+
const below = window.innerHeight - r.bottom - GAP
|
|
36
|
+
const openUp = height > below && r.top > window.innerHeight - r.bottom
|
|
37
|
+
p.style.top = openUp ? `${Math.max(r.top - GAP - height, GAP)}px` : `${r.bottom + GAP}px`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function show() {
|
|
41
|
+
const p = panel.value
|
|
42
|
+
if (!p || typeof p.showPopover !== 'function') return
|
|
43
|
+
// Neutraliza los estilos UA de [popover] que el CSS del componente no pisa.
|
|
44
|
+
p.style.position = 'fixed'
|
|
45
|
+
p.style.margin = '0'
|
|
46
|
+
p.style.inset = 'auto'
|
|
47
|
+
if (!p.matches(':popover-open')) p.showPopover()
|
|
48
|
+
place()
|
|
49
|
+
window.addEventListener('scroll', place, { capture: true, passive: true })
|
|
50
|
+
window.addEventListener('resize', place)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hide() {
|
|
54
|
+
window.removeEventListener('scroll', place, true)
|
|
55
|
+
window.removeEventListener('resize', place)
|
|
56
|
+
// El v-if del consumidor desmonta el panel (y eso ya lo saca de la top
|
|
57
|
+
// layer); hidePopover solo si sigue vivo y abierto.
|
|
58
|
+
const p = panel.value
|
|
59
|
+
if (p?.isConnected && p.matches(':popover-open')) p.hidePopover()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// flush post: al abrir, el panel ya está en el DOM cuando se promociona
|
|
63
|
+
// (y antes del nextTick del consumidor: el focus del buscador funciona).
|
|
64
|
+
watch(open, (value) => (value ? show() : hide()), { flush: 'post' })
|
|
65
|
+
onBeforeUnmount(hide)
|
|
66
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -23,9 +23,12 @@ export { default as ImageUpload } from './components/ImageUpload.vue'
|
|
|
23
23
|
export { default as FontUpload } from './components/FontUpload.vue'
|
|
24
24
|
export { default as BaseModal } from './components/BaseModal.vue'
|
|
25
25
|
export { default as EditModal } from './components/EditModal.vue'
|
|
26
|
-
export { default as FiltersModal } from './components/FiltersModal.vue'
|
|
27
26
|
export { default as SortToggles, type SortValue } from './components/SortToggles.vue'
|
|
28
27
|
export { default as IndexToolbar } from './components/IndexToolbar.vue'
|
|
28
|
+
// Barra derecha contextual de la web pública (filtros de los index): App.vue
|
|
29
|
+
// la monta junto al contenido; cada vista la activa con useAppRightSidebar()
|
|
30
|
+
// y Teleport a #app-right-sidebar-target.
|
|
31
|
+
export { default as AppRightSidebar } from './components/AppRightSidebar.vue'
|
|
29
32
|
export { default as BaseTabs } from './components/BaseTabs.vue'
|
|
30
33
|
export { default as BaseToast } from './components/BaseToast.vue'
|
|
31
34
|
export { default as ToastContainer } from './components/ToastContainer.vue'
|
|
@@ -39,6 +42,7 @@ export { useToast, type Toast } from './composables/useToast'
|
|
|
39
42
|
export { useConfirm, type ConfirmOptions } from './composables/useConfirm'
|
|
40
43
|
export { useTheme, type ThemeMode } from './composables/useTheme'
|
|
41
44
|
export { useHead, type HeadInput } from './composables/useHead'
|
|
45
|
+
export { useAppRightSidebar, type AppRightSidebarToken } from './composables/useAppRightSidebar'
|
|
42
46
|
|
|
43
47
|
// Bloques de presentación del CRM (doc 03).
|
|
44
48
|
export * from './blocks'
|
|
@@ -1,21 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
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>
|