Browse documentation

UI / Deep dive

UI collections, focus, and keyboard behavior

Build a changing toolbar and prove identity, registration, focus, and removal behavior.

An editor has three tools: Move, Draw, and Erase. The user tabs into its toolbar, presses Right to inspect Draw, then presses Enter to use it. Later, Erase becomes unavailable and disappears. A good implementation must answer two questions that a row of styled buttons does not answer: did moving focus also change the editor tool, and where does focus go when its current control vanishes?

We will build that interaction in two stages. First, give the toolbar a keyboard location without confusing it with the selected editor tool. Then make the command list change while preserving the relationship between logical identity and actual DOM elements. Read Component first if acquiring local state inside a component is new.

Start with two different facts

The editor’s current tool is application state: it changes what a pointer drag does on the canvas. The toolbar’s active item is interaction state: it tells the next arrow key where to start. A user must be able to navigate the toolbar without changing the canvas tool on every arrow press.

import { RefSubject } from "@typed/fx";
import { html, component } from "@typed/template";
import * as Toolbar from "@typed/ui/Toolbar";

export const DrawingTools = component(function* () {
  const tool = yield* RefSubject.make("move");

  const state = yield* Toolbar.makeState({ activeId: "drawing-move" });
  const collection = yield* Toolbar.makeCollection();

  return html`<section>
    ${Toolbar.Root({ state, collection, label: "Drawing tools", content: [
      Toolbar.Item({ state, collection, id: "drawing-move", content: "Move",
        props: {
          "aria-pressed": RefSubject.map(tool, (value) => value === "move"),
          onclick: RefSubject.set(tool, "move"),
        },
      }),
      Toolbar.Item({ state, collection, id: "drawing-draw", content: "Draw",
        props: {
          "aria-pressed": RefSubject.map(tool, (value) => value === "draw"),
          onclick: RefSubject.set(tool, "draw"),
        },
      }),
    ] })}
    <p>Canvas tool: ${tool}</p>
  </section>`;
});

Render DrawingTools as a component value. Its tool state, toolbar state, and collection share the mounted instance’s lifetime.

Tab into Move and press Right. state.activeId becomes drawing-draw, the actual Draw element receives focus, and tool stays move. Press Enter: the root activates the registered item, which runs its click effect and sets tool to draw. The pressed attributes and the visible text now agree. This separation is why the example does not subscribe to active-ID changes to select tools.

Understand what the collection contributes

There is no array of elements in our application code. Each rendered Toolbar.Item supplies a ref that registers its stable ID and actual DOM element in collection. The root uses that registry to find the next item and focus it. Mounting an item acquires a registration; removing its rendered scope releases that registration. Replacement cleanup is identity-safe: an older registration’s finalizer cannot remove a newer registration with the same ID.

The active ID is a string because it names a logical command. An array index would instead name a position, and a DOM element would tie application state to one render instance. The registry bridges those layers. It reads current DOM order for movement rather than assuming the order in which asynchronous registrations happened is visual order.

In the default horizontal toolbar, Left/Right move through enabled registered items, Home/End move to the bounds, and Enter/Space activate. Only the active item has tabindex zero; other items have minus one. If no active item exists, the root can take the initial tab stop and establish one. Normal operation then uses real focus on the item. The root does not implement Menu’s printable-key typeahead merely because both families use Collection.

Make the commands change without losing their identity

Now allow Erase to be removed from the toolbar. A keyed many gives each command ID one retained rendered range. The collection gives each mounted item its DOM registration. These solve related but different problems: keyed rendering preserves nodes through updates; registration lets keyboard behavior find the nodes that currently exist.

This version repairs active identity before removing Erase, and moves browser focus only if Erase still owns it. That distinction matters: clicking the external Remove button normally focuses that button, and the toolbar should not steal focus back. The editor also switches away from Erase when necessary, because removing a control and removing the underlying capability are one application operation.

import * as Effect from "effect/Effect";
import { RefSubject } from "@typed/fx";
import { component, html, many } from "@typed/template";
import * as Composite from "@typed/ui/Composite";
import * as Toolbar from "@typed/ui/Toolbar";

interface DrawingCommand {
  readonly id: string;
  readonly label: string;
}

export const ChangingDrawingTools = component(function* () {
  const commands = yield* RefSubject.make<ReadonlyArray<DrawingCommand>>([
    { id: "drawing-move", label: "Move" },
    { id: "drawing-draw", label: "Draw" },
    { id: "drawing-erase", label: "Erase" },
  ]);
  const tool = yield* RefSubject.make("drawing-move");

  const state = yield* Toolbar.makeState({ activeId: "drawing-move" });
  const collection = yield* Toolbar.makeCollection();

  const removeErase = Effect.andThen(
    Effect.flatMap(state, ({ activeId }) => activeId === "drawing-erase"
      ? Effect.flatMap(collection, (items) => {
          const erased = items.find((item) => item.id === "drawing-erase")?.element;
          const heldFocus = erased !== undefined && erased.ownerDocument.activeElement === erased;

          return Effect.andThen(
            RefSubject.update(state, (current) => ({ ...current, activeId: "drawing-move" })),
            heldFocus ? Composite.focusActive({ state, collection }) : Effect.void,
          );
        })
      : Effect.void),
    Effect.andThen(
      RefSubject.update(tool, (current) => current === "drawing-erase" ? "drawing-move" : current),
      RefSubject.update(commands, (current) => current.filter((command) => command.id !== "drawing-erase")),
    ),
  );

  return html`<section>
    ${Toolbar.Root({ state, collection, label: "Drawing tools", content: many(
      commands,
      (command) => command.id,
      (command, id) => Toolbar.Item({ state, collection, id,
        content: RefSubject.map(command, (current) => current.label),
        props: {
          "aria-pressed": RefSubject.map(tool, (current) => current === id),
          onclick: RefSubject.set(tool, id),
        },
      }),
    ) })}
    <button type="button" onclick=${removeErase}>Remove Erase tool</button>
    <p>Canvas tool: ${tool}</p>
  </section>`;
});

This example has a known Move command that always survives, so it is an adequate successor policy. A tab strip that can close any tab needs a different policy, often the preceding or following neighbor. An empty list needs an explicit external focus destination. Those are domain decisions; a registry cannot infer them from an unmount notification.

Notice that the removal action is idempotent. Repeating it does not add duplicate items or move focus away from a retained active tool. If a permission update can arrive from outside this component, route it through the same reconciliation operation rather than filtering the rendered array in one place and repairing interaction state somewhere else.

Check the boundary that state cannot prove

A state-only check can confirm the active ID. It cannot prove the ref was registered on the correct node, the browser accepted focus, or a custom host retained the keyboard handlers. After pressing Right in a browser, inspect both state.activeId and document.activeElement.id. In this toolbar they should identify the same command. After an external update removes a focused Erase, Move should receive focus and no registration should point at the detached Erase node. When removal comes from the external button, that button should retain focus instead.

When wrapping an item, keep its supplied ref, role, tabindex, and event props on the element that actually receives focus. A decorative outer div can make the page look correct while registering the wrong node. If arrow state changes but the visual focus indicator stays behind, inspect that boundary before changing the movement algorithm. Disabled commands are skipped by toolbar movement; a custom application click effect still needs to respect the same disabled condition.

Choose the focus assertion for the family

FamilyBrowser focusWhat movement changes
ToolbarActive itemActive command; activation is separate
ListboxActive optionActive option and selected value
ComboboxNative inputActive suggestion via aria-activedescendant
GridGrid rootActive cell via aria-activedescendant

For Combobox and Grid, checking that document.activeElement.id equals the active item’s ID would be the wrong assertion. Check the focused host and its aria-activedescendant instead. See Collection and Composite when implementing a new interaction family.