APG Patterns
日本語 GitHub
日本語 GitHub

Tree View

A hierarchical list where items with children can be expanded or collapsed. Common uses include file browsers, navigation menus, and organizational charts.

🤖 AI Implementation Guide

Demo

Single-Select with Activation

  • Documents
    • report.pdf
    • notes.txt
  • readme.md
Activated: Select a node with Enter, Space, or Click

Multi-Select

  • Documents
    • report.pdf
    • notes.txt
  • Images
    • vacation.jpg
    • profile.png
  • readme.md

With Disabled Nodes

  • Accessible Folder
    • file1.txt
    • file2.txt
  • public.txt

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
tree Container (<ul>) The tree widget container
treeitem Each node (<li>) Individual tree nodes (both parent and leaf)
group Child container (<ul>) Container for child nodes of an expanded parent

WAI-ARIA tree role (opens in new tab)

WAI-ARIA Properties (Tree Container)

Attribute Values Required Description
role="tree" - Yes Identifies the container as a tree widget
aria-label String Yes* Accessible name for the tree
aria-labelledby ID reference Yes* Alternative to aria-label (takes precedence)
aria-multiselectable true No Only present for multi-select mode

* Either aria-label or aria-labelledby is required.

WAI-ARIA States (Tree Items)

Attribute Values Required Description
aria-expanded true | false Yes* Present on parent nodes only. Indicates expansion state.
aria-selected true | false Yes** All treeitems must have this (selected=true, others=false)
aria-disabled true No Indicates the node is disabled
tabindex 0 | -1 Yes Roving tabindex for focus management

* Required on parent nodes. Leaf nodes must NOT have aria-expanded.
** When selection is supported, ALL treeitems must have aria-selected.

Keyboard Support

Navigation

Key Action
Move focus to next visible node
Move focus to previous visible node
Closed parent: expand / Open parent: move to first child / Leaf: no action
Open parent: collapse / Child or closed parent: move to parent / Root: no action
Home Move focus to first node
End Move focus to last visible node
Enter Select and activate node (see Selection section below)
* Expand all siblings at current level
Type characters Move focus to next visible node starting with that character

Selection (Single-Select Mode)

Key Action
/ Move focus only (selection does NOT follow focus)
Enter Select focused node and activate (fire onActivate callback)
Space Select focused node and activate (fire onActivate callback)
Click Select clicked node and activate (fire onActivate callback)

Selection (Multi-Select Mode)

Key Action
Space Toggle selection of focused node
Ctrl + Space Toggle selection without moving focus
Shift + / Extend selection range from anchor
Shift + Home Extend selection to first node
Shift + End Extend selection to last visible node
Ctrl + A Select all visible nodes

Focus Management

This component uses roving tabindex for focus management:

  • Only one node has tabindex="0" (the focused node)
  • All other nodes have tabindex="-1"
  • Tree is a single Tab stop (Tab enters tree, Shift+Tab exits)
  • Focus moves only among visible nodes (collapsed children are skipped)
  • When a parent is collapsed while a child has focus, focus moves to the parent

Disabled Nodes

  • Have aria-disabled="true"
  • Are focusable (included in keyboard navigation)
  • Cannot be selected or activated
  • Cannot be expanded or collapsed if a parent node
  • Visually distinct (e.g., grayed out)

Source Code

TreeView.astro
---
/**
 * APG Tree View Pattern - Astro Implementation
 *
 * A widget that presents a hierarchical list where items with children can be
 * expanded or collapsed.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
 */

export interface TreeNode {
  id: string;
  label: string;
  children?: TreeNode[];
  disabled?: boolean;
}

export interface Props {
  /** Tree node data */
  nodes: TreeNode[];
  /** Enable multi-select mode */
  multiselectable?: boolean;
  /** Initially selected node IDs */
  defaultSelectedIds?: string[];
  /** Initially expanded node IDs */
  defaultExpandedIds?: string[];
  /** Accessible label for the tree */
  'aria-label'?: string;
  /** ID of element that labels the tree */
  'aria-labelledby'?: string;
  /** Type-ahead timeout in ms */
  typeAheadTimeout?: number;
  /** Additional CSS class */
  class?: string;
}

interface FlatNode {
  node: TreeNode;
  depth: number;
  parentId: string | null;
  hasChildren: boolean;
}

const {
  nodes = [],
  multiselectable = false,
  defaultSelectedIds = [],
  defaultExpandedIds = [],
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  typeAheadTimeout = 500,
  class: className = '',
} = Astro.props;

const instanceId = `treeview-${Math.random().toString(36).slice(2, 11)}`;

// Flatten tree for navigation
function flattenTree(
  treeNodes: TreeNode[],
  depth: number = 0,
  parentId: string | null = null
): FlatNode[] {
  const result: FlatNode[] = [];
  for (const node of treeNodes) {
    const hasChildren = Boolean(node.children && node.children.length > 0);
    result.push({ node, depth, parentId, hasChildren });
    if (node.children) {
      result.push(...flattenTree(node.children, depth + 1, node.id));
    }
  }
  return result;
}

const allNodes = flattenTree(nodes);
const nodeMap = new Map<string, FlatNode>(allNodes.map((fn) => [fn.node.id, fn]));

// Initialize expanded IDs
const expandedIds = new Set(defaultExpandedIds);

// Initialize selected IDs (filter out disabled nodes)
const selectedIds = new Set<string>();
if (defaultSelectedIds.length > 0) {
  for (const id of defaultSelectedIds) {
    const flatNode = nodeMap.get(id);
    if (flatNode && !flatNode.node.disabled) {
      selectedIds.add(id);
    }
  }
}
// No auto-selection - user must explicitly select via Enter/Space/Click

// Find initial focus
let initialFocusId = [...selectedIds][0];
if (!initialFocusId) {
  const firstEnabled = allNodes.find((fn) => !fn.node.disabled);
  if (firstEnabled) {
    initialFocusId = firstEnabled.node.id;
  }
}

const containerClass = `apg-treeview ${className}`.trim();

function getNodeClass(node: TreeNode, hasChildren: boolean): string {
  const classes = ['apg-treeview-item'];
  if (selectedIds.has(node.id)) {
    classes.push('apg-treeview-item--selected');
  }
  if (node.disabled) {
    classes.push('apg-treeview-item--disabled');
  }
  if (hasChildren) {
    classes.push('apg-treeview-item--parent');
  } else {
    classes.push('apg-treeview-item--leaf');
  }
  return classes.join(' ');
}

// Generate HTML string for recursive rendering
function renderNodeHTML(node: TreeNode, depth: number): string {
  const hasChildren = Boolean(node.children && node.children.length > 0);
  const flatNode = nodeMap.get(node.id);
  const isExpanded = expandedIds.has(node.id);
  const isSelected = selectedIds.has(node.id);
  const isFocused = node.id === initialFocusId;
  const labelId = `${instanceId}-label-${node.id}`;
  const parentId = flatNode?.parentId ?? '';
  const nodeClass = getNodeClass(node, hasChildren);

  const ariaExpandedAttr = hasChildren ? ` aria-expanded="${isExpanded}"` : '';
  const ariaDisabledAttr = node.disabled ? ' aria-disabled="true"' : '';
  const parentIdAttr = parentId ? ` data-parent-id="${parentId}"` : '';
  const hasChildrenAttr = hasChildren ? ' data-has-children="true"' : '';
  const hiddenAttr = !isExpanded ? ' hidden' : '';

  const icon = hasChildren
    ? `<span class="apg-treeview-item-icon" aria-hidden="true">${isExpanded ? '▼' : '▶'}</span>`
    : '';

  const childrenHTML =
    hasChildren && node.children
      ? `<ul role="group" class="apg-treeview-group"${hiddenAttr}>${node.children.map((child) => renderNodeHTML(child, depth + 1)).join('')}</ul>`
      : '';

  return `<li role="treeitem" data-node-id="${node.id}"${parentIdAttr}${hasChildrenAttr} data-depth="${depth}" aria-labelledby="${labelId}"${ariaExpandedAttr} aria-selected="${isSelected}"${ariaDisabledAttr} tabindex="${isFocused ? 0 : -1}" class="${nodeClass}" style="--depth: ${depth}"><span class="apg-treeview-item-content">${icon}<span id="${labelId}" class="apg-treeview-item-label">${node.label}</span></span>${childrenHTML}</li>`;
}

const treeHTML = nodes.map((node) => renderNodeHTML(node, 0)).join('');
---

<apg-treeview
  data-multiselectable={multiselectable ? 'true' : undefined}
  data-type-ahead-timeout={typeAheadTimeout}
  data-initial-selected={JSON.stringify([...selectedIds])}
  data-initial-expanded={JSON.stringify([...expandedIds])}
  data-initial-focus-id={initialFocusId}
>
  <ul
    role="tree"
    aria-multiselectable={multiselectable || undefined}
    aria-label={ariaLabel}
    aria-labelledby={ariaLabelledby}
    class={containerClass}
    set:html={treeHTML}
  />
</apg-treeview>

<script>
  interface FlatNode {
    id: string;
    label: string;
    depth: number;
    parentId: string | null;
    hasChildren: boolean;
    disabled: boolean;
  }

  class ApgTreeView extends HTMLElement {
    private tree: HTMLElement | null = null;
    private rafId: number | null = null;
    private focusedId = '';
    private selectionAnchor = '';
    private selectedIds: Set<string> = new Set();
    private expandedIds: Set<string> = new Set();
    private typeAheadBuffer = '';
    private typeAheadTimeoutId: number | null = null;
    private allNodes: FlatNode[] = [];
    private nodeMap: Map<string, FlatNode> = new Map();

    connectedCallback() {
      this.rafId = requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.rafId = null;
      this.tree = this.querySelector('[role="tree"]');
      if (!this.tree) {
        console.warn('apg-treeview: tree element not found');
        return;
      }

      // Build node map from DOM
      this.buildNodeMap();

      // Initialize from data attributes
      try {
        this.selectedIds = new Set(JSON.parse(this.dataset.initialSelected || '[]'));
        this.expandedIds = new Set(JSON.parse(this.dataset.initialExpanded || '[]'));
      } catch {
        this.selectedIds = new Set();
        this.expandedIds = new Set();
      }

      this.focusedId = this.dataset.initialFocusId || '';
      this.selectionAnchor = this.focusedId;

      this.tree.addEventListener('keydown', this.handleKeyDown);
      this.tree.addEventListener('click', this.handleClick);
      this.tree.addEventListener('focusin', this.handleFocus);

      this.updateVisualState();
    }

    disconnectedCallback() {
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      if (this.typeAheadTimeoutId !== null) {
        clearTimeout(this.typeAheadTimeoutId);
        this.typeAheadTimeoutId = null;
      }
      this.tree?.removeEventListener('keydown', this.handleKeyDown);
      this.tree?.removeEventListener('click', this.handleClick);
      this.tree?.removeEventListener('focusin', this.handleFocus);
      this.tree = null;
    }

    private get isMultiselectable(): boolean {
      return this.dataset.multiselectable === 'true';
    }

    private get typeAheadTimeout(): number {
      return parseInt(this.dataset.typeAheadTimeout || '500', 10);
    }

    private buildNodeMap() {
      this.allNodes = [];
      this.nodeMap.clear();

      const items = this.querySelectorAll<HTMLLIElement>('[role="treeitem"]');
      items.forEach((item) => {
        const id = item.dataset.nodeId || '';
        const label = item.querySelector('.apg-treeview-item-label')?.textContent || '';
        const depth = parseInt(item.dataset.depth || '0', 10);
        const parentId = item.dataset.parentId || null;
        const hasChildren = item.dataset.hasChildren === 'true';
        const disabled = item.getAttribute('aria-disabled') === 'true';

        const node: FlatNode = { id, label, depth, parentId, hasChildren, disabled };
        this.allNodes.push(node);
        this.nodeMap.set(id, node);
      });
    }

    private getVisibleNodes(): FlatNode[] {
      const result: FlatNode[] = [];
      const collapsedParents = new Set<string>();

      for (const node of this.allNodes) {
        let isHidden = false;
        let currentParentId = node.parentId;

        while (currentParentId) {
          if (collapsedParents.has(currentParentId) || !this.expandedIds.has(currentParentId)) {
            isHidden = true;
            break;
          }
          const parent = this.nodeMap.get(currentParentId);
          currentParentId = parent?.parentId ?? null;
        }

        if (!isHidden) {
          result.push(node);
          if (node.hasChildren && !this.expandedIds.has(node.id)) {
            collapsedParents.add(node.id);
          }
        }
      }
      return result;
    }

    private getVisibleIndexMap(): Map<string, number> {
      const map = new Map<string, number>();
      this.getVisibleNodes().forEach((node, index) => map.set(node.id, index));
      return map;
    }

    private getNodeElement(nodeId: string): HTMLLIElement | null {
      return this.querySelector<HTMLLIElement>(`[data-node-id="${CSS.escape(nodeId)}"]`);
    }

    private updateVisualState() {
      // Update tabindex for roving tabindex
      this.querySelectorAll<HTMLLIElement>('[role="treeitem"]').forEach((item) => {
        const nodeId = item.dataset.nodeId || '';
        item.tabIndex = nodeId === this.focusedId ? 0 : -1;
      });
    }

    private focusNode(nodeId: string) {
      this.focusedId = nodeId;
      this.updateVisualState();
      this.getNodeElement(nodeId)?.focus();
    }

    private expandNode(nodeId: string) {
      const node = this.nodeMap.get(nodeId);
      if (!node?.hasChildren || node.disabled) return;
      if (this.expandedIds.has(nodeId)) return;

      this.expandedIds.add(nodeId);
      this.updateExpansionDOM(nodeId, true);

      this.dispatchEvent(
        new CustomEvent('expandedchange', {
          detail: { expandedIds: [...this.expandedIds] },
          bubbles: true,
        })
      );
    }

    private collapseNode(nodeId: string) {
      const node = this.nodeMap.get(nodeId);
      if (!node?.hasChildren || node.disabled) return;
      if (!this.expandedIds.has(nodeId)) return;

      this.expandedIds.delete(nodeId);
      this.updateExpansionDOM(nodeId, false);

      // If a child was focused, move focus to the collapsed parent
      const currentFocused = this.nodeMap.get(this.focusedId);
      if (currentFocused) {
        let parentId = currentFocused.parentId;
        while (parentId) {
          if (parentId === nodeId) {
            this.focusNode(nodeId);
            break;
          }
          const parent = this.nodeMap.get(parentId);
          parentId = parent?.parentId ?? null;
        }
      }

      this.dispatchEvent(
        new CustomEvent('expandedchange', {
          detail: { expandedIds: [...this.expandedIds] },
          bubbles: true,
        })
      );
    }

    private updateExpansionDOM(nodeId: string, expanded: boolean) {
      const item = this.getNodeElement(nodeId);
      if (!item) return;

      item.setAttribute('aria-expanded', String(expanded));

      const icon = item.querySelector('.apg-treeview-item-icon');
      if (icon) {
        icon.textContent = expanded ? '▼' : '▶';
      }

      const group = item.querySelector(':scope > [role="group"]');
      if (group) {
        if (expanded) {
          group.removeAttribute('hidden');
        } else {
          group.setAttribute('hidden', '');
        }
      }
    }

    private expandAllSiblings(nodeId: string) {
      const node = this.nodeMap.get(nodeId);
      if (!node) return;

      for (const n of this.allNodes) {
        if (n.parentId === node.parentId && n.hasChildren && !n.disabled) {
          if (!this.expandedIds.has(n.id)) {
            this.expandedIds.add(n.id);
            this.updateExpansionDOM(n.id, true);
          }
        }
      }

      this.dispatchEvent(
        new CustomEvent('expandedchange', {
          detail: { expandedIds: [...this.expandedIds] },
          bubbles: true,
        })
      );
    }

    private updateSelection(nodeId: string | null, action: 'toggle' | 'set' | 'range' | 'all') {
      const visibleNodes = this.getVisibleNodes();
      const visibleIndexMap = this.getVisibleIndexMap();

      if (action === 'all') {
        this.selectedIds = new Set(visibleNodes.filter((n) => !n.disabled).map((n) => n.id));
      } else if (action === 'range' && nodeId) {
        const anchorIndex = visibleIndexMap.get(this.selectionAnchor) ?? 0;
        const targetIndex = visibleIndexMap.get(nodeId) ?? 0;
        const start = Math.min(anchorIndex, targetIndex);
        const end = Math.max(anchorIndex, targetIndex);

        for (let i = start; i <= end; i++) {
          const node = visibleNodes[i];
          if (node && !node.disabled) {
            this.selectedIds.add(node.id);
          }
        }
      } else if (nodeId) {
        const node = this.nodeMap.get(nodeId);
        if (node?.disabled) return;

        if (this.isMultiselectable && action === 'toggle') {
          if (this.selectedIds.has(nodeId)) {
            this.selectedIds.delete(nodeId);
          } else {
            this.selectedIds.add(nodeId);
          }
        } else {
          this.selectedIds = new Set([nodeId]);
        }
      }

      // Update aria-selected
      this.querySelectorAll<HTMLLIElement>('[role="treeitem"]').forEach((item) => {
        const id = item.dataset.nodeId || '';
        const isSelected = this.selectedIds.has(id);
        item.setAttribute('aria-selected', String(isSelected));
        item.classList.toggle('apg-treeview-item--selected', isSelected);
      });

      this.dispatchEvent(
        new CustomEvent('selectionchange', {
          detail: { selectedIds: [...this.selectedIds] },
          bubbles: true,
        })
      );
    }

    private handleTypeAhead(char: string) {
      const visibleNodes = this.getVisibleNodes();
      if (visibleNodes.length === 0) return;

      if (this.typeAheadTimeoutId !== null) {
        clearTimeout(this.typeAheadTimeoutId);
      }

      this.typeAheadBuffer += char.toLowerCase();
      const buffer = this.typeAheadBuffer;
      const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);

      const visibleIndexMap = this.getVisibleIndexMap();
      const currentIndex = visibleIndexMap.get(this.focusedId) ?? 0;
      let startIndex: number;
      let searchStr: string;

      if (isSameChar) {
        this.typeAheadBuffer = buffer[0];
        startIndex = (currentIndex + 1) % visibleNodes.length;
        searchStr = buffer[0];
      } else if (buffer.length === 1) {
        startIndex = (currentIndex + 1) % visibleNodes.length;
        searchStr = buffer;
      } else {
        startIndex = currentIndex;
        searchStr = buffer;
      }

      for (let i = 0; i < visibleNodes.length; i++) {
        const index = (startIndex + i) % visibleNodes.length;
        const node = visibleNodes[index];
        if (node.disabled) continue;
        if (node.label.toLowerCase().startsWith(searchStr)) {
          this.focusNode(node.id);
          this.selectionAnchor = node.id;
          break;
        }
      }

      this.typeAheadTimeoutId = window.setTimeout(() => {
        this.typeAheadBuffer = '';
        this.typeAheadTimeoutId = null;
      }, this.typeAheadTimeout);
    }

    private handleClick = (event: MouseEvent) => {
      const target = event.target as HTMLElement;
      const item = target.closest('[role="treeitem"]') as HTMLLIElement | null;
      if (!item) return;

      const nodeId = item.dataset.nodeId || '';
      const node = this.nodeMap.get(nodeId);
      if (!node) return;

      event.stopPropagation();
      this.focusNode(nodeId);

      if (node.hasChildren) {
        if (node.disabled) return;
        if (this.expandedIds.has(nodeId)) {
          this.collapseNode(nodeId);
        } else {
          this.expandNode(nodeId);
        }
      }

      if (!node.disabled) {
        if (this.isMultiselectable) {
          this.updateSelection(nodeId, 'toggle');
        } else {
          this.updateSelection(nodeId, 'set');
        }
        this.selectionAnchor = nodeId;

        // Fire activate event on click
        this.dispatchEvent(
          new CustomEvent('activate', {
            detail: { nodeId },
            bubbles: true,
          })
        );
      }
    };

    private handleFocus = (event: FocusEvent) => {
      const target = event.target as HTMLElement;
      if (target.getAttribute('role') !== 'treeitem') return;

      const nodeId = (target as HTMLLIElement).dataset.nodeId || '';
      if (nodeId && nodeId !== this.focusedId) {
        this.focusedId = nodeId;
        this.updateVisualState();
      }
    };

    private handleKeyDown = (event: KeyboardEvent) => {
      const visibleNodes = this.getVisibleNodes();
      if (visibleNodes.length === 0) return;

      const { key, shiftKey, ctrlKey, metaKey } = event;
      const visibleIndexMap = this.getVisibleIndexMap();
      const currentIndex = visibleIndexMap.get(this.focusedId) ?? 0;
      const currentNode = visibleNodes[currentIndex];

      const handledKeys = [
        'ArrowDown',
        'ArrowUp',
        'ArrowRight',
        'ArrowLeft',
        'Home',
        'End',
        'Enter',
        ' ',
        '*',
      ];
      if (
        handledKeys.includes(key) ||
        (key.length === 1 && !ctrlKey && !metaKey) ||
        ((key === 'a' || key === 'A') && (ctrlKey || metaKey) && this.isMultiselectable)
      ) {
        event.preventDefault();
      }

      switch (key) {
        case 'ArrowDown': {
          if (currentIndex < visibleNodes.length - 1) {
            const nextIndex = currentIndex + 1;
            const nextNode = visibleNodes[nextIndex];
            this.focusNode(nextNode.id);
            if (this.isMultiselectable && shiftKey) {
              this.updateSelection(nextNode.id, 'range');
            } else {
              this.selectionAnchor = nextNode.id;
            }
          }
          break;
        }

        case 'ArrowUp': {
          if (currentIndex > 0) {
            const prevIndex = currentIndex - 1;
            const prevNode = visibleNodes[prevIndex];
            this.focusNode(prevNode.id);
            if (this.isMultiselectable && shiftKey) {
              this.updateSelection(prevNode.id, 'range');
            } else {
              this.selectionAnchor = prevNode.id;
            }
          }
          break;
        }

        case 'ArrowRight': {
          if (!currentNode) break;
          if (currentNode.hasChildren && !currentNode.disabled) {
            if (!this.expandedIds.has(this.focusedId)) {
              this.expandNode(this.focusedId);
            } else {
              const nextIndex = currentIndex + 1;
              if (nextIndex < visibleNodes.length) {
                const nextNode = visibleNodes[nextIndex];
                if (nextNode.parentId === this.focusedId) {
                  this.focusNode(nextNode.id);
                  this.selectionAnchor = nextNode.id;
                }
              }
            }
          }
          break;
        }

        case 'ArrowLeft': {
          if (!currentNode) break;
          if (
            currentNode.hasChildren &&
            this.expandedIds.has(this.focusedId) &&
            !currentNode.disabled
          ) {
            this.collapseNode(this.focusedId);
          } else if (currentNode.parentId) {
            this.focusNode(currentNode.parentId);
            this.selectionAnchor = currentNode.parentId;
          }
          break;
        }

        case 'Home': {
          const firstNode = visibleNodes[0];
          this.focusNode(firstNode.id);
          if (this.isMultiselectable && shiftKey) {
            this.updateSelection(firstNode.id, 'range');
          } else {
            this.selectionAnchor = firstNode.id;
          }
          break;
        }

        case 'End': {
          const lastNode = visibleNodes[visibleNodes.length - 1];
          this.focusNode(lastNode.id);
          if (this.isMultiselectable && shiftKey) {
            this.updateSelection(lastNode.id, 'range');
          } else {
            this.selectionAnchor = lastNode.id;
          }
          break;
        }

        case 'Enter': {
          if (currentNode && !currentNode.disabled) {
            if (this.isMultiselectable) {
              this.updateSelection(this.focusedId, 'toggle');
              this.selectionAnchor = this.focusedId;
            } else {
              this.updateSelection(this.focusedId, 'set');
            }
            this.dispatchEvent(
              new CustomEvent('activate', {
                detail: { nodeId: this.focusedId },
                bubbles: true,
              })
            );
          }
          break;
        }

        case ' ': {
          if (currentNode && !currentNode.disabled) {
            if (this.isMultiselectable) {
              this.updateSelection(this.focusedId, 'toggle');
              // Ctrl+Space: toggle without updating anchor
              // Space alone: update anchor for subsequent Shift+Arrow operations
              if (!ctrlKey) {
                this.selectionAnchor = this.focusedId;
              }
            } else {
              this.updateSelection(this.focusedId, 'set');
              this.dispatchEvent(
                new CustomEvent('activate', {
                  detail: { nodeId: this.focusedId },
                  bubbles: true,
                })
              );
            }
          }
          break;
        }

        case '*': {
          this.expandAllSiblings(this.focusedId);
          break;
        }

        case 'a':
        case 'A': {
          if ((ctrlKey || metaKey) && this.isMultiselectable) {
            this.updateSelection(null, 'all');
          } else if (!ctrlKey && !metaKey) {
            this.handleTypeAhead(key);
          }
          break;
        }

        default: {
          if (key.length === 1 && !ctrlKey && !metaKey) {
            this.handleTypeAhead(key);
          }
        }
      }
    };
  }

  if (!customElements.get('apg-treeview')) {
    customElements.define('apg-treeview', ApgTreeView);
  }
</script>

Usage

Example
---
import TreeView from './TreeView.astro';

const nodes = [
  {
    id: 'documents',
    label: 'Documents',
    children: [
      { id: 'report', label: 'report.pdf' },
      { id: 'notes', label: 'notes.txt' },
    ],
  },
  { id: 'readme', label: 'readme.md' },
];
---

<!-- Basic single-select -->
<TreeView
  nodes={nodes}
  aria-label="File Explorer"
/>

<!-- With default expanded -->
<TreeView
  nodes={nodes}
  aria-label="Files"
  defaultExpandedIds={['documents']}
/>

<!-- Multi-select mode -->
<TreeView
  nodes={nodes}
  aria-label="Files"
  multiselectable
/>

<!-- With callbacks via custom events -->
<TreeView
  nodes={nodes}
  aria-label="Files"
/>

<script>
  document.querySelector('apg-treeview')?.addEventListener('selection-change', (e) => {
    console.log('Selected:', e.detail);
  });
  document.querySelector('apg-treeview')?.addEventListener('expanded-change', (e) => {
    console.log('Expanded:', e.detail);
  });
  document.querySelector('apg-treeview')?.addEventListener('activate', (e) => {
    console.log('Activated:', e.detail);
  });
</script>

API

TreeNode Interface

Property Type Required Description
id string Yes Unique identifier for the node
label string Yes Display label for the node
children TreeNode[] No Child nodes (makes this a parent node)
disabled boolean No Prevents selection, activation, and expansion

Props

Prop Type Default Description
nodes TreeNode[] Required Array of tree nodes
multiselectable boolean false Enable multi-select mode
defaultSelectedIds string[] [] Initially selected node IDs
defaultExpandedIds string[] [] Initially expanded node IDs
typeAheadTimeout number 500 Type-ahead buffer reset timeout (ms)
aria-label string - Accessible label for the tree
aria-labelledby string - ID of labeling element

Custom Events

Event Detail Description
selection-change string[] Dispatched when selection changes
expanded-change string[] Dispatched when expansion state changes
activate string Dispatched when node is activated (Enter key)

Testing

Tests verify APG compliance for ARIA attributes, keyboard interactions, expansion/collapse behavior, selection models, and accessibility requirements.

Test Categories

High Priority: ARIA Structure

Test Description
role="tree" Container element has the tree role
role="treeitem" Each node has the treeitem role
role="group" Child containers have the group role
aria-expanded (parent) Parent nodes have aria-expanded (true or false)
aria-expanded (leaf) Leaf nodes do NOT have aria-expanded
aria-selected All treeitems have aria-selected (true or false)
aria-multiselectable Tree has aria-multiselectable="true" in multi-select mode
aria-disabled Disabled nodes have aria-disabled="true"

High Priority: Accessible Name

Test Description
aria-label Tree has accessible name via aria-label
aria-labelledby Tree can use aria-labelledby (takes precedence)

High Priority: Navigation

Test Description
ArrowDown Moves to next visible node
ArrowUp Moves to previous visible node
Home Moves to first node
End Moves to last visible node
Type-ahead Typing characters focuses matching visible node

High Priority: Expand/Collapse

Test Description
ArrowRight (closed parent) Expands the parent node
ArrowRight (open parent) Moves to first child
ArrowRight (leaf) Does nothing
ArrowLeft (open parent) Collapses the parent node
ArrowLeft (child/closed) Moves to parent node
ArrowLeft (root) Does nothing
* (asterisk) Expands all siblings at current level
Enter Activates node (does NOT toggle expansion)

High Priority: Selection (Single-Select)

Test Description
Arrow navigation Selection follows focus (arrows change selection)
Space Has no effect in single-select mode
Only one selected Only one node can be selected at a time

High Priority: Selection (Multi-Select)

Test Description
Space Toggles selection of focused node
Ctrl+Space Toggles selection without moving focus
Shift+Arrow Extends selection range from anchor
Shift+Home Extends selection to first node
Shift+End Extends selection to last visible node
Ctrl+A Selects all visible nodes
Multiple selection Multiple nodes can be selected simultaneously

High Priority: Focus Management

Test Description
Single Tab stop Tree is a single Tab stop (Tab/Shift+Tab)
tabindex="0" Focused node has tabindex="0"
tabindex="-1" Other nodes have tabindex="-1"
Skip collapsed Collapsed children are skipped during navigation
Focus to parent Focus moves to parent when child's parent is collapsed

Medium Priority: Disabled Nodes

Test Description
Focusable Disabled nodes can receive focus
Not selectable Disabled nodes cannot be selected
Not expandable Disabled parent nodes cannot be expanded/collapsed
Not activatable Enter key does not activate disabled nodes

Medium Priority: Type-Ahead

Test Description
Visible nodes only Type-ahead only searches visible nodes
Cycle on repeat Repeated character cycles through matches
Multi-character prefix Multiple characters form a search prefix
Timeout reset Buffer resets after timeout (default 500ms)
Skip disabled Type-ahead skips disabled nodes

Medium Priority: Mouse Interaction

Test Description
Click parent Toggles expansion and selects node
Click leaf Selects the leaf node
Click disabled Disabled nodes cannot be selected or expanded

Low Priority: Callbacks

Test Description
onSelectionChange Called with selected IDs when selection changes
onExpandedChange Called with expanded IDs when expansion changes
onActivate Called with node ID on Enter key

Testing Tools

Resources