Tree View
<oge-tree-view> renders a hierarchy from either a flat parent-referencing array or nested children. It follows the WAI-ARIA APG treeview pattern — a roving tabindex over role="treeitem" rows, arrow / Home / End / type-ahead navigation and * to open a level — and expresses depth with aria-level / aria-posinset / aria-setsize over a flat DOM, which is what lets it virtualize.
Flat data
The canonical shape: rows carrying a parent reference. expandedKeys and selectedKeys are two-way models keyed by identity, so they survive reordering. The disabled node is skipped by both clicks and arrow keys.
expandedKeys: 1 · selectedKeys: (none)
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeTreeView } from '@oge-ui/navigation';
import type { RowKey } from '@oge-ui/core';
interface Folder {
id: number;
parentId: number | null;
name: string;
hasItems?: boolean;
}
@Component({
selector: 'demo-root',
imports: [OgeTreeView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-tree-view
[items]="folders"
keyExpr="id"
parentIdExpr="parentId"
displayExpr="name"
selectionMode="single"
[(expandedKeys)]="open"
[(selectedKeys)]="picked"
/>
`,
})
export class Demo {
protected readonly folders: Folder[] = [
{ id: 1, parentId: null, name: 'Documents' },
{ id: 2, parentId: 1, name: 'Reports' },
{ id: 3, parentId: 2, name: 'Q1.pdf' },
];
protected readonly open = signal<RowKey[]>([1]);
protected readonly picked = signal<RowKey[]>([]);
}Nested data
Point itemsExpr at the children field and the parent links are derived for you — core's flattenNestedTree normalizes the payload into the same single pipeline the flat shape uses.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeTreeView } from '@oge-ui/navigation';
interface NestedFolder {
id: number;
name: string;
children?: NestedFolder[];
}
@Component({
selector: 'demo-root',
imports: [OgeTreeView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- nested payloads need only itemsExpr; the parent links are derived -->
<oge-tree-view [items]="tree" itemsExpr="children" displayExpr="name" />
`,
})
export class Demo {
protected readonly tree: NestedFolder[] = [
{
id: 1,
name: 'src',
children: [{ id: 2, name: 'app', children: [{ id: 3, name: 'main.ts' }] }],
},
];
}Checkboxes & cascade
Checking a node cascades down to its descendants and normalizes up: a parent is checked only when every child is, and indeterminate whenever some are. The checkbox is an aria-hidden glyph and the state lives on the row as aria-checked — a real checkbox inside role="treeitem" would be a nested-interactive violation.
selectedKeys: (none)
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeTreeView } from '@oge-ui/navigation';
import type { RowKey } from '@oge-ui/core';
interface Folder {
id: number;
parentId: number | null;
name: string;
hasItems?: boolean;
}
@Component({
selector: 'demo-root',
imports: [OgeTreeView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-tree-view
[items]="folders"
displayExpr="name"
selectionMode="multiple"
showCheckBoxes="selectAll"
[(selectedKeys)]="picked"
/>
<!-- selectedKeysMode projects the stored set on the way out:
'all' (default) · 'leavesOnly' · 'excludeRecursive' -->
`,
})
export class Demo {
protected readonly folders: Folder[] = [
{ id: 1, parentId: null, name: 'Documents' },
{ id: 2, parentId: 1, name: 'Reports' },
{ id: 3, parentId: 2, name: 'Q1.pdf' },
];
protected readonly picked = signal<RowKey[]>([]);
}Search
Matching is accent- and locale-insensitive (core's foldText), the ancestors of a hit are auto-expanded so it is reachable, and the matched substring is wrapped in <mark>. filterMode: 'fullBranch' also keeps a match's descendants.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeTreeView } from '@oge-ui/navigation';
interface Folder {
id: number;
parentId: number | null;
name: string;
hasItems?: boolean;
}
@Component({
selector: 'demo-root',
imports: [OgeTreeView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-tree-view
[items]="folders"
displayExpr="name"
[searchEnabled]="true"
searchMode="contains"
filterMode="withAncestors"
/>
`,
})
export class Demo {
protected readonly folders: Folder[] = [
{ id: 1, parentId: null, name: 'Documents' },
{ id: 2, parentId: 1, name: 'Reports' },
{ id: 3, parentId: 2, name: 'Q1.pdf' },
];
}Lazy load on demand
Only the roots are bound; hasItemsExpr tells the tree which of them can expand. The first expand calls loadChildren and the engine renders a placeholder row until it resolves. Fetched children are folded into the index, so cascading selection reaches them.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeTreeView } from '@oge-ui/navigation';
interface Folder {
id: number;
parentId: number | null;
name: string;
hasItems?: boolean;
}
@Component({
selector: 'demo-root',
imports: [OgeTreeView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-tree-view
[items]="roots"
displayExpr="name"
hasItemsExpr="hasItems"
[loadChildren]="loadChildren"
/>
`,
})
export class Demo {
protected readonly roots: Folder[] = [
{ id: 1, parentId: null, name: 'Documents', hasItems: true },
];
// a skeleton row shows while the promise is pending
protected readonly loadChildren = (parent: Folder): Promise<Folder[]> =>
fetch(`/api/folders?parent=${parent.id}`).then((r) => r.json());
}Virtual scrolling
Because the tree renders a flat list with aria-level rather than nested groups, it can window the DOM. Built on core's OffsetTree + computeWindow; keyboard focus moves scroll the target into view first, since the row may not exist yet.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeTreeView } from '@oge-ui/navigation';
interface Folder {
id: number;
parentId: number | null;
name: string;
hasItems?: boolean;
}
@Component({
selector: 'demo-root',
imports: [OgeTreeView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- every row must actually be itemHeight tall -->
<oge-tree-view
[items]="tenThousand"
displayExpr="name"
[virtualScroll]="{ itemHeight: 30 }"
height="320px"
/>
`,
})
export class Demo {
protected readonly tenThousand: Folder[] = Array.from(
{ length: 10000 },
(_, i) => ({ id: i + 1, parentId: null, name: `Item ${i + 1}` }),
);
}Drag & drop reparenting
Drag a node onto the middle of another to make it a child, or onto an edge to place it as a sibling. Hovering a collapsed parent opens it, Escape cancels, and a node can never be dropped into its own subtree. The tree emits itemReordered and leaves the data change to you.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeTreeView } from '@oge-ui/navigation';
import type { OgeTreeReorderedEvent } from '@oge-ui/navigation';
interface Folder {
id: number;
parentId: number | null;
name: string;
hasItems?: boolean;
}
@Component({
selector: 'demo-root',
imports: [OgeTreeView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-tree-view
[items]="folders()"
displayExpr="name"
[allowDragging]="true"
[allowDropInside]="true"
(itemReordered)="reparent($event)"
/>
`,
})
export class Demo {
protected readonly folders = signal<Folder[]>([
{ id: 1, parentId: null, name: 'Documents' },
{ id: 2, parentId: 1, name: 'Reports' },
]);
// the tree never mutates your data; apply the move yourself
protected reparent(e: OgeTreeReorderedEvent<Folder>): void {
this.folders.update((rows) =>
rows.map((row) =>
row.id === e.dragKey
? {
...row,
parentId:
e.position === 'inside'
? (e.dropKey as number)
: e.dropItem.parentId,
}
: row,
),
);
}
}Custom node template
The item template replaces the built-in label. It renders inside the role="treeitem" row, so it must stay free of focusable controls.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeTreeItemTemplate, OgeTreeView } from '@oge-ui/navigation';
interface Folder {
id: number;
parentId: number | null;
name: string;
hasItems?: boolean;
}
@Component({
selector: 'demo-root',
imports: [OgeTreeItemTemplate, OgeTreeView],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-tree-view [items]="folders" displayExpr="name">
<!-- the TypeFor input is a pure type anchor: bind the same array you pass
to [items] and let-item is typed as your row, not unknown -->
<ng-template
ogeTreeItemTemplate
[ogeTreeItemTemplateTypeFor]="folders"
let-item
let-level="level"
>
<strong>{{ item.name }}</strong>
<small>level {{ level }}</small>
</ng-template>
</oge-tree-view>
<!-- the template renders inside role="treeitem", so it must not contain
focusable controls — that would be a nested-interactive violation -->
`,
})
export class Demo {
protected readonly folders: Folder[] = [
{ id: 1, parentId: null, name: 'Documents' },
{ id: 2, parentId: 1, name: 'Reports' },
{ id: 3, parentId: 2, name: 'Q1.pdf' },
];
}