Tree

Summary

An expandable hierarchy for resources, files and component inventories. The markup is a nested list with role="tree" semantics; branch items expand and collapse on their chevron toggle, leaves take a spacer so labels stay aligned. Auto-init binds every .soma-tree on load. Expanded state lives on aria-expanded, and the toggle's accessible name ("Collapse" / "Expand") is kept in sync, localised. Keyboard follows the WAI-ARIA tree pattern: the whole tree is one tab stop and the arrow keys walk it (see Keyboard navigation).

When to use

PatternUse it for
TreeHierarchies the operator browses and folds: environments > services, folders > files, component inventories.
List groupFlat row lists with no nesting. See List group.
Sidebar nav groupsOne level of collapsible navigation in the shell sidebar; see the Shell page.
AccordionFolding content sections, not items — see Expander.

Examples

Basic hierarchy

Branches, leaves, trailing badges:

  • prod-web
    • api-backend running
    • postgres-db
  • README.md

Start collapsed vs expanded

Initial state lives in the markup:

  • expanded on load
    • visible child

Branches without an explicit aria-expanded default to expanded.

Deep nesting

Environments > services > instances:

  • production
    • api-backend
      • instance-1
      • instance-2

Imperative API

Drives the deep-nesting tree; toggles are logged:

Last toggle: (none)

The chevrons in the other examples also feed the log: soma-tree-toggle bubbles from every tree.

Keyboard navigation

The tree implements the WAI-ARIA tree pattern: the whole tree is one tab stop. Treeitems carry a roving tabindex (the current item is tabbable, the rest are not), the arrows move focus through the visible items, and the toggle buttons are taken out of the tab order at bind. They stay as mouse affordances while keyboard and screen-reader users act on the treeitem itself.

KeyAction
TabEnter or leave the tree (one stop — focus lands on the current item).
/ Next / previous visible item. No wrap at the ends.
Expand a collapsed branch; on an expanded branch, step to its first child. (Flips with RTL — the keys follow visual direction.)
Collapse an expanded branch; on a leaf or collapsed item, step to the parent. (Flips with RTL.)
Home / EndFirst / last visible item.
Enter / SpaceToggle an expandable item.

Each toggle's accessible name is synced to its state ("Collapse" while expanded, "Expand" while collapsed) from the tree.collapse / tree.expand i18n keys (see Localisation below). The chevron rotation is skipped under prefers-reduced-motion, and mirrored under RTL: collapsed points left, expanded still points down. The focused item paints the focus ring on its row.

HTML

The core contract. A branch has a toggle button and a nested ul[role="group"], with aria-expanded on the item; a leaf swaps the toggle for a spacer so labels align:

<ul class="soma-tree" role="tree">
  <!-- branch: has a toggle and a nested group; aria-expanded on the item -->
  <li class="soma-tree-item" role="treeitem" aria-expanded="true">
    <div class="soma-tree-row">
      <button class="soma-tree-toggle"><span class="soma-icon soma-icon-chevron-right"></span></button>
      <span class="soma-icon soma-icon-folder"></span>
      <span class="soma-tree-label">prod-web</span>
    </div>
    <ul role="group">
      <!-- leaf: a spacer instead of a toggle keeps labels aligned -->
      <li class="soma-tree-item" role="treeitem">
        <div class="soma-tree-row">
          <span class="soma-tree-toggle-spacer"></span>
          <span class="soma-icon soma-icon-file"></span>
          <span class="soma-tree-label">api-backend</span>
        </div>
      </li>
    </ul>
  </li>
</ul>

Start a branch collapsed by authoring aria-expanded="false"; on bind, any branch (an item with a child group) that has no explicit attribute is normalised to aria-expanded="true":

<li class="soma-tree-item" role="treeitem" aria-expanded="false">
  <div class="soma-tree-row">…</div>
  <ul role="group">…</ul>
</li>

Labels can be links. An a.soma-tree-label gets hover styling and keeps the row layout; trailing content (badges) sits after the label:

<div class="soma-tree-row">
  <span class="soma-tree-toggle-spacer"></span>
  <span class="soma-icon soma-icon-file"></span>
  <a class="soma-tree-label" href="/services/api-backend">api-backend</a>
  <span class="soma-badge soma-badge-success">running</span>
</div>

Branches nest by repeating the same item structure inside ul[role="group"] — any depth. The chevron rotates via CSS and mirrors in RTL.

CSS classes

ClassEffect
.soma-treeRoot <ul> with role="tree"; auto-init hook.
.soma-tree-itemAn item (role="treeitem"); branches carry aria-expanded, which hides the child group and rotates the chevron when false.
.soma-tree-rowThe visible row: toggle (or spacer) + icon + label, plus anything trailing (badges).
.soma-tree-toggleThe chevron <button> on branch items; its aria-label is set and kept in sync by the component (localised).
.soma-tree-toggle-spacerLeaf-row placeholder so labels align with branch labels.
.soma-tree-labelThe item's text: a span, or a link (a.soma-tree-label) with hover styling.
ul[role="group"]The nested child list inside a branch item; hidden while the item has aria-expanded="false".

JavaScript

Constructor

MemberDescription
Soma.tree(elOrSelector)Get or create the singleton for a .soma-tree root (throws when nothing matches or the class is missing). Binding normalises branch state (missing aria-expanded"true") and sets every toggle's localised label. Auto-init covers markup present at load, so call this to reach the API — or to bind a tree rendered later.

Instance methods

MemberDescription
.expand(item)Expand a branch: pass the .soma-tree-item element. Sets aria-expanded="true", re-labels the toggle, dispatches toggle. Items without aria-expanded (leaves) are ignored, and a redundant call (already expanded) no-ops without an event.
.collapse(item)Collapse a branch: same contract, incl. the redundant-call no-op.
.toggleItem(item)Flip the branch's current state.
.on(event, fn) / .off(event, fn)Subscribe / unsubscribe: 'toggle'. Listeners attach to the root element.
.destroy()Unbind and restore pre-init markup: aria-expanded the component added is removed (author-supplied values keep their current state), toggle aria-labels return to whatever the author wrote (or none), and the roving tabindex is stripped from items and toggles.

All methods return the instance, so calls chain. The underlying DOM event is a bubbling soma-tree-toggle CustomEvent dispatched on the root with e.detail = { item, expanded }, usable directly with addEventListener for delegated listening across every tree on the page. It fires for chevron clicks and for each imperative expand/collapse/toggleItem call.

Expand or collapse everything by iterating the branch items (only items with aria-expanded are branches):

const tree = Soma.tree('#resources');
const branches = document.querySelectorAll('#resources .soma-tree-item[aria-expanded]');

branches.forEach((item) => tree.expand(item));     // expand all
branches.forEach((item) => tree.collapse(item));   // collapse all

Load children lazily — populate a branch's group on its first expansion:

Soma.tree('#resources').on('toggle', async (e) => {
  const { item, expanded } = e.detail;
  const group = item.querySelector(':scope > ul');
  if (expanded && group.dataset.loaded !== 'true') {
    group.dataset.loaded = 'true';
    group.innerHTML = await fetchChildrenMarkup(item);
  }
});

Bind a tree rendered after page load (auto-init runs once, at DOMContentLoaded). The call is a get-or-create singleton, safe after any DOM update:

container.insertAdjacentHTML('beforeend', renderedTreeMarkup);
Soma.tree(container.querySelector('.soma-tree'));

The live examples above, exactly as this page wires them:

const tree = Soma.tree('#tree-deep');
const branches = () =>
  document.querySelectorAll('#tree-deep .soma-tree-item[aria-expanded]');

document.getElementById('tree-expand-all').addEventListener('click', () => {
  branches().forEach((item) => tree.expand(item));
});
document.getElementById('tree-collapse-all').addEventListener('click', () => {
  branches().forEach((item) => tree.collapse(item));
});

// Log every toggle from every tree on the page (the event bubbles).
document.addEventListener('soma-tree-toggle', (e) => {
  const label = e.detail.item.querySelector('.soma-tree-label').textContent;
  document.getElementById('tree-log').textContent =
    `soma-tree-toggle — ${label}: ${e.detail.expanded ? 'expanded' : 'collapsed'}`;
});

Localisation

The toggle labels come from the tree.expand / tree.collapse i18n keys ("Expand" / "Collapse" in English). Override them via Soma.i18n before components initialise — labels are written at bind time and on every toggle (see Themes and i18n):

Soma.i18n({ locale: 'cs' });                       // built-in pack
Soma.i18n({ 'tree.expand': 'Rozbalit',             // or per-key overrides
            'tree.collapse': 'Sbalit' });