深入指南
Angular Aria
概觀
樹狀結構用來顯示階層式資料,項目可以展開以顯示子項目,也可以收合將其隱藏。使用者以方向鍵導覽、展開與收合節點,並可視需要選取項目,用於導覽或資料選取等情境。
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;
}
使用方式
當使用者需要在巢狀結構中導覽時,樹狀結構很適合顯示階層式資料。
適合使用樹狀結構的情況:
- 建立檔案系統導覽
- 顯示資料夾與文件的階層
- 建立巢狀選單結構
- 顯示組織圖
- 瀏覽階層式資料
- 實作含有巢狀區段的網站導覽
應避免使用樹狀結構的情況:
功能特色
- 階層式導覽:巢狀樹狀結構,具備展開與收合功能
- 選取模式:單選或多選,可選擇明確選取或選取跟隨焦點的行為
- 選取跟隨焦點:焦點變更時可選擇自動選取
- 鍵盤導覽:方向鍵、Home、End,以及輸入即搜尋(type-ahead)
- 展開/收合:以右/左方向鍵或 Enter 切換父節點
- 停用項目:停用特定節點並管理焦點
- 焦點模式:roving tabindex 或 activedescendant 焦點策略
- RTL 支援:支援由右至左語言的導覽
範例
導覽樹
當點選項目是觸發動作而非選取項目時,可將樹狀結構用於導覽。
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;
}
設定 [nav]="true" 即可啟用導覽模式。此模式會使用 aria-current 來表明目前頁面,而不是選取狀態。
單選
在使用者需要從樹狀結構中選擇一個項目的情境下,啟用單選。
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;
}
單選時保持 [multi]="false"(預設值)。使用者按下 Space 即可選取目前聚焦的項目。
多選
允許使用者從樹狀結構中選取多個項目。
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;
}
在樹狀結構上設定 [multi]="true"。使用者以 Space 逐一選取項目,或以 Shift+方向鍵選取範圍。
選取跟隨焦點
當選取跟隨焦點時,目前聚焦的項目會自動被選取。這能簡化導覽情境下的互動。
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;
}
在樹狀結構上設定 [selectionMode]="'follow'"。當使用者以方向鍵導覽時,選取狀態會自動更新。
停用的樹狀項目
停用特定樹狀節點以防止互動,並可控制停用的項目是否能接收焦點。
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;
}
當樹狀結構的 [softDisabled]="true" 時,停用的項目可以接收焦點,但無法被啟用或選取。當 [softDisabled]="false" 時,停用的項目會在鍵盤導覽時被略過。
測試
Angular Aria 提供元件測試工具(component 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 參考
如需詳細的 API 文件,請查看下列 API 參考: