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="{
|
||||
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">×</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 }}'
|
||||
|
||||
Reference in New Issue
Block a user