Nexanexa
Paso 18

Más componentes de @nexa/ui: Table, Tabs, Dropdown y un buscador reactivo

Nueve componentes puramente visuales, y seis con estado real en cliente — sin que ninguno de estos últimos necesite una isla (Paso 9). Todos los ejemplos de este capítulo son funcionales tal cual están escritos: cada uno se probó en un navegador real antes de escribirse acá.

Table, Badge y Avatar

<div class="nx-table-wrap">
    <table class="nx-table nx-table-zebra">
        <thead><tr><th>Cliente</th><th>Estado</th></tr></thead>
        <tbody>
            <tr>
                <td><span class="nx-avatar nx-avatar-sm">HM</span> Hector M.</td>
                <td><span class="nx-badge nx-badge-success">Pagado</span></td>
            </tr>
            <tr>
                <td><span class="nx-avatar nx-avatar-sm">AR</span> Ana R.</td>
                <td><span class="nx-badge nx-badge-warning">Pendiente</span></td>
            </tr>
        </tbody>
    </table>
</div>

nx-table-wrap agrega scroll horizontal en pantallas chicas; nx-table-zebra alterna el fondo de las filas. nx-badge y nx-avatar son solo CSS — ninguno de los tres necesita JavaScript todavía (eso viene en la sección de ordenar/filtrar, más abajo).

Breadcrumbs, Alert y Divider

<nav class="nx-breadcrumbs" aria-label="breadcrumb">
    <a class="nx-breadcrumbs-link" href="/">Inicio</a>
    <a class="nx-breadcrumbs-link" href="/pedidos">Pedidos</a>
    <span class="nx-breadcrumbs-link" aria-current="page">#1024</span>
</nav>

<div class="nx-alert nx-alert-warning">
    <p class="nx-alert-title">Stock bajo</p>
    <p>Quedan 3 unidades.</p>
</div>

<hr class="nx-divider" />

nx-breadcrumbs pone el "/" entre pasos solo con CSS (::after) — no hace falta escribirlo a mano. aria-current="page" marca cuál es el paso actual. nx-alert tiene tres variantes de color (success/warning/danger) vía el borde izquierdo.

Tooltip, Progress y Skeleton

<span class="nx-tooltip">
    Pasá el mouse acá
    <span class="nx-tooltip-content">Info extra, sin JavaScript</span>
</span>

<div class="nx-progress">
    <div class="nx-progress-bar" style={`width: ${data.percent}%`}></div>
</div>

<div class="nx-skeleton" style="width: 200px; height: 1rem;"></div>

nx-tooltip-content se muestra con :hover/:focus-within — accesible con teclado, sin una línea de JS. nx-progress-bar necesita su width puesto a mano (fijo, o dinámico con data.*). nx-skeleton es un placeholder animado mientras algo carga.

Tabs, sin isla

function selectTab(event) {
    ui.selectTab(event);
}

<div class="nx-tab-group">
    <div class="nx-tab-list" role="tablist">
        <button class="nx-tab" data-tab="uno" aria-selected="true" onClick={selectTab}>Uno</button>
        <button class="nx-tab" data-tab="dos" onClick={selectTab}>Dos</button>
    </div>
    <div class="nx-tab-panel" data-tab="uno">Contenido uno</div>
    <div class="nx-tab-panel" data-tab="dos" hidden>Contenido dos</div>
</div>

ui.selectTab(event) lee de qué botón vino el click (event.currentTarget) y su data-tab, encuentra el .nx-tab-group más cercano, y hace el resto — no hace falta pasarle ningún elemento a mano. La misma función selectTab de una línea sirve para todos los botones de todos los grupos de pestañas de la página.

Accordion, con modo exclusivo opcional

function toggle(event) {
    ui.toggleAccordionItem(event);
}

<div class="nx-accordion" data-exclusive="true">
    <div class="nx-accordion-item">
        <button class="nx-accordion-trigger" aria-expanded="false" onClick={toggle}>¿Envían a todo el país?</button>
        <div class="nx-accordion-panel" hidden>Sí, a todo el país.</div>
    </div>
</div>

Por defecto varios items pueden estar abiertos a la vez. Con data-exclusive="true" en el .nx-accordion contenedor, abrir uno cierra los demás — útil para un FAQ donde solo tiene sentido leer una respuesta a la vez.

Dropdown, cerrado con click afuera o Escape

function toggleMenu(event) {
    ui.toggleDropdown(event);
}
function pickOption(event) {
    ui.selectDropdownOption(event);
}

<div class="nx-dropdown">
    <button class="nx-dropdown-trigger" aria-haspopup="listbox" aria-expanded="false" onClick={toggleMenu}>
        Elegir talle…
    </button>
    <ul class="nx-dropdown-menu" role="listbox" hidden>
        <li class="nx-dropdown-option" role="option" data-value="s" onClick={pickOption}>S</li>
        <li class="nx-dropdown-option" role="option" data-value="m" onClick={pickOption}>M</li>
        <li class="nx-dropdown-option" role="option" data-value="l" onClick={pickOption}>L</li>
    </ul>
</div>

Igual que Dialog (Paso 7), pero sin atrapar el foco — un dropdown no bloquea el resto de la página. Elegir una opción actualiza el texto del trigger, marca aria-selected en la opción elegida, y cierra el menú solo.

Un buscador dentro de una tabla (y ordenarla por columna)

function filterOrders(event) {
    ui.filterTable(document.getElementById("orders"), event.target.value);
}
function sortByColumn(event) {
    ui.sortTable(event);
}

<input type="search" onInput={filterOrders} placeholder="Buscar pedido…" />
<table id="orders" class="nx-table">
    <thead>
        <tr>
            <th data-sortable onClick={sortByColumn}>Cliente</th>
            <th data-sortable onClick={sortByColumn}>Total</th>
        </tr>
    </thead>
    <tbody>
        <tr><td>Beta</td><td>30</td></tr>
        <tr><td>Alfa</td><td>10</td></tr>
        <tr><td>Gama</td><td>20</td></tr>
    </tbody>
</table>

ui.filterTable(table, query) oculta filas de tbody cuyo texto no matchea — es el buscador "dentro de la tabla". ui.sortTable(event) ordena por la columna del <th> clickeado, alternando ascendente/descendente, y compara como número si ambas celdas lo son (10 antes que 20, no "10" antes que "2" por orden de texto). Las dos operan client-side, sobre filas que el servidor ya renderizó — sin volver a pedir nada al backend, y sin ninguna isla.

Un buscador de página completa, como componente reactivo

function onSearchInput(event) {
    const input = event.currentTarget;
    input.pageSearch ??= ui.createPageSearch(document.getElementById("catalogo"));
    input.pageSearch.setQuery(event.target.value);
}

<input type="search" onInput={onSearchInput} placeholder="Buscar en la página…" />
<div id="catalogo">
    <article data-searchable>Zapatillas Nezha</article>
    <article data-searchable>Camiseta Jade</article>
    <article data-searchable>Gorra roja</article>
</div>

ui.createPageSearch(container) filtra cualquier elemento data-searchable dentro de container, con un Signal real de @nexa/reactivity por debajo (el mismo Paso 4 de este tutorial) — no una simulación. Ojo con un detalle real: el chunk que Nexa extrae de onSearchInput es un módulo aislado, así que no puede depender de una variable declarada aparte en la página (un let a nivel de archivo no viaja con el chunk). Por eso el controlador se cuelga del propio <input> (input.pageSearch), que sí persiste entre una tecleada y la siguiente porque es el mismo elemento del DOM.

¿Isla, o función de ui? Cómo decidir

Si el estado es local a un widget concreto y se puede modelar como "manipular directamente el DOM que ya está en la página" (abrir/cerrar, marcar una pestaña activa, ordenar/filtrar filas ya renderizadas), es una función de ui + onClick/onInput — sin Virtual DOM, sin re-render, activación progresiva de siempre. Una isla (Paso 9) queda reservada para cuando de verdad hace falta un framework completo con su propio ciclo de vida (Vue, React) o composición real con props/children profundos — nunca como opción por defecto para "este botón hace algo". Los seis componentes de este capítulo (Tabs, Accordion, Dropdown, orden/filtro de Table, y el buscador de página) son la prueba de que se puede llegar bastante lejos sin abrir esa puerta.