forty-cdk
llms.txt

Primitives

Listbox

A scrollable list of selectable options with roving-tabindex navigation, single or multi selection.

forty-cdk/listbox WAI-ARIA APG

Move the highlight with the arrow keys and select with Space. One tab stop serves the whole list, and the selected option carries data-state.

It also supports typeahead and FormValueControl<readonly T[]> integration. [forListbox] is generic over the option value type T (default string). Bind primitive ids for the simple case or full objects for richer models. The directive infers T from [(value)] and [forListboxOption][value]. See Object values for the object-mode contract.

When to choose

  • Listbox: an in-page list of options with roving tabindex and typeahead. No trigger, no overlay, no text field: the options are always visible.
  • Select: the same option semantics behind a trigger that opens a portaled popup. Choose it when the list should stay collapsed until asked for.
  • Combobox: a popup driven by an editable input, so the user narrows the list by typing.
  • Dropdown Menu: for commands rather than a value. Menu items run an action and the surface holds no selection.

Anatomy

<ul forListbox [(value)]="picked" aria-label="Fruit">
  <li>
    <button type="button" forListboxOption value="apple">
      Apple
      <span forListboxOptionIndicator>✓</span>
    </button>
  </li>
  <li>
    <button type="button" forListboxOption value="banana">Banana</button>
  </li>
</ul>

Wrap options in a [forListboxGroup] (labelled by [forListboxGroupLabel]) for advisory grouping, or add [forListboxReorder] on the same element as [forListbox] to make the options pointer- and keyboard-sortable (see Reordering).

Examples

Multi select

multiple lets several options be selected, so a click toggles an option instead of replacing the selection, and enables the APG range model: Shift+Arrow extends the selection, Shift+Space fills a range, and Ctrl+A toggles all.

Option groups

forListboxGroup wraps options in a role="group" labelled by forListboxGroupLabel. Grouping is advisory: arrow navigation, Home/End, and typeahead traverse across group boundaries in DOM order.

  • Americas
  • Europe
  • Asia / Pacific

Sortable (reorder)

With [forListboxReorder] on the listbox, drag a chip to a new place, or focus one and press Ctrl+Space to lift it, move it with the arrow keys and drop it with Space or Enter. Selection works as before: a click without movement still toggles the chip. Reordering covers the output you apply, the full keyboard and the announcements.

Signal Forms

ForListbox implements FormValueControl<readonly T[]>, so a multi-select binds to a form field with one [formField] directive. The field requires at least one topic and reflects data-invalid until then, flipping touched once focus leaves it.

Virtualized (10,000 options)

Setting [totalCount] switches ForListbox to the activedescendant model: the container becomes the single Tab stop and the active option is tracked by aria-activedescendant, so options recycle as you scroll. Arrow / Home / End reach options outside the rendered window via (scrollToIndex).

Object values

Real apps usually have richer option models ({ id, name, ... }) where the comparison key differs from what you'd serialize for a form. [forListbox] is generic over T to support that without forcing the consumer to stringify and re-hydrate.

Two inputs configure the object behaviour. Defaults make string mode work unchanged:

InputDefaultPurpose
[compareWith](a, b) => a === bHow two items compare. Override for object values so selection / range actions locate by id (or any stable key).
[itemToFormValue](item) => typeof item === 'string' ? item : JSON.stringify(item)Serialize an item for the hidden form input. Override to emit a per-item id (or any wire format your backend wants).

The visible option label is just the rendered textContent, so there's no separate label function. All of the multi-select range actions (Shift+Arrow, Shift+Space, Ctrl/Cmd+A, Ctrl+Shift+Home/End) dedupe by compareWith, so object values never accumulate duplicates.

import { Component, signal } from '@angular/core';
import { ForListbox, ForListboxOption } from 'forty-cdk/listbox';

interface City {
  id: string;
  name: string;
}

@Component({
  selector: 'demo-cities',
  imports: [ForListbox, ForListboxOption],
  template: `
    <ul forListbox multiple [(value)]="picked" [compareWith]="byId" aria-label="Cities">
      @for (c of cities; track c.id) {
        <li>
          <button type="button" forListboxOption class="listbox-option" [value]="c">
            {{ c.name }}
          </button>
        </li>
      }
    </ul>
  `,
})
export class DemoCities {
  readonly picked = signal<readonly City[]>([]);
  readonly cities: readonly City[] = [
    { id: 'paris', name: 'Paris' },
    { id: 'berlin', name: 'Berlin' },
  ];
  readonly byId = (a: City, b: City) => a.id === b.id;
}

Reordering

Add [forListboxReorder] on the same element as [forListbox] to make a listbox sortable: a selectable and sortable list (e.g. a chip grid) in one composition.

[forDraggable] can't stack on a [forListboxOption]: both manage the option's roving tabindex and keyboard, so they collide on tabindex, on Space / Enter activation, and on orientation. [forListboxReorder] is a container-level coordinator (the same shape as [forTreeNodeDrag] / [forTableRowReorder]): it lives on the listbox, never touches the option's roving tabindex, intercepts keys in the capture phase with a dedicated lift chord, and owns its own 2D drop geometry. Selection, typeahead, and arrow navigation therefore keep working unchanged.

It never reorders the options itself (BYO-data): (optionReorder) emits { from, to } on each committed drop; apply moveItemInArray(items, from, to) to your own array.

import { Component, signal } from '@angular/core';
import { moveItemInArray } from 'forty-cdk/drag-drop';
import { ForListbox, ForListboxOption, ForListboxReorder } from 'forty-cdk/listbox';

@Component({
  selector: 'demo-sortable-tags',
  imports: [ForListbox, ForListboxOption, ForListboxReorder],
  template: `
    <ul
      forListbox
      forListboxReorder
      multiple
      [(value)]="selected"
      (optionReorder)="reorder($event)"
      aria-label="Tags"
      style="display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0"
    >
      @for (tag of tags(); track tag) {
        <li>
          <button type="button" forListboxOption [value]="tag" class="chip">{{ tag }}</button>
        </li>
      }
    </ul>
  `,
})
export class DemoSortableTags {
  readonly tags = signal<readonly string[]>(['urgent', 'bug', 'ui', 'docs']);
  readonly selected = signal<readonly string[]>([]);

  reorder({ from, to }: { from: number; to: number }): void {
    this.tags.update((tags) => moveItemInArray(tags, from, to));
  }
}

Inputs / output

APITypeDescription
reorderDisabled
input
Disable reorder while keeping selection / typeahead. The listbox's own disabled also disables reorder. Default false.
optionReorder
output
Fires once per committed reorder with the previous / new index (both 0-based, DOM order). Apply moveItemInArray.

Reorder keyboard

  • Ctrl+Space (or Cmd+Space) lifts the focused option.
  • While lifted: arrow keys step the target position (linearly in DOM order, so a wrapping grid sorts with either axis), Home / End jump to the ends, Space / Enter drop, keeping focus on the dropped option, Escape / Tab cancel.
  • The lift chord is intercepted in the capture phase, so it never collides with the option's native Space / Enter selection or with arrow navigation while idle.

Pointer

Drag an option past a small threshold to reorder; a short press without movement still selects (the post-drag click is suppressed). A floating preview follows the pointer and drop geometry is resolved in 2D, so vertical lists, horizontal lists, and wrapping chip grids all work without configuring orientation.

Scope: [forListboxReorder] targets the standard roving-tabindex listbox. A virtualized listbox ([totalCount] set) is left untouched, since reordering a windowed subset is ill-defined.

While a reorder is in flight, data-dragging is reflected on two pieces:

Data attributeValuesNotes
data-dragging (reorder)present | absentOn [forListboxReorder] while any drag is in flight.
data-dragging (option)present | absentOn the lifted [forListboxOption] for the duration of a drag.

Localizing reorder announcements

While a reorder is in flight, [forListboxReorder] announces lift / move / drop / cancel through an off-screen live region. The phrasing is English by default; override it per injector scope with provideForListboxDefaults so screen readers speak the consumer's language. index / total are 1-based.

import { provideForListboxDefaults } from 'forty-cdk/defaults';

provideForListboxDefaults({
  reorderAnnounceLift: (label, index, total) => `${label} levantado. ${index} de ${total}.`,
  reorderAnnounceMove: (label, index, total) =>
    `${label} movido a la posición ${index} de ${total}.`,
  reorderAnnounceDrop: (label, index, total) =>
    `${label} soltado en la posición ${index} de ${total}.`,
  reorderAnnounceCancel: (label) => `Movimiento de ${label} cancelado.`,
});
DefaultTypeDescription
reorderAnnounceLift
(label: string, index: number, total: number) => string
Announced when an option is lifted for reorder.
reorderAnnounceMove
(label: string, index: number, total: number) => string
Announced when the drop position changes.
reorderAnnounceDrop
(label: string, index: number, total: number) => string
Announced on a committed drop.
reorderAnnounceCancel
(label: string) => string
Announced when the reorder is cancelled.

Virtualization

Setting [totalCount] on [forListbox] enables the activedescendant focus model: the listbox container becomes the single Tab stop (tabindex="0") and focus never moves to individual options. Keyboard navigation and selection work exactly as in the roving-tabindex path, but the active option is tracked by aria-activedescendant instead of DOM focus, so the active row can be unmounted as it scrolls out of the window.

Without [totalCount] (the default), the roving-tabindex model is used unchanged.

Inputs and output

Input / OutputTypeDescription
[totalCount]
number | undefined
Total number of items in the source data. Setting this switches to the activedescendant model.
[visibleRange]
readonly [number, number] | undefined
Inclusive-exclusive [start, end) range of rendered options. Provided by injectVirtualizer.
[forListboxOption][posInSet]
number | null
Zero-based absolute position of this option in the full data. Required in the virtualized path.
(scrollToIndex)
number
Emitted when navigation lands on an off-window option. Pass to injectVirtualizer's scrollToIndex to recenter the window.

Focus-model switch

ModeTab stopActive option tracking
Roving-tabindex (default)Active optionDOM focus + data-highlighted
Activedescendant (totalCount set)Listbox containeraria-activedescendant + data-highlighted

Both paths reflect data-highlighted="" on the active option, so consumer CSS for hover/focus rings works the same way in either mode. In both paths the pointer takes it over too (see Pointer highlight).

  1. Consumer provides [totalCount], [visibleRange], and handles (scrollToIndex).
  2. On focus, the listbox seeds aria-activedescendant to the first selected enabled option, or the first enabled option ordered by posInSet.
  3. Arrow / Home / End navigation computes the target index against the full totalCount. If the target is inside [visibleRange], aria-activedescendant is set immediately. If outside, (scrollToIndex) is emitted with the target index.
  4. When the consumer's virtualizer scrolls and the target option mounts, the listbox resolves the pending navigation and sets aria-activedescendant.

Example

import {
  ChangeDetectionStrategy,
  Component,
  type ElementRef,
  computed,
  signal,
  viewChild,
} from '@angular/core';
import { ForListbox, ForListboxOption } from 'forty-cdk/listbox';
import { injectVirtualizer } from 'forty-cdk/virtualization';

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

@Component({
  selector: 'demo-virtualized-listbox',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ForListbox, ForListboxOption],
  template: `
    <div
      forListbox
      #scroll
      aria-label="Virtualized items"
      [(value)]="picked"
      [totalCount]="items.length"
      [visibleRange]="v.range()"
      (scrollToIndex)="v.scrollToIndex($event, { align: 'auto' })"
      style="overflow: auto; max-height: 300px; position: relative"
    >
      <div [style.height.px]="v.totalSize()" style="position: relative">
        @for (vi of v.virtualItems(); track vi.key) {
          <button
            type="button"
            forListboxOption
            [value]="items[vi.index]!.id"
            [posInSet]="vi.index"
            [style.transform]="'translateY(' + vi.start + 'px)'"
            style="position: absolute; left: 0; right: 0;"
          >
            {{ items[vi.index]!.label }}
          </button>
        }
      </div>
    </div>
  `,
})
export class DemoVirtualizedListbox {
  protected readonly items: readonly Item[] = Array.from({ length: 10000 }, (_, i) => ({
    id: `item-${i}`,
    label: `Item ${i}`,
  }));
  protected readonly picked = signal<readonly string[]>([]);
  private readonly scrollRef = viewChild<ElementRef<HTMLElement>>('scroll');
  private readonly scrollElement = computed(() => this.scrollRef()?.nativeElement ?? null);
  protected readonly v = injectVirtualizer({
    count: computed(() => this.items.length),
    estimateSize: () => 36,
    scrollElement: this.scrollElement,
  });
}

Intentional limitations

  • Multi-select range modifiers (Shift+Arrow, Shift+Space, Ctrl+A, Ctrl+Shift+Home/End) are not available in the virtualized path. These require the full materialized option set to compute ranges, which contradicts windowing. Pressing one of these combinations on a virtualized multi-select listbox ([multiple] + [totalCount]) throws in dev mode rather than silently doing nothing, so the unsupported path surfaces during development; production builds no-op. Per-option toggling via Enter, Space, or click works normally in both single and multi mode.
  • Typeahead reaches only positions the window has rendered at least once. The search runs over the persisted position snapshot rather than the live options, so an option the virtualizer has since unmounted is still reachable: the match moves aria-activedescendant to it and emits (scrollToIndex) so your virtualizer brings it back. A position the window has never rendered carries no text the library can match: the keystroke is consumed, the buffer grows, and nothing moves. The shape that triggers it is a freshly-rendered [totalCount]="10000" listbox where the user types before scrolling, and a [dataVersion] bump or invalidateSnapshot() narrows the reachable set back to the current window. Arrow / Home / End navigation reaches every position regardless (it walks absolute indices, not text), so that is the workaround for a target the user cannot type their way to; rendering a larger window widens the reachable set.

Self-hiding pieces

[forListboxOptionIndicator] hides itself while its option is unselected with an inline display: none in addition to the hidden attribute that removes it from the accessibility tree. Because the inline style beats any author selector rule, you can give the indicator a custom display (e.g. display: inline-flex for a check icon) without a .x[hidden] { display: none } workaround. The directive's display: none still wins while the option is unselected, and your display applies once it's selected.

Pointer highlight

Moving the pointer over an enabled option hands it data-highlighted, so exactly one option is ever decorated no matter which device the user reached for. That is the same feel as [forCombobox] and the menu family. Style that one attribute; you do not need a separate :hover rule (and combining both is what puts two rows in a highlighted state at once).

Four properties of the pointer channel:

  • It never selects and never moves DOM focus, not even with selectionFollowsFocus set: hovering is not activation, and a listbox is an in-flow surface that must not steal focus from whatever the user is typing in. The pointer's own click still activates, and the range anchor Shift+Space spans from is untouched.
  • The keyboard takes it back on the next move. In the roving-tabindex path the pointer highlight is a styling channel only: the tab stop and Enter / Space activation stay with the DOM-focused option, and the first arrow / typeahead move drops the pointer highlight. In the activedescendant path (totalCount set) hover moves aria-activedescendant itself, so the highlight and the option Enter activates never disagree there.
  • Moving the pointer off the listbox releases it. In the roving-tabindex path the highlight goes back to the DOM-focused option, or to none when focus is elsewhere. That way an always-visible listbox never keeps a row decorated with the cursor somewhere else on the page. Crossing between two adjacent options is not a leave: the highlight moves straight from one to the other without blinking off. In the activedescendant path the pointer's claim persists, because there it is aria-activedescendant and dropping it would leave the container with no active option.
  • A programmatic scroll cannot hijack it. Keyboard navigation scrolls the active option into view, which can slide a different option under a stationary cursor and make the browser fire a synthetic pointermove for it. Moves arriving in a short window after such a scroll are ignored, so the keyboard keeps the highlight.

A hover on a disabled option is ignored, and the highlight falls back to the keyboard's option if the hovered one is disabled or unmounted while the cursor rests on it.

API

ForListbox

PropertyTypeDescription
value
model
Two-way bindable. Selected values. Single mode keeps 0 or 1; multi any number. Required by FormValueControl<readonly T[]>.
Default: —
selected
Signal
Read-only single-select convenience view of value: the sole selected value, or null when none / many are selected. Lets single-select consumers skip value()[0].
Default: —
compareWith
input
How two items compare. Defaults to ===. Override for object values so selection / range actions locate entries by id (or any stable key).
Default: —
itemToFormValue
input
Serialize an item for the hidden form input. Defaults to String for strings and JSON.stringify for objects. Override to emit a per-item id.
Default: —
multiple
input
When true, multiple options can be selected.
Default: false
ariaLabel
input
Reactive accessible name for the listbox, reflected as aria-label. Prefer native aria-labelledby when a visible label element exists.
Default: null (and an empty string) emits no attribute
orientation
input
Drives keyboard nav and aria-orientation.
Default: 'vertical'
loop
input
When true, arrow nav wraps at the ends. Set false to stop at the boundaries. Range extension (Shift+Arrow) never wraps regardless, per the APG.
Default: true
dir
input
Text direction.
Default: 'ltr'
selectionFollowsFocus
input
Single-mode only. When true, a keyboard focus move (arrow nav or a typeahead match) also selects the focused option. APG flags this as case-by-case, so leave off unless your UX specifically benefits.
Default: false
disabled / readonly / required / invalid / pending
input
Reflected as aria-* / data-*.
Default: —
name
input
For form association.
Default: —
errors
input
Wired by [formField].
Default: —
touched
model
Set on focusout outside the listbox.
Default: —
Data attributeValues
data-orientationhorizontal | vertical
data-disabledpresent | absent
data-readonlypresent | absent

Requiring a non-empty selection. The value is a readonly T[], and Angular's required() treats only '', false, null, and NaN as empty. An empty array counts as present, so required() on the field reflects aria-required="true" but never makes the form invalid on its own. Enforce "at least one" with an explicit validate(...) length rule, as the Signal Forms example does, or with Angular's minLength(field, 1), which emits a minLengthError instead of a requiredError.

Single-select fields. Model the field as a readonly string[] you keep at length ≤ 1 and bind it with [formField] directly, so single mode needs no adapter. A FieldTree<T | null> cannot bind here; map to that shape at the edge that needs it. See the selection value-type contract.

ForListboxOption

PropertyTypeDescription
value
input.required
The option's value (defaults to string). Must be unique within the listbox per compareWith.
Default: —
disabled
input
Disables this option independently of the group.
Default: —
Data attributeValuesNotes
data-statechecked | unchecked
data-highlightedpresent | absentWorks in both roving-tabindex and activedescendant paths, and follows the pointer as well as the keyboard (see Pointer highlight).
data-disabledpresent | absent

ForListboxOptionIndicator

Optional slot inside an option. Mirrors data-state and self-hides while the option is unselected (see Self-hiding pieces).

Data attributeValues
data-statechecked | unchecked

Keyboard

Single mode (and the basics for both)

Virtualized path ([totalCount] set): the listbox container is always the single Tab stop. Arrow / Home / End / Enter / Space all fire on the container (not individual options). See the Virtualization section for the full contract.

  • Tab moves focus into / out of the listbox; lands on the first selected option (or the first enabled one if nothing is selected, or the last user-focused option after first interaction). With several preselected options in multi mode, only the first selected one is the tab stop: the group exposes a single tabindex="0". When no option can serve as that entry point (the listbox is empty, or every option is disabled), the listbox host itself becomes the single Tab stop (tabindex="0") so the control stays reachable; a disabled listbox is never tabbable.
  • ArrowDown / ArrowUp in vertical, ArrowRight / ArrowLeft in horizontal: move focus, wrap-around, skip disabled.
  • Home / End jump to first / last enabled option.
  • PageUp / PageDown jump to first / last enabled option.
  • Space / Enter activate the focused option (toggles in multi, selects in single) via the underlying button.
  • Typeahead: typing characters focuses the first option whose visible text starts with the typed prefix (case-insensitive, debounced).
  • Disabled options are skipped on arrow nav. They keep aria-disabled="true" and data-disabled="" (no native disabled attribute, per APG): still reachable by a screen reader, but click and keyboard activation are no-ops, and a mouse press on one leaves focus where it was, so arrow keys and typeahead carry on from the option that had it.

The full WAI-ARIA APG "Recommended Selection" model is implemented and active automatically when multiple is set. All shortcuts skip disabled options.

ShortcutBehavior
Shift+ArrowDown / ArrowUpMove focus to the next / previous enabled option AND toggle its selected state.
Shift+SpaceSelect every enabled option between the anchor (most recent unmodified click / Space) and the focused option, inclusive. Existing selection outside the range is preserved.
Ctrl+A (or Cmd+A on mac)Select every enabled option. If every enabled option is already selected, deselects them. A selected disabled or unrendered option stays selected either way.
Ctrl+Shift+HomeSelect from the focused option to the first enabled option, and move focus there.
Ctrl+Shift+EndSelect from the focused option to the last enabled option, and move focus there.

The anchor for Shift+Space is set on every unmodified activation (click, plain Space, plain Enter) and is unaffected by Shift+ArrowDown/ArrowUp. That lets users click an option, navigate away with Shift+Arrow, and then Shift+Space to select the contiguous block back to where they started.

When readonly is set, the focus-moving shortcuts (Shift+Arrow, Ctrl+Shift+Home/End) still move focus but do not change the selection. That is the same contract as plain arrow nav under readonly. Pure-selection shortcuts (Shift+Space, Ctrl+A) are no-ops.

Accessibility

Implements the WAI-ARIA Listbox pattern.

  • Label the listbox via the reactive [ariaLabel] input or a native aria-labelledby pointing at a visible label element.
  • Use <button> for each option so Space / Enter activate via native click. Other host elements break keyboard activation.
  • Visible text on each option is what typeahead matches against, so keep it descriptive and unique-prefixed.
  • selectionFollowsFocus is an opt-in for single-select. Avoid combining it with side effects that depend on commit semantics, because it changes the form value on every arrow key and typeahead match.
  • data-highlighted="" is reflected on the option that is the current active item in both the roving-tabindex and activedescendant paths. It shares its vocabulary with the menu / select / combobox primitives and is useful when you want a uniform "keyboard focus ring" across surfaces without coupling to :focus. It follows the pointer as well as the keyboard (see Pointer highlight), so it is the one hook to style rather than pairing it with :hover.
  • Virtualized path: the listbox publishes aria-activedescendant on the container and each rendered option carries aria-setsize / aria-posinset so screen readers announce the true list size even when only a window is mounted.

Styling

forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under API, not off the for* selectors (Styling forty-cdk explains why).

.listbox-option[data-highlighted] {
  background: rgb(0 0 0 / 0.06);
}

.listbox-option[data-state='checked'] {
  font-weight: 600;
}

Wrapping in a design system

Wrapping form primitives documents both supported wrapper patterns: hostDirectives with the exported FOR_LISTBOX_HOST_DIRECTIVE_INPUTS / FOR_LISTBOX_HOST_DIRECTIVE_OUTPUTS name tuples, and subclassing.