List
An accessible list of selectable rows built on @angular/aria/listbox, with composable slots for content lists, action menus and navigation lists.
List is a container of selectable rows built on @angular/aria/listbox (role="listbox"/role="option").
ListItem is a row, and a set of slot components/directives (ListItemText, ListItemLeft, ListItemIcon,
ListItemAvatar, ListItemAction, ListItemChevron, ListItemExpander) compose the row content for content
lists, action menus, navigation lists and tree-style rows. A row can also carry one action (typically
a delete) that a keyboard user reaches with a key rather than with Tab — see
Row action and its key.
Minimal example
import { Component, signal } from "@angular/core";
import {
ListComponent,
ListItemComponent,
ListItemLeftDirective,
ListItemIconDirective,
ListItemTextComponent,
FileIcon,
ImageIcon,
UserIcon,
} from "@sinequa/galactik";
@Component({
selector: "sample-component",
imports: [ListComponent, ListItemComponent, ListItemLeftDirective, ListItemIconDirective, ListItemTextComponent, FileIcon, ImageIcon, UserIcon],
template: `
<List class="gap-(--spacing-3xs)" [(value)]="picked">
<ListItem value="doc">
<ListItemLeft><ListItemIcon><file-icon /></ListItemIcon><ListItemText>Documents</ListItemText></ListItemLeft>
</ListItem>
<ListItem value="img">
<ListItemLeft><ListItemIcon><image-icon /></ListItemIcon><ListItemText>Images</ListItemText></ListItemLeft>
</ListItem>
<ListItem value="ppl">
<ListItemLeft><ListItemIcon><user-icon /></ListItemIcon><ListItemText>People</ListItemText></ListItemLeft>
</ListItem>
</List>
`,
})
export class SampleComponent {
// `value` is always a V[] model, even for single selection.
picked = signal<string[]>(["doc"]);
}How it works
List composes @angular/aria/listbox's Listbox as a host directive on its own host element (not a
wrapper), and ListItem composes Option the same way — required so a projected ListItem resolves its
ancestor Listbox through the element-injector hierarchy, which an ng-content wrapper would break. Every
selection, keyboard-navigation and ARIA-reflection concern (aria-selected, aria-disabled, roving tabindex,
typeahead) comes from @angular/aria, not from galactik.
By default, selectionMode="follow" means moving keyboard focus (arrow keys) immediately changes the
selection — the listbox default. Set selectionMode="explicit" (paired with multi, typically) whenever a
row also carries its own interactive affordance like ListItemAction, since "select on focus" would otherwise
fight with reaching that action.
Row action and its key
A ListItemAction is not focusable: its host ListItem is a listbox option, and ARIA forbids an option from
containing an interactive control. A mouse user clicks the icon, which appears on hover. A keyboard user presses
the action's key while the row has focus, and ListItem calls the action's trigger() — your (click) handler
fires exactly as it would for a click.
That key is declared on the action (keys, Delete by default) and made discoverable in three ways, because no
single one reaches everybody:
aria-keyshortcutson the row, set fromkeys;- a sentence (
description), rendered visually hidden and linked to the row byaria-describedby, so a screen reader reads it when the row takes focus.aria-keyshortcutsalone is not reliably read: it depends on the reader and its settings, and is then the raw key name, in English; - a second line in the row (only when
descriptionis set), shown only while the row has keyboard focus (:focus-visible): the same sentence with the key set apart as a keycap. It is hidden otherwise — the mouse has the click, and showing it on hover would move the rows under the pointer. The row grows by that one line, and its trailing tag and action stay vertically centred on it. The rows below move down by that height while the row is focused, and back up when the focus leaves: reserving the space, overlaying the line and animating the height were tried and look worse, so the shift is accepted.
Both visible and hidden copies are aria-hidden or kept out of the option's name, so nothing is announced twice.
A disabled action promises nothing: no key, no sentence, no line.
Recipes
Multi-selection with a per-row action
import { Component, signal } from "@angular/core";
import {
ListComponent,
ListItemComponent,
ListItemLeftDirective,
ListItemAvatarDirective,
ListItemTextComponent,
ListItemActionComponent,
TrashIcon,
} from "@sinequa/galactik";
interface User {
id: string;
name: string;
email: string;
initials: string;
}
@Component({
selector: "sample-component",
imports: [ListComponent, ListItemComponent, ListItemLeftDirective, ListItemAvatarDirective, ListItemTextComponent, ListItemActionComponent, TrashIcon],
template: `
<List class="gap-(--spacing-3xs)" multi size="medium" selectionMode="explicit" [(value)]="picked">
@for (u of users(); track u.id) {
<ListItem [value]="u.id">
<ListItemLeft>
<ListItemAvatar>{{ u.initials }}</ListItemAvatar>
<ListItemText [subtext]="u.email">{{ u.name }}</ListItemText>
</ListItemLeft>
<ListItemAction ariaLabel="Remove" (click)="remove(u.id, $event)"><trash-icon /></ListItemAction>
</ListItem>
}
</List>
`,
})
export class SampleComponent {
users = signal<User[]>([
{ id: "ada", name: "Ada Lovelace", email: "ada@example.com", initials: "AL" },
{ id: "alan", name: "Alan Turing", email: "alan@example.com", initials: "AT" },
]);
picked = signal<string[]>(["alan"]);
remove(id: string, event: Event): void {
event.stopPropagation(); // do not toggle the row's selection while removing it
this.users.update((list) => list.filter((u) => u.id !== id));
this.picked.update((p) => p.filter((v) => v !== id));
}
}Name the key of a row action
Galactik has no translation layer: the sentence and the key's name are yours to provide. description must
contain shortcut as is for the key to be set apart as a keycap inside it. A key that only exists on some
keyboards is the caller's to add — a MacBook has no dedicated Delete (it is fn+⌫, and ⌫ alone emits
Backspace), so declare both there. isApplePlatform() is exported for that.
import { Component, signal } from "@angular/core";
import { isApplePlatform, ListComponent, ListItemComponent, ListItemTextComponent, ListItemActionComponent, TrashIcon } from "@sinequa/galactik";
@Component({
selector: "sample-component",
imports: [ListComponent, ListItemComponent, ListItemTextComponent, ListItemActionComponent, TrashIcon],
template: `
<List aria-label="Recent searches" size="medium" selectionMode="explicit">
@for (search of searches(); track search) {
<ListItem [value]="search">
<ListItemText>{{ search }}</ListItemText>
<ListItemAction
ariaLabel="Remove"
[keys]="deleteKeys"
shortcut="Del"
description="Press Del to delete"
(click)="remove(search, $event)">
<trash-icon />
</ListItemAction>
</ListItem>
}
</List>
`,
})
export class SampleComponent {
searches = signal(["annual report", "contract template", "onboarding"]);
// `Delete` everywhere, plus `Backspace` on a Mac, whose keyboard has no dedicated `Delete`.
protected readonly deleteKeys = isApplePlatform() ? ["Delete", "Backspace"] : ["Delete"];
remove(search: string, event: Event): void {
event.stopPropagation(); // do not toggle the row's selection while removing it
this.searches.update((list) => list.filter((s) => s !== search));
}
}The action does not have to be a delete: keys takes any KeyboardEvent.key values (["Enter"], ["F2"]…),
and the first one names the action in aria-keyshortcuts. There is still one action per row: ListItem
triggers the first ListItemAction it projects.
Another icon, another key
The icon is projected content and the key is keys, so an action is not a delete unless you make it one. Here
a rename: a pencil, answering to F2 (the key file managers use). What the action does is whatever your
(click) handler does — the key only calls it.
import { Component, signal } from "@angular/core";
import { EditIcon, ListComponent, ListItemActionComponent, ListItemComponent, ListItemTextComponent } from "@sinequa/galactik";
@Component({
selector: "sample-component",
imports: [ListComponent, ListItemComponent, ListItemTextComponent, ListItemActionComponent, EditIcon],
template: `
<List aria-label="Documents" size="medium" selectionMode="explicit">
@for (name of names(); track name) {
<ListItem [value]="name">
<ListItemText>{{ name }}</ListItemText>
<ListItemAction
ariaLabel="Rename"
[keys]="['F2']"
shortcut="F2"
description="Press F2 to rename"
(click)="rename(name, $event)">
<edit-icon />
</ListItemAction>
</ListItem>
}
</List>
`,
})
export class SampleComponent {
names = signal(["Annual report", "Contract template"]);
rename(name: string, event: Event): void {
event.stopPropagation(); // do not toggle the row's selection
const next = window.prompt("New name", name);
if (next) this.names.update((list) => list.map((n) => (n === name ? next : n)));
}
}The hover colour is part of the row, not of the action: every ListItemAction turns to the error colour on keyboard
focus and when hovered, since the delete is its main use. There is no class input to change that, and an icon
is the only thing you control. If an action needs its own look, or several actions side by side, it is not a
ListItemAction — see Limits.
Show a hint while the keyboard is in the list
A list is a single tab stop and the arrow keys do the rest, which nothing tells a keyboard user.
List.keyboardFocused is true while the keyboard focus — :focus-visible, so not a mouse click — is inside the
list, which is exactly when a hint is useful. Read it through the list template reference.
import { Component } from "@angular/core";
import { KbdComponent, ListComponent, ListItemComponent, ListItemTextComponent } from "@sinequa/galactik";
@Component({
selector: "sample-component",
imports: [ListComponent, ListItemComponent, ListItemTextComponent, KbdComponent],
template: `
<List #list="list" aria-label="Documents" size="medium">
<ListItem value="a"><ListItemText>Annual report</ListItemText></ListItem>
<ListItem value="b"><ListItemText>Contract template</ListItemText></ListItem>
</List>
<!-- aria-hidden: a listbox already announces its arrow-key navigation to a screen reader -->
@if (list.keyboardFocused()) {
<p aria-hidden="true"><kbd>↑</kbd> <kbd>↓</kbd> to navigate</p>
}
`,
})
export class SampleComponent {}Tree rows
depth indents a row by 20px per level, and ListItemExpander renders the rotating expand/collapse chevron.
In today's listbox-backed List, tree behavior is style only — expansion state and which children render
is entirely up to the consumer; there is no role="tree" keyboard semantics yet (a future
@angular/aria/tree-backed variant will add it).
import { Component, signal } from "@angular/core";
import {
ListComponent,
ListItemComponent,
ListItemLeftDirective,
ListItemIconDirective,
ListItemTextComponent,
ListItemExpanderComponent,
FolderIcon,
FileIcon,
} from "@sinequa/galactik";
@Component({
selector: "sample-component",
imports: [ListComponent, ListItemComponent, ListItemLeftDirective, ListItemIconDirective, ListItemTextComponent, ListItemExpanderComponent, FolderIcon, FileIcon],
template: `
<List [(value)]="picked">
<ListItem value="src">
<ListItemExpander [expanded]="open()" ariaLabel="Toggle" (click)="toggle($event)" />
<ListItemLeft><ListItemIcon><folder-icon /></ListItemIcon><ListItemText>src</ListItemText></ListItemLeft>
</ListItem>
@if (open()) {
<ListItem value="main" [depth]="1">
<ListItemLeft><ListItemIcon><file-icon /></ListItemIcon><ListItemText>main.ts</ListItemText></ListItemLeft>
</ListItem>
}
</List>
`,
})
export class SampleComponent {
picked = signal<string[]>([]);
open = signal(true);
toggle(event: Event): void {
event.stopPropagation(); // do not select the row while expanding/collapsing it
this.open.update((v) => !v);
}
}Options
Prop
Type
ListItemAction:
Prop
Type
trigger() is also public: it does what a click on the icon does, unless the action is disabled. ListItem calls it
on the key.
disabled, readonly, softDisabled, wrap, orientation, typeaheadDelay are forwarded from
@angular/aria/listbox's Listbox and default to whatever that package ships. ListItem additionally takes
depth (tree indentation, 20px/level) and forwards value (required), disabled, label from Option.
Limits
One action per row. ListItem looks for a single ListItemAction (the first it projects) and gives it the key.
That is a property of the component, not an omission of this page.
Projecting a second one does not fail, but only half works:
- the second icon is drawn and reacts to the mouse — its
(click)fires; - it is unreachable from the keyboard: the row sets
aria-keyshortcutsandaria-describedbyfrom the first action only, and a key press only ever triggers the first; - each action still draws its own hint line on keyboard focus, so a focused row would advertise a key that does nothing.
Do not use it that way. When a row needs several actions, or an action with its own look, use a component that
can hold several focusable controls: RowGrid is a keyboard-navigable ARIA grid whose rows
hold any number of them — Tab enters the focused row and walks its controls, Enter activates the row.
No editable content in a row. A ListItem is a listbox option: ARIA forbids an interactive element inside
it, and the row takes Delete (and Backspace on a Mac) for its action whatever has the focus. A row that holds a
text field, a select or any other control is not a list row — use RowGrid instead.
The limit is on actions, not on keys: keys may list several KeyboardEvent.key values
(["Delete", "Backspace"]), all of which trigger the same action.