Skip to content
UniAria
Color scheme
Contrast
Color scheme
Contrast

Collections

Lists in UniAria are data: an array of items plus functions that read each item’s key, text and disabled state. Listbox, Select and Combobox build on the two entries on this page, and so can your own definitions.

  • @uniaria/core/collections turns items into a list with the lookups keyboard navigation and selection need, and implements typeahead. It has no DOM code, so it runs anywhere.
  • @uniaria/core/virtual is the engine a virtualized list takes. Only lists that virtualize import it.

For using collections in components (items, keys, sections, disabled keys), see Collections and Listbox.

List collections

createListCollection<Item>(
  items: readonly Item[],
  accessors: ListAccessors<Item>,
  disabledKeys?: Iterable<string>,
): ListCollection<Item>

Flattens items once into options in display order. An item with children is a section: its children are options, and a section is never an option itself. Sections don’t nest. Keys map to indexes, so moving from a key costs only the disabled options it skips, never a search.

import { createListCollection } from '@uniaria/core/collections';

interface Fruit {
  id: string;
  name: string;
  isSoldOut?: boolean;
  children?: Fruit[];
}

const fruit = createListCollection<Fruit>(
  [
    { id: 'apple', name: 'Apple' },
    {
      id: 'citrus',
      name: 'Citrus',
      children: [
        { id: 'lemon', name: 'Lemon', isSoldOut: true },
        { id: 'lime', name: 'Lime' },
      ],
    },
  ],
  {
    getKey: (item) => item.id,
    getTextValue: (item) => item.name,
    isDisabled: (item) => item.isSoldOut === true,
    getChildren: (item) => item.children,
  },
);

fruit.keys; // ['apple', 'lemon', 'lime']
fruit.next('apple'); // 'lime': lemon is disabled
fruit.get('lime')?.section; // 'citrus'

Accessors

Accessor Description
getKey(item) The item’s key, a string unique in the collection.
getTextValue(item) Its plain text: typeahead matches it, and Select and Combobox show it as the value.
isDisabled(item) Whether the option can’t be focused or selected.
getChildren(item) A section’s items, or undefined for an option.

disabledKeys disables options by key on top of isDisabled. A key that appears twice keeps its first item; the rest are dropped and listed in duplicates.

ListCollection

Member Description
options Every option in display order, sections flattened: { key, textValue, isDisabled, item, index, section }.
sections Every section: { key, item, keys }, keys being its options’ keys in order.
keys Every option key in display order.
duplicates Keys that appeared more than once.
get(key), section(key) An option or a section by key, or undefined.
isEnabled(key) The option exists and isn’t disabled.
first(), last() The first or last enabled option, or null.
next(key, isWrapping?) The enabled option after key: null past the last, or the first with isWrapping. From null or an unknown key, the first.
previous(key, isWrapping?) The enabled option before key, the same way.
range(a, b) The enabled options from a to b, both included, in display order, for Shift-selection.
enabledKeys() Every enabled option, in display order.

Typeahead

Typing moves focus to the first item whose text starts with what was typed, as in a native <select>.

createTypeahead(): Typeahead
findByText<Key>(query: string, options: FindByTextOptions<Key>): Key | null

createTypeahead() keeps the query. handleKey(input, search) adds a printable key to it and asks search for the key to focus; it returns { isHandled, key }, where isHandled means the key joined the query and the caller should call preventDefault(). It never moves focus or changes selection itself.

import { createTypeahead, findByText } from '@uniaria/core/collections';

const typeahead = createTypeahead();

/** The key to focus for a keydown, or `null`. */
function keyForTyping(event: KeyboardEvent, focusedKey: string | null) {
  const { isHandled, key } = typeahead.handleKey(event, (query) =>
    findByText(query, {
      keys: fruit.keys,
      getTextValue: (key) => fruit.get(key)!.textValue,
      isDisabled: (key) => !fruit.isEnabled(key),
      fromKey: focusedKey,
    }),
  );
  if (isHandled) event.preventDefault();
  return key;
}
  • The query lasts until a second passes without a keystroke, matched or not, measured with the events’ own timeStamp, so there are no timers. A miss keeps the query, so a typo leaves focus where it is.
  • Space joins the query only while one is in progress, so “New York” can be typed; otherwise it keeps its meaning (selection). isSearching(timeStamp) tells a handler that sees Space first whether to leave it to typeahead. reset() forgets the query.
  • Shortcuts don’t type: a key with Ctrl, Meta or Alt isn’t handled.
  • handleKey reads only key, ctrlKey, metaKey, altKey and timeStamp, so a DOM KeyboardEvent fits as is.

findByText(query, options) returns the first key whose text starts with the query, from fromKey (inclusive) to the end, then from the top. Starting at the focused item keeps focus there while its text still matches, so typing the same letter twice extends the query instead of jumping.

Option Description
keys Every key, in display order.
getTextValue(key) A key’s text. Items without text never match.
isDisabled?(key) Keys typeahead never lands on.
fromKey? Where the search starts: the focused key.
collator? Compares the start of each text with the query. Defaults to Intl.Collator(undefined, { usage: 'search', sensitivity: 'base' }), case- and accent-insensitive; pass one for the component’s locale.

Virtualization

A virtualized list renders only the options in and near its scrolled view: a few dozen elements stand in for thousands of items. Listbox, Select and Combobox virtualize when you pass an engine as their virtualizer prop:

import { virtualizer } from '@uniaria/core/virtual';

virtualizer wraps TanStack Virtual’s @tanstack/virtual-core, bundled into this entry at one pinned version, so there is nothing else to install and a list that doesn’t virtualize never loads it. Nothing about the list changes for the user: keys, selection, typeahead, focus and what screen readers say stay the same, and moving to an option that isn’t rendered renders it, scrolls it into view and focuses it.

How a Listbox uses it

With a virtualizer, the list keeps a rendered set: the options in the view, overscan more beyond each edge, and a few it pins, such as the focused option. You render only that set, inside a scroller part, and UniAria positions each row.

Listbox API Description
virtualizer prop The engine. Property only on the element.
itemSize prop A row’s size in pixels along the list’s axis, or a function of the key. Exact unless shouldMeasureItems; unset, rows are measured and estimated at 32. Attribute item-size for a number.
shouldMeasureItems prop itemSize is an estimate and each rendered row is measured. Attribute measure-items.
overscan prop Options rendered beyond each edge of the view. Default 10.
initialItemCount prop Options rendered before the scroller has a size (on the server, in a closed popover). Default 20.
scroller part The element that scrolls, around the list. Give it a height (or a max height) and overflow: auto.
renderedKeys, renderedSections state The options and the sections to render, in list order. Each array is new only when its contents change.
renderedKeysChange event The rendered set changed: { keys, sections }. renderedkeyschange on the element.

While virtualized, UniAria writes position: relative and the total size on the list, position: absolute with a translate on each row (so your own transform still composes), overflow-anchor: none on the scroller, and aria-posinset and aria-setsize on each option, since not every option is in the DOM. The root, scroller and list get data-virtualized, and the scroller tabindex="-1", so a box that scrolls isn’t a tab stop of its own. Width is yours: give options inset-inline-end: 0 or inline-size: 100%, and put gaps inside the row size, since absolutely positioned rows ignore margins.

In React, read renderedKeys in a child component, so a change of the rendered set renders only the rows:

import { listbox } from '@uniaria/core/listbox';
import { virtualizer } from '@uniaria/core/virtual';
import type { BehaviorHandle } from '@uniaria/react';
import { useBehavior, useBehaviorState } from '@uniaria/react';

const zones = Intl.supportedValuesOf('timeZone').map((zone) => ({
  key: zone,
  textValue: zone,
}));

export function TimeZones() {
  const ui = useBehavior(listbox, { items: zones, virtualizer, itemSize: 32 });
  return (
    <div {...ui.parts.root}>
      <span {...ui.parts.label}>Time zone</span>
      <div {...ui.parts.scroller} className="scroller">
        <div {...ui.parts.list}>
          <Rows ui={ui} />
        </div>
      </div>
    </div>
  );
}

function Rows({ ui }: { ui: BehaviorHandle<typeof listbox> }) {
  const keys = useBehaviorState(ui, (state) => state.renderedKeys);
  return keys.map((key) => (
    <div key={key} {...ui.getItemProps('option', key)}>
      {key}
    </div>
  ));
}
.scroller {
  block-size: 240px;
  overflow: auto;
}

.scroller [data-element='Option'] {
  inset-inline-end: 0;
  block-size: 32px;
}

A custom element takes items as a property, since the markup can’t hold items that aren’t rendered. Render the rows from renderedKeys, and again on each renderedkeyschange, synchronously. Keep the element of a row that stays: removing the focused option’s element would drop focus.

<uni-listbox id="zones" item-size="32">
  <span data-element="Label">Time zone</span>
  <div data-element="Scroller" class="scroller">
    <div data-element="List"></div>
  </div>
</uni-listbox>

<script type="module">
  import '@uniaria/elements/listbox';
  import { virtualizer } from '@uniaria/core/virtual';

  const host = document.querySelector('#zones');
  const list = host.querySelector('[data-element="List"]');

  function render(keys) {
    const wanted = new Set(keys);
    for (const row of [...list.children]) {
      if (!wanted.has(row.dataset.key)) row.remove();
    }
    let next = list.firstElementChild;
    for (const key of keys) {
      if (next?.dataset.key === key) {
        next = next.nextElementSibling;
        continue;
      }
      const row = document.createElement('div');
      row.dataset.element = 'Option';
      row.dataset.key = key;
      row.textContent = key;
      list.insertBefore(row, next);
    }
  }

  host.addEventListener('renderedkeyschange', (event) => {
    if (event.target === host) render(event.detail.keys);
  });
  host.virtualizer = virtualizer;
  host.items = Intl.supportedValuesOf('timeZone').map((zone) => ({
    key: zone,
    textValue: zone,
  }));
  render(host.instance.getState().renderedKeys);
</script>

In development, UniAria warns once when the scroller part is missing, when a pinned option in renderedKeys has no element, and, with an exact itemSize, when a row’s measured size differs from it.

The Virtualizer interface

The virtualizer prop takes any object with this shape, so another engine can stand in for TanStack Virtual. The types come from @uniaria/core/virtual (and @uniaria/core/dom).

interface Virtualizer {
  create(options: VirtualRowsOptions): VirtualRows;
}
VirtualRowsOptions Description
scroller The element that scrolls.
count How many rows. A row is an option or a section’s label row.
keyOf(index) A row’s key: measured sizes stay with it when the rows change.
sizeOf(index) A row’s size along the axis: exact, or an estimate until measured.
isHorizontal, isRtl The list’s axis and direction.
overscan Rows kept beyond each edge of the view.
onChange() Call it when the rows in view, the view’s size or a row’s size change.
VirtualRows Description
update(options) New rows, sizes or overscan.
viewSize() The view’s length along the axis; 0 until the scroller has a size.
range() The first and last row in view with the overscan, both included, or null while the view has no size.
start(index), size(index), total() Where a row starts, its size, and the list’s length.
resize(index, size) A row’s measured size. A row above the view keeps the first visible row in place.
scrollTo(index) Scrolls a row into view, to the nearest edge, inside the scroller’s scroll-padding; once the view has a size, if it has none yet.
destroy() Stops observing the scroller.