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:
Lizandro Guarnizo
2026-08-15 20:43:11 -05:00
co-authored by Claude Sonnet 5
parent 55f28f5c06
commit c6266d9f69
+83 -7
View File
@@ -1,7 +1,62 @@
<div x-data="{
isSidebarOpen: false,
currentPath: window.location.pathname
}" class="flex min-h-screen">
<script>
// 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 o un texto con
// 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 -->
<div class="md:hidden fixed top-3 left-3 z-50">
@@ -53,11 +108,32 @@
</button>
</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">&times;</button>
</div>
</div>
<!-- Navigation -->
<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">
{{range $moduleName, $submodules := .modules}}
<li x-data="{ showSubMenu: true }">
<li x-data="{ showSubMenu: true }" data-modulo-menu="{{ $moduleName }}">
<!-- Module header -->
<button @click="showSubMenu = !showSubMenu"
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>
<!-- Submodule links -->
<ul x-show="showSubMenu"
<ul x-show="showSubMenu || busqueda.trim() !== ''"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
class="mt-0.5 ml-3 pl-3 space-y-0.5"
style="border-left:1px solid rgba(255,255,255,0.07)">
{{range $submodule := $submodules}}
<li>
<li data-item-menu data-titulo="{{ $submodule.title }}">
<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="currentPath === '{{ $submodule.url }}'