Primitives
Listbox
A scrollable list of selectable options with roving-tabindex navigation, single or multi selection.
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:
| Input | Default | Purpose | |
|---|---|---|---|
[compareWith] | (a, b) => a === b | How 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
| API | Type | Description |
|---|---|---|
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 attribute | Values | Notes | |
|---|---|---|---|
data-dragging (reorder) | present | absent | On [forListboxReorder] while any drag is in flight. | |
data-dragging (option) | present | absent | On 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.`,
});
| Default | Type | Description |
|---|---|---|
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 / Output | Type | Description |
|---|---|---|
[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
| Mode | Tab stop | Active option tracking | |
|---|---|---|---|
| Roving-tabindex (default) | Active option | DOM focus + data-highlighted | |
Activedescendant (totalCount set) | Listbox container | aria-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).
Navigation flow
- Consumer provides
[totalCount],[visibleRange], and handles(scrollToIndex). - On focus, the listbox seeds
aria-activedescendantto the first selected enabled option, or the first enabled option ordered byposInSet. - Arrow / Home / End navigation computes the target index against the full
totalCount. If the target is inside[visibleRange],aria-activedescendantis set immediately. If outside,(scrollToIndex)is emitted with the target index. - 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-activedescendantto 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 orinvalidateSnapshot()narrows the reachable set back to the current window. Arrow /Home/Endnavigation 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
selectionFollowsFocusset: 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 anchorShift+Spacespans 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/Spaceactivation stay with the DOM-focused option, and the first arrow / typeahead move drops the pointer highlight. In the activedescendant path (totalCountset) hover movesaria-activedescendantitself, so the highlight and the optionEnteractivates 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-activedescendantand 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
pointermovefor 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
| Property | Type | Description |
|---|---|---|
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 attribute | Values | |
|---|---|---|
data-orientation | horizontal | vertical | |
data-disabled | present | absent | |
data-readonly | present | absent |
Requiring a non-empty selection. The value is a
readonly T[], and Angular'srequired()treats only'',false,null, andNaNas empty. An empty array counts as present, sorequired()on the field reflectsaria-required="true"but never makes the form invalid on its own. Enforce "at least one" with an explicitvalidate(...)length rule, as the Signal Forms example does, or with Angular'sminLength(field, 1), which emits aminLengthErrorinstead of arequiredError.
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. AFieldTree<T | null>cannot bind here; map to that shape at the edge that needs it. See the selection value-type contract.
ForListboxOption
| Property | Type | Description |
|---|---|---|
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 attribute | Values | Notes | |
|---|---|---|---|
data-state | checked | unchecked | ||
data-highlighted | present | absent | Works in both roving-tabindex and activedescendant paths, and follows the pointer as well as the keyboard (see Pointer highlight). | |
data-disabled | present | absent |
ForListboxOptionIndicator
Optional slot inside an option. Mirrors data-state and self-hides while the option is unselected (see Self-hiding pieces).
| Data attribute | Values | |
|---|---|---|
data-state | checked | 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"anddata-disabled=""(no nativedisabledattribute, 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.
Multi mode (APG-recommended range selection)
The full WAI-ARIA APG "Recommended Selection" model is implemented and active automatically when multiple is set. All shortcuts skip disabled options.
| Shortcut | Behavior | |
|---|---|---|
| Shift+ArrowDown / ArrowUp | Move focus to the next / previous enabled option AND toggle its selected state. | |
| Shift+Space | Select 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+Home | Select from the focused option to the first enabled option, and move focus there. | |
| Ctrl+Shift+End | Select 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 nativearia-labelledbypointing 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.
selectionFollowsFocusis 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-activedescendanton the container and each rendered option carriesaria-setsize/aria-posinsetso 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.