busy-office-ui
Menu

Tree table

Hierarchical rows in a data table — a BOM explosion, account rollups, org-unit budgets: expand and collapse branches while every row keeps real table columns. A plain table with disclosure buttons, so screen readers keep native table browse mode. Not for hierarchy navigation — when the reader is going somewhere (a cost-center to open, a category to drill into) and the rows carry no data columns, this is the heavier tool: use Tree, whose links and disclosures are native elements with zero JS. Reach for the tree table only when every row genuinely needs table columns beside its branch. Without the JS: the table renders exactly as the server sent it — indentation comes from data-tree-level and the open/closed state from aria-expanded plus hidden, both markup. initTreeTable() adds only the toggling, so a server-rendered tree is fully readable without it; the disclosure buttons are what go inert.

Basic — a two-level BOM

ComponentQty
Pump assembly 1
Impeller 1
Seal ring set 2
Mounting plate 1
View code
<table class="bo-data-table bo-tree-table" data-tree-table>
  <thead><tr><th scope="col">Component</th><th scope="col" class="bo-data-table__col--numeric">Qty</th></tr></thead>
  <tbody>
    <tr data-tree-level="1"><td>
      <button class="bo-tree-table__toggle" type="button" aria-expanded="true" aria-label="Pump assembly"></button>Pump assembly</td>
      <td class="bo-data-table__col--numeric">1</td></tr>
    <tr data-tree-level="2"><td><span class="bo-tree-table__spacer"></span>Impeller</td>
      <td class="bo-data-table__col--numeric">1</td></tr>
    <tr data-tree-level="2"><td><span class="bo-tree-table__spacer"></span>Seal ring set</td>
      <td class="bo-data-table__col--numeric">2</td></tr>
    <tr data-tree-level="1"><td><span class="bo-tree-table__spacer"></span>Mounting plate</td>
      <td class="bo-data-table__col--numeric">1</td></tr>
  </tbody>
</table>

Click the chevron: descendants hide via the real hidden attribute, and aria-expanded tracks it — chevron rotation is decoration, two channels carry the state. Leaf rows use __spacer so the first column stays aligned.

Nested branches — collapsed state is preserved

Cost centerBudget
Operations 120,000.00
Warehouse 80,000.00
Front office 40,000.00
View code
<!-- a branch rendered PRE-COLLAPSED: aria-expanded="false" on its
     toggle + hidden on every descendant row, server-side -->
<table class="bo-data-table bo-tree-table" data-tree-table>
  <thead><tr><th scope="col">Cost center</th><th scope="col" class="bo-data-table__col--numeric">Budget</th></tr></thead>
  <tbody>
    <tr data-tree-level="1"><td>
      <button class="bo-tree-table__toggle" type="button" aria-expanded="true" aria-label="Operations"></button>Operations</td>
      <td class="bo-data-table__col--numeric">120,000.00</td></tr>
    <tr data-tree-level="2"><td>
      <button class="bo-tree-table__toggle" type="button" aria-expanded="false" aria-label="Warehouse"></button>Warehouse</td>
      <td class="bo-data-table__col--numeric">80,000.00</td></tr>
    <tr data-tree-level="3" hidden><td><span class="bo-tree-table__spacer"></span>CC-1180 — Facilities</td>
      <td class="bo-data-table__col--numeric">56,000.00</td></tr>
    <tr data-tree-level="3" hidden><td><span class="bo-tree-table__spacer"></span>CC-2205 — Logistics</td>
      <td class="bo-data-table__col--numeric">24,000.00</td></tr>
    <tr data-tree-level="2"><td><span class="bo-tree-table__spacer"></span>Front office</td>
      <td class="bo-data-table__col--numeric">40,000.00</td></tr>
  </tbody>
</table>

Warehouse renders pre-collapsed (aria-expanded="false" + hidden children, server-side). Collapse and re-expand Operations: Warehouse's own children stay hidden until Warehouse itself is expanded — a folded sub-branch never springs open by side effect.

When to use which — and why not treegrid

This component is deliberately a plain <table> + disclosure buttons, not ARIA role="treegrid": treegrid is the weakest-supported APG composite, and adopting it forfeits native screen-reader table browse mode — the wrong trade for read-mostly ERP hierarchy. The visible channel is the indent; the programmatic channel is each toggle's aria-expanded plus genuinely-hidden rows. Programmatic level semantics (aria-level) are a treegrid feature and are honestly not claimed — if a real adopter's assistive-tech workflow needs them, that reopens the ADR. Totals note: data-sum-of keeps counting collapsed rows — collapse is a view state, not a filter.

Markup

<!-- A PLAIN table + disclosure buttons — deliberately NOT
     role="treegrid" (native table browse mode beats the weakest-supported
     APG composite for read-mostly hierarchy; full reasoning in the ADR).
     Rows: data-tree-level="1..12". Parent rows: the toggle button, named
     for its branch. Leaf rows: the spacer keeps columns aligned.
     Hierarchy = document order (descendants are the following rows with
     a higher level). -->
<table class="bo-data-table bo-tree-table" data-tree-table>
  <tbody>
    <tr data-tree-level="1"><td>
      <button class="bo-tree-table__toggle" type="button"
          aria-expanded="true" aria-label="Assembly A"></button>Assembly A</td></tr>
    <tr data-tree-level="2"><td><span class="bo-tree-table__spacer"></span>Part A-1</td></tr>
  </tbody>
</table>
<script type="module">
  import { initTreeTable } from "@busy-office/ui/js";
  initTreeTable();
</script>

Quick reference generated

ClassRole
.bo-tree-table block (root)
.bo-tree-table__spacer part: spacer
.bo-tree-table__toggle part: toggle

State attributes: data-tree-levelaria-expanded

API generated from CSS

Blocks bo-tree-table
Parts bo-tree-table__spacerbo-tree-table__toggle
data-* hooks data-tree-level="10|11|12|2|3|4|5|6|7|8|9"
ARIA styled aria-expanded
JS required initTreeTable() — delegated toggle handling: collapse hides ALL descendant rows; expand reveals direct children and only those deeper rows whose own parents are expanded (nested collapsed state preserved). Hierarchy is document order via data-tree-level.
  • Twelve indent levels ship (data-tree-level="1..12") — real engineering BOMs run 8-12 deep; past that, compose your own padding rule.
  • Name each toggle for its BRANCH, without a verb (aria-label="Pump assembly") — aria-expanded carries the state, so a baked-in Collapse/Expand prefix would go stale the moment it toggles.
  • Keyboard: plain Tab reaches each toggle; Enter/Space activates — native buttons, nothing reimplemented.
  • A toggle with no following deeper rows is INERT (the chevron never flips over nothing) — if a branch will not open, check its children have data-tree-level.
  • Big hierarchies: render deep branches collapsed server-side and lazy-load on bo:tree-toggle (fetch/htmx append child rows on first expand); a 10k-node BOM should not ship 10k rows up front. Expand-all / expand-to-level are app code over the same event — loop the toggles below level N and click the collapsed ones.
  • Render a pre-collapsed branch server-side: aria-expanded="false" on the toggle + hidden on every descendant row.
  • Composes with everything .bo-data-table has — numeric columns, badges, row states; it IS a data table.
Contrast — 12 AA pairs this component uses (generated, CI-gated)
TextonLightDark
text-primary bg-surface 17.74 16.15
text-primary bg-canvas 16.98 18.08
text-secondary bg-surface 10.31 11.46
text-secondary bg-muted 9.37 9.75
bg-surface text-primary 17.74 16.15
text-primary bg-hover 16.12 13.75
text-primary bg-selected 17.01 11.86
text-secondary bg-selected 9.88 8.41
text-primary bg-surface-raised 17.74 14.51
text-secondary bg-canvas 9.86 12.82
border-control bg-hover 4.39 5.66
text-primary bg-muted 16.12 13.75

Maturity generated

Introduced
v0.3.0
CSS floor
Chrome/Edge 99 · Firefox 97 · Safari 15.4
Alignment scored
2026-08-23
Assistive-tech evidence
None recorded

Pin this component's first release with npm i @busy-office/ui@0.3.0. The floor is derived from this component's stylesheet alone — set by @layer — so it sits at or below the whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5. No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour. What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see accessibility.

Design-system alignment generated

100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-23.

Rule Score Evidence
Typography 3 / 3 no raw font-size
Colour 3 / 3 zero raw hex; the 1px ButtonText border is a forced-colors requirement, not a themed colour
Spacing 3 / 3 1.5em disclosure box states why it is one line-box tall, and __spacer cites it (94.2)
Interaction 3 / 3 the opener states that indentation (data-tree-level) and open state (aria-expanded + hidden) are markup, so a server-rendered tree reads fine without the JS; initTreeTable() adds only the toggling (94.14)
Content 3 / 3 opener carries the wrong-choice clause: "Not for hierarchy navigation" — links with no data columns want tree's native zero-JS elements; earn this heavier tool only when every row needs columns beside its branch. Blind re-scored 2026-08-23 (Polish round 14); pair-coherence with tree verified.
Fit 3 / 3 hierarchical rows in a data table; the ADR names the context where the heavier composite would be wrong
↑↓ move Enter open Esc close