Nexanexa
Step 18

More @nexa/ui components: Table, Tabs, Dropdown and a reactive search box

Nine purely visual components, and six with real client-side state — without any of the latter needing an island (Step 9). Every example in this chapter is functional exactly as written: each one was tested in a real browser before being written down here.

Table, Badge and Avatar

<div class="nx-table-wrap">
    <table class="nx-table nx-table-zebra">
        <thead><tr><th>Client</th><th>Status</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">Paid</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">Pending</span></td>
            </tr>
        </tbody>
    </table>
</div>

nx-table-wrap adds horizontal scroll on small screens; nx-table-zebra alternates row backgrounds. nx-badge and nx-avatar are CSS-only — none of the three needs JavaScript yet (that comes in the sort/filter section below).

Breadcrumbs, Alert and Divider

<nav class="nx-breadcrumbs" aria-label="breadcrumb">
    <a class="nx-breadcrumbs-link" href="/">Home</a>
    <a class="nx-breadcrumbs-link" href="/orders">Orders</a>
    <span class="nx-breadcrumbs-link" aria-current="page">#1024</span>
</nav>

<div class="nx-alert nx-alert-warning">
    <p class="nx-alert-title">Low stock</p>
    <p>Only 3 units left.</p>
</div>

<hr class="nx-divider" />

nx-breadcrumbs puts the "/" between steps with plain CSS (::after) — no need to write it by hand. aria-current="page" marks the current step. nx-alert has three color variants (success/warning/danger) via the left border.

Tooltip, Progress and Skeleton

<span class="nx-tooltip">
    Hover here
    <span class="nx-tooltip-content">Extra info, no 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 shows on :hover/:focus-within — keyboard accessible, zero lines of JS. nx-progress-bar needs its width set by hand (fixed, or dynamic with data.*). nx-skeleton is an animated placeholder while something loads.

Tabs, no island needed

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) reads which button was clicked (event.currentTarget) and its data-tab, finds the nearest .nx-tab-group, and does the rest — no need to pass it any element by hand. The same one-line selectTab function works for every button in every tab group on the page.

Accordion, with an optional exclusive mode

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>

By default several items can be open at once. With data-exclusive="true" on the containing .nx-accordion, opening one closes the rest — useful for an FAQ where only one answer makes sense to read at a time.

Dropdown, closed by an outside click or 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>

Same as Dialog (Step 7), but without trapping focus — a dropdown doesn't block the rest of the page. Picking an option updates the trigger's text, marks aria-selected on the chosen option, and closes the menu on its own.

A search box inside a table (and sorting it by column)

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) hides tbody rows whose text doesn't match — this is the "search inside the table" piece. ui.sortTable(event) sorts by the clicked <th>'s column, toggling ascending/descending, comparing as numbers when both cells are numeric (10 before 20, not "10" before "2" by text order). Both run client-side, over rows the server already rendered — no re-fetch, and no island.

A full page-wide search box, as a reactive component

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) filters any data-searchable element inside container, with a real Signal from @nexa/reactivity underneath (the same Step 4 of this tutorial) — not a simulation. One real gotcha: the chunk Nexa extracts from onSearchInput is an isolated module, so it can't depend on a variable declared elsewhere in the page (a file-level let doesn't travel with the chunk). That's why the controller is hung off the <input> itself (input.pageSearch), which does persist between one keystroke and the next because it's the same DOM element.

Island, or a ui function? How to decide

If the state is local to one concrete widget and can be modeled as "directly manipulate the DOM that's already on the page" (open/close, mark an active tab, sort/filter already-rendered rows), it's a ui function + onClick/onInput — no Virtual DOM, no re-render, the usual progressive activation. Reserve an island (Step 9) for when you genuinely need a full framework with its own lifecycle (Vue, React) or real composition with deep props/children — never as the default choice for "this button does something". The six components in this chapter (Tabs, Accordion, Dropdown, Table sort/filter, and the page search box) are proof you can go quite far without opening that door.