Дерево
Обзор
Дерево отображает иерархические данные, элементы которых можно раскрывать, чтобы показать дочерние элементы, или сворачивать, чтобы скрыть их. Пользователи перемещаются с помощью клавиш со стрелками, раскрывают и сворачивают узлы и при необходимости выбирают элементы для навигации или работы с данными.
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']);
}
<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>
@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;
}
Использование
Деревья хорошо подходят для отображения иерархических данных, по которым пользователям нужно перемещаться во вложенных структурах.
Используйте деревья, когда нужно:
- Создать навигацию по файловой системе
- Показать иерархию папок и документов
- Создать вложенную структуру меню
- Отобразить организационную диаграмму
- Просматривать иерархические данные
- Реализовать навигацию по сайту с вложенными разделами
Не используйте деревья, когда нужно:
- Отобразить плоские списки (вместо этого используйте Listbox)
- Показать таблицы данных (вместо этого используйте Grid)
- Создать простые раскрывающиеся списки (вместо этого используйте Select)
- Создать навигацию в виде цепочки навигационных ссылок (используйте соответствующие шаблоны)
Возможности
- Иерархическая навигация — вложенная структура дерева с возможностью раскрытия и сворачивания
- Режимы выбора — выбор одного или нескольких элементов с явным подтверждением либо при перемещении фокуса
- Выбор следует за фокусом — автоматический выбор при изменении фокуса (необязательно)
- Навигация с клавиатуры — клавиши со стрелками, Home, End и поиск по вводимым символам
- Раскрытие и сворачивание — клавиши со стрелками вправо/влево или Enter для переключения состояния родительских узлов
- Отключённые элементы — отключение отдельных узлов с управлением фокусом
- Режимы фокуса — стратегии фокуса roving tabindex или activedescendant
- Поддержка RTL — навигация для языков с письмом справа налево
Примеры
Дерево навигации
Используйте дерево для навигации, если нажатие на элементы выполняет действия, а не выбирает их.
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']);
}
<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"
aria-hidden="true"
>{{ 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>
@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;
}
Установите [nav]="true", чтобы включить режим навигации. В этом режиме для обозначения текущей страницы используется aria-current, а не выбор элемента.
Выбор одного элемента
Включите выбор одного элемента, если пользователям нужно выбрать один элемент в дереве.
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']);
}
<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>
@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;
}
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);
}
<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"
aria-hidden="true"
>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>
@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;
}
Оставьте [multi]="false" (значение по умолчанию), чтобы разрешить выбор одного элемента. Пользователи нажимают пробел, чтобы выбрать элемент, на котором установлен фокус.
Выбор нескольких элементов
Разрешите пользователям выбирать несколько элементов в дереве.
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']);
}
<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"
aria-hidden="true"
>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>
@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;
}
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);
}
<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"
aria-hidden="true"
>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>
@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;
}
Установите [multi]="true" для дерева. Пользователи выбирают элементы по отдельности клавишей пробела или диапазоны клавишами Shift+Arrow.
Выбор следует за фокусом
Когда выбор следует за фокусом, элемент, на котором установлен фокус, выбирается автоматически. Это упрощает взаимодействие в сценариях навигации.
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']);
}
<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"
aria-hidden="true"
>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>
@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;
}
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);
}
<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"
aria-hidden="true"
>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>
@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;
}
Установите [selectionMode]="'follow'" для дерева. Выбор автоматически обновляется при перемещении пользователей с помощью клавиш со стрелками.
Отключённые элементы дерева
Отключите определённые узлы дерева, чтобы запретить взаимодействие с ними. Настройте, смогут ли отключённые элементы получать фокус.
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']);
}
<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"
aria-hidden="true"
>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>
@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;
}
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);
}
<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"
aria-hidden="true"
>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>
@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;
}
Если для дерева установлено [softDisabled]="true", отключённые элементы могут получать фокус, но их нельзя активировать или выбирать. Если установлено [softDisabled]="false", отключённые элементы пропускаются при навигации с клавиатуры.
Тестирование
Angular Aria предоставляет harness-компоненты для тестирования компонентов дерева. Ниже приведён пример использования harness-компонентов в тесте компонента:
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'},
],
});
});
}); API
Tree
Директива контейнера, управляющая иерархической навигацией и выбором.
Входные параметры
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
disabled | boolean | false | Отключает всё дерево |
softDisabled | boolean | true | Если установлено true, отключённые элементы получают фокус, но не поддерживают взаимодействие |
multi | boolean | false | Можно ли выбрать несколько элементов |
selectionMode | 'explicit' | 'follow' | 'explicit' | Требуется ли для выбора явное действие или выбор следует за фокусом |
nav | boolean | false | Находится ли дерево в режиме навигации (используется aria-current) |
wrap | boolean | true | Зацикливается ли навигация с клавиатуры с переходом от последнего элемента к первому |
focusMode | 'roving' | 'activedescendant' | 'roving' | Используемая деревом стратегия фокуса |
values | any[] | [] | Значения выбранных элементов (поддерживается двусторонняя привязка) |
Методы
| Метод | Параметры | Описание |
|---|---|---|
expandAll | нет | Раскрывает все узлы дерева |
collapseAll | нет | Сворачивает все узлы дерева |
selectAll | нет | Выбирает все элементы (только в режиме множественного выбора) |
clearSelection | нет | Снимает выбор со всех элементов |
TreeItem
Отдельный узел дерева, который может содержать дочерние узлы.
Входные параметры
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
parent | Tree | TreeItemGroup | — | Обязательный. Корневой элемент родительского Tree или TreeItemGroup. |
value | any | — | Обязательный. Уникальное значение этого элемента дерева |
disabled | boolean | false | Отключает этот элемент |
expanded | boolean | false | Раскрыт ли узел (поддерживается двусторонняя привязка) |
Сигналы
| Свойство | Тип | Описание |
|---|---|---|
selected | Signal<boolean> | Выбран ли элемент |
active | Signal<boolean> | Находится ли фокус в данный момент на этом элементе |
hasChildren | Signal<boolean> | Есть ли у элемента дочерние узлы |
Методы
| Метод | Параметры | Описание |
|---|---|---|
expand | нет | Раскрывает этот узел |
collapse | нет | Сворачивает этот узел |
toggle | нет | Переключает состояние раскрытия |
TreeItemGroup
Структурная директива, применяемая к ng-template, в котором хранятся дочерние узлы раскрываемого элемента дерева.
Входные параметры
| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
ownedBy | TreeItem | — |
Обязательный. Ссылка на родительский ngTreeItem. |
Использование
<ul ngTree #tree="ngTree">
<li ngTreeItem [parent]="tree" value="parent" #parentItem="ngTreeItem">
Parent Item
<ul role="group">
<ng-template ngTreeItemGroup [ownedBy]="parentItem" #group="ngTreeItemGroup">
<li ngTreeItem [parent]="group" value="child1">Child 1</li>
<li ngTreeItem [parent]="group" value="child2">Child 2</li>
</ng-template>
</ul>
</li>
</ul>
Super-powered by Google ©2010–2025.
Code licensed under an MIT-style License. Documentation licensed under CC BY 4.0.
https://angular.dev/guide/aria/tree