آنلاین

Tree

Overview

Tree داده‌های سلسله‌مراتبی را نمایش می‌دهد؛ جایی که itemها می‌توانند expand شوند تا childها را نشان دهند یا collapse شوند تا آن‌ها را پنهان کنند. کاربران با arrow keyها navigate می‌کنند، nodeها را expand و collapse می‌کنند، و در صورت نیاز برای سناریوهای navigation یا data selection، itemها را select می‌کنند.

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;
}

Usage

Treeها برای نمایش داده‌های سلسله‌مراتبی مناسب‌اند، جایی که کاربران باید در ساختارهای nested navigate کنند.

از tree استفاده کنید وقتی:

  • file system navigation می‌سازید.
  • hierarchyهای folder و document را نمایش می‌دهید.
  • ساختارهای nested menu ایجاد می‌کنید.
  • organization chart نمایش می‌دهید.
  • data سلسله‌مراتبی را browse می‌کنید.
  • site navigation با sectionهای nested پیاده‌سازی می‌کنید.

از tree پرهیز کنید وقتی:

  • listهای flat نمایش می‌دهید \(به جای آن از Listbox استفاده کنید\).
  • data table نمایش می‌دهید \(به جای آن از Grid استفاده کنید\).
  • dropdownهای ساده می‌سازید \(به جای آن از Select استفاده کنید\).
  • breadcrumb navigation می‌سازید \(از breadcrumb patternها استفاده کنید\).

قابلیت‌ها

  • Hierarchical navigation - ساختار tree nested با قابلیت expand و collapse.
  • Selection modeها - single یا multi-selection با behavior مربوط به explicit یا follow-focus.
  • Selection follows focus - selection خودکار اختیاری هنگام تغییر focus.
  • Keyboard navigation - arrow keyها، Home، End و type-ahead search.
  • Expand/collapse - arrowهای راست/چپ یا Enter برای toggle کردن parent nodeها.
  • Disabled items - nodeهای مشخص را با focus management disabled کنید.
  • Focus modeها - strategyهای focus مربوط به roving tabindex یا activedescendant.
  • RTL support - navigation برای زبان‌های راست‌به‌چپ.

مثال‌ها

برای navigationای که کلیک روی itemها به جای select کردن آن‌ها action trigger می‌کند، از tree استفاده کنید.

ts
import {Component, signal} from '@angular/core';
import {NgTemplateOutlet} from '@angular/common';
import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';

type TreeNode = {
  name: string;
  value: string;
  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"
        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>
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;
}

برای فعال کردن navigation mode، [nav]="true" را تنظیم کنید. این mode به جای selection، از aria-current برای نشان دادن page فعلی استفاده می‌کند.

Single selection

برای سناریوهایی که کاربران یک item را از tree انتخاب می‌کنند، single selection را فعال کنید.

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>&#x25A1;</span></button>
      <button tabindex="-1" aria-label="Close"><span>&#xd7;</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">&#x2190;</span> Back</button>
    <button tabindex="-1" class="win95-btn"><span class="icon">&#x2192;</span> Forward</button>
    <button tabindex="-1" class="win95-btn"><span class="icon">&#x2191;</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>
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;
}

برای single selection، [multi]="false" را به حالت پیش‌فرض رها کنید. کاربران Space را فشار می‌دهند تا item دارای focus را select کنند.

Multi-selection

اجازه دهید کاربران چند item را از tree انتخاب کنند.

ts
import {Component, signal} from '@angular/core';
import {NgTemplateOutlet} from '@angular/common';
import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';

type TreeNode = {
  name: string;
  value: string;
  children?: TreeNode[];
  disabled?: boolean;
  expanded?: boolean;
};

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Tree, TreeItem, TreeItemGroup, NgTemplateOutlet],
})
export class App {
  readonly nodes: TreeNode[] = [
    {
      name: 'public',
      value: 'public',
      children: [
        {name: 'index.html', value: 'public/index.html'},
        {name: 'favicon.ico', value: 'public/favicon.ico'},
        {name: 'styles.css', value: 'public/styles.css'},
      ],
      expanded: true,
    },
    {
      name: 'src',
      value: 'src',
      children: [
        {
          name: 'app',
          value: 'src/app',
          children: [
            {name: 'app.ts', value: 'src/app/app.ts'},
            {name: 'app.html', value: 'src/app/app.html'},
            {name: 'app.css', value: 'src/app/app.css'},
          ],
          expanded: false,
        },
        {
          name: 'assets',
          value: 'src/assets',
          children: [{name: 'logo.png', value: 'src/assets/logo.png'}],
          expanded: false,
        },
        {
          name: 'environments',
          value: 'src/environments',
          children: [
            {
              name: 'environment.prod.ts',
              value: 'src/environments/environment.prod.ts',
              expanded: false,
            },
            {name: 'environment.ts', value: 'src/environments/environment.ts'},
          ],
          expanded: false,
        },
        {name: 'main.ts', value: 'src/main.ts'},
        {name: 'polyfills.ts', value: 'src/polyfills.ts'},
        {name: 'styles.css', value: 'src/styles.css', disabled: true},
        {name: 'test.ts', value: 'src/test.ts'},
      ],
      expanded: false,
    },
    {name: 'angular.json', value: 'angular.json'},
    {name: 'package.json', value: 'package.json'},
    {name: 'README.md', value: 'README.md'},
  ];

  readonly selected = signal(['angular.json', 'public/styles.css']);
}
html
<ul ngTree #tree="ngTree" [multi]="true" [(value)]="selected" class="basic-tree">
  <ng-template
    [ngTemplateOutlet]="treeNodes"
    [ngTemplateOutletContext]="{nodes: nodes, parent: tree}"
  />
</ul>

<ng-template #treeNodes let-nodes="nodes" let-parent="parent">
  @for (node of nodes; track node.value) {
    <li
      ngTreeItem
      [parent]="parent"
      [value]="node.value"
      [label]="node.name"
      [disabled]="node.disabled"
      [(expanded)]="node.expanded"
      #treeItem="ngTreeItem"
    >
      <span aria-hidden="true" class="material-symbols-outlined expand-icon" translate="no">{{
        node.children ? 'chevron_right' : ''
      }}</span>
      <span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
        node.children ? 'folder' : 'docs'
      }}</span>
      {{ node.name }}
      <span
        aria-hidden="true"
        class="material-symbols-outlined selected-icon"
        translate="no"
        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>
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>&#x25A1;</span></button>
      <button tabindex="-1" aria-label="Close"><span>&#xd7;</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">&#x2190;</span> Back</button>
    <button tabindex="-1" class="win95-btn"><span class="icon">&#x2192;</span> Forward</button>
    <button tabindex="-1" class="win95-btn"><span class="icon">&#x2191;</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>
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" را روی tree تنظیم کنید. کاربران itemها را جداگانه با Space انتخاب می‌کنند یا rangeها را با Shift+Arrow keyها select می‌کنند.

Selection follows focus

وقتی selection از focus پیروی کند، item دارای focus به صورت خودکار selected می‌شود. این behavior interaction را برای سناریوهای navigation ساده‌تر می‌کند.

ts
import {Component, signal} from '@angular/core';
import {NgTemplateOutlet} from '@angular/common';
import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';

type TreeNode = {
  name: string;
  value: string;
  children?: TreeNode[];
  disabled?: boolean;
  expanded?: boolean;
};

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Tree, TreeItem, TreeItemGroup, NgTemplateOutlet],
})
export class App {
  readonly nodes: TreeNode[] = [
    {
      name: 'public',
      value: 'public',
      children: [
        {name: 'index.html', value: 'public/index.html'},
        {name: 'favicon.ico', value: 'public/favicon.ico'},
        {name: 'styles.css', value: 'public/styles.css'},
      ],
      expanded: true,
    },
    {
      name: 'src',
      value: 'src',
      children: [
        {
          name: 'app',
          value: 'src/app',
          children: [
            {name: 'app.ts', value: 'src/app/app.ts'},
            {name: 'app.html', value: 'src/app/app.html'},
            {name: 'app.css', value: 'src/app/app.css'},
          ],
          expanded: false,
        },
        {
          name: 'assets',
          value: 'src/assets',
          children: [{name: 'logo.png', value: 'src/assets/logo.png'}],
          expanded: false,
        },
        {
          name: 'environments',
          value: 'src/environments',
          children: [
            {
              name: 'environment.prod.ts',
              value: 'src/environments/environment.prod.ts',
              expanded: false,
            },
            {name: 'environment.ts', value: 'src/environments/environment.ts'},
          ],
          expanded: false,
        },
        {name: 'main.ts', value: 'src/main.ts'},
        {name: 'polyfills.ts', value: 'src/polyfills.ts'},
        {name: 'styles.css', value: 'src/styles.css', disabled: true},
        {name: 'test.ts', value: 'src/test.ts'},
      ],
      expanded: false,
    },
    {name: 'angular.json', value: 'angular.json'},
    {name: 'package.json', value: 'package.json'},
    {name: 'README.md', value: 'README.md'},
  ];

  readonly selected = signal(['angular.json']);
}
html
<ul ngTree #tree="ngTree" [(value)]="selected" selectionMode="follow" class="basic-tree">
  <ng-template
    [ngTemplateOutlet]="treeNodes"
    [ngTemplateOutletContext]="{nodes: nodes, parent: tree}"
  />
</ul>

<ng-template #treeNodes let-nodes="nodes" let-parent="parent">
  @for (node of nodes; track node.value) {
    <li
      ngTreeItem
      [parent]="parent"
      [value]="node.value"
      [label]="node.name"
      [disabled]="node.disabled"
      [(expanded)]="node.expanded"
      #treeItem="ngTreeItem"
    >
      <span aria-hidden="true" class="material-symbols-outlined expand-icon" translate="no">{{
        node.children ? 'chevron_right' : ''
      }}</span>
      <span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
        node.children ? 'folder' : 'docs'
      }}</span>
      {{ node.name }}
      <span
        aria-hidden="true"
        class="material-symbols-outlined selected-icon"
        translate="no"
        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>
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>&#x25A1;</span></button>
      <button tabindex="-1" aria-label="Close"><span>&#xd7;</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">&#x2190;</span> Back</button>
    <button tabindex="-1" class="win95-btn"><span class="icon">&#x2192;</span> Forward</button>
    <button tabindex="-1" class="win95-btn"><span class="icon">&#x2191;</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>
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'" را روی tree تنظیم کنید. با navigate کردن کاربران با arrow keyها، selection خودکار update می‌شود.

Tree itemهای disabled

tree nodeهای مشخص را disabled کنید تا از interaction جلوگیری شود. کنترل کنید itemهای disabled بتوانند focus دریافت کنند یا نه.

ts
import {Component, signal} from '@angular/core';
import {NgTemplateOutlet} from '@angular/common';
import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';

type TreeNode = {
  name: string;
  value: string;
  children?: TreeNode[];
  disabled?: boolean;
  expanded?: boolean;
};

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Tree, TreeItem, TreeItemGroup, NgTemplateOutlet],
})
export class App {
  readonly nodes: TreeNode[] = [
    {
      name: 'public',
      value: 'public',
      children: [
        {name: 'index.html', value: 'public/index.html'},
        {name: 'favicon.ico', value: 'public/favicon.ico'},
        {name: 'styles.css', value: 'public/styles.css'},
      ],
      expanded: true,
      disabled: true,
    },
    {
      name: 'src',
      value: 'src',
      children: [
        {
          name: 'app',
          value: 'src/app',
          children: [
            {name: 'app.ts', value: 'src/app/app.ts'},
            {name: 'app.html', value: 'src/app/app.html'},
            {name: 'app.css', value: 'src/app/app.css'},
          ],
          expanded: false,
        },
        {
          name: 'assets',
          value: 'src/assets',
          children: [{name: 'logo.png', value: 'src/assets/logo.png'}],
          expanded: false,
        },
        {
          name: 'environments',
          value: 'src/environments',
          children: [
            {
              name: 'environment.prod.ts',
              value: 'src/environments/environment.prod.ts',
              expanded: false,
            },
            {name: 'environment.ts', value: 'src/environments/environment.ts'},
          ],
          expanded: false,
        },
        {name: 'main.ts', value: 'src/main.ts'},
        {name: 'polyfills.ts', value: 'src/polyfills.ts'},
        {name: 'styles.css', value: 'src/styles.css', disabled: true},
        {name: 'test.ts', value: 'src/test.ts'},
      ],
      expanded: false,
      disabled: true,
    },
    {name: 'angular.json', value: 'angular.json'},
    {name: 'package.json', value: 'package.json'},
    {name: 'README.md', value: 'README.md'},
  ];

  readonly selected = signal(['angular.json']);
}
html
<ul ngTree #tree="ngTree" [(value)]="selected" class="basic-tree">
  <ng-template
    [ngTemplateOutlet]="treeNodes"
    [ngTemplateOutletContext]="{nodes: nodes, parent: tree}"
  />
</ul>

<ng-template #treeNodes let-nodes="nodes" let-parent="parent">
  @for (node of nodes; track node.value) {
    <li
      ngTreeItem
      [parent]="parent"
      [value]="node.value"
      [label]="node.name"
      [disabled]="node.disabled"
      [(expanded)]="node.expanded"
      #treeItem="ngTreeItem"
    >
      <span aria-hidden="true" class="material-symbols-outlined expand-icon" translate="no">{{
        node.children ? 'chevron_right' : ''
      }}</span>
      <span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
        node.children ? 'folder' : 'docs'
      }}</span>
      {{ node.name }}
      <span
        aria-hidden="true"
        class="material-symbols-outlined selected-icon"
        translate="no"
        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>
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>&#x25A1;</span></button>
      <button tabindex="-1" aria-label="Close"><span>&#xd7;</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">&#x2190;</span> Back</button>
    <button tabindex="-1" class="win95-btn"><span class="icon">&#x2192;</span> Forward</button>
    <button tabindex="-1" class="win95-btn"><span class="icon">&#x2191;</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>
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" روی tree باشد، itemهای disabled می‌توانند focus دریافت کنند اما activate یا select نمی‌شوند. وقتی [softDisabled]="false" باشد، itemهای disabled هنگام keyboard navigation skip می‌شوند.

Testing

Angular Aria برای testing کامپوننت‌های tree، component harness ارائه می‌کند. این نمونه نحوه استفاده از harnessها را در یک component test نشان می‌دهد:

ts
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 reference

برای مستندات دقیق API، referenceهای API زیر را بررسی کنید: