← All components

TreeView.e2e

Everything needed to reproduce this component is inlined below (README and full source). Individual raw files: README.md TreeView.e2e.ts TreeView.stories.ts TreeView.test.ts TreeView.ts treeview.css

README.md (raw)

# TreeView

A framework-agnostic, WCAG-conformant **tree view** in vanilla TypeScript. It is a
faithful, heavily-documented implementation of the [WAI-ARIA APG *Tree View*
pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/), written so it can
double as a reference you hand to an LLM (or a person) to reproduce in React,
Vue, Svelte, etc. without losing accessibility semantics.

The class **enhances** semantic nested-list markup rather than generating it, so
the tree degrades gracefully to a plain nested list without JavaScript.

## Markup contract

Provide nested `<ul>`/`<li>` lists. Each item is an `<li class="treeview__item">`
whose visible text lives in a `.treeview__label` child; a **parent** item also
contains a `<ul class="treeview__group">` of child items.

```html
<ul class="treeview" data-treeview aria-label="File system">
  <li class="treeview__item">
    <span class="treeview__label">README.md</span>
  </li>

  <li class="treeview__item" data-expanded>
    <span class="treeview__label">src</span>
    <ul class="treeview__group">
      <li class="treeview__item">
        <span class="treeview__label">index.ts</span>
      </li>
    </ul>
  </li>
</ul>
```

- A node is a **parent** iff its `<li>` has a direct `.treeview__group` child.
- Mark a parent open initially with `data-expanded` on its `<li>`.
- Mark a node selected initially with `data-selected` on its `<li>` (only
  meaningful when `selectionMode` is not `"none"`).
- You do **not** author any ARIA or ids — the class wires up `role`,
  `aria-expanded`, `aria-selected`, `aria-level`, `aria-setsize`,
  `aria-posinset`, `hidden`, roving `tabindex`, and generates missing ids.

## Usage

```ts
import { TreeView } from './TreeView';
import './treeview.css';

const root = document.querySelector<HTMLElement>('[data-treeview]')!;
const tree = new TreeView(root, { selectionMode: 'single', label: 'File system' });

root.addEventListener('treeview:activate', (e) => {
  const { index, item } = (e as CustomEvent).detail;
  // e.g. navigate to the resource represented by `item`
});
```

Passing a non-`HTMLElement` root throws a `TypeError`; a treeitem without a
`.treeview__label` child throws an `Error`.

## Options

All options are optional. Defaults match the APG's baseline example.

| Option          | Type                                  | Default        | Description |
| --------------- | ------------------------------------- | -------------- | ----------- |
| `selectionMode` | `'none' \| 'single' \| 'multiple'`    | `'single'`     | `none`: navigation only, no `aria-selected`. `single`: one node selected. `multiple`: any number selected; root gets `aria-multiselectable="true"`. |
| `wrapFocus`     | `boolean`                             | `false`        | Wrap Up/Down focus around the first/last visible node. The APG example does not wrap. |
| `typeAhead`     | `boolean`                             | `true`         | Focus the next visible node whose label starts with the typed characters. |
| `idPrefix`      | `string`                              | `'treeview'`   | Prefix for generated element ids. |
| `label`         | `string`                              | `''`           | Applied as `aria-label` on the root **only** if the markup has no `aria-label`/`aria-labelledby`. |

## Events

All events are `CustomEvent`s dispatched on the root element and **bubble**.
Events fired during initial setup are suppressed.

| Event                | `detail`                                                    | Fired when |
| -------------------- | ---------------------------------------------------------- | ---------- |
| `treeview:toggle`    | `{ index, expanded, item, group }`                         | A parent expands or collapses. |
| `treeview:select`    | `{ index, selected, item }`                                | A node's selection state changes. |
| `treeview:activate`  | `{ index, item }`                                          | A node's primary action runs (click / Enter / Space). |

`index` is the zero-based document-order position of the node; `item` is its
`<li>`; `group` is the child `<ul>`.

## Public API

| Member                  | Description |
| ----------------------- | ----------- |
| `size`                  | Number of treeitems. |
| `isExpanded(index)`     | Whether the node is an expanded parent. |
| `isSelected(index)`     | Whether the node is selected. |
| `expand(index)`         | Expand a parent (no-op for leaves / already open). |
| `collapse(index)`       | Collapse a parent (no-op for leaves / already closed). |
| `toggle(index)`         | Toggle a parent's expansion. |
| `select(index)`         | Select a node (clears others in single mode). |
| `deselect(index)`       | Deselect a node. |
| `focusItem(index)`      | Move keyboard focus to a node, if currently visible. |
| `destroy()`             | Remove all listeners and clear pending timers. |

## Accessibility contract

### Roles

| Element                        | Role     |
| ------------------------------ | -------- |
| Root list (`[data-treeview]`)  | `tree`   |
| Each `.treeview__item`         | `treeitem` |
| Each nested `.treeview__group` | `group`  |

### States & properties (per treeitem)

| Attribute                        | Applied to | Meaning |
| -------------------------------- | ---------- | ------- |
| `aria-expanded`                  | parents only | `true`/`false` — leaf nodes never get it. |
| `aria-selected`                  | when selection enabled | `true`/`false`. |
| `aria-level`                     | all        | 1-based depth. |
| `aria-setsize` / `aria-posinset` | all        | Position within the group of siblings. |
| roving `tabindex`                | all        | Exactly one node is `0`; the rest are `-1`. Tab is a single stop into/out of the tree. |

Collapsed groups are hidden with the native `hidden` attribute, removing their
items from both the tab order and the accessibility tree.

### Keyboard interaction

All of the following are **required** by the APG for this pattern (tree
navigation is not optional the way the Accordion's arrow keys are).

| Key                | Action |
| ------------------ | ------ |
| Down / Up Arrow    | Move focus to the next / previous visible node. |
| Right Arrow        | Collapsed parent → expand; expanded parent → focus first child; leaf → nothing. |
| Left Arrow         | Expanded parent → collapse; otherwise → focus parent. |
| Home / End         | Move focus to the first / last visible node. |
| Enter / Space      | Primary action: toggle a parent, apply selection, emit `treeview:activate`. |
| Type a character   | Focus the next visible node whose label starts with the typed string. |
| `*` (asterisk)     | Expand every sibling parent at the current level. |

## Styling notes

The bundled `treeview.css` is deliberately minimal — indentation, a focus ring,
and ARIA-driven expand/collapse and selection affordances only. Override freely.

- **Never set `display` on `.treeview__group`.** Collapsed groups rely on the
  `hidden` attribute (`[hidden] { display: none }` from the UA stylesheet); a
  `display` rule would override it and break hiding.
- The **treeitem `<li>`**, not the label, is the focusable element, so the
  focus indicator (`:focus-visible`) belongs on `.treeview__item`. Never remove it.
- Expand/collapse (`▸`/`▾`) and selection styling are driven purely by the ARIA
  attributes, so the visuals always match what assistive tech announces.

TreeView.e2e.ts (raw)

/**
 * TreeView — end-to-end tests (Playwright, real browser).
 *
 * These drive the rendered "Default" Storybook story and verify the REQUIRED
 * APG keyboard model that only works with real focus / `activeElement`: Up/Down
 * navigation over visible nodes, Right/Left expand-collapse-and-move, Home/End,
 * type-ahead, and `*` to expand siblings. The ARIA wiring, selection modes and
 * JS API are covered by the Vitest unit suite (`TreeView.test.ts`).
 *
 * Requires `bunx playwright install chromium` first. The Storybook web server is
 * started automatically (see `playwright.config.ts`).
 *
 * The Default story tree (document order, with `src` and `src/components`
 * initially expanded):
 *   README.md
 *   src            [expanded]
 *     index.ts
 *     components   [expanded]
 *       Accordion.ts
 *       TreeView.ts
 *   docs           [collapsed]
 *     getting-started.md
 *     accessibility.md
 */
import { test, expect } from '@playwright/test';

const STORY = '/iframe.html?id=components-treeview--default&viewMode=story';

/**
 * Locate a treeitem by the exact text of its OWN label. An XPath `./span`
 * step matches only the item's direct-child label, so a parent node is never
 * matched by one of its descendants' labels (which live in nested `<li>`s).
 */
function item(page: import('@playwright/test').Page, label: string) {
  return page.locator(
    `xpath=//li[contains(concat(" ", normalize-space(@class), " "), " treeview__item ")]` +
      `[./span[contains(concat(" ", normalize-space(@class), " "), " treeview__label ")` +
      ` and normalize-space(.)=${JSON.stringify(label)}]]`,
  );
}

test.beforeEach(async ({ page }) => {
  await page.goto(STORY);
  await page.waitForSelector('.treeview__item');
});

test('Down and Up move focus over visible nodes only', async ({ page }) => {
  await item(page, 'README.md').focus();

  await page.keyboard.press('ArrowDown');
  await expect(item(page, 'src')).toBeFocused();

  await page.keyboard.press('ArrowDown');
  await expect(item(page, 'index.ts')).toBeFocused();

  await page.keyboard.press('ArrowUp');
  await expect(item(page, 'src')).toBeFocused();
});

test('Right expands a collapsed parent then moves to its first child', async ({ page }) => {
  const docs = item(page, 'docs');
  await docs.focus();
  await expect(docs).toHaveAttribute('aria-expanded', 'false');

  await page.keyboard.press('ArrowRight');
  await expect(docs).toHaveAttribute('aria-expanded', 'true');

  await page.keyboard.press('ArrowRight');
  await expect(item(page, 'getting-started.md')).toBeFocused();
});

test('Left collapses an expanded parent then moves to the parent', async ({ page }) => {
  const src = item(page, 'src');
  await src.focus();
  await expect(src).toHaveAttribute('aria-expanded', 'true');

  await page.keyboard.press('ArrowLeft');
  await expect(src).toHaveAttribute('aria-expanded', 'false');

  // index.ts is now hidden; move onto a child then Left returns to the parent.
  await page.keyboard.press('ArrowRight'); // re-expand
  await page.keyboard.press('ArrowRight'); // focus index.ts
  await expect(item(page, 'index.ts')).toBeFocused();
  await page.keyboard.press('ArrowLeft'); // leaf → focus parent
  await expect(src).toBeFocused();
});

test('Home and End move to the first and last visible node', async ({ page }) => {
  await item(page, 'index.ts').focus();

  await page.keyboard.press('End');
  await expect(item(page, 'TreeView.ts')).toBeFocused();

  await page.keyboard.press('Home');
  await expect(item(page, 'README.md')).toBeFocused();
});

test('type-ahead focuses the next node whose label matches', async ({ page }) => {
  await item(page, 'README.md').focus();
  await page.keyboard.press('d'); // "docs"
  await expect(item(page, 'docs')).toBeFocused();
});

test('asterisk expands all sibling parents at the level', async ({ page }) => {
  // Collapse src first so both top-level parents (src, docs) are collapsed.
  await item(page, 'src').focus();
  await page.keyboard.press('ArrowLeft');
  await expect(item(page, 'src')).toHaveAttribute('aria-expanded', 'false');

  await page.keyboard.press('*');
  await expect(item(page, 'src')).toHaveAttribute('aria-expanded', 'true');
  await expect(item(page, 'docs')).toHaveAttribute('aria-expanded', 'true');
});

test('Enter selects the focused node (aria-selected)', async ({ page }) => {
  const readme = item(page, 'README.md');
  await readme.focus();
  await page.keyboard.press('Enter');
  await expect(readme).toHaveAttribute('aria-selected', 'true');
});

TreeView.stories.ts (raw)

import type { Meta, StoryObj } from '@storybook/html';
import { TreeView, type TreeViewOptions } from './TreeView';
import './treeview.css';
// `storybook-readme` (installed for this repo) targets Storybook 5 and is
// incompatible with this Storybook 8 setup, so the README is surfaced through
// SB8's native docs mechanism: imported as raw Markdown and shown on the Docs
// page via `parameters.docs.description.component`. See the Accordion stories.
import readme from './README.md?raw';

/**
 * Storybook stories for the TreeView component.
 *
 * The library is framework-agnostic vanilla TS, so each story's `render` builds
 * real nested-list DOM, then enhances it with `new TreeView(...)` — exactly how
 * a consumer would use it. The `@storybook/addon-a11y` panel runs axe-core
 * against the rendered output so regressions in the ARIA wiring surface here.
 */

interface Node {
  label: string;
  /** Start this parent expanded (maps to `data-expanded` on the item). */
  open?: boolean;
  /** Start this node selected (maps to `data-selected` on the item). */
  selected?: boolean;
  children?: readonly Node[];
}

const SAMPLE_TREE: readonly Node[] = [
  { label: 'README.md' },
  {
    label: 'src',
    open: true,
    children: [
      { label: 'index.ts', selected: true },
      {
        label: 'components',
        open: true,
        children: [{ label: 'Accordion.ts' }, { label: 'TreeView.ts' }],
      },
    ],
  },
  {
    label: 'docs',
    children: [{ label: 'getting-started.md' }, { label: 'accessibility.md' }],
  },
];

/** Recursively build the `<li>`/`<ul>` markup the component expects. */
function buildItems(nodes: readonly Node[]): HTMLLIElement[] {
  return nodes.map((node) => {
    const li = document.createElement('li');
    li.className = 'treeview__item';
    if (node.open) li.setAttribute('data-expanded', '');
    if (node.selected) li.setAttribute('data-selected', '');

    const label = document.createElement('span');
    label.className = 'treeview__label';
    label.textContent = node.label;
    li.append(label);

    if (node.children?.length) {
      const group = document.createElement('ul');
      group.className = 'treeview__group';
      group.append(...buildItems(node.children));
      li.append(group);
    }
    return li;
  });
}

/** Build the semantic tree markup and activate the component. */
function renderTree(nodes: readonly Node[], options: TreeViewOptions = {}): HTMLElement {
  const root = document.createElement('ul');
  root.className = 'treeview';
  root.setAttribute('data-treeview', '');
  root.append(...buildItems(nodes));

  new TreeView(root, { label: 'Project files', ...options });
  return root;
}

const meta: Meta<TreeViewOptions> = {
  title: 'Components/TreeView',
  // `autodocs` (see `.storybook/main.ts` → `docs.autodocs: "tag"`) generates a
  // "Docs" page for this component; the README is rendered at the top of it.
  tags: ['autodocs'],
  parameters: {
    docs: {
      description: {
        component: readme,
      },
    },
  },
  argTypes: {
    // `table.defaultValue.summary` fills the Controls/Docs "Default" column.
    // `@storybook/html` cannot read these from the TS interface, so they are
    // kept in sync with the defaults in `TreeViewOptions` by hand.
    selectionMode: {
      control: 'select',
      options: ['none', 'single', 'multiple'],
      description:
        'Selection behaviour: "none" (navigation only), "single" (one node), or "multiple" (any number; adds aria-multiselectable).',
      table: { defaultValue: { summary: 'single' } },
    },
    wrapFocus: {
      control: 'boolean',
      description: 'Wrap Up/Down arrow focus around the first/last visible node.',
      table: { defaultValue: { summary: 'false' } },
    },
    typeAhead: {
      control: 'boolean',
      description: 'Focus the next visible node whose label matches typed characters.',
      table: { defaultValue: { summary: 'true' } },
    },
  },
  render: (args) => renderTree(SAMPLE_TREE, args),
};

export default meta;
type Story = StoryObj<TreeViewOptions>;

/** Default configuration: single-selection navigation tree. */
export const Default: Story = {
  args: {
    selectionMode: 'single',
    wrapFocus: false,
    typeAhead: true,
  },
};

/** Any number of nodes may be selected; the tree is `aria-multiselectable`. */
export const MultiSelect: Story = {
  args: {
    ...Default.args,
    selectionMode: 'multiple',
  },
};

/** Pure navigation: no `aria-selected` is applied to nodes. */
export const NavigationOnly: Story = {
  args: {
    ...Default.args,
    selectionMode: 'none',
  },
};

TreeView.test.ts (raw)

/**
 * TreeView — unit / DOM tests (Vitest + happy-dom).
 *
 * These exercise the public API and the ARIA contract documented in
 * `TreeView.ts` against a real (happy-dom) document. Focus-driven keyboard
 * navigation (arrow keys, Home/End, type-ahead, `*`) depends on real
 * `element.focus()` / `activeElement`, which happy-dom does not model reliably,
 * so those are covered by the Playwright `TreeView.e2e.ts` suite. Here we assert
 * through the public API and resulting attributes.
 */
import { describe, it, expect } from 'vitest';
import { TreeView, type TreeViewOptions } from './TreeView';

interface NodeSpec {
  label: string;
  open?: boolean;
  selected?: boolean;
  children?: readonly NodeSpec[];
}

const TREE: readonly NodeSpec[] = [
  { label: 'README.md' },
  {
    label: 'src',
    open: true,
    children: [
      { label: 'index.ts', selected: true },
      {
        label: 'components',
        open: true,
        children: [{ label: 'Accordion.ts' }, { label: 'TreeView.ts' }],
      },
    ],
  },
  {
    label: 'docs',
    children: [{ label: 'getting-started.md' }, { label: 'accessibility.md' }],
  },
];

function buildItems(nodes: readonly NodeSpec[]): HTMLLIElement[] {
  return nodes.map((node) => {
    const li = document.createElement('li');
    li.className = 'treeview__item';
    if (node.open) li.setAttribute('data-expanded', '');
    if (node.selected) li.setAttribute('data-selected', '');

    const label = document.createElement('span');
    label.className = 'treeview__label';
    label.textContent = node.label;
    li.append(label);

    if (node.children?.length) {
      const group = document.createElement('ul');
      group.className = 'treeview__group';
      group.append(...buildItems(node.children));
      li.append(group);
    }
    return li;
  });
}

/** Build the semantic nested-list markup and enhance it. */
function build(
  nodes: readonly NodeSpec[] = TREE,
  options: TreeViewOptions = {},
): { root: HTMLElement; instance: TreeView } {
  const root = document.createElement('ul');
  root.className = 'treeview';
  root.append(...buildItems(nodes));
  document.body.append(root);
  const instance = new TreeView(root, { label: 'Project files', ...options });
  return { root, instance };
}

/** All treeitem `<li>` elements in document order. */
function items(root: HTMLElement): HTMLElement[] {
  return Array.from(root.querySelectorAll<HTMLElement>('.treeview__item'));
}

/** Find the index (document order) of the item whose own label is `text`. */
function indexOfLabel(root: HTMLElement, text: string): number {
  return items(root).findIndex(
    (li) => li.querySelector(':scope > .treeview__label')?.textContent === text,
  );
}

describe('TreeView — construction', () => {
  it('throws when root is not an element', () => {
    // @ts-expect-error deliberately wrong type
    expect(() => new TreeView(undefined)).toThrow(TypeError);
  });

  it('reports node count via `size`', () => {
    const { instance } = build();
    expect(instance.size).toBe(9);
  });
});

describe('TreeView — roles and structure', () => {
  it('applies tree/treeitem/group roles', () => {
    const { root } = build();
    expect(root.getAttribute('role')).toBe('tree');
    for (const li of items(root)) {
      expect(li.getAttribute('role')).toBe('treeitem');
    }
    for (const group of root.querySelectorAll('.treeview__group')) {
      expect(group.getAttribute('role')).toBe('group');
    }
  });

  it('sets aria-level, aria-setsize and aria-posinset', () => {
    const { root } = build();
    const readme = items(root)[indexOfLabel(root, 'README.md')]!;
    expect(readme.getAttribute('aria-level')).toBe('1');
    expect(readme.getAttribute('aria-setsize')).toBe('3'); // README.md, src, docs
    expect(readme.getAttribute('aria-posinset')).toBe('1');

    const accordion = items(root)[indexOfLabel(root, 'Accordion.ts')]!;
    expect(accordion.getAttribute('aria-level')).toBe('3');
    expect(accordion.getAttribute('aria-setsize')).toBe('2');
    expect(accordion.getAttribute('aria-posinset')).toBe('1');
  });

  it('puts aria-expanded only on parents, reflecting data-expanded', () => {
    const { root } = build();
    const src = items(root)[indexOfLabel(root, 'src')]!;
    const docs = items(root)[indexOfLabel(root, 'docs')]!;
    const readme = items(root)[indexOfLabel(root, 'README.md')]!;
    expect(src.getAttribute('aria-expanded')).toBe('true'); // data-expanded
    expect(docs.getAttribute('aria-expanded')).toBe('false');
    expect(readme.hasAttribute('aria-expanded')).toBe(false); // leaf
  });

  it('hides groups of collapsed parents and shows groups of expanded ones', () => {
    const { root } = build();
    const src = items(root)[indexOfLabel(root, 'src')]!;
    const docs = items(root)[indexOfLabel(root, 'docs')]!;
    expect(src.querySelector<HTMLElement>(':scope > .treeview__group')!.hidden).toBe(false);
    expect(docs.querySelector<HTMLElement>(':scope > .treeview__group')!.hidden).toBe(true);
  });

  it('removes data-expanded / data-selected authoring hints', () => {
    const { root } = build();
    for (const li of items(root)) {
      expect(li.hasAttribute('data-expanded')).toBe(false);
      expect(li.hasAttribute('data-selected')).toBe(false);
    }
  });
});

describe('TreeView — roving tabindex', () => {
  it('leaves exactly one item with tabindex 0 and the rest -1', () => {
    const { root } = build();
    const all = items(root);
    const zeros = all.filter((li) => li.tabIndex === 0);
    expect(zeros.length).toBe(1);
    expect(zeros[0]).toBe(all[0]); // first node is the initial tab stop
    for (const li of all.slice(1)) expect(li.tabIndex).toBe(-1);
  });
});

describe('TreeView — expand / collapse / toggle API', () => {
  it('expands and collapses a parent, syncing group.hidden', () => {
    const { root, instance } = build();
    const i = indexOfLabel(root, 'docs');
    const group = items(root)[i]!.querySelector<HTMLElement>(':scope > .treeview__group')!;

    expect(instance.isExpanded(i)).toBe(false);
    instance.expand(i);
    expect(instance.isExpanded(i)).toBe(true);
    expect(group.hidden).toBe(false);

    instance.collapse(i);
    expect(instance.isExpanded(i)).toBe(false);
    expect(group.hidden).toBe(true);
  });

  it('toggle flips expansion', () => {
    const { root, instance } = build();
    const i = indexOfLabel(root, 'docs');
    instance.toggle(i);
    expect(instance.isExpanded(i)).toBe(true);
    instance.toggle(i);
    expect(instance.isExpanded(i)).toBe(false);
  });

  it('expand is a no-op on leaf nodes', () => {
    const { root, instance } = build();
    const i = indexOfLabel(root, 'README.md');
    instance.expand(i);
    expect(instance.isExpanded(i)).toBe(false);
    expect(items(root)[i]!.hasAttribute('aria-expanded')).toBe(false);
  });
});

describe('TreeView — selection modes', () => {
  it('single mode reflects initial data-selected and dedups selection', () => {
    const { root, instance } = build(TREE, { selectionMode: 'single' });
    const index = indexOfLabel(root, 'index.ts');
    const treeview = indexOfLabel(root, 'TreeView.ts');
    expect(instance.isSelected(index)).toBe(true); // data-selected

    instance.select(treeview);
    expect(instance.isSelected(treeview)).toBe(true);
    expect(instance.isSelected(index)).toBe(false); // previous cleared
  });

  it('multiple mode toggles independently and sets aria-multiselectable', () => {
    const { root, instance } = build(TREE, { selectionMode: 'multiple' });
    expect(root.getAttribute('aria-multiselectable')).toBe('true');

    const readme = indexOfLabel(root, 'README.md');
    const index = indexOfLabel(root, 'index.ts');
    instance.select(readme);
    expect(instance.isSelected(readme)).toBe(true);
    expect(instance.isSelected(index)).toBe(true); // initial selection kept

    instance.deselect(index);
    expect(instance.isSelected(index)).toBe(false);
    expect(instance.isSelected(readme)).toBe(true);
  });

  it('none mode applies no aria-selected and ignores select()', () => {
    const { root, instance } = build(TREE, { selectionMode: 'none' });
    expect(root.hasAttribute('aria-multiselectable')).toBe(false);
    for (const li of items(root)) {
      expect(li.hasAttribute('aria-selected')).toBe(false);
    }
    instance.select(0);
    expect(items(root)[0]!.hasAttribute('aria-selected')).toBe(false);
  });
});

describe('TreeView — events', () => {
  it('fires treeview:toggle with detail on expand', () => {
    const { root, instance } = build();
    let detail: { index: number; expanded: boolean } | null = null;
    root.addEventListener('treeview:toggle', (e) => {
      const d = (e as CustomEvent<{ index: number; expanded: boolean }>).detail;
      detail = { index: d.index, expanded: d.expanded };
    });
    const i = indexOfLabel(root, 'docs');
    instance.expand(i);
    expect(detail).toEqual({ index: i, expanded: true });
  });

  it('fires treeview:select with detail', () => {
    const { root, instance } = build();
    let detail: { index: number; selected: boolean } | null = null;
    root.addEventListener('treeview:select', (e) => {
      const d = (e as CustomEvent<{ index: number; selected: boolean }>).detail;
      detail = { index: d.index, selected: d.selected };
    });
    const i = indexOfLabel(root, 'README.md');
    instance.select(i);
    expect(detail).toEqual({ index: i, selected: true });
  });

  it('fires treeview:activate on click and toggles a clicked parent', () => {
    const { root, instance } = build();
    const activated: number[] = [];
    root.addEventListener('treeview:activate', (e) => {
      activated.push((e as CustomEvent<{ index: number }>).detail.index);
    });
    const i = indexOfLabel(root, 'docs');
    const label = items(root)[i]!.querySelector<HTMLElement>(':scope > .treeview__label')!;
    label.click();
    expect(activated).toEqual([i]);
    expect(instance.isExpanded(i)).toBe(true);
  });
});

describe('TreeView — destroy', () => {
  it('removes listeners so later clicks do nothing', () => {
    const { root, instance } = build();
    instance.destroy();
    const i = indexOfLabel(root, 'docs');
    items(root)[i]!.querySelector<HTMLElement>(':scope > .treeview__label')!.click();
    expect(instance.isExpanded(i)).toBe(false);
  });
});

TreeView.ts (raw)

/**
 * TreeView
 * ========
 *
 * A framework-agnostic, class-based implementation of the WAI-ARIA Authoring
 * Practices Guide (APG) "Tree View" pattern:
 *
 *   https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
 *
 * Like the {@link ../accordion/Accordion Accordion}, this file is written as a
 * **reference / translation template**: plain, strictly-typed vanilla TypeScript
 * with heavy documentation so it can be hand- or AI-translated into React, Vue,
 * Svelte, etc. without losing any of the accessibility semantics. Prefer clarity
 * over cleverness here.
 *
 * ---------------------------------------------------------------------------
 * Accessibility contract (what the APG requires and what this class guarantees)
 * ---------------------------------------------------------------------------
 *
 * Roles & structure
 *  - The root list becomes `role="tree"`. Because there is no native "tree"
 *    element, roles are applied to semantic `<ul>`/`<li>` markup — the class
 *    ENHANCES that markup rather than generating it, so the tree degrades to a
 *    plain nested list without JavaScript.
 *  - Every list item becomes `role="treeitem"`.
 *  - Every nested list (a `<ul>` that holds an item's children) becomes
 *    `role="group"`. Groups belonging to a collapsed parent are hidden with the
 *    native `hidden` attribute, which removes them from the tab order and the
 *    accessibility tree at once (no extra CSS required).
 *
 * States & properties on each treeitem
 *  - `aria-expanded`  : "true"/"false" — ONLY on items that own a child group
 *                       (parent nodes). Leaf nodes never get it, exactly as the
 *                       APG requires.
 *  - `aria-selected`  : "true"/"false" — only when `selectionMode` is not
 *                       "none". The root gets `aria-multiselectable="true"` in
 *                       multiple-selection mode.
 *  - `aria-level`     : 1-based depth of the node.
 *  - `aria-setsize` / `aria-posinset` : position within its group of siblings.
 *  - Roving `tabindex` : exactly ONE treeitem is in the tab order at a time
 *                        (`tabindex="0"`); all others are `tabindex="-1"`. Tab
 *                        moves into/out of the whole tree as a single stop; the
 *                        arrow keys move between nodes.
 *
 * Keyboard interaction (all REQUIRED by the APG for this pattern — unlike the
 * Accordion's arrow keys, tree navigation is not optional)
 *  - Down / Up Arrow  : move focus to the next / previous VISIBLE node.
 *  - Right Arrow      : on a collapsed parent → expand it; on an expanded parent
 *                       → move to its first child; on a leaf → do nothing.
 *  - Left Arrow       : on an expanded parent → collapse it; otherwise → move
 *                       focus to the node's parent.
 *  - Home / End       : move focus to the first / last visible node.
 *  - Enter / Space    : perform the node's primary action (toggle a parent,
 *                       select per `selectionMode`, and emit `treeview:activate`).
 *  - Type-ahead       : typing printable characters moves focus to the next
 *                       visible node whose label starts with the typed string.
 *  - `*` (asterisk)   : expand every sibling parent at the current node's level.
 *
 * ---------------------------------------------------------------------------
 * Expected markup (progressive enhancement)
 * ---------------------------------------------------------------------------
 *
 * Provide nested lists. Each item is an `<li class="treeview__item">` whose
 * label is a `.treeview__label` child; a parent item additionally holds a
 * `<ul class="treeview__group">` of child items. No ARIA is authored by hand.
 *
 * ```html
 * <ul class="treeview" data-treeview aria-label="File system">
 *   <li class="treeview__item">
 *     <span class="treeview__label">README.md</span>
 *   </li>
 *   <li class="treeview__item" data-expanded>
 *     <span class="treeview__label">src</span>
 *     <ul class="treeview__group">
 *       <li class="treeview__item">
 *         <span class="treeview__label">index.ts</span>
 *       </li>
 *     </ul>
 *   </li>
 * </ul>
 * ```
 *
 * Notes on the markup:
 *  - A node is a PARENT iff its `<li>` contains a direct `.treeview__group`.
 *  - Mark a parent as initially open with `data-expanded` on its `<li>`.
 *  - Mark a node as initially selected with `data-selected` on its `<li>`
 *    (only meaningful when `selectionMode` is not "none").
 *  - You do NOT write any ARIA or ids yourself — the class wires up `role`,
 *    `aria-expanded`, `aria-selected`, `aria-level`, `aria-setsize`,
 *    `aria-posinset`, `hidden`, roving `tabindex`, and generates missing ids.
 */

/** How the tree handles selection. */
export type TreeViewSelectionMode = 'none' | 'single' | 'multiple';

/** Options controlling tree-view behaviour. All fields are optional. */
export interface TreeViewOptions {
  /**
   * Selection behaviour:
   *  - `"none"`     — pure navigation tree; no `aria-selected` is applied.
   *  - `"single"`   — at most one node selected at a time.
   *  - `"multiple"` — any number of nodes may be selected; the root gains
   *                   `aria-multiselectable="true"`.
   * @default "single"
   */
  selectionMode?: TreeViewSelectionMode;

  /**
   * When moving with Up/Down at the ends of the visible list, wrap around
   * (Down on the last node focuses the first, and vice versa). The APG example
   * does not wrap, so this defaults off.
   * @default false
   */
  wrapFocus?: boolean;

  /**
   * Enable printable-character type-ahead: focus moves to the next visible node
   * whose label starts with the recently typed string.
   * @default true
   */
  typeAhead?: boolean;

  /**
   * Prefix used when generating element ids. Useful to keep ids stable/readable.
   * @default "treeview"
   */
  idPrefix?: string;

  /**
   * Accessible name applied as `aria-label` on the root ONLY when the markup
   * doesn't already provide `aria-label`/`aria-labelledby`. A tree should always
   * have a name.
   * @default ""
   */
  label?: string;
}

/** Detail payload for the `treeview:toggle` custom event. */
export interface TreeViewToggleDetail {
  /** Zero-based index (document order) of the affected node. */
  index: number;
  /** Whether the node is now expanded. */
  expanded: boolean;
  /** The affected treeitem element. */
  item: HTMLElement;
  /** The child group element that was shown/hidden. */
  group: HTMLElement;
}

/** Detail payload for the `treeview:select` custom event. */
export interface TreeViewSelectDetail {
  /** Zero-based index (document order) of the affected node. */
  index: number;
  /** Whether the node is now selected. */
  selected: boolean;
  /** The affected treeitem element. */
  item: HTMLElement;
}

/** Detail payload for the `treeview:activate` custom event (Enter/Space/click). */
export interface TreeViewActivateDetail {
  /** Zero-based index (document order) of the activated node. */
  index: number;
  /** The activated treeitem element. */
  item: HTMLElement;
}

/** Internal representation of one tree node. */
interface TreeNode {
  /** The `<li>` element carrying `role="treeitem"`. */
  readonly li: HTMLElement;
  /** The label element whose text drives type-ahead. */
  readonly label: HTMLElement;
  /** The child group (`<ul role="group">`), or `null` for a leaf node. */
  readonly group: HTMLElement | null;
  /** The parent node, or `null` for a root-level node. */
  parent: TreeNode | null;
  /** 1-based depth (root nodes are level 1). */
  level: number;
}

/** Module-level counter guaranteeing unique generated ids across instances. */
let uidCounter = 0;

/** How long a type-ahead buffer lives before resetting, in milliseconds. */
const TYPEAHEAD_TIMEOUT = 500;

/** CSS selectors for the required markup hooks. Single source of truth. */
const SELECTORS = {
  item: '.treeview__item',
  label: '.treeview__label',
  group: '.treeview__group',
} as const;

export class TreeView {
  private readonly root: HTMLElement;
  private readonly options: Required<TreeViewOptions>;
  private readonly nodes: readonly TreeNode[];
  /** Fast lookup from a treeitem element to its index in {@link nodes}. */
  private readonly indexByLi: Map<HTMLElement, number>;

  /** Accumulated type-ahead keystrokes and the timer that clears them. */
  private typeaheadBuffer = '';
  private typeaheadTimer: number | null = null;

  /** Delegated click handler; bound as a field so `destroy()` can remove it. */
  private readonly onClick = (event: MouseEvent): void => {
    const node = this.findNode(event.target as Element | null);
    if (!node) return;
    node.li.focus();
    this.primaryAction(this.indexOf(node));
  };

  /**
   * Keep the roving tabstop in sync with real focus so that tabbing away and
   * back returns to the last-focused node.
   */
  private readonly onFocusIn = (event: FocusEvent): void => {
    const node = this.findNode(event.target as Element | null);
    if (node) this.setTabStop(this.indexOf(node));
  };

  /** Delegated keydown handler implementing the full tree keyboard model. */
  private readonly onKeydown = (event: KeyboardEvent): void => {
    const node = this.findNode(event.target as Element | null);
    // Only act when a treeitem itself is focused, never when focus is on some
    // interactive element the author nested inside a label.
    if (!node || event.target !== node.li) return;

    const index = this.indexOf(node);

    switch (event.key) {
      case 'ArrowDown':
        event.preventDefault();
        this.focusRelative(node, +1);
        return;
      case 'ArrowUp':
        event.preventDefault();
        this.focusRelative(node, -1);
        return;
      case 'ArrowRight':
        event.preventDefault();
        if (node.group && !this.isExpanded(index)) this.expand(index);
        else if (node.group) this.firstChildOf(node)?.li.focus();
        return;
      case 'ArrowLeft':
        event.preventDefault();
        if (node.group && this.isExpanded(index)) this.collapse(index);
        else node.parent?.li.focus();
        return;
      case 'Home': {
        event.preventDefault();
        this.visibleNodes()[0]?.li.focus();
        return;
      }
      case 'End': {
        event.preventDefault();
        const visible = this.visibleNodes();
        visible[visible.length - 1]?.li.focus();
        return;
      }
      case 'Enter':
      case ' ':
      case 'Spacebar': // legacy value
        event.preventDefault();
        this.primaryAction(index);
        return;
      case '*':
        event.preventDefault();
        this.expandSiblings(node);
        return;
      default:
        // Printable single characters (excluding modifiers) drive type-ahead.
        if (
          this.options.typeAhead &&
          event.key.length === 1 &&
          !event.ctrlKey &&
          !event.metaKey &&
          !event.altKey &&
          /\S/.test(event.key)
        ) {
          this.typeahead(event.key, node);
        }
    }
  };

  /**
   * @param root    The list element that should become the tree (`role="tree"`).
   * @param options Behavioural options; see {@link TreeViewOptions}.
   */
  constructor(root: HTMLElement, options: TreeViewOptions = {}) {
    if (!(root instanceof HTMLElement)) {
      throw new TypeError('TreeView: `root` must be an HTMLElement.');
    }
    this.root = root;

    this.options = {
      selectionMode: options.selectionMode ?? 'single',
      wrapFocus: options.wrapFocus ?? false,
      typeAhead: options.typeAhead ?? true,
      idPrefix: options.idPrefix ?? 'treeview',
      label: options.label ?? '',
    };

    this.nodes = this.collectNodes();
    this.indexByLi = new Map(this.nodes.map((node, i) => [node.li, i]));
    this.setupAria();
    this.bindEvents();
  }

  // ---------------------------------------------------------------------------
  // Setup
  // ---------------------------------------------------------------------------

  /**
   * Discover every treeitem, then link each to its parent and depth. Because
   * `querySelectorAll` returns nodes in document order, parents always appear
   * before their children, so a single forward pass can resolve levels.
   */
  private collectNodes(): TreeNode[] {
    const lis = Array.from(this.root.querySelectorAll<HTMLElement>(SELECTORS.item));

    const nodes: TreeNode[] = lis.map((li, i) => {
      const label = li.querySelector<HTMLElement>(`:scope > ${SELECTORS.label}`);
      if (!label) {
        throw new Error(`TreeView: treeitem #${i} is missing a "${SELECTORS.label}" child.`);
      }
      const group = li.querySelector<HTMLElement>(`:scope > ${SELECTORS.group}`);
      return { li, label, group, parent: null, level: 1 };
    });

    const nodeByLi = new Map(nodes.map((node) => [node.li, node]));

    for (const node of nodes) {
      const parentLi = node.li.parentElement?.closest<HTMLElement>(SELECTORS.item) ?? null;
      node.parent = parentLi ? nodeByLi.get(parentLi) ?? null : null;
      node.level = node.parent ? node.parent.level + 1 : 1;
    }

    return nodes;
  }

  /** Apply all static ARIA wiring and reconcile the initial state from markup. */
  private setupAria(): void {
    this.root.setAttribute('role', 'tree');
    if (this.options.selectionMode === 'multiple') {
      this.root.setAttribute('aria-multiselectable', 'true');
    }
    const named =
      this.root.hasAttribute('aria-label') || this.root.hasAttribute('aria-labelledby');
    if (!named && this.options.label) {
      this.root.setAttribute('aria-label', this.options.label);
    }

    // Group siblings by parent once so set-size / position are cheap to derive.
    const siblingsByParent = new Map<TreeNode | null, TreeNode[]>();
    for (const node of this.nodes) {
      const list = siblingsByParent.get(node.parent) ?? [];
      list.push(node);
      siblingsByParent.set(node.parent, list);
    }

    this.nodes.forEach((node) => {
      const uid = `${this.options.idPrefix}-${++uidCounter}`;
      if (!node.li.id) node.li.id = uid;

      node.li.setAttribute('role', 'treeitem');
      node.li.setAttribute('aria-level', String(node.level));

      const siblings = siblingsByParent.get(node.parent) ?? [node];
      node.li.setAttribute('aria-setsize', String(siblings.length));
      node.li.setAttribute('aria-posinset', String(siblings.indexOf(node) + 1));

      if (node.group) {
        node.group.setAttribute('role', 'group');
        const open = node.li.hasAttribute('data-expanded');
        node.li.setAttribute('aria-expanded', String(open));
        node.group.hidden = !open;
      }

      if (this.options.selectionMode !== 'none') {
        node.li.setAttribute('aria-selected', String(node.li.hasAttribute('data-selected')));
      }

      // Clean up authoring hints so they don't linger in the DOM.
      node.li.removeAttribute('data-expanded');
      node.li.removeAttribute('data-selected');

      // Roving tabindex: everything starts out of the tab order...
      node.li.tabIndex = -1;
    });

    // ...then the first node becomes the single tab stop.
    if (this.nodes[0]) this.nodes[0].li.tabIndex = 0;
  }

  /** Attach delegated event listeners on the root. */
  private bindEvents(): void {
    this.root.addEventListener('click', this.onClick);
    this.root.addEventListener('keydown', this.onKeydown);
    this.root.addEventListener('focusin', this.onFocusIn);
  }

  // ---------------------------------------------------------------------------
  // Public API
  // ---------------------------------------------------------------------------

  /** Number of treeitems managed by this instance. */
  get size(): number {
    return this.nodes.length;
  }

  /** Whether the node at `index` is an expanded parent. */
  isExpanded(index: number): boolean {
    return this.isExpandedNode(this.nodes[index]);
  }

  /** Whether the node at `index` is selected. */
  isSelected(index: number): boolean {
    return this.nodes[index]?.li.getAttribute('aria-selected') === 'true';
  }

  /** Expand the parent node at `index` (no-op for leaves or if already open). */
  expand(index: number): void {
    const node = this.nodes[index];
    if (node?.group && !this.isExpanded(index)) this.setExpanded(index, true);
  }

  /** Collapse the parent node at `index` (no-op for leaves or if already shut). */
  collapse(index: number): void {
    const node = this.nodes[index];
    if (node?.group && this.isExpanded(index)) this.setExpanded(index, false);
  }

  /** Toggle the parent node at `index`. */
  toggle(index: number): void {
    if (this.isExpanded(index)) this.collapse(index);
    else this.expand(index);
  }

  /**
   * Select the node at `index`. In single-selection mode any other selected
   * node is cleared first; in multiple mode the node is simply added.
   */
  select(index: number): void {
    if (this.options.selectionMode === 'none' || !this.nodes[index]) return;
    if (this.options.selectionMode === 'single') {
      this.nodes.forEach((_, i) => {
        if (i !== index && this.isSelected(i)) this.setSelected(i, false);
      });
    }
    this.setSelected(index, true);
  }

  /** Deselect the node at `index`. */
  deselect(index: number): void {
    if (this.options.selectionMode === 'none') return;
    this.setSelected(index, false);
  }

  /** Move keyboard focus to the node at `index` (only if it is currently visible). */
  focusItem(index: number): void {
    const node = this.nodes[index];
    if (node && this.isVisible(node)) node.li.focus();
  }

  /**
   * Remove all listeners and clear any pending type-ahead timer. ARIA attributes
   * are left in place. Call before discarding the subtree or re-initialising.
   */
  destroy(): void {
    this.root.removeEventListener('click', this.onClick);
    this.root.removeEventListener('keydown', this.onKeydown);
    this.root.removeEventListener('focusin', this.onFocusIn);
    if (this.typeaheadTimer !== null) window.clearTimeout(this.typeaheadTimer);
  }

  // ---------------------------------------------------------------------------
  // Internal helpers
  // ---------------------------------------------------------------------------

  /** Resolve the {@link TreeNode} owning `target`, or `null` if outside the tree. */
  private findNode(target: Element | null): TreeNode | null {
    const li = target?.closest<HTMLElement>(SELECTORS.item) ?? null;
    if (!li || !this.root.contains(li)) return null;
    const index = this.indexByLi.get(li);
    return index === undefined ? null : this.nodes[index] ?? null;
  }

  /** Index of a node in {@link nodes}. */
  private indexOf(node: TreeNode): number {
    return this.indexByLi.get(node.li) ?? -1;
  }

  private isExpandedNode(node: TreeNode | undefined): boolean {
    return !!node?.group && node.li.getAttribute('aria-expanded') === 'true';
  }

  /** A node is visible iff every ancestor is an expanded parent. */
  private isVisible(node: TreeNode): boolean {
    for (let p = node.parent; p; p = p.parent) {
      if (!this.isExpandedNode(p)) return false;
    }
    return true;
  }

  /** All nodes currently visible, in document order. */
  private visibleNodes(): TreeNode[] {
    return this.nodes.filter((node) => this.isVisible(node));
  }

  /** The first child of `node` in document order, or `null` for a leaf. */
  private firstChildOf(node: TreeNode): TreeNode | null {
    return this.nodes.find((candidate) => candidate.parent === node) ?? null;
  }

  /** Move focus to the visible node `delta` steps from `node` (honours wrap). */
  private focusRelative(node: TreeNode, delta: 1 | -1): void {
    const visible = this.visibleNodes();
    const here = visible.indexOf(node);
    if (here === -1) return;

    let next = here + delta;
    if (next < 0) next = this.options.wrapFocus ? visible.length - 1 : -1;
    else if (next >= visible.length) next = this.options.wrapFocus ? 0 : -1;

    if (next !== -1) visible[next]?.li.focus();
  }

  /** Expand every sibling of `node` (same parent) that is itself a parent. */
  private expandSiblings(node: TreeNode): void {
    this.nodes.forEach((candidate, i) => {
      if (candidate.parent === node.parent && candidate.group) this.expand(i);
    });
  }

  /** Focus the next visible node whose label matches the typed buffer. */
  private typeahead(char: string, current: TreeNode): void {
    this.typeaheadBuffer += char.toLowerCase();
    if (this.typeaheadTimer !== null) window.clearTimeout(this.typeaheadTimer);
    this.typeaheadTimer = window.setTimeout(() => {
      this.typeaheadBuffer = '';
      this.typeaheadTimer = null;
    }, TYPEAHEAD_TIMEOUT);

    const visible = this.visibleNodes();
    const start = visible.indexOf(current);
    if (start === -1 || visible.length === 0) return;

    // Search forward from the node after the current one, wrapping around.
    for (let step = 1; step <= visible.length; step++) {
      const candidate = visible[(start + step) % visible.length];
      const text = (candidate?.label.textContent ?? '').trim().toLowerCase();
      if (candidate && text.startsWith(this.typeaheadBuffer)) {
        candidate.li.focus();
        return;
      }
    }
  }

  /** Make the node at `index` the single tab stop. */
  private setTabStop(index: number): void {
    this.nodes.forEach((node, i) => {
      node.li.tabIndex = i === index ? 0 : -1;
    });
  }

  /**
   * The primary action for a node, shared by click and Enter/Space: toggle a
   * parent's expansion, apply selection per mode, and always emit
   * `treeview:activate` so consumers can react (e.g. follow a link).
   */
  private primaryAction(index: number): void {
    const node = this.nodes[index];
    if (!node) return;

    if (node.group) this.toggle(index);

    if (this.options.selectionMode === 'single') this.select(index);
    else if (this.options.selectionMode === 'multiple') {
      this.setSelected(index, !this.isSelected(index));
    }

    this.dispatch<TreeViewActivateDetail>('treeview:activate', { index, item: node.li });
  }

  // ---------------------------------------------------------------------------
  // Internal state mutation — every change funnels through these two methods so
  // the DOM can never drift out of a valid ARIA state.
  // ---------------------------------------------------------------------------

  private setExpanded(index: number, expanded: boolean, opts: { silent?: boolean } = {}): void {
    const node = this.nodes[index];
    if (!node?.group) return;

    node.li.setAttribute('aria-expanded', String(expanded));
    node.group.hidden = !expanded;

    // If focus was inside the subtree we just collapsed, pull it up to the
    // parent so keyboard focus never lands on a hidden node.
    if (!expanded) {
      const active = document.activeElement;
      if (active instanceof HTMLElement && node.group.contains(active)) node.li.focus();
    }

    if (!opts.silent) {
      this.dispatch<TreeViewToggleDetail>('treeview:toggle', {
        index,
        expanded,
        item: node.li,
        group: node.group,
      });
    }
  }

  private setSelected(index: number, selected: boolean, opts: { silent?: boolean } = {}): void {
    const node = this.nodes[index];
    if (!node || this.options.selectionMode === 'none') return;
    if (this.isSelected(index) === selected) return; // already in the target state

    node.li.setAttribute('aria-selected', String(selected));

    if (!opts.silent) {
      this.dispatch<TreeViewSelectDetail>('treeview:select', { index, selected, item: node.li });
    }
  }

  /** Dispatch a bubbling CustomEvent from the root. */
  private dispatch<T>(type: string, detail: T): void {
    this.root.dispatchEvent(new CustomEvent<T>(type, { detail, bubbles: true }));
  }
}

treeview.css (raw)

/*
 * TreeView — functional-only skeleton of styling hooks
 * ====================================================
 *
 * This file is deliberately EMPTY of cosmetics. It exists as a documented
 * skeleton: every class the component produces and every state/ARIA selector it
 * toggles is listed below as a (mostly empty) rule block, so a design system can
 * see exactly which hooks exist and drop its own values in. There is no
 * indentation, no list-marker handling, no expand/collapse glyphs, no selection
 * highlight, no colours or fonts here — the component falls back entirely to the
 * browser's default rendering of nested lists.
 *
 * Why empty is enough:
 *  - Collapsed groups are hidden by JS setting the native `hidden` attribute;
 *    the UA stylesheet's `[hidden] { display: none }` does the hiding.
 *  - The keyboard focus indicator is the browser's default focus ring on the
 *    focusable treeitem.
 *  - Expansion and selection are ARIA state on the treeitem, applied by JS;
 *    none of it depends on CSS.
 * So removing the cosmetic CSS cannot break functionality.
 *
 * IMPORTANT — do not reintroduce these:
 *  - Never set `display` on `.treeview__group`. Collapsed groups are hidden via
 *    the native `hidden` attribute; a `display` rule here would override
 *    `[hidden]` and break hiding. `[hidden] { display: none }` from the UA
 *    stylesheet must always win.
 *  - Never remove the focus indicator (e.g. `outline: 0` / `outline: none` on
 *    the treeitem or its `:focus-visible`). The focusable element is the
 *    `.treeview__item`, not the label; the browser default provides its focus
 *    ring and suppressing it would make keyboard use inaccessible.
 */

/* ---------------------------------------------------------------------------
 * Element hooks — one placeholder per class the component emits.
 * ------------------------------------------------------------------------- */

.treeview {
}

.treeview__group {
}

.treeview__item {
}

.treeview__label {
}

/* ---------------------------------------------------------------------------
 * State / ARIA hooks — these mirror the attributes the JS toggles, so styling
 * always matches what assistive tech announces.
 * ------------------------------------------------------------------------- */

/* Expanded parent node. */
.treeview__item[aria-expanded='true'] {
}

/* Collapsed parent node. */
.treeview__item[aria-expanded='false'] {
}

/* Selected node. */
.treeview__item[aria-selected='true'] {
}

/* Unselected node. */
.treeview__item[aria-selected='false'] {
}

/* Keyboard focus lands on the treeitem, not the label. Leave empty so the
   browser default focus ring stands; never suppress it. */
.treeview__item:focus-visible {
}

/* Collapsed child group. Hidden by the native `hidden` attribute — never add a
   `display` here (see header note). */
.treeview__group[hidden] {
}