Vue d'ensemble
Un tree affiche des données hiérarchiques dont les éléments peuvent se déplier pour révéler leurs enfants ou se replier pour les masquer. Les utilisateurs naviguent avec les touches fléchées, déplient et replient les nœuds et, éventuellement, sélectionnent des éléments dans des scénarios de navigation ou de sélection de données.
TS
import {Component, signal} from '@angular/core';
import {NgTemplateOutlet} from '@angular/common';
import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';
type TreeNode = {
name: string;
value: string;
children?: TreeNode[];
disabled?: boolean;
expanded?: boolean;
};
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Tree, TreeItem, TreeItemGroup, NgTemplateOutlet],
})
export class App {
readonly nodes: TreeNode[] = [
{
name: 'public',
value: 'public',
children: [
{name: 'index.html', value: 'public/index.html'},
{name: 'favicon.ico', value: 'public/favicon.ico'},
{name: 'styles.css', value: 'public/styles.css'},
],
expanded: true,
},
{
name: 'src',
value: 'src',
children: [
{
name: 'app',
value: 'src/app',
children: [
{name: 'app.ts', value: 'src/app/app.ts'},
{name: 'app.html', value: 'src/app/app.html'},
{name: 'app.css', value: 'src/app/app.css'},
],
expanded: false,
},
{
name: 'assets',
value: 'src/assets',
children: [{name: 'logo.png', value: 'src/assets/logo.png'}],
expanded: false,
},
{
name: 'environments',
value: 'src/environments',
children: [
{
name: 'environment.prod.ts',
value: 'src/environments/environment.prod.ts',
expanded: false,
},
{name: 'environment.ts', value: 'src/environments/environment.ts'},
],
expanded: false,
},
{name: 'main.ts', value: 'src/main.ts'},
{name: 'polyfills.ts', value: 'src/polyfills.ts'},
{name: 'styles.css', value: 'src/styles.css', disabled: true},
{name: 'test.ts', value: 'src/test.ts'},
],
expanded: false,
},
{name: 'angular.json', value: 'angular.json'},
{name: 'package.json', value: 'package.json'},
{name: 'README.md', value: 'README.md'},
];
readonly selected = signal(['angular.json']);
}
HTML
<ul ngTree #tree="ngTree" [(value)]="selected" class="basic-tree">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: nodes, parent: tree}"
/>
</ul>
<ng-template #treeNodes let-nodes="nodes" let-parent="parent">
@for (node of nodes; track node.value) {
<li
ngTreeItem
[parent]="parent"
[value]="node.value"
[label]="node.name"
[disabled]="node.disabled"
[(expanded)]="node.expanded"
#treeItem="ngTreeItem"
>
<span aria-hidden="true" class="material-symbols-outlined expand-icon" translate="no">{{
node.children ? 'chevron_right' : ''
}}</span>
<span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
node.children ? 'folder' : 'docs'
}}</span>
{{ node.name }}
<span aria-hidden="true" class="material-symbols-outlined selected-icon" translate="no"
>check</span
>
</li>
@if (node.children) {
<ul role="group">
<ng-template ngTreeItemGroup [ownedBy]="treeItem" #group="ngTreeItemGroup">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: node.children, parent: group}"
/>
</ng-template>
</ul>
}
}
</ng-template>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
user-select: none;
font-family: var(--inter-font);
}
[ngTree] {
min-width: 24rem;
background-color: var(--septenary-contrast);
border-radius: 0.5rem;
padding: 0.5rem;
}
[ngTreeItem] {
cursor: pointer;
list-style: none;
text-decoration: none;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.3rem 1rem;
}
[ngTreeItem][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
[ngTreeItem]:focus,
[ngTreeItem]:hover {
background-color: var(--quinary-contrast);
}
[ngTreeItem]:focus {
outline: 1px solid color-mix(in srgb, var(--hot-pink) 60%, transparent);
}
[ngTreeItem][aria-selected='true'],
[ngTreeItem][aria-selected='true'] .expand-icon {
background-image: var(--pink-to-purple-horizontal-gradient);
background-clip: text;
color: transparent;
}
.material-symbols-outlined {
margin: 0;
width: 24px;
}
.expand-icon {
transition: transform 0.2s ease;
}
[ngTreeItem][aria-expanded='true'] .expand-icon {
transform: rotate(90deg);
}
.selected-icon {
visibility: hidden;
margin-left: auto;
}
[ngTreeItem][aria-current] .selected-icon,
[ngTreeItem][aria-selected='true'] .selected-icon {
visibility: visible;
}
li[aria-expanded='false'] + ul[role='group'] {
display: none;
}
Utilisation
Les trees conviennent bien pour afficher des données hiérarchiques dans lesquelles les utilisateurs doivent parcourir des structures imbriquées.
Utilisez des trees lorsque vous :
- Construisez une navigation dans un système de fichiers
- Affichez des hiérarchies de dossiers et de documents
- Créez des structures de menus imbriqués
- Affichez des organigrammes
- Parcourez des données hiérarchiques
- Implémentez une navigation de site avec des sections imbriquées
Évitez les trees lorsque vous :
- Affichez des listes plates (utilisez plutôt Listbox)
- Affichez des tableaux de données (utilisez plutôt Grid)
- Créez des listes déroulantes simples (utilisez plutôt Select)
- Construisez un fil d'Ariane (utilisez des patterns breadcrumb)
Fonctionnalités
- Navigation hiérarchique - Structure arborescente imbriquée, avec dépliage et repliage
- Modes de sélection - Sélection unique ou multiple, avec un comportement explicite ou qui suit le focus
- La sélection suit le focus - Sélection automatique facultative lorsque le focus change
- Navigation au clavier - Touches fléchées, Début, Fin et recherche par saisie (type-ahead)
- Déplier/replier - Flèches Droite/Gauche ou Entrée pour basculer les nœuds parents
- Éléments désactivés - Désactivez des nœuds spécifiques avec gestion du focus
- Modes de focus - Stratégies de focus roving tabindex ou activedescendant
- Prise en charge RTL - Navigation pour les langues de droite à gauche
Exemples
Tree de navigation
Utilisez un tree pour la navigation lorsque le clic sur les éléments déclenche des actions plutôt que de les sélectionner.
TS
import {Component, signal} from '@angular/core';
import {NgTemplateOutlet} from '@angular/common';
import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';
type TreeNode = {
name: string;
value: string;
icon: string;
children?: TreeNode[];
disabled?: boolean;
expanded?: boolean;
};
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Tree, TreeItem, TreeItemGroup, NgTemplateOutlet],
})
export class App {
readonly nodes: TreeNode[] = [
{
name: 'Inbox',
value: 'inbox',
icon: 'inbox',
},
{
name: 'Sent',
value: 'sent',
icon: 'send',
},
{
name: 'Drafts',
value: 'drafts',
icon: 'draft',
},
{
name: 'Spam',
value: 'spam',
icon: 'report',
},
{
name: 'Trash',
value: 'trash',
icon: 'delete',
},
{
name: 'Labels',
value: 'labels',
expanded: true,
icon: 'label',
children: [
{name: 'Personal', value: 'folders/personal', icon: 'label'},
{name: 'Work', value: 'folders/work', icon: 'label'},
{name: 'Travel', value: 'folders/travel', icon: 'label'},
{name: 'Receipts', value: 'folders/receipts', icon: 'label'},
],
},
];
readonly selected = signal(['inbox']);
}
HTML
<ul ngTree #tree="ngTree" [nav]="true" [(value)]="selected" class="basic-tree">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: nodes, parent: tree}"
/>
</ul>
<ng-template #treeNodes let-nodes="nodes" let-parent="parent">
@for (node of nodes; track node.value) {
<a
ngTreeItem
[parent]="parent"
[value]="node.value"
[label]="node.name"
[disabled]="node.disabled"
[selectable]="!node.children"
[(expanded)]="node.expanded"
#treeItem="ngTreeItem"
href="#{{ node.name }}"
(click)="$event.preventDefault()"
>
<span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
node.icon
}}</span>
{{ node.name }}
<span aria-hidden="true" class="material-symbols-outlined expand-icon" translate="no">{{
node.children ? 'keyboard_arrow_up' : ''
}}</span>
</a>
@if (node.children) {
<ul role="group">
<ng-template ngTreeItemGroup [ownedBy]="treeItem" #group="ngTreeItemGroup">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: node.children, parent: group}"
/>
</ng-template>
</ul>
}
}
</ng-template>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
user-select: none;
font-family: var(--inter-font);
}
[ngTree] {
min-width: 24rem;
background-color: var(--septenary-contrast);
border-radius: 0.5rem;
padding: 0.5rem;
}
[ngTreeItem] {
cursor: pointer;
list-style: none;
text-decoration: none;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.3rem 1rem;
color: var(--primary-contrast);
}
[ngTreeItem][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
[ngTreeItem]:focus,
[ngTreeItem]:hover {
background-color: var(--quinary-contrast);
}
[ngTreeItem]:focus {
outline: 1px solid color-mix(in srgb, var(--hot-pink) 60%, transparent);
}
[ngTreeItem][aria-current] {
background-image: var(--pink-to-purple-horizontal-gradient);
background-clip: text;
color: transparent;
}
.material-symbols-outlined {
margin: 0;
width: 24px;
}
.expand-icon {
transition: transform 0.2s ease;
}
[ngTreeItem][aria-expanded='true'] .expand-icon {
transform: rotate(180deg);
}
li[aria-expanded='false'] + ul[role='group'] {
display: none;
}
Définissez [nav]="true" pour activer le mode navigation. Celui-ci utilise aria-current pour indiquer la page courante au lieu de la sélection.
Sélection unique
Activez la sélection unique pour les scénarios où les utilisateurs choisissent un seul élément du tree.
TS
import {Component, signal} from '@angular/core';
import {NgTemplateOutlet} from '@angular/common';
import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';
type TreeNode = {
name: string;
value: string;
children?: TreeNode[];
disabled?: boolean;
expanded?: boolean;
};
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Tree, TreeItem, TreeItemGroup, NgTemplateOutlet],
})
export class App {
readonly nodes: TreeNode[] = [
{
name: 'public',
value: 'public',
children: [
{name: 'index.html', value: 'public/index.html'},
{name: 'favicon.ico', value: 'public/favicon.ico'},
{name: 'styles.css', value: 'public/styles.css'},
],
expanded: true,
},
{
name: 'src',
value: 'src',
children: [
{
name: 'app',
value: 'src/app',
children: [
{name: 'app.ts', value: 'src/app/app.ts'},
{name: 'app.html', value: 'src/app/app.html'},
{name: 'app.css', value: 'src/app/app.css'},
],
expanded: false,
},
{
name: 'assets',
value: 'src/assets',
children: [{name: 'logo.png', value: 'src/assets/logo.png'}],
expanded: false,
},
{
name: 'environments',
value: 'src/environments',
children: [
{
name: 'environment.prod.ts',
value: 'src/environments/environment.prod.ts',
expanded: false,
},
{name: 'environment.ts', value: 'src/environments/environment.ts'},
],
expanded: false,
},
{name: 'main.ts', value: 'src/main.ts'},
{name: 'polyfills.ts', value: 'src/polyfills.ts'},
{name: 'styles.css', value: 'src/styles.css', disabled: true},
{name: 'test.ts', value: 'src/test.ts'},
],
expanded: false,
},
{name: 'angular.json', value: 'angular.json'},
{name: 'package.json', value: 'package.json'},
{name: 'README.md', value: 'README.md'},
];
readonly selected = signal(['angular.json']);
}
HTML
<ul ngTree #tree="ngTree" [(value)]="selected" class="basic-tree">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: nodes, parent: tree}"
/>
</ul>
<ng-template #treeNodes let-nodes="nodes" let-parent="parent">
@for (node of nodes; track node.value) {
<li
ngTreeItem
[parent]="parent"
[value]="node.value"
[label]="node.name"
[disabled]="node.disabled"
[(expanded)]="node.expanded"
#treeItem="ngTreeItem"
>
<span aria-hidden="true" class="material-symbols-outlined expand-icon" translate="no">{{
node.children ? 'chevron_right' : ''
}}</span>
<span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
node.children ? 'folder' : 'docs'
}}</span>
{{ node.name }}
<span aria-hidden="true" class="material-symbols-outlined selected-icon" translate="no"
>check</span
>
</li>
@if (node.children) {
<ul role="group">
<ng-template ngTreeItemGroup [ownedBy]="treeItem" #group="ngTreeItemGroup">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: node.children, parent: group}"
/>
</ng-template>
</ul>
}
}
</ng-template>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
user-select: none;
font-family: var(--inter-font);
}
[ngTree] {
min-width: 24rem;
background-color: var(--septenary-contrast);
border-radius: 0.5rem;
padding: 0.5rem;
}
[ngTreeItem] {
cursor: pointer;
list-style: none;
text-decoration: none;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.3rem 1rem;
}
[ngTreeItem][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
[ngTreeItem]:focus,
[ngTreeItem]:hover {
background-color: var(--quinary-contrast);
}
[ngTreeItem]:focus {
outline: 1px solid color-mix(in srgb, var(--hot-pink) 60%, transparent);
}
[ngTreeItem][aria-selected='true'],
[ngTreeItem][aria-selected='true'] .expand-icon {
background-image: var(--pink-to-purple-horizontal-gradient);
background-clip: text;
color: transparent;
}
.material-symbols-outlined {
margin: 0;
width: 24px;
}
.expand-icon {
transition: transform 0.2s ease;
}
[ngTreeItem][aria-expanded='true'] .expand-icon {
transform: rotate(90deg);
}
.selected-icon {
visibility: hidden;
margin-left: auto;
}
[ngTreeItem][aria-current] .selected-icon,
[ngTreeItem][aria-selected='true'] .selected-icon {
visibility: visible;
}
li[aria-expanded='false'] + ul[role='group'] {
display: none;
}
TS
import {Component, computed, signal} from '@angular/core';
import {NgTemplateOutlet} from '@angular/common';
import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';
type TreeNode = {
name: string;
value: string;
children?: TreeNode[];
disabled?: boolean;
expanded?: boolean;
};
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Tree, TreeItem, TreeItemGroup, NgTemplateOutlet],
})
export class App {
readonly nodes: readonly TreeNode[] = [
{
name: 'C:',
value: 'C:',
expanded: true,
children: [
{
name: 'Program Files/',
value: 'C:/Program Files',
children: [
{name: 'Common Files', value: 'C:/Program Files/Common Files'},
{name: 'Internet Explorer', value: 'C:/Program Files/Internet Explorer'},
],
expanded: false,
},
{
name: 'Users/',
value: 'C:/Users',
children: [
{name: 'Default', value: 'C:/Users/Default'},
{name: 'Public', value: 'C:/Users/Public'},
],
expanded: false,
},
{
name: 'Windows/',
value: 'C:/Windows',
children: [
{name: 'System32', value: 'C:/Windows/System32'},
{name: 'Web', value: 'C:/Windows/Web'},
],
expanded: false,
},
{name: 'pagefile.sys', value: 'C:/pagefile.sys'},
{name: 'swapfile.sys', value: 'C:/swapfile.sys', disabled: true},
],
},
];
readonly selected = signal([]);
readonly selectedCount = computed(() => this.selected().length);
}
HTML
<div class="win95-file-explorer">
<div class="title-bar">
<div class="title-bar-text">Exploring - (C:)</div>
<div class="title-bar-controls">
<button tabindex="-1" aria-label="Minimize"><span>-</span></button>
<button tabindex="-1" aria-label="Maximize"><span>□</span></button>
<button tabindex="-1" aria-label="Close"><span>×</span></button>
</div>
</div>
<div class="menu-bar">
<div class="menu-item"><u>F</u>ile</div>
<div class="menu-item"><u>E</u>dit</div>
<div class="menu-item"><u>V</u>iew</div>
<div class="menu-item"><u>T</u>ools</div>
<div class="menu-item"><u>H</u>elp</div>
</div>
<div class="toolbar">
<button tabindex="-1" class="win95-btn"><span class="icon">←</span> Back</button>
<button tabindex="-1" class="win95-btn"><span class="icon">→</span> Forward</button>
<button tabindex="-1" class="win95-btn"><span class="icon">↑</span> Up</button>
</div>
<div class="tree-view">
<ul ngTree #tree="ngTree" class="retro-tree" [(value)]="selected">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: nodes, parent: tree}"
/>
</ul>
</div>
<div class="status-bar">
<div class="status-panel status-panel-grow">{{ selectedCount() }} object(s) selected</div>
<div class="status-panel status-panel-right">4.5MB</div>
</div>
</div>
<ng-template #treeNodes let-nodes="nodes" let-parent="parent">
@for (node of nodes; track node.value) {
<li
ngTreeItem
[parent]="parent"
[value]="node.value"
[label]="node.name"
[(expanded)]="node.expanded"
[disabled]="node.disabled"
#treeItem="ngTreeItem"
>
<span aria-hidden="true">
@if (node.children) {
{{ treeItem.expanded() ? '📂' : '📁' }}
} @else {
📄
}
</span>
{{ node.name }}
<span aria-hidden="true" class="material-symbols-outlined selected-icon" translate="no"
>check</span
>
</li>
@if (node.children) {
<ul role="group">
<ng-template ngTreeItemGroup [ownedBy]="treeItem" #group="ngTreeItemGroup">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: node.children, parent: group}"
/>
</ng-template>
</ul>
}
}
</ng-template>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Jersey+20&display=swap');
:host {
display: flex;
justify-content: center;
user-select: none;
--win95-gray: #c0c0c0;
--win95-dark-gray: #808080;
--win95-light: #ffffff;
--win95-shadow: #000000;
--win95-blue: #000080;
--win95-active-blue: linear-gradient(to right, #000080, #1084d0);
--win95-font: "Jersey 20", sans-serif;
font-family: var(--win95-font);
font-size: 1.1rem;
}
.win95-file-explorer {
border-style: solid;
border-width: 1px;
border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
box-shadow: 1px 1px 0 var(--win95-shadow);
background-color: var(--win95-gray);
min-width: 350px;
}
.win95-btn {
background-color: var(--win95-gray);
padding: 3px 8px;
cursor: pointer;
border-style: solid;
border-width: 1px;
border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
box-shadow: 1px 1px 0 var(--win95-shadow);
}
.win95-btn:active {
box-shadow: 1px 1px 0 var(--win95-shadow) inset;
padding: 4px 7px 2px 9px;
border-style: solid;
border-width: 1px;
border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
}
.title-bar {
background: var(--win95-active-blue);
color: var(--win95-light);
padding: 3px 6px;
height: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.title-bar-text {
font-weight: bold;
}
.title-bar-controls button {
background: var(--win95-gray);
border: 1px solid var(--win95-light);
color: var(--win95-shadow);
width: 16px;
height: 14px;
line-height: 8px;
font-size: 14px;
padding: 0;
margin-left: 1px;
border-style: solid;
border-width: 1px;
border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
box-shadow: 1px 1px 0 var(--win95-shadow);
}
.title-bar-controls button span {
display: block;
margin-top: -3px;
}
.menu-bar {
display: flex;
background-color: var(--win95-gray);
border-bottom: 1px solid var(--win95-dark-gray);
padding: 1px 2px;
}
.menu-item {
padding: 0 6px;
cursor: default;
margin-right: 4px;
}
.menu-item:hover {
background-color: var(--win95-blue);
color: var(--win95-light);
}
.toolbar {
display: flex;
align-items: center;
padding: 4px;
border-top: 1px solid var(--win95-light);
border-bottom: 1px solid var(--win95-dark-gray);
}
.toolbar .win95-btn {
display: flex;
align-items: center;
margin-right: 8px;
}
.toolbar .icon {
font-size: 1.25rem;
line-height: 1;
margin-right: 4px;
}
.tree-view {
background-color: var(--win95-light);
min-height: 300px;
padding: 4px;
border-width: 1px;
border-style: solid;
border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
color: var(--win95-shadow);
}
.status-bar {
margin-top: 2px;
}
.status-panel-grow {
flex-grow: 1;
}
.status-panel-right {
text-align: right;
}
.status-bar {
height: 18px;
background-color: var(--win95-gray);
border-top: 1px solid var(--win95-light);
border-left: 1px solid var(--win95-light);
border-right: 1px solid var(--win95-dark-gray);
border-bottom: 1px solid var(--win95-dark-gray);
display: flex;
font-size: 14px;
}
.status-panel {
padding: 0 4px;
height: 100%;
display: flex;
align-items: center;
margin-right: 1px;
border-style: solid;
border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
border-width: 1px;
flex-grow: 1;
}
.status-panel:last-child {
flex-grow: 0;
width: 120px;
}
[ngTree] {
padding: 0;
margin: 0;
}
[ngTreeItem] {
cursor: pointer;
list-style: none;
text-decoration: none;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.25rem 0.75rem;
}
[ngTreeItem][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
[ngTreeItem]:focus,
[ngTreeItem]:hover {
background-color: var(--senary-contrast);
color: var(--primary-contrast);
outline: none;
}
[ngTreeItem][aria-selected='true'] {
background-color: var(--win95-blue);
color: var(--win95-light);
}
.selected-icon {
visibility: hidden;
margin-left: auto;
}
[ngTreeItem][aria-current] .selected-icon,
[ngTreeItem][aria-selected='true'] .selected-icon {
visibility: visible;
}
li[aria-expanded='false'] + ul[role='group'] {
display: none;
}
Laissez [multi]="false" (la valeur par défaut) pour une sélection unique. Les utilisateurs appuient sur Espace pour sélectionner l'élément qui a le focus.
Sélection multiple
Permettez aux utilisateurs de sélectionner plusieurs éléments du tree.
TS
import {Component, signal} from '@angular/core';
import {NgTemplateOutlet} from '@angular/common';
import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';
type TreeNode = {
name: string;
value: string;
children?: TreeNode[];
disabled?: boolean;
expanded?: boolean;
};
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Tree, TreeItem, TreeItemGroup, NgTemplateOutlet],
})
export class App {
readonly nodes: TreeNode[] = [
{
name: 'public',
value: 'public',
children: [
{name: 'index.html', value: 'public/index.html'},
{name: 'favicon.ico', value: 'public/favicon.ico'},
{name: 'styles.css', value: 'public/styles.css'},
],
expanded: true,
},
{
name: 'src',
value: 'src',
children: [
{
name: 'app',
value: 'src/app',
children: [
{name: 'app.ts', value: 'src/app/app.ts'},
{name: 'app.html', value: 'src/app/app.html'},
{name: 'app.css', value: 'src/app/app.css'},
],
expanded: false,
},
{
name: 'assets',
value: 'src/assets',
children: [{name: 'logo.png', value: 'src/assets/logo.png'}],
expanded: false,
},
{
name: 'environments',
value: 'src/environments',
children: [
{
name: 'environment.prod.ts',
value: 'src/environments/environment.prod.ts',
expanded: false,
},
{name: 'environment.ts', value: 'src/environments/environment.ts'},
],
expanded: false,
},
{name: 'main.ts', value: 'src/main.ts'},
{name: 'polyfills.ts', value: 'src/polyfills.ts'},
{name: 'styles.css', value: 'src/styles.css', disabled: true},
{name: 'test.ts', value: 'src/test.ts'},
],
expanded: false,
},
{name: 'angular.json', value: 'angular.json'},
{name: 'package.json', value: 'package.json'},
{name: 'README.md', value: 'README.md'},
];
readonly selected = signal(['angular.json', 'public/styles.css']);
}
HTML
<ul ngTree #tree="ngTree" [multi]="true" [(value)]="selected" class="basic-tree">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: nodes, parent: tree}"
/>
</ul>
<ng-template #treeNodes let-nodes="nodes" let-parent="parent">
@for (node of nodes; track node.value) {
<li
ngTreeItem
[parent]="parent"
[value]="node.value"
[label]="node.name"
[disabled]="node.disabled"
[(expanded)]="node.expanded"
#treeItem="ngTreeItem"
>
<span aria-hidden="true" class="material-symbols-outlined expand-icon" translate="no">{{
node.children ? 'chevron_right' : ''
}}</span>
<span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
node.children ? 'folder' : 'docs'
}}</span>
{{ node.name }}
<span aria-hidden="true" class="material-symbols-outlined selected-icon" translate="no"
>check</span
>
</li>
@if (node.children) {
<ul role="group">
<ng-template ngTreeItemGroup [ownedBy]="treeItem" #group="ngTreeItemGroup">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: node.children, parent: group}"
/>
</ng-template>
</ul>
}
}
</ng-template>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
user-select: none;
font-family: var(--inter-font);
}
[ngTree] {
min-width: 24rem;
background-color: var(--septenary-contrast);
border-radius: 0.5rem;
padding: 0.5rem;
}
[ngTreeItem] {
cursor: pointer;
list-style: none;
text-decoration: none;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.3rem 1rem;
}
[ngTreeItem][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
[ngTreeItem]:focus,
[ngTreeItem]:hover {
background-color: var(--quinary-contrast);
}
[ngTreeItem]:focus {
outline: 1px solid color-mix(in srgb, var(--hot-pink) 60%, transparent);
}
[ngTreeItem][aria-selected='true'],
[ngTreeItem][aria-selected='true'] .expand-icon {
background-image: var(--pink-to-purple-horizontal-gradient);
background-clip: text;
color: transparent;
}
.material-symbols-outlined {
margin: 0;
width: 24px;
}
.expand-icon {
transition: transform 0.2s ease;
}
[ngTreeItem][aria-expanded='true'] .expand-icon {
transform: rotate(90deg);
}
.selected-icon {
visibility: hidden;
margin-left: auto;
}
[ngTreeItem][aria-current] .selected-icon,
[ngTreeItem][aria-selected='true'] .selected-icon {
visibility: visible;
}
li[aria-expanded='false'] + ul[role='group'] {
display: none;
}
TS
import {Component, computed, signal} from '@angular/core';
import {NgTemplateOutlet} from '@angular/common';
import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';
type TreeNode = {
name: string;
value: string;
children?: TreeNode[];
disabled?: boolean;
expanded?: boolean;
};
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Tree, TreeItem, TreeItemGroup, NgTemplateOutlet],
})
export class App {
readonly nodes: readonly TreeNode[] = [
{
name: 'C:',
value: 'C:',
expanded: true,
children: [
{
name: 'Program Files/',
value: 'C:/Program Files',
children: [
{name: 'Common Files', value: 'C:/Program Files/Common Files'},
{name: 'Internet Explorer', value: 'C:/Program Files/Internet Explorer'},
],
expanded: false,
},
{
name: 'Users/',
value: 'C:/Users',
children: [
{name: 'Default', value: 'C:/Users/Default'},
{name: 'Public', value: 'C:/Users/Public'},
],
expanded: false,
},
{
name: 'Windows/',
value: 'C:/Windows',
children: [
{name: 'System32', value: 'C:/Windows/System32'},
{name: 'Web', value: 'C:/Windows/Web'},
],
expanded: false,
},
{name: 'pagefile.sys', value: 'C:/pagefile.sys'},
{name: 'swapfile.sys', value: 'C:/swapfile.sys', disabled: true},
],
},
];
readonly selected = signal([]);
readonly selectedCount = computed(() => this.selected().length);
}
HTML
<div class="win95-file-explorer">
<div class="title-bar">
<div class="title-bar-text">Exploring - (C:)</div>
<div class="title-bar-controls">
<button tabindex="-1" aria-label="Minimize"><span>-</span></button>
<button tabindex="-1" aria-label="Maximize"><span>□</span></button>
<button tabindex="-1" aria-label="Close"><span>×</span></button>
</div>
</div>
<div class="menu-bar">
<div class="menu-item"><u>F</u>ile</div>
<div class="menu-item"><u>E</u>dit</div>
<div class="menu-item"><u>V</u>iew</div>
<div class="menu-item"><u>T</u>ools</div>
<div class="menu-item"><u>H</u>elp</div>
</div>
<div class="toolbar">
<button tabindex="-1" class="win95-btn"><span class="icon">←</span> Back</button>
<button tabindex="-1" class="win95-btn"><span class="icon">→</span> Forward</button>
<button tabindex="-1" class="win95-btn"><span class="icon">↑</span> Up</button>
</div>
<div class="tree-view">
<ul ngTree #tree="ngTree" class="retro-tree" [(value)]="selected" [multi]="true">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: nodes, parent: tree}"
/>
</ul>
</div>
<div class="status-bar">
<div class="status-panel status-panel-grow">{{ selectedCount() }} object(s) selected</div>
<div class="status-panel status-panel-right">4.5MB</div>
</div>
</div>
<ng-template #treeNodes let-nodes="nodes" let-parent="parent">
@for (node of nodes; track node.value) {
<li
ngTreeItem
[parent]="parent"
[value]="node.value"
[label]="node.name"
[(expanded)]="node.expanded"
[disabled]="node.disabled"
#treeItem="ngTreeItem"
>
<span aria-hidden="true">
@if (node.children) {
{{ treeItem.expanded() ? '📂' : '📁' }}
} @else {
📄
}
</span>
{{ node.name }}
<span aria-hidden="true" class="material-symbols-outlined selected-icon" translate="no"
>check</span
>
</li>
@if (node.children) {
<ul role="group">
<ng-template ngTreeItemGroup [ownedBy]="treeItem" #group="ngTreeItemGroup">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: node.children, parent: group}"
/>
</ng-template>
</ul>
}
}
</ng-template>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Jersey+20&display=swap');
:host {
display: flex;
justify-content: center;
user-select: none;
--win95-gray: #c0c0c0;
--win95-dark-gray: #808080;
--win95-light: #ffffff;
--win95-shadow: #000000;
--win95-blue: #000080;
--win95-active-blue: linear-gradient(to right, #000080, #1084d0);
--win95-font: "Jersey 20", sans-serif;
font-family: var(--win95-font);
font-size: 1.1rem;
}
.win95-file-explorer {
border-style: solid;
border-width: 1px;
border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
box-shadow: 1px 1px 0 var(--win95-shadow);
background-color: var(--win95-gray);
min-width: 350px;
}
.win95-btn {
background-color: var(--win95-gray);
padding: 3px 8px;
cursor: pointer;
border-style: solid;
border-width: 1px;
border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
box-shadow: 1px 1px 0 var(--win95-shadow);
}
.win95-btn:active {
box-shadow: 1px 1px 0 var(--win95-shadow) inset;
padding: 4px 7px 2px 9px;
border-style: solid;
border-width: 1px;
border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
}
.title-bar {
background: var(--win95-active-blue);
color: var(--win95-light);
padding: 3px 6px;
height: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.title-bar-text {
font-weight: bold;
}
.title-bar-controls button {
background: var(--win95-gray);
border: 1px solid var(--win95-light);
color: var(--win95-shadow);
width: 16px;
height: 14px;
line-height: 8px;
font-size: 14px;
padding: 0;
margin-left: 1px;
border-style: solid;
border-width: 1px;
border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
box-shadow: 1px 1px 0 var(--win95-shadow);
}
.title-bar-controls button span {
display: block;
margin-top: -3px;
}
.menu-bar {
display: flex;
background-color: var(--win95-gray);
border-bottom: 1px solid var(--win95-dark-gray);
padding: 1px 2px;
}
.menu-item {
padding: 0 6px;
cursor: default;
margin-right: 4px;
}
.menu-item:hover {
background-color: var(--win95-blue);
color: var(--win95-light);
}
.toolbar {
display: flex;
align-items: center;
padding: 4px;
border-top: 1px solid var(--win95-light);
border-bottom: 1px solid var(--win95-dark-gray);
}
.toolbar .win95-btn {
display: flex;
align-items: center;
margin-right: 8px;
}
.toolbar .icon {
font-size: 1.25rem;
line-height: 1;
margin-right: 4px;
}
.tree-view {
background-color: var(--win95-light);
min-height: 300px;
padding: 4px;
border-width: 1px;
border-style: solid;
border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
color: var(--win95-shadow);
}
.status-bar {
margin-top: 2px;
}
.status-panel-grow {
flex-grow: 1;
}
.status-panel-right {
text-align: right;
}
.status-bar {
height: 18px;
background-color: var(--win95-gray);
border-top: 1px solid var(--win95-light);
border-left: 1px solid var(--win95-light);
border-right: 1px solid var(--win95-dark-gray);
border-bottom: 1px solid var(--win95-dark-gray);
display: flex;
font-size: 14px;
}
.status-panel {
padding: 0 4px;
height: 100%;
display: flex;
align-items: center;
margin-right: 1px;
border-style: solid;
border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
border-width: 1px;
flex-grow: 1;
}
.status-panel:last-child {
flex-grow: 0;
width: 120px;
}
[ngTree] {
padding: 0;
margin: 0;
}
[ngTreeItem] {
cursor: pointer;
list-style: none;
text-decoration: none;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.25rem 0.75rem;
}
[ngTreeItem][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
[ngTreeItem]:focus,
[ngTreeItem]:hover {
background-color: var(--senary-contrast);
color: var(--primary-contrast);
outline: none;
}
[ngTreeItem][aria-selected='true'] {
background-color: var(--win95-blue);
color: var(--win95-light);
}
.selected-icon {
visibility: hidden;
margin-left: auto;
}
[ngTreeItem][aria-current] .selected-icon,
[ngTreeItem][aria-selected='true'] .selected-icon {
visibility: visible;
}
li[aria-expanded='false'] + ul[role='group'] {
display: none;
}
Définissez [multi]="true" sur le tree. Les utilisateurs sélectionnent les éléments un par un avec Espace, ou des plages avec Maj+touches fléchées.
La sélection suit le focus
Lorsque la sélection suit le focus, l'élément qui a le focus est automatiquement sélectionné. Cela simplifie l'interaction dans les scénarios de navigation.
TS
import {Component, signal} from '@angular/core';
import {NgTemplateOutlet} from '@angular/common';
import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';
type TreeNode = {
name: string;
value: string;
children?: TreeNode[];
disabled?: boolean;
expanded?: boolean;
};
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Tree, TreeItem, TreeItemGroup, NgTemplateOutlet],
})
export class App {
readonly nodes: TreeNode[] = [
{
name: 'public',
value: 'public',
children: [
{name: 'index.html', value: 'public/index.html'},
{name: 'favicon.ico', value: 'public/favicon.ico'},
{name: 'styles.css', value: 'public/styles.css'},
],
expanded: true,
},
{
name: 'src',
value: 'src',
children: [
{
name: 'app',
value: 'src/app',
children: [
{name: 'app.ts', value: 'src/app/app.ts'},
{name: 'app.html', value: 'src/app/app.html'},
{name: 'app.css', value: 'src/app/app.css'},
],
expanded: false,
},
{
name: 'assets',
value: 'src/assets',
children: [{name: 'logo.png', value: 'src/assets/logo.png'}],
expanded: false,
},
{
name: 'environments',
value: 'src/environments',
children: [
{
name: 'environment.prod.ts',
value: 'src/environments/environment.prod.ts',
expanded: false,
},
{name: 'environment.ts', value: 'src/environments/environment.ts'},
],
expanded: false,
},
{name: 'main.ts', value: 'src/main.ts'},
{name: 'polyfills.ts', value: 'src/polyfills.ts'},
{name: 'styles.css', value: 'src/styles.css', disabled: true},
{name: 'test.ts', value: 'src/test.ts'},
],
expanded: false,
},
{name: 'angular.json', value: 'angular.json'},
{name: 'package.json', value: 'package.json'},
{name: 'README.md', value: 'README.md'},
];
readonly selected = signal(['angular.json']);
}
HTML
<ul ngTree #tree="ngTree" [(value)]="selected" selectionMode="follow" class="basic-tree">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: nodes, parent: tree}"
/>
</ul>
<ng-template #treeNodes let-nodes="nodes" let-parent="parent">
@for (node of nodes; track node.value) {
<li
ngTreeItem
[parent]="parent"
[value]="node.value"
[label]="node.name"
[disabled]="node.disabled"
[(expanded)]="node.expanded"
#treeItem="ngTreeItem"
>
<span aria-hidden="true" class="material-symbols-outlined expand-icon" translate="no">{{
node.children ? 'chevron_right' : ''
}}</span>
<span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
node.children ? 'folder' : 'docs'
}}</span>
{{ node.name }}
<span aria-hidden="true" class="material-symbols-outlined selected-icon" translate="no"
>check</span
>
</li>
@if (node.children) {
<ul role="group">
<ng-template ngTreeItemGroup [ownedBy]="treeItem" #group="ngTreeItemGroup">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: node.children, parent: group}"
/>
</ng-template>
</ul>
}
}
</ng-template>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
user-select: none;
font-family: var(--inter-font);
}
[ngTree] {
min-width: 24rem;
background-color: var(--septenary-contrast);
border-radius: 0.5rem;
padding: 0.5rem;
}
[ngTreeItem] {
cursor: pointer;
list-style: none;
text-decoration: none;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.3rem 1rem;
}
[ngTreeItem][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
[ngTreeItem]:focus,
[ngTreeItem]:hover {
background-color: var(--quinary-contrast);
}
[ngTreeItem]:focus {
outline: 1px solid color-mix(in srgb, var(--hot-pink) 60%, transparent);
}
[ngTreeItem][aria-selected='true'],
[ngTreeItem][aria-selected='true'] .expand-icon {
background-image: var(--pink-to-purple-horizontal-gradient);
background-clip: text;
color: transparent;
}
.material-symbols-outlined {
margin: 0;
width: 24px;
}
.expand-icon {
transition: transform 0.2s ease;
}
[ngTreeItem][aria-expanded='true'] .expand-icon {
transform: rotate(90deg);
}
.selected-icon {
visibility: hidden;
margin-left: auto;
}
[ngTreeItem][aria-current] .selected-icon,
[ngTreeItem][aria-selected='true'] .selected-icon {
visibility: visible;
}
li[aria-expanded='false'] + ul[role='group'] {
display: none;
}
TS
import {Component, computed, signal} from '@angular/core';
import {NgTemplateOutlet} from '@angular/common';
import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';
type TreeNode = {
name: string;
value: string;
children?: TreeNode[];
disabled?: boolean;
expanded?: boolean;
};
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Tree, TreeItem, TreeItemGroup, NgTemplateOutlet],
})
export class App {
readonly nodes: readonly TreeNode[] = [
{
name: 'C:',
value: 'C:',
expanded: true,
children: [
{
name: 'Program Files/',
value: 'C:/Program Files',
children: [
{name: 'Common Files', value: 'C:/Program Files/Common Files'},
{name: 'Internet Explorer', value: 'C:/Program Files/Internet Explorer'},
],
expanded: false,
},
{
name: 'Users/',
value: 'C:/Users',
children: [
{name: 'Default', value: 'C:/Users/Default'},
{name: 'Public', value: 'C:/Users/Public'},
],
expanded: false,
},
{
name: 'Windows/',
value: 'C:/Windows',
children: [
{name: 'System32', value: 'C:/Windows/System32'},
{name: 'Web', value: 'C:/Windows/Web'},
],
expanded: false,
},
{name: 'pagefile.sys', value: 'C:/pagefile.sys'},
{name: 'swapfile.sys', value: 'C:/swapfile.sys', disabled: true},
],
},
];
readonly selected = signal([]);
readonly selectedCount = computed(() => this.selected().length);
}
HTML
<div class="win95-file-explorer">
<div class="title-bar">
<div class="title-bar-text">Exploring - (C:)</div>
<div class="title-bar-controls">
<button tabindex="-1" aria-label="Minimize"><span>-</span></button>
<button tabindex="-1" aria-label="Maximize"><span>□</span></button>
<button tabindex="-1" aria-label="Close"><span>×</span></button>
</div>
</div>
<div class="menu-bar">
<div class="menu-item"><u>F</u>ile</div>
<div class="menu-item"><u>E</u>dit</div>
<div class="menu-item"><u>V</u>iew</div>
<div class="menu-item"><u>T</u>ools</div>
<div class="menu-item"><u>H</u>elp</div>
</div>
<div class="toolbar">
<button tabindex="-1" class="win95-btn"><span class="icon">←</span> Back</button>
<button tabindex="-1" class="win95-btn"><span class="icon">→</span> Forward</button>
<button tabindex="-1" class="win95-btn"><span class="icon">↑</span> Up</button>
</div>
<div class="tree-view">
<ul ngTree #tree="ngTree" class="retro-tree" selectionMode="follow" [(value)]="selected">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: nodes, parent: tree}"
/>
</ul>
</div>
<div class="status-bar">
<div class="status-panel status-panel-grow">{{ selectedCount() }} object(s) selected</div>
<div class="status-panel status-panel-right">4.5MB</div>
</div>
</div>
<ng-template #treeNodes let-nodes="nodes" let-parent="parent">
@for (node of nodes; track node.value) {
<li
ngTreeItem
[parent]="parent"
[value]="node.value"
[label]="node.name"
[(expanded)]="node.expanded"
[disabled]="node.disabled"
#treeItem="ngTreeItem"
>
<span aria-hidden="true">
@if (node.children) {
{{ treeItem.expanded() ? '📂' : '📁' }}
} @else {
📄
}
</span>
{{ node.name }}
<span aria-hidden="true" class="material-symbols-outlined selected-icon" translate="no"
>check</span
>
</li>
@if (node.children) {
<ul role="group">
<ng-template ngTreeItemGroup [ownedBy]="treeItem" #group="ngTreeItemGroup">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: node.children, parent: group}"
/>
</ng-template>
</ul>
}
}
</ng-template>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Jersey+20&display=swap');
:host {
display: flex;
justify-content: center;
user-select: none;
--win95-gray: #c0c0c0;
--win95-dark-gray: #808080;
--win95-light: #ffffff;
--win95-shadow: #000000;
--win95-blue: #000080;
--win95-active-blue: linear-gradient(to right, #000080, #1084d0);
--win95-font: "Jersey 20", sans-serif;
font-family: var(--win95-font);
font-size: 1.1rem;
}
.win95-file-explorer {
border-style: solid;
border-width: 1px;
border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
box-shadow: 1px 1px 0 var(--win95-shadow);
background-color: var(--win95-gray);
min-width: 350px;
}
.win95-btn {
background-color: var(--win95-gray);
padding: 3px 8px;
cursor: pointer;
border-style: solid;
border-width: 1px;
border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
box-shadow: 1px 1px 0 var(--win95-shadow);
}
.win95-btn:active {
box-shadow: 1px 1px 0 var(--win95-shadow) inset;
padding: 4px 7px 2px 9px;
border-style: solid;
border-width: 1px;
border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
}
.title-bar {
background: var(--win95-active-blue);
color: var(--win95-light);
padding: 3px 6px;
height: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.title-bar-text {
font-weight: bold;
}
.title-bar-controls button {
background: var(--win95-gray);
border: 1px solid var(--win95-light);
color: var(--win95-shadow);
width: 16px;
height: 14px;
line-height: 8px;
font-size: 14px;
padding: 0;
margin-left: 1px;
border-style: solid;
border-width: 1px;
border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
box-shadow: 1px 1px 0 var(--win95-shadow);
}
.title-bar-controls button span {
display: block;
margin-top: -3px;
}
.menu-bar {
display: flex;
background-color: var(--win95-gray);
border-bottom: 1px solid var(--win95-dark-gray);
padding: 1px 2px;
}
.menu-item {
padding: 0 6px;
cursor: default;
margin-right: 4px;
}
.menu-item:hover {
background-color: var(--win95-blue);
color: var(--win95-light);
}
.toolbar {
display: flex;
align-items: center;
padding: 4px;
border-top: 1px solid var(--win95-light);
border-bottom: 1px solid var(--win95-dark-gray);
}
.toolbar .win95-btn {
display: flex;
align-items: center;
margin-right: 8px;
}
.toolbar .icon {
font-size: 1.25rem;
line-height: 1;
margin-right: 4px;
}
.tree-view {
background-color: var(--win95-light);
min-height: 300px;
padding: 4px;
border-width: 1px;
border-style: solid;
border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
color: var(--win95-shadow);
}
.status-bar {
margin-top: 2px;
}
.status-panel-grow {
flex-grow: 1;
}
.status-panel-right {
text-align: right;
}
.status-bar {
height: 18px;
background-color: var(--win95-gray);
border-top: 1px solid var(--win95-light);
border-left: 1px solid var(--win95-light);
border-right: 1px solid var(--win95-dark-gray);
border-bottom: 1px solid var(--win95-dark-gray);
display: flex;
font-size: 14px;
}
.status-panel {
padding: 0 4px;
height: 100%;
display: flex;
align-items: center;
margin-right: 1px;
border-style: solid;
border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
border-width: 1px;
flex-grow: 1;
}
.status-panel:last-child {
flex-grow: 0;
width: 120px;
}
[ngTree] {
padding: 0;
margin: 0;
}
[ngTreeItem] {
cursor: pointer;
list-style: none;
text-decoration: none;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.25rem 0.75rem;
}
[ngTreeItem][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
[ngTreeItem]:focus,
[ngTreeItem]:hover {
background-color: var(--senary-contrast);
color: var(--primary-contrast);
outline: none;
}
[ngTreeItem][aria-selected='true'] {
background-color: var(--win95-blue);
color: var(--win95-light);
}
.selected-icon {
visibility: hidden;
margin-left: auto;
}
[ngTreeItem][aria-current] .selected-icon,
[ngTreeItem][aria-selected='true'] .selected-icon {
visibility: visible;
}
li[aria-expanded='false'] + ul[role='group'] {
display: none;
}
Définissez [selectionMode]="'follow'" sur le tree. La sélection se met à jour automatiquement à mesure que les utilisateurs naviguent avec les touches fléchées.
Éléments de tree désactivés
Désactivez des nœuds spécifiques du tree pour empêcher toute interaction. Vous contrôlez si les éléments désactivés peuvent recevoir le focus.
TS
import {Component, signal} from '@angular/core';
import {NgTemplateOutlet} from '@angular/common';
import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';
type TreeNode = {
name: string;
value: string;
children?: TreeNode[];
disabled?: boolean;
expanded?: boolean;
};
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Tree, TreeItem, TreeItemGroup, NgTemplateOutlet],
})
export class App {
readonly nodes: TreeNode[] = [
{
name: 'public',
value: 'public',
children: [
{name: 'index.html', value: 'public/index.html'},
{name: 'favicon.ico', value: 'public/favicon.ico'},
{name: 'styles.css', value: 'public/styles.css'},
],
expanded: true,
disabled: true,
},
{
name: 'src',
value: 'src',
children: [
{
name: 'app',
value: 'src/app',
children: [
{name: 'app.ts', value: 'src/app/app.ts'},
{name: 'app.html', value: 'src/app/app.html'},
{name: 'app.css', value: 'src/app/app.css'},
],
expanded: false,
},
{
name: 'assets',
value: 'src/assets',
children: [{name: 'logo.png', value: 'src/assets/logo.png'}],
expanded: false,
},
{
name: 'environments',
value: 'src/environments',
children: [
{
name: 'environment.prod.ts',
value: 'src/environments/environment.prod.ts',
expanded: false,
},
{name: 'environment.ts', value: 'src/environments/environment.ts'},
],
expanded: false,
},
{name: 'main.ts', value: 'src/main.ts'},
{name: 'polyfills.ts', value: 'src/polyfills.ts'},
{name: 'styles.css', value: 'src/styles.css', disabled: true},
{name: 'test.ts', value: 'src/test.ts'},
],
expanded: false,
disabled: true,
},
{name: 'angular.json', value: 'angular.json'},
{name: 'package.json', value: 'package.json'},
{name: 'README.md', value: 'README.md'},
];
readonly selected = signal(['angular.json']);
}
HTML
<ul ngTree #tree="ngTree" [(value)]="selected" class="basic-tree">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: nodes, parent: tree}"
/>
</ul>
<ng-template #treeNodes let-nodes="nodes" let-parent="parent">
@for (node of nodes; track node.value) {
<li
ngTreeItem
[parent]="parent"
[value]="node.value"
[label]="node.name"
[disabled]="node.disabled"
[(expanded)]="node.expanded"
#treeItem="ngTreeItem"
>
<span aria-hidden="true" class="material-symbols-outlined expand-icon" translate="no">{{
node.children ? 'chevron_right' : ''
}}</span>
<span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
node.children ? 'folder' : 'docs'
}}</span>
{{ node.name }}
<span aria-hidden="true" class="material-symbols-outlined selected-icon" translate="no"
>check</span
>
</li>
@if (node.children) {
<ul role="group">
<ng-template ngTreeItemGroup [ownedBy]="treeItem" #group="ngTreeItemGroup">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: node.children, parent: group}"
/>
</ng-template>
</ul>
}
}
</ng-template>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
user-select: none;
font-family: var(--inter-font);
}
[ngTree] {
min-width: 24rem;
background-color: var(--septenary-contrast);
border-radius: 0.5rem;
padding: 0.5rem;
}
[ngTreeItem] {
cursor: pointer;
list-style: none;
text-decoration: none;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.3rem 1rem;
}
[ngTreeItem][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
[ngTreeItem]:focus,
[ngTreeItem]:hover {
background-color: var(--quinary-contrast);
}
[ngTreeItem]:focus {
outline: 1px solid color-mix(in srgb, var(--hot-pink) 60%, transparent);
}
[ngTreeItem][aria-selected='true'],
[ngTreeItem][aria-selected='true'] .expand-icon {
background-image: var(--pink-to-purple-horizontal-gradient);
background-clip: text;
color: transparent;
}
.material-symbols-outlined {
margin: 0;
width: 24px;
}
.expand-icon {
transition: transform 0.2s ease;
}
[ngTreeItem][aria-expanded='true'] .expand-icon {
transform: rotate(90deg);
}
.selected-icon {
visibility: hidden;
margin-left: auto;
}
[ngTreeItem][aria-current] .selected-icon,
[ngTreeItem][aria-selected='true'] .selected-icon {
visibility: visible;
}
li[aria-expanded='false'] + ul[role='group'] {
display: none;
}
TS
import {Component, computed, signal} from '@angular/core';
import {NgTemplateOutlet} from '@angular/common';
import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';
type TreeNode = {
name: string;
value: string;
children?: TreeNode[];
disabled?: boolean;
expanded?: boolean;
};
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Tree, TreeItem, TreeItemGroup, NgTemplateOutlet],
})
export class App {
readonly nodes: readonly TreeNode[] = [
{
name: 'C:',
value: 'C:',
expanded: true,
children: [
{
name: 'Program Files/',
value: 'C:/Program Files',
children: [
{name: 'Common Files', value: 'C:/Program Files/Common Files'},
{name: 'Internet Explorer', value: 'C:/Program Files/Internet Explorer'},
],
expanded: true,
disabled: true,
},
{
name: 'Users/',
value: 'C:/Users',
children: [
{name: 'Default', value: 'C:/Users/Default'},
{name: 'Public', value: 'C:/Users/Public'},
],
expanded: false,
},
{
name: 'Windows/',
value: 'C:/Windows',
children: [
{name: 'System32', value: 'C:/Windows/System32'},
{name: 'Web', value: 'C:/Windows/Web'},
],
expanded: false,
},
{name: 'pagefile.sys', value: 'C:/pagefile.sys'},
{name: 'swapfile.sys', value: 'C:/swapfile.sys', disabled: true},
],
},
];
readonly selected = signal([]);
readonly selectedCount = computed(() => this.selected().length);
}
HTML
<div class="win95-file-explorer">
<div class="title-bar">
<div class="title-bar-text">Exploring - (C:)</div>
<div class="title-bar-controls">
<button tabindex="-1" aria-label="Minimize"><span>-</span></button>
<button tabindex="-1" aria-label="Maximize"><span>□</span></button>
<button tabindex="-1" aria-label="Close"><span>×</span></button>
</div>
</div>
<div class="menu-bar">
<div class="menu-item"><u>F</u>ile</div>
<div class="menu-item"><u>E</u>dit</div>
<div class="menu-item"><u>V</u>iew</div>
<div class="menu-item"><u>T</u>ools</div>
<div class="menu-item"><u>H</u>elp</div>
</div>
<div class="toolbar">
<button tabindex="-1" class="win95-btn"><span class="icon">←</span> Back</button>
<button tabindex="-1" class="win95-btn"><span class="icon">→</span> Forward</button>
<button tabindex="-1" class="win95-btn"><span class="icon">↑</span> Up</button>
</div>
<div class="tree-view">
<ul ngTree #tree="ngTree" class="retro-tree" [(value)]="selected">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: nodes, parent: tree}"
/>
</ul>
</div>
<div class="status-bar">
<div class="status-panel status-panel-grow">{{ selectedCount() }} object(s) selected</div>
<div class="status-panel status-panel-right">4.5MB</div>
</div>
</div>
<ng-template #treeNodes let-nodes="nodes" let-parent="parent">
@for (node of nodes; track node.value) {
<li
ngTreeItem
[parent]="parent"
[value]="node.value"
[label]="node.name"
[(expanded)]="node.expanded"
[disabled]="node.disabled"
#treeItem="ngTreeItem"
>
<span aria-hidden="true">
@if (node.children) {
{{ treeItem.expanded() ? '📂' : '📁' }}
} @else {
📄
}
</span>
{{ node.name }}
<span aria-hidden="true" class="material-symbols-outlined selected-icon" translate="no"
>check</span
>
</li>
@if (node.children) {
<ul role="group">
<ng-template ngTreeItemGroup [ownedBy]="treeItem" #group="ngTreeItemGroup">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: node.children, parent: group}"
/>
</ng-template>
</ul>
}
}
</ng-template>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Jersey+20&display=swap');
:host {
display: flex;
justify-content: center;
user-select: none;
--win95-gray: #c0c0c0;
--win95-dark-gray: #808080;
--win95-light: #ffffff;
--win95-shadow: #000000;
--win95-blue: #000080;
--win95-active-blue: linear-gradient(to right, #000080, #1084d0);
--win95-font: "Jersey 20", sans-serif;
font-family: var(--win95-font);
font-size: 1.1rem;
}
.win95-file-explorer {
border-style: solid;
border-width: 1px;
border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
box-shadow: 1px 1px 0 var(--win95-shadow);
background-color: var(--win95-gray);
min-width: 350px;
}
.win95-btn {
background-color: var(--win95-gray);
padding: 3px 8px;
cursor: pointer;
border-style: solid;
border-width: 1px;
border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
box-shadow: 1px 1px 0 var(--win95-shadow);
}
.win95-btn:active {
box-shadow: 1px 1px 0 var(--win95-shadow) inset;
padding: 4px 7px 2px 9px;
border-style: solid;
border-width: 1px;
border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
}
.title-bar {
background: var(--win95-active-blue);
color: var(--win95-light);
padding: 3px 6px;
height: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.title-bar-text {
font-weight: bold;
}
.title-bar-controls button {
background: var(--win95-gray);
border: 1px solid var(--win95-light);
color: var(--win95-shadow);
width: 16px;
height: 14px;
line-height: 8px;
font-size: 14px;
padding: 0;
margin-left: 1px;
border-style: solid;
border-width: 1px;
border-color: var(--win95-light) var(--win95-dark-gray) var(--win95-dark-gray) var(--win95-light);
box-shadow: 1px 1px 0 var(--win95-shadow);
}
.title-bar-controls button span {
display: block;
margin-top: -3px;
}
.menu-bar {
display: flex;
background-color: var(--win95-gray);
border-bottom: 1px solid var(--win95-dark-gray);
padding: 1px 2px;
}
.menu-item {
padding: 0 6px;
cursor: default;
margin-right: 4px;
}
.menu-item:hover {
background-color: var(--win95-blue);
color: var(--win95-light);
}
.toolbar {
display: flex;
align-items: center;
padding: 4px;
border-top: 1px solid var(--win95-light);
border-bottom: 1px solid var(--win95-dark-gray);
}
.toolbar .win95-btn {
display: flex;
align-items: center;
margin-right: 8px;
}
.toolbar .icon {
font-size: 1.25rem;
line-height: 1;
margin-right: 4px;
}
.tree-view {
background-color: var(--win95-light);
min-height: 300px;
padding: 4px;
border-width: 1px;
border-style: solid;
border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
color: var(--win95-shadow);
}
.status-bar {
margin-top: 2px;
}
.status-panel-grow {
flex-grow: 1;
}
.status-panel-right {
text-align: right;
}
.status-bar {
height: 18px;
background-color: var(--win95-gray);
border-top: 1px solid var(--win95-light);
border-left: 1px solid var(--win95-light);
border-right: 1px solid var(--win95-dark-gray);
border-bottom: 1px solid var(--win95-dark-gray);
display: flex;
font-size: 14px;
}
.status-panel {
padding: 0 4px;
height: 100%;
display: flex;
align-items: center;
margin-right: 1px;
border-style: solid;
border-color: var(--win95-dark-gray) var(--win95-light) var(--win95-light) var(--win95-dark-gray);
border-width: 1px;
flex-grow: 1;
}
.status-panel:last-child {
flex-grow: 0;
width: 120px;
}
[ngTree] {
padding: 0;
margin: 0;
}
[ngTreeItem] {
cursor: pointer;
list-style: none;
text-decoration: none;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.25rem 0.75rem;
}
[ngTreeItem][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
[ngTreeItem]:focus,
[ngTreeItem]:hover {
background-color: var(--senary-contrast);
color: var(--primary-contrast);
outline: none;
}
[ngTreeItem][aria-selected='true'] {
background-color: var(--win95-blue);
color: var(--win95-light);
}
.selected-icon {
visibility: hidden;
margin-left: auto;
}
[ngTreeItem][aria-current] .selected-icon,
[ngTreeItem][aria-selected='true'] .selected-icon {
visibility: visible;
}
li[aria-expanded='false'] + ul[role='group'] {
display: none;
}
Lorsque [softDisabled]="true" est défini sur le tree, les éléments désactivés peuvent recevoir le focus mais ne peuvent être ni activés ni sélectionnés. Lorsque [softDisabled]="false", les éléments désactivés sont ignorés lors de la navigation au clavier.
Tests
Angular Aria fournit des harnais de composant pour tester les composants de tree. Voici un exemple d'utilisation des harnais dans un test de composant :
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {HarnessLoader} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {TreeHarness} from '@angular/aria/tree/testing';
import {MyTreeComponent} from './my-tree'; // Your component
describe('MyTreeComponent', () => {
let fixture: ComponentFixture<MyTreeComponent>;
let loader: HarnessLoader;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [MyTreeComponent],
});
fixture = TestBed.createComponent(MyTreeComponent);
await fixture.whenStable();
loader = TestbedHarnessEnvironment.loader(fixture);
});
it('should navigate and expand tree items', async () => {
const tree = await loader.getHarness(TreeHarness);
// Get top-level structure representation
expect(await tree.getTreeStructure()).toEqual({
children: [{text: 'public'}, {text: 'src'}, {text: 'package.json'}],
});
// Get all items (currently visible)
const items = await tree.getItems();
expect(items.length).toBe(3);
// Expand the first item ('public')
expect(await items[0].isExpanded()).toBe(false);
await items[0].click();
expect(await items[0].isExpanded()).toBe(true);
// Verifying tree structure updates after expansion
expect(await tree.getTreeStructure()).toEqual({
children: [
{
text: 'public',
children: [{text: 'index.html'}, {text: 'styles.css'}],
},
{text: 'src'},
{text: 'package.json'},
],
});
});
});
Référence de l'API
Pour une documentation détaillée de l'API, consultez les références d'API suivantes :