Skip to content
Nana UI

Guides

Listbox Rules

The filtering, keyboard and panel rules behind Select and Combobox, published as a module you can build your own view on.

View .md Download

Select and Combobox agree about a great deal. An arrow key moves the highlight and wraps at the ends. Enter takes the highlighted option. Typing opens the panel and starts the search again. A panel with nothing in it says whether it filtered everything out or never had anything to begin with.

Those rules do not live in either component. They live in one module in the headless layer that both of them — and the React and Svelte builds, which share no code with either — read from. It is exported, so your own views can read it too.

npm install @nana-tec/ui-headless
import { project, reduce, keyToAction } from "@nana-tec/ui-headless/listbox";

Nothing in the module touches the DOM or a framework. It takes plain data and returns plain data; painting, focus and events stay with whoever is drawing.

project works out the whole panel from the state and the options: what is hidden, what reads as selected, what can be moved to, and what an empty panel should say.

const view = project(
{ open: true, query: "ug", filtering: true, selectedValues: ["ke"] },
[
{ value: "ke", label: "Kenya" },
{ value: "ug", label: "Uganda" },
{ value: "rw", label: "Rwanda", disabled: true },
],
);
view.hidden;
view.navigable;
view.shown;
view.emptyText;

shown and navigable are counted separately on purpose. A disabled option is still on screen, so a query that only matches one is not an empty panel — it is a panel you cannot pick from.

filtering is yours to decide, because the two components disagree. A combobox types into the field that also displays its value, so it ignores a query that still reads as the selected label — isFiltering(open, query, selectedLabel) answers that. A select searches in a box of its own and filters on anything typed there.

keyToAction says what a key means, or null when the key is none of the listbox’s business and should be left to the page.

keyToAction("ArrowDown", { open: false, activeIndex: -1 }, 3);
keyToAction("Tab", { open: true, activeIndex: 0 }, 3);

consumesKey is the listbox saying whether it takes the key for itself. Tab is the one that does not: it closes the panel and still moves focus onwards.

reduce moves the state on. It is pure, and it never touches the state you hand it.

reduce({ open: false, activeIndex: -1, query: "" }, { type: "query", value: "ug" }, 3);

Events are open, close, move, moveTo, query and select. Closing and selecting drop the highlight but leave the query alone — whether the field snaps back to the selected label is a decision about value, and that stays with your view.

optionId(baseId, index) and activeDescendantId(baseId, activeItem) give every view one id scheme, so aria-activedescendant names an element that exists.

const view = project({ open, query, filtering, selectedValues: [value] }, options);
function onKeyDown(event) {
const result = keyToAction(event.key, { open, activeIndex }, view.navigable.length);
if (!result) return;
if (result.consumesKey) event.preventDefault();
if (result.action.type === "select") choose(view.navigable[result.action.index]);
else setState((state) => reduce(state, result.action, view.navigable.length));
}

That is the whole contract. The library’s own components are the same handful of calls plus their markup — the web components add scrollIntoView and the shadow DOM, React adds its own JSX, and neither adds a rule.

Focus management, click-outside, panel positioning and form participation stay in the components. They cannot be expressed without an element, and a rules module that takes HTMLElement arguments is no longer a rules module.

If you install with the CLI rather than the package, the rules come with the component:

npx @nana-tec/ui add combobox

lands combobox and utils/listbox.ts in your project, on Web Components and React alike. Edit the view all you like; the behaviour underneath is the same file this page documents.