@edc-motor/ui 0.4.8 → 0.4.9

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.8",
3
+ "version": "0.4.9",
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 debajo toggles + botón se reparten el ancho.
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
- &__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.
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%; }
@@ -8,7 +8,7 @@
8
8
  @use "search-select";
9
9
  @use "sort-toggles";
10
10
  @use "index-toolbar";
11
- @use "filters-modal";
11
+ @use "app-right-sidebar";
12
12
  @use "blocks";
13
13
  @use "block-related";
14
14
  @use "preview-grid";
@@ -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 v-if="open" :id="panelId" class="base-select__panel" role="listbox">
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 { Funnel, Search } from '@lucide/vue'
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), 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.
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>