Tree
Do not use in Production
Accessible tree container. Owns keyboard navigation (arrows, Home/End, typeahead), roving
tabindex and focus for the <certara-tree-item> rows inside it, and reports user intent
through events. It does not hold tree data: the parent app renders the visible rows (usually
from a TreeModel) and updates open / active in response to the events.
Rows may be wrapped in other elements (e.g. a virtual-scroll viewport); navigation follows the
DOM order of the rendered rows. The tree never moves its children, so frameworks and virtualizers
can add, remove and reorder rows freely. Give the tree an accessible name with aria-label or
aria-labelledby.
How it fits together
Section titled “How it fits together”The tree is split into three parts so it behaves the same in plain HTML, React and Angular, and so it can be virtualized for very large data sets:
TreeModel(exported from@certara/certara-ui, and from@certara/certara-ui-reactfor React) holds the data: nodes, expansion, lazy loading, loading/empty/error placeholders and filtering. It producesvisibleRows, a flat array of the rows that should be on screen.<certara-tree-item>renders one row. Rows are flat siblings, not nested; depth comes fromlevel.<certara-tree>handles keyboard navigation, focus and ARIA for the rows inside it, and reports what the user asked for through events. It never changes rows itself: updateopen/active(usually through the model) in response tocertaraTreeExpandedChangeandcertaraTreeItemSelect.
Because your framework renders the rows, any row content works (checkboxes, menus, drag handles), and any virtualizer can decide which rows exist in the DOM.
placeholder for loading, empty and error rows. Arrow keys skip them.import {useState} from 'react';import {CertaraIcon, CertaraTree, CertaraTreeItem} from '@certara/certara-ui-react';
// Same rows as the HTML Default example in tree.mdx. Rows with `expandable` are folders.const rows = [ {id: 'docs', level: 1, label: 'Documents', expandable: true, setSize: 2, posInSet: 1}, {id: 'report', level: 2, label: 'report.csv', setSize: 3, posInSet: 1}, {id: 'summary', level: 2, label: 'summary.xlsx', setSize: 3, posInSet: 2}, {id: 'drafts', level: 2, label: 'Drafts', expandable: true, setSize: 3, posInSet: 3}, {id: 'draft-1', level: 3, label: 'draft-1.docx', setSize: 2, posInSet: 1}, {id: 'draft-2', level: 3, label: 'draft-2.docx', setSize: 2, posInSet: 2}, {id: 'images', level: 1, label: 'Images', expandable: true, setSize: 2, posInSet: 2}, {id: 'photo-1', level: 2, label: 'photo-1.png', setSize: 2, posInSet: 1}, {id: 'photo-2', level: 2, label: 'photo-2.png', setSize: 2, posInSet: 2},];
// Leaves out rows under a collapsed folder.function getVisibleRows(allRows, openIds) { let collapsedLevel = null; return allRows.filter((row) => { if (collapsedLevel !== null && row.level > collapsedLevel) { return false; } collapsedLevel = row.expandable && !openIds.includes(row.id) ? row.level : null; return true; });}
export function CertaraTreeExample() { // The tree only reports what the user asked for, so the example keeps the open folders and the active row in state. const [openIds, setOpenIds] = useState(['docs']); const [activeId, setActiveId] = useState('summary');
const onExpandedChange = (e) => { const {id, expanded} = e.detail; setOpenIds((ids) => (expanded ? [...ids, id] : ids.filter((openId) => openId !== id))); };
const onItemSelect = (e) => setActiveId(e.detail.id);
return ( <CertaraTree aria-label="Files" toggleOnClick onCertaraTreeExpandedChange={onExpandedChange} onCertaraTreeItemSelect={onItemSelect}> {getVisibleRows(rows, openIds).map((row) => { const open = openIds.includes(row.id); return ( <CertaraTreeItem key={row.id} itemId={row.id} level={row.level} expandable={row.expandable} open={open} active={row.id === activeId} setSize={row.setSize} posInSet={row.posInSet}> {row.expandable && <CertaraIcon slot="icon" variant="regular" icon={open ? 'folder-open' : 'folder'} className="text-tertiary" role="presentation" />} {row.label} </CertaraTreeItem> ); })} </CertaraTree> );}placeholder for loading, empty and error rows. Arrow keys skip them.import {CertaraTree, CertaraTreeItem} from '@certara/certara-ui-react';
// Loading and empty states are placeholder rows. Arrow keys skip them.export function CertaraTreePlaceholdersExample() { return ( <CertaraTree aria-label="Files"> <CertaraTreeItem itemId="a" level={1} expandable open loading> Loading folder </CertaraTreeItem> <CertaraTreeItem itemId="a::loading" level={2} placeholder> Loading… </CertaraTreeItem> <CertaraTreeItem itemId="b" level={1} expandable open> Empty folder </CertaraTreeItem> <CertaraTreeItem itemId="b::empty" level={2} placeholder> <span className="text-muted">Empty folder</span> </CertaraTreeItem> </CertaraTree> );}import {Component} from '@angular/core';import {CommonModule} from '@angular/common';import {CertaraUiAngularModule} from '@certara/certara-ui-angular';
@Component({ selector: 'app-tree-example', standalone: true, imports: [CommonModule, CertaraUiAngularModule], template: ` <certara-tree aria-label="Files" [toggleOnClick]="true" (certaraTreeExpandedChange)="expanded[$event.detail.id] = $event.detail.expanded" (certaraTreeItemSelect)="active = $event.detail.id"> <certara-tree-item itemId="docs" [level]="1" [expandable]="true" [open]="expanded['docs']" [setSize]="2" [posInSet]="1"> <certara-icon slot="icon" variant="regular" [name]="expanded['docs'] ? 'folder-open' : 'folder'" class="text-tertiary" role="presentation"></certara-icon> Documents </certara-tree-item> <certara-tree-item *ngIf="expanded['docs']" itemId="report" [level]="2" [setSize]="3" [posInSet]="1" [active]="active === 'report'">report.csv</certara-tree-item> <certara-tree-item *ngIf="expanded['docs']" itemId="summary" [level]="2" [setSize]="3" [posInSet]="2" [active]="active === 'summary'">summary.xlsx</certara-tree-item> <certara-tree-item *ngIf="expanded['docs']" itemId="drafts" [level]="2" [expandable]="true" [open]="expanded['drafts']" [setSize]="3" [posInSet]="3"> <certara-icon slot="icon" variant="regular" [name]="expanded['drafts'] ? 'folder-open' : 'folder'" class="text-tertiary" role="presentation"></certara-icon> Drafts </certara-tree-item> <certara-tree-item *ngIf="expanded['docs'] && expanded['drafts']" itemId="draft-1" [level]="3" [setSize]="2" [posInSet]="1">draft-1.docx</certara-tree-item> <certara-tree-item *ngIf="expanded['docs'] && expanded['drafts']" itemId="draft-2" [level]="3" [setSize]="2" [posInSet]="2">draft-2.docx</certara-tree-item> <certara-tree-item itemId="images" [level]="1" [expandable]="true" [open]="expanded['images']" [setSize]="2" [posInSet]="2"> <certara-icon slot="icon" variant="regular" [name]="expanded['images'] ? 'folder-open' : 'folder'" class="text-tertiary" role="presentation"></certara-icon> Images </certara-tree-item> <certara-tree-item *ngIf="expanded['images']" itemId="photo-1" [level]="2" [setSize]="2" [posInSet]="1">photo-1.png</certara-tree-item> <certara-tree-item *ngIf="expanded['images']" itemId="photo-2" [level]="2" [setSize]="2" [posInSet]="2">photo-2.png</certara-tree-item> </certara-tree> `,})export class TreeExampleComponent { expanded: Record<string, boolean> = {docs: true, drafts: false, images: false}; active = 'summary';}The default example needs a script so folders open and close and the active row moves. Place it in the same container as the tree, right after </certara-tree>:
<script> (() => { const tree = document.currentScript.parentElement.querySelector('certara-tree'); // Hides a row when any folder above it is collapsed. const syncVisibility = () => { let collapsedLevel = null; tree.querySelectorAll('certara-tree-item').forEach(row => { if (collapsedLevel !== null && row.level > collapsedLevel) { row.hidden = true; return; } row.hidden = false; collapsedLevel = row.expandable && !row.open ? row.level : null; }); }; tree.addEventListener('certaraTreeExpandedChange', ({ detail }) => { const row = tree.querySelector('[item-id="' + detail.id + '"]'); row.open = detail.expanded; // The folder icon does not follow the open state on its own, so swap it here too. const icon = row.querySelector('[slot="icon"]'); if (icon) icon.name = detail.expanded ? 'folder-open' : 'folder'; syncVisibility(); }); tree.addEventListener('certaraTreeItemSelect', ({ detail }) => { tree.querySelectorAll('certara-tree-item').forEach(row => (row.active = row.itemId === detail.id)); }); })();</script>Listen for the tree’s events and update the rows yourself, or render rows from a TreeModel, which handles expansion and lazy loading for you:
import {TreeModel} from '@certara/certara-ui';
const model = new TreeModel({loadChildren: (id) => fetchFolder(id)});model.setRoots(rootFolders);
tree.addEventListener('certaraTreeExpandedChange', (e) => model.setExpanded(e.detail.id, e.detail.expanded));model.subscribe(() => render(model.visibleRows)); // keyed: reuse existing <certara-tree-item>s by row.idVirtualized
Section titled “Virtualized”Virtualization renders only the rows inside the scroll viewport. In plain HTML, keep the visible rows in a scroll container inside <certara-tree>, add spacer elements above and below so the scrollbar reflects the full list, and set --certara-tree-row-height to the fixed row height so the spacer math matches. Handle certaraTreeEdgeNavigate so Home, End, and arrow keys can reach rows outside the window: scroll the target into view, then call focusItem(id).
useTreeRows(model) subscribes a component to a TreeModel (React 16.8+).
import {useMemo, useState} from 'react';import {CertaraTree, CertaraTreeItem, TreeModel, useTreeRows} from '@certara/certara-ui-react';
const placeholderText = {loading: 'Loading…', empty: 'Empty folder', error: 'Could not load folder'};
function FileTree({roots, fetchFolder}) { const model = useMemo(() => { const m = new TreeModel({loadChildren: (id) => fetchFolder(id)}); m.setRoots(roots); return m; }, []); const rows = useTreeRows(model); const [selectedId, setSelectedId] = useState(null);
return ( <CertaraTree aria-label="Files" onCertaraTreeExpandedChange={(e) => model.setExpanded(e.detail.id, e.detail.expanded)} onCertaraTreeItemSelect={(e) => setSelectedId(e.detail.id)}> {rows.map((row) => ( <CertaraTreeItem key={row.id} itemId={row.id} level={row.level} expandable={row.expandable} open={row.expanded} loading={row.loading} placeholder={row.kind !== 'node'} setSize={row.setSize} posInSet={row.posInSet} active={row.id === selectedId}> {row.kind === 'node' ? row.data.name : placeholderText[row.kind]} </CertaraTreeItem> ))} </CertaraTree> );}Put the scroll container inside <certara-tree> and render only the virtualizer’s rows. Handle certaraTreeEdgeNavigate so Home/End (and arrows past the rendered window) can reach rows that are not in the DOM.
const viewportRef = useRef(null);const treeRef = useRef(null);const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => viewportRef.current, estimateSize: () => 32, getItemKey: (index) => rows[index].id,});
const onEdgeNavigate = (e) => { const {key, fromId} = e.detail; const index = key === 'Home' ? 0 : key === 'End' ? rows.length - 1 : model.indexOf(fromId) + (key === 'ArrowUp' ? -1 : 1); if (!rows[index]) return; virtualizer.scrollToIndex(index); requestAnimationFrame(() => treeRef.current.focusItem(rows[index].id));};
<CertaraTree ref={treeRef} aria-label="Files" onCertaraTreeEdgeNavigate={onEdgeNavigate} /* ...other handlers */> <div ref={viewportRef} style={{height: 400, overflowY: 'auto'}}> <div style={{height: virtualizer.getTotalSize(), position: 'relative'}}> <div style={{transform: `translateY(${virtualizer.getVirtualItems()[0]?.start ?? 0}px)`}}> {virtualizer.getVirtualItems().map(({index}) => { const row = rows[index]; return <CertaraTreeItem key={row.id} itemId={row.id} level={row.level} /* ... */ />; })} </div> </div> </div></CertaraTree>;Create the model, set its roots in ngOnInit, and subscribe to re-read visibleRows on every change.
The virtualized version wraps the rows in a cdk-virtual-scroll-viewport inside <certara-tree>, so only the rows in view are in the DOM. It also handles certaraTreeEdgeNavigate, so Home, End, and arrow keys can reach rows that are not rendered. Set templateCacheSize: 0 so the CDK does not recycle a focused row’s element for a different row.
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild,} from '@angular/core';import { CdkVirtualScrollViewport, ScrollingModule,} from '@angular/cdk/scrolling';import {CertaraUiAngularModule} from '@certara/certara-ui-angular';import { TREE_ROW_HEIGHT, TreeEdgeNavigateDetail, TreeModel, TreeNodeInput, TreeRow,} from '@certara/certara-ui';
interface Folder { name: string;}
@Component({ selector: 'app-file-tree-virtual', standalone: true, imports: [ScrollingModule, CertaraUiAngularModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <certara-tree #tree aria-label="Files" (certaraTreeExpandedChange)="model.setExpanded($event.detail.id, $event.detail.expanded)" (certaraTreeItemSelect)="selectedId = $event.detail.id" (certaraTreeEdgeNavigate)="onEdgeNavigate($event.detail)"> <cdk-virtual-scroll-viewport [itemSize]="rowHeight" [style.--certara-tree-row-height]="rowHeight + 'px'" style="height: 400px"> <certara-tree-item *cdkVirtualFor="let row of rows; trackBy: trackById; templateCacheSize: 0" [itemId]="row.id" [level]="row.level" [expandable]="row.expandable" [open]="row.expanded" [loading]="row.loading" [placeholder]="row.kind !== 'node'" [setSize]="row.setSize" [posInSet]="row.posInSet" [active]="row.id === selectedId"> {{ row.kind === 'node' ? row.data?.name : row.kind === 'loading' ? 'Loading…' : 'Empty folder' }} </certara-tree-item> </cdk-virtual-scroll-viewport> </certara-tree> `,})export class FileTreeVirtualComponent implements OnInit, OnDestroy { // Row height in px, from the library so the virtualizer and the tree rows share one value. // The virtualizer needs it before any row renders, so it cannot be measured from the DOM. rowHeight = TREE_ROW_HEIGHT; @Input() roots: TreeNodeInput<Folder>[] = []; @Input() fetchFolder!: (id: string) => Promise<TreeNodeInput<Folder>[]>; @ViewChild('tree', {read: ElementRef}) tree!: ElementRef<HTMLCertaraTreeElement>; @ViewChild(CdkVirtualScrollViewport) viewport!: CdkVirtualScrollViewport;
model = new TreeModel<Folder>({loadChildren: (id) => this.fetchFolder(id)}); rows: TreeRow<Folder>[] = []; selectedId: string | null = null; private unsubscribe = this.model.subscribe(() => { this.rows = this.model.visibleRows; this.cdr.markForCheck(); });
constructor(private cdr: ChangeDetectorRef) {}
ngOnInit() { this.model.setRoots(this.roots); }
trackById = (_: number, row: TreeRow<Folder>) => row.id;
onEdgeNavigate({key, fromId}: TreeEdgeNavigateDetail) { if (fromId === null) return;
const index = this.model.indexOf(fromId); const parentId = this.model.getParentId(fromId); let target: number; switch (key) { case 'Home': target = 0; break; case 'End': target = this.rows.length - 1; break; case 'ArrowUp': target = index - 1; break; case 'ArrowDown': target = index + 1; break; case 'ArrowLeft': target = parentId === null ? -1 : this.model.indexOf(parentId); break; default: return; }
// Skip loading, empty, and error placeholders, which cannot take focus. const step = key === 'ArrowUp' || key === 'End' ? -1 : 1; while (this.rows[target] && this.rows[target].kind !== 'node') { target += step; }
const row = this.rows[target]; if (!row) return; this.viewport.scrollToIndex(target); requestAnimationFrame(() => this.tree.nativeElement.focusItem(row.id)); }
ngOnDestroy() { this.unsubscribe(); }}Keyboard
Section titled “Keyboard”Keys pressed inside embedded controls (checkboxes, buttons, inputs) keep their native behavior. Otherwise, keyboard navigation is handled by the <certara-tree> container (arrow keys, Home, and End).
certara-tree
Section titled “certara-tree”Properties
Section titled “Properties”activeId
| Attribute | active-id |
| Type | string |
| Default | null |
| Required | false |
| Description | Id of the row that holds the roving tab stop. |
selectionFollowsFocus
| Attribute | selection-follows-focus |
| Type | boolean |
| Default | false |
| Required | false |
| Description | When true, moving focus with the keyboard also emits certaraTreeItemSelect. |
selectionMode
| Attribute | selection-mode |
| Type | "none" | "single" |
| Default | 'single' |
| Required | false |
| Description | single emits certaraTreeItemSelect on click / Enter / Space; none never does. |
toggleOnClick
| Attribute | toggle-on-click |
| Type | boolean |
| Default | false |
| Required | false |
| Description | When true, clicking an expandable row toggles it instead of selecting it. |
Events
Section titled “Events”| Name | Description | Bubbles |
|---|---|---|
certaraTreeActiveChange | Emitted when the row holding the tab stop changes. | true |
certaraTreeEdgeNavigate | Emitted on Home / End, and when an arrow key’s target row is not rendered (e.g. scrolled out of a virtualized window). Scroll the target into view, then call focusItem(id). | true |
certaraTreeExpandedChange | Emitted when the user asks to expand or collapse a row. Update the row’s open in response. | true |
certaraTreeItemSelect | Emitted when the user selects a row (click, Enter, Space, or focus with selection-follows-focus). | true |
Methods
Section titled “Methods”| Name | Signature | Description |
|---|---|---|
focusItem | (id: string) => Promise<boolean> | Move focus to the row with this id and scroll it into view. Resolves false when the row is not rendered. |
There are no slots available for this component
CSS Custom Properties
Section titled “CSS Custom Properties”| Name | Description |
|---|---|
--certara-tree-row-height | Height of each tree row. Inherits from any ancestor, so set it on certara-tree or a scroll container, and use the same value in virtualizer spacer math. Falls back to 33px. |
certara-tree-item
Section titled “certara-tree-item”One row of a <certara-tree>. Rows are flat siblings (not nested): depth comes from level,
so the parent app can render exactly the visible rows, including through a virtualizer.
Use TreeModel.visibleRows to get the rows and their props.
Keyboard focus (roving tabindex), expansion and selection events are handled by the
enclosing <certara-tree>; this component only renders the row and its ARIA state.
Children are never moved: the row is a flex container and the “slots” below are placed with
CSS order, so React / Angular can freely add, remove and reorder row content.
level sets the indentation via 1-based depth.disabled row can take focus but cannot be selected or expanded.loading sets aria-busy on a row while its children load. It has no visual change of its own, so show the wait as a placeholder child row. Arrow keys skip the placeholder.setSize is the number of siblings at this level, posInSet is the 1-based position among siblingsslot="expander" replaces the chevron on an expandable row. Clicking it still toggles the row.slot="icon" places a leading icon after the expander.slot="end" places trailing content, such as a badge or an action, at the end of the row.Properties
Section titled “Properties”active
| Attribute | active |
| Type | boolean |
| Default | false |
| Required | false |
| Description | Whether the row is selected. |
disabled
| Attribute | disabled |
| Type | boolean |
| Default | false |
| Required | false |
| Description | Disabled rows can be focused but not selected or expanded. |
expandable
| Attribute | expandable |
| Type | boolean |
| Default | false |
| Required | false |
| Description | Whether the row has children. Renders the expander chevron. |
itemId
| Attribute | item-id |
| Type | string |
| Default | undefined |
| Required | true |
| Description | Unique id of the row. |
level
| Attribute | level |
| Type | number |
| Default | 1 |
| Required | false |
| Description | 1-based depth controls indentation. |
loading
| Attribute | loading |
| Type | boolean |
| Default | false |
| Required | false |
| Description | True while this row’s children are loading. |
open
| Attribute | open |
| Type | boolean |
| Default | false |
| Required | false |
| Description | Whether the row’s children are shown. |
placeholder
| Attribute | placeholder |
| Type | boolean |
| Default | false |
| Required | false |
| Description | Marks a non-navigable row such as a loading, empty or error placeholder. Arrow keys skip it, but focusable content inside it is still reachable with Tab key. |
posInSet
| Attribute | pos-in-set |
| Type | number |
| Default | undefined |
| Required | false |
| Description | 1-based position among siblings. |
setSize
| Attribute | set-size |
| Type | number |
| Default | undefined |
| Required | false |
| Description | Number of siblings at this level. |
Events
Section titled “Events”There are no events available for this component
Methods
Section titled “Methods”There are no public methods available for this component
| Name | Description |
|---|---|
default | Row label / content. |
end | Trailing content such as badges or actions. |
expander | Replaces the default chevron on expandable rows. Clicking it still toggles the row. |
icon | Leading icon, rendered after the expander. |