feat(menu): buscador en la barra lateral
Con ~40 submódulos repartidos en una docena de módulos, encontrar una
pantalla era ir abriendo y cerrando secciones.
El buscador filtra por título de submódulo y también por nombre de módulo
(quien escribe "integraciones" espera ver todo lo que cuelga de ahí). La
comparación normaliza acentos y mayúsculas: nadie escribe "Documentación"
con tilde cuando busca. Los módulos sin coincidencias se ocultan, Enter
entra al primer resultado y Esc limpia.
Dos decisiones que salieron de verlo fallar:
- La lógica va en un <script> y no dentro de x-data="{...}". El atributo se
corta con la primera comilla doble, así que un comentario con comillas
rompía la barra entera de forma silenciosa.
- El filtrado es una pasada imperativa sobre el DOM en vez de una condición
por elemento. Las condiciones dependían de una lista armada al iniciar y
no se re-evaluaban al escribir: el menú se filtraba a medias. Recorrer
unas decenas de nodos por tecleo no se nota y no deja lugar a esas
sutilezas.
Tampoco se interpola el título dentro de las expresiones: va en data-, que
Go sí escapa bien. Verificado con títulos que traen comillas y ampersand,
que con la versión anterior rompían el binding.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
55f28f5c06
commit
c6266d9f69
@@ -1,7 +1,62 @@
|
|||||||
<div x-data="{
|
<script>
|
||||||
isSidebarOpen: false,
|
// La lógica va en un script y no dentro de x-data="{...}": el atributo se
|
||||||
currentPath: window.location.pathname
|
// corta con la primera comilla doble, así que un comentario o un texto con
|
||||||
}" class="flex min-h-screen">
|
// comillas rompía la barra entera de forma silenciosa.
|
||||||
|
function menuLateral() {
|
||||||
|
return {
|
||||||
|
isSidebarOpen: false,
|
||||||
|
currentPath: window.location.pathname,
|
||||||
|
busqueda: '',
|
||||||
|
|
||||||
|
// Se compara sin acentos ni mayúsculas: nadie escribe "Documentación"
|
||||||
|
// con tilde cuando está buscando.
|
||||||
|
normalizar(t) {
|
||||||
|
return (t || '').toString().toLowerCase()
|
||||||
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Recorrido explícito en vez de una condición por elemento: es trivial
|
||||||
|
// para unas decenas de ítems y evita depender de cuándo re-evalúa cada
|
||||||
|
// binding.
|
||||||
|
filtrarMenu() {
|
||||||
|
const q = this.normalizar(this.busqueda.trim());
|
||||||
|
let visibles = 0;
|
||||||
|
|
||||||
|
this.$el.querySelectorAll('[data-modulo-menu]').forEach(mod => {
|
||||||
|
let delModulo = 0;
|
||||||
|
const nombreModulo = this.normalizar(mod.dataset.moduloMenu);
|
||||||
|
|
||||||
|
mod.querySelectorAll('[data-item-menu]').forEach(item => {
|
||||||
|
// Coincide por el título del ítem o por el del módulo:
|
||||||
|
// quien busca por el nombre del módulo espera ver todo lo
|
||||||
|
// que cuelga de él.
|
||||||
|
const ok = !q ||
|
||||||
|
this.normalizar(item.dataset.titulo).includes(q) ||
|
||||||
|
nombreModulo.includes(q);
|
||||||
|
item.hidden = !ok;
|
||||||
|
if (ok) delModulo++;
|
||||||
|
});
|
||||||
|
|
||||||
|
mod.hidden = delModulo === 0;
|
||||||
|
visibles += delModulo;
|
||||||
|
});
|
||||||
|
|
||||||
|
// El aviso se conmuta acá mismo, como el resto: así toda la
|
||||||
|
// función es una sola pasada imperativa y no depende de cuándo
|
||||||
|
// Alpine decide aplicar un binding.
|
||||||
|
const aviso = this.$el.querySelector('[data-aviso-menu]');
|
||||||
|
if (aviso) aviso.hidden = !(q !== '' && visibles === 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
limpiar() {
|
||||||
|
this.busqueda = '';
|
||||||
|
this.filtrarMenu();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div x-data="menuLateral()" class="flex min-h-screen">
|
||||||
|
|
||||||
<!-- Mobile menu button -->
|
<!-- Mobile menu button -->
|
||||||
<div class="md:hidden fixed top-3 left-3 z-50">
|
<div class="md:hidden fixed top-3 left-3 z-50">
|
||||||
@@ -53,11 +108,32 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Buscador -->
|
||||||
|
<div class="px-3 pt-3 pb-1">
|
||||||
|
<div class="relative">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35M17 11a6 6 0 11-12 0 6 6 0 0112 0z"/>
|
||||||
|
</svg>
|
||||||
|
<input x-model="busqueda" x-ref="buscador" type="text" placeholder="Buscar en el menú..."
|
||||||
|
@input="filtrarMenu()"
|
||||||
|
@keydown.escape="limpiar()"
|
||||||
|
@keydown.enter="$root.querySelector('[data-item-menu]:not([hidden]) a')?.click()"
|
||||||
|
class="w-full bg-white/5 text-white text-xs rounded-lg pl-8 pr-7 py-2 placeholder-slate-500 outline-none focus:bg-white/10 transition-colors"
|
||||||
|
style="border:1px solid rgba(255,255,255,0.08)">
|
||||||
|
<button x-show="busqueda" @click="limpiar(); $refs.buscador.focus()" x-cloak
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white text-sm leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav class="flex-1 overflow-y-auto sidebar-scroll px-3 py-3">
|
<nav class="flex-1 overflow-y-auto sidebar-scroll px-3 py-3">
|
||||||
|
<p data-aviso-menu hidden class="px-3 py-6 text-center text-xs text-slate-500">
|
||||||
|
Nada coincide con <span class="text-slate-300" x-text="busqueda"></span>
|
||||||
|
</p>
|
||||||
<ul class="space-y-0.5">
|
<ul class="space-y-0.5">
|
||||||
{{range $moduleName, $submodules := .modules}}
|
{{range $moduleName, $submodules := .modules}}
|
||||||
<li x-data="{ showSubMenu: true }">
|
<li x-data="{ showSubMenu: true }" data-modulo-menu="{{ $moduleName }}">
|
||||||
<!-- Module header -->
|
<!-- Module header -->
|
||||||
<button @click="showSubMenu = !showSubMenu"
|
<button @click="showSubMenu = !showSubMenu"
|
||||||
class="w-full flex items-center justify-between px-3 py-2.5 rounded-xl transition-all duration-150 group"
|
class="w-full flex items-center justify-between px-3 py-2.5 rounded-xl transition-all duration-150 group"
|
||||||
@@ -77,14 +153,14 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Submodule links -->
|
<!-- Submodule links -->
|
||||||
<ul x-show="showSubMenu"
|
<ul x-show="showSubMenu || busqueda.trim() !== ''"
|
||||||
x-transition:enter="transition ease-out duration-150"
|
x-transition:enter="transition ease-out duration-150"
|
||||||
x-transition:enter-start="opacity-0 -translate-y-1"
|
x-transition:enter-start="opacity-0 -translate-y-1"
|
||||||
x-transition:enter-end="opacity-100 translate-y-0"
|
x-transition:enter-end="opacity-100 translate-y-0"
|
||||||
class="mt-0.5 ml-3 pl-3 space-y-0.5"
|
class="mt-0.5 ml-3 pl-3 space-y-0.5"
|
||||||
style="border-left:1px solid rgba(255,255,255,0.07)">
|
style="border-left:1px solid rgba(255,255,255,0.07)">
|
||||||
{{range $submodule := $submodules}}
|
{{range $submodule := $submodules}}
|
||||||
<li>
|
<li data-item-menu data-titulo="{{ $submodule.title }}">
|
||||||
<a href="{{ $submodule.url }}"
|
<a href="{{ $submodule.url }}"
|
||||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium transition-all duration-150"
|
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium transition-all duration-150"
|
||||||
:class="currentPath === '{{ $submodule.url }}'
|
:class="currentPath === '{{ $submodule.url }}'
|
||||||
|
|||||||
Reference in New Issue
Block a user