List View
oge-list-view renders a templated list — contacts, an inbox, a feed — with selection, sticky group headers, windowed rendering for long lists, search, load-more and swipe actions.
Its role follows selectionMode: a selectable list is a WAI-ARIA APG listbox whose scroll viewport takes focus and tracks the active option with aria-activedescendant — the focus model that survives virtualization — and selectionMode: 'none' renders a role="list" with a roving tab stop. Every item announces its position in the whole list (aria-posinset / aria-setsize).
Basics & templates
Pass items and a displayExpr; an [ogeListViewItemTemplate] replaces the row content while the list keeps the role, focus and selection. Without selection the list is a role="list": arrows, Home / End, PageUp / PageDown and type-ahead move one roving tab stop, Enter or a click reports itemClick.
Opened: —
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeListView, OgeListViewItemTemplate } from '@oge-ui/layout';
import type { OgeListViewItemClickEvent } from '@oge-ui/layout';
interface Person {
id: number;
name: string;
role: string;
team: string;
away?: boolean;
}
const PEOPLE: Person[] = [
{ id: 1, name: 'Ada Lovelace', role: 'Analyst', team: 'Research' },
{ id: 2, name: 'Grace Hopper', role: 'Compiler lead', team: 'Platform' },
{ id: 3, name: 'Alan Turing', role: 'Cryptanalyst', team: 'Research', away: true },
{ id: 4, name: 'Margaret Hamilton', role: 'Flight software', team: 'Platform' },
{ id: 7, name: 'Barbara Liskov', role: 'Abstractions', team: 'Design' },
];
@Component({
selector: 'demo-root',
imports: [OgeListView, OgeListViewItemTemplate],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- selectionMode "none" (the default) renders role="list": the items hold
one roving tab stop, arrows / Home / End / type-ahead move it and Enter
(or a click) reports itemClick. -->
<oge-list-view
[items]="people"
displayExpr="name"
ariaLabel="Team"
(itemClick)="onClick($event)"
>
<ng-template ogeListViewItemTemplate let-person>
<span class="flex flex-col">
<strong>{{ person.name }}</strong>
<small>{{ person.role }}</small>
</span>
</ng-template>
</oge-list-view>
<p>Opened: {{ opened() ?? '—' }}</p>
`,
})
export class Demo {
protected readonly people = PEOPLE;
protected readonly opened = signal<string | null>(null);
protected onClick(event: OgeListViewItemClickEvent<Person>): void {
this.opened.set(event.item.name);
}
}Selection
single or multiple turns the list into an APG listbox. Space toggles, Enter selects (single), Shift+arrows and Shift+click extend a range, Ctrl+A selects all and announces the count; disabledExpr keeps an option visible but aria-disabled, and the keyboard skips it.
Selected: 2
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeListView } from '@oge-ui/layout';
import type { OgeListViewKey } from '@oge-ui/layout';
interface Person {
id: number;
name: string;
role: string;
team: string;
away?: boolean;
}
const PEOPLE: Person[] = [
{ id: 1, name: 'Ada Lovelace', role: 'Analyst', team: 'Research' },
{ id: 2, name: 'Grace Hopper', role: 'Compiler lead', team: 'Platform' },
{ id: 3, name: 'Alan Turing', role: 'Cryptanalyst', team: 'Research', away: true },
{ id: 4, name: 'Margaret Hamilton', role: 'Flight software', team: 'Platform' },
{ id: 7, name: 'Barbara Liskov', role: 'Abstractions', team: 'Design' },
];
@Component({
selector: 'demo-root',
imports: [OgeListView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- A selectable list is an APG listbox: the viewport takes focus and tracks
the active option with aria-activedescendant. Space toggles, Shift+arrows
extend, Ctrl+A selects all; disabledExpr marks options aria-disabled. -->
<oge-list-view
[items]="people"
displayExpr="name"
disabledExpr="away"
selectionMode="multiple"
[showSelectionControls]="true"
[(selectedKeys)]="picked"
ariaLabel="Reviewers"
/>
<p>Selected: {{ picked().join(', ') || 'none' }}</p>
`,
})
export class Demo {
protected readonly people = PEOPLE;
protected readonly picked = signal<readonly OgeListViewKey[]>([2]);
}Grouping & sticky headers
groupExpr splits the items into labelled segments (role="group" in a listbox, a nested list in a plain list). The visible header is aria-hidden — the segment already carries the name — and sticks to the top of its segment, so the next header pushes the previous one out.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeListView, OgeListViewGroupTemplate } from '@oge-ui/layout';
interface Person {
id: number;
name: string;
role: string;
team: string;
away?: boolean;
}
const PEOPLE: Person[] = [
{ id: 1, name: 'Ada Lovelace', role: 'Analyst', team: 'Research' },
{ id: 2, name: 'Grace Hopper', role: 'Compiler lead', team: 'Platform' },
{ id: 3, name: 'Alan Turing', role: 'Cryptanalyst', team: 'Research', away: true },
{ id: 4, name: 'Margaret Hamilton', role: 'Flight software', team: 'Platform' },
{ id: 7, name: 'Barbara Liskov', role: 'Abstractions', team: 'Design' },
];
@Component({
selector: 'demo-root',
imports: [OgeListView, OgeListViewGroupTemplate],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- Each group is a labelled segment; its header is aria-hidden and
position: sticky inside the segment, so the next header pushes it out. -->
<oge-list-view
[items]="people"
displayExpr="name"
groupExpr="team"
selectionMode="single"
height="260px"
ariaLabel="People by team"
>
<ng-template ogeListViewGroupTemplate let-team let-count="count">
{{ team }} · {{ count }}
</ng-template>
</oge-list-view>
`,
})
export class Demo {
protected readonly people = PEOPLE;
}Virtual scrolling
With fixed row heights the list renders only the rows in view plus a small overscan, on core's offset tree — 10 000 items scroll and type-ahead instantly. Keyboard navigation scrolls the active option into the window before pointing aria-activedescendant at it, and group headers stay pinned however far a group scrolled.
Selected: none
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeListView } from '@oge-ui/layout';
import type { OgeListViewKey } from '@oge-ui/layout';
@Component({
selector: 'demo-root',
imports: [OgeListView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- Fixed row heights let the list render only a window of rows — 10 000
tickets stay instant. Keyboard navigation scrolls the active option into
the window first, so aria-activedescendant always resolves. -->
<oge-list-view
[items]="tickets"
displayExpr="name"
selectionMode="single"
[(selectedKeys)]="selected"
[virtualScroll]="{ itemHeight: 44 }"
[height]="320"
ariaLabel="Tickets"
/>
`,
})
export class Demo {
protected readonly tickets = Array.from({ length: 10_000 }, (_, i) => ({
id: i + 1,
name: `Ticket #${String(i + 1).padStart(5, '0')}`,
}));
protected readonly selected = signal<readonly OgeListViewKey[]>([]);
}Search & infinite scroll
searchEnabled adds a labelled search field that filters locale- and accent-insensitively ("istanbul" finds "İstanbul") and announces the result count. pageLoadMode: 'scroll' emits loadMoreRequested near the end (once per page), 'button' renders a Load more button, and the arrival is announced.
Loaded: 16
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeListView } from '@oge-ui/layout';
interface City {
id: number;
name: string;
country: string;
}
const NAMES = ['İstanbul', 'Ankara', 'Berlin', 'München', 'Paris', 'Zürich', 'Kraków', 'São Paulo'];
function page(index: number, size = 16): City[] {
return Array.from({ length: size }, (_, i) => {
const n = index * size + i;
return { id: n + 1, name: `${NAMES[n % NAMES.length]} ${n + 1}`, country: 'Demo' };
});
}
@Component({
selector: 'demo-root',
imports: [OgeListView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- The search field filters locale- and accent-insensitively ("istanbul"
finds "İstanbul") and announces the result count. pageLoadMode "scroll"
asks for the next page near the end; "button" renders a Load more button. -->
<oge-list-view
[items]="cities()"
displayExpr="name"
[searchEnabled]="true"
pageLoadMode="scroll"
[hasMore]="cities().length < 96"
[loading]="loading()"
[height]="300"
ariaLabel="Cities"
(loadMoreRequested)="loadMore()"
/>
`,
})
export class Demo {
protected readonly cities = signal<City[]>(page(0));
protected readonly loading = signal(false);
protected loadMore(): void {
this.loading.set(true);
setTimeout(() => {
const next = page(this.cities().length / 16);
this.cities.set([...this.cities(), ...next]);
this.loading.set(false);
}, 600);
}
}Swipe actions
Actions are revealed by a horizontal swipe on touch (mirrored in RTL) and on hover or focus with a mouse; tapping or clicking one runs it. Inside an option they are aria-hidden glyphs — a listbox cannot hold buttons — so their keyboard twin is each action's shortcut, advertised in aria-keyshortcuts and described to screen readers.
Last action: —
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeListView } from '@oge-ui/layout';
import type { OgeListViewItemAction, OgeListViewItemActionClickEvent } from '@oge-ui/layout';
interface Message {
id: number;
from: string;
subject: string;
}
@Component({
selector: 'demo-root',
imports: [OgeListView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- Swipe a row sideways on touch (mirrored in RTL) or hover it with a mouse
to reveal the actions; the keyboard twin is each action's shortcut,
advertised in aria-keyshortcuts. -->
<oge-list-view
[items]="inbox()"
displayExpr="subject"
selectionMode="single"
[itemActions]="actions"
ariaLabel="Inbox"
(itemActionClick)="onAction($event)"
/>
<p>Last action: {{ last() ?? '—' }}</p>
`,
})
export class Demo {
protected readonly inbox = signal<Message[]>([
{ id: 1, from: 'Build bot', subject: 'Nightly build passed' },
{ id: 2, from: 'Ayşe Kaya', subject: 'Design review moved to Friday' },
{ id: 3, from: 'Billing', subject: 'Your invoice for October' },
]);
protected readonly last = signal<string | null>(null);
protected readonly actions: OgeListViewItemAction[] = [
{ key: 'archive', label: 'Archive', severity: 'accent', shortcut: 'Shift+A' },
{ key: 'delete', label: 'Delete', severity: 'danger', shortcut: 'Delete' },
];
protected onAction(event: OgeListViewItemActionClickEvent<Message>): void {
this.last.set(`${event.action.label}: ${event.item.subject}`);
this.inbox.set(this.inbox().filter((m) => m.id !== event.key));
}
}