Skip to content

feat(tree): hierarchical tree view with the full APG keyboard map - #634

Merged
nhobes merged 8 commits into
mainfrom
feat/tree
Aug 24, 2026
Merged

nhobes merged 8 commits into
mainfrom
feat/tree

Conversation

@mplatts

@mplatts mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member

Closes #611

Summary

<.tree> renders a nested list of maps to arbitrary depth: expand/collapse, single selection, connecting indent guides, and the WAI-ARIA TreeView pattern in full. Five parts per CONTRIBUTING.md, plus a PetalTree hook and a ### Unreleased changelog entry.

  1. Module lib/petal_components/tree.ex, exported from lib/petal_components.ex (tree was free). Every attr has a doc:; the moduledoc carries the node-map reference, both expansion models, the keyboard table and usage examples.
  2. Styles - a pc-tree section appended to assets/default.css in its own @layer components block, nothing reflowed. --pc-radius with the nested-child formula, gray ramp neutrals, dark: pairs, soft primary selection, focus-visible only, reduced-motion safe.
  3. Tests - test/petal/tree_test.exs (30 specs) and test/js/tree.test.js (24 specs, vitest + jsdom).
  4. Showcase - lib/petal_components/showcase/tree.ex plus its registry entry.
  5. Playground - nav entry under Navigation, slug tree, dials plus three scenarios at /c/tree. Verified rendering.

How recursion is handled

A private tree_node/1 function component renders its own row and, when it is an expanded branch, a role="group" list that calls straight back into tree_node/1. Depth is whatever the data has - no level cap, no per-level code.

Shared context (the expanded set, selection, the slots, the tree id, guides on/off) is bundled into one ctx map built once in tree/1 and threaded down, so a node carries only what is genuinely per-node: the node map, its level, and its position in its sibling group. aria-level, aria-setsize and aria-posinset fall out of that on the way down.

Indentation is one custom property per row (--pc-tree-depth) times a fixed step. The guides are a separate aria-hidden absolutely-positioned layer holding one 1px column per ancestor level, offset half a step so each line falls through its parent's chevron - which is why they line up at any depth without per-level rules.

Both expansion models

Both render identical markup and identical ARIA; only the chevron's phx-click differs.

Client-side (default). default_expanded seeds it; the chevron toggles data-expanded and aria-expanded with JS.toggle_attribute, and the 0fr -> 1fr grid technique animates the height. visibility rides along on that transition so a collapsed branch leaves the accessibility tree rather than sitting there clipped but exposed. No round-trips.

Server-controlled. Passing :expanded switches modes: the chevron pushes :on_expand with the node id in phx-value-id. Required for :lazy branches, whose children only exist once the server has been asked, and for trees that must survive a patch.

The moduledoc states the trade plainly, including that client-side state is lost on a patch. I deliberately did not reach for phx-update="ignore" (the accordion's approach): the issue's own wording describes the state as "lost on patch unless the server mirrors it", and ignoring updates would break any tree whose data changes.

Roving tabindex, and why there is a hook

The APG allows either roving tabindex or aria-activedescendant; I went with roving tabindex, which the issue names as the recommended default for tree views. PetalCommand uses aria-activedescendant because its virtual highlight lives in a listbox the user never leaves - focus stays in the text input while the highlight moves, which is the whole point of a combobox. A tree has no input. Its nodes are the interactive elements the user is on, so real DOM focus on the treeitem is what screen readers and focus-visible both expect, and it keeps "which node am I on" in one place (the document) rather than mirrored in an attribute. Exactly one node carries tabindex="0" at render - the selected node when it is reachable without expanding anything, otherwise the first visible node - so the tree is one Tab stop.

The hook is justified by exactly that. Moving DOM focus between arbitrary nodes on an arrow key is not something CSS or Phoenix.LiveView.JS can do: JS.focus/2 cannot pick "the next node that is visible given which ancestors happen to be expanded". So PetalTree does the keyboard map and the tabindex bookkeeping, and nothing else - to expand or select it clicks the chevron or the label the component already wired. Consequences:

  • Expansion and selection stay in JS commands and server events, so a pointer-only user gets a fully working tree if the hook never mounts.
  • The hook is identical under both expansion models; it never needs to know which one is in play.
  • It re-establishes the tab stop in updated(), since a server patch re-renders tabindex from the server's guess, and hands it back to the first visible node if the branch above it closed underneath.

Keyboard map implemented: Down/Up through visible nodes, Right to expand a collapsed branch or descend into an open one, Left to collapse or ascend, Home/End, Enter/Space to select, * to expand the focused node's sibling branches. Typeahead is out, per the issue's non-goals.

Deviations from the issue's API sketch

All small, all deliberate:

  1. select_event added. The sketch has on_select (a JS struct) and says the node "also emits a phx-click style event carrying the node id", but names no attr for it. select_event is that name; the id rides in phx-value-id as the test checklist specifies. on_select still composes user JS onto the built-in selection behaviour, exactly as sketched.
  2. expanded and on_expand added. The "Expansion model" section requires both but does not list them as attrs. :expanded being non-nil is what switches the tree to the server-controlled model.
  3. label added. The issue asked for "an :aria_label-friendly path via rest or a dedicated attr". label sets aria-label; rest still works if you prefer aria-labelledby.
  4. target added. phx-target, so a tree inside a LiveComponent can route its events.
  5. Selection also fires client-side. The component flips aria-selected and the selected class on click before the server replies, so the highlight is not a round-trip behind. Not in the sketch; it makes selection feel native and degrades cleanly.
  6. The chevron is a <span>, not a <button>. A button inside the tree would be a second Tab stop and break the one-Tab-stop requirement. It is aria-hidden, and keyboard users reach expansion through Left/Right per the APG. Chevron and label are siblings inside the row, so a chevron click never bubbles into selection.
  7. aria-labelledby on each treeitem, pointing at its own label span. Without it, a branch's accessible name is computed from its entire subtree.
  8. Guides render per row rather than per branch, for the alignment reason above. Behaviour matches the spec: show_guides toggles them and they are aria-hidden.

No new dependencies

None added, npm or hex.

Test counts

Suite Before (origin/main @ 871b2cf) After
mix test 913 tests, 0 failures, 1 skipped 943 tests, 0 failures, 1 skipped
npm test 157 passing 181 passing

mix format --check-formatted clean, mix compile --force --warnings-as-errors clean, and mix credo is unchanged against origin/main (14 refactoring opportunities, 31 readability issues on both).

Verified by hand

In the playground at /c/tree, light and dark, on the real page rather than in jsdom:

  • Every key in the map. Down/Up walk visible nodes and skip collapsed subtrees; Right descends into an open branch and expands a closed one; Left collapses, then ascends; Home/End hit the ends; * expanded the focused node's collapsed siblings and left the already-open one alone; Enter selected the focused node and cleared the previous aria-selected. Confirmed by reading tabindex / aria-expanded / aria-selected off the live DOM after each keystroke - including across the server round-trip in the explorer scenario, where the collapse and the selection both went through handle_event and the hook put the tab stop back afterwards.
  • Pointer expand/collapse in both models, and the :lazy branch: clicking deps/ showed the loading row, then its children landed 900ms later with their custom icons.
  • Guides, selection fill, the disabled node, the folder icon flip on expand, and label alignment all check out in both themes.

Screenshots

Captured light and dark at 1280x2200 with branches expanded 3-4 levels deep, so the nesting and the guide lines are actually visible rather than a row of collapsed roots. Not committed to the repo (no image files, no pr-assets) - I will attach them here before taking this out of draft.

<.tree> renders a nested list of maps down to arbitrary depth. Closes
the gap between menu, navigation_menu and accordion - none of which
render a hierarchy.

- recursion: tree_node/1 renders its own row and, when expanded, a
  role="group" list that calls straight back into itself, so depth is
  whatever the data has. aria-level, aria-setsize and aria-posinset are
  computed per depth and sibling group on the way down.
- two expansion models, identical markup and ARIA in both. Client-side
  by default: default_expanded seeds it, the chevron toggles
  data-expanded/aria-expanded with JS.toggle_attribute and the
  0fr -> 1fr grid trick animates the height. Server-controlled when
  :expanded is passed: the chevron pushes :on_expand with the node id in
  phx-value-id, which is what :lazy branches need.
- selection is single-select and server-owned (:selected + :select_event),
  with the aria-selected and class move applied client-side on click so
  the highlight is instant. :on_select composes user JS onto it.
- slots: :item replaces the row content and receives the node map, while
  the chevron, indent and ARIA stay with the component; :empty for no
  data; :loading for a lazy branch awaiting children.
- disabled nodes carry aria-disabled and lose the selection binding but
  stay focusable, per the APG.
- guides: an aria-hidden decorative layer, one 1px column per ancestor
  level, offset half an indent step so each line falls through its
  parent's chevron.

PetalTree hook does keyboard only. Roving tabindex is the reason it
exists - moving DOM focus between arbitrary nodes on an arrow key is
not something CSS or LiveView.JS can do. It clicks the chevron and the
label the component already wired rather than owning expansion or
selection, so a pointer user gets a working tree if it never mounts.

pc-tree CSS section appended in its own @layer components block: gray
ramp neutrals, soft primary selection, --pc-radius, focus-visible only,
reduced-motion safe. Showcase module + registry entry, and a playground
page at /c/tree with dials plus three scenarios (file explorer with a
simulated lazy fetch, an :item-slot org chart, a settings nav).

Tests: 913 -> 943 Elixir, 157 -> 181 JS. Credo unchanged vs main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.37500% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 92.61%. Comparing base (871b2cf) to head (c13805d).
⚠️ Report is 28 commits behind head on main.

Files with missing lines Patch % Lines
lib/petal_components/tree.ex 99.29% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #634      +/-   ##
==========================================
+ Coverage   92.40%   92.61%   +0.21%     
==========================================
  Files         119      121       +2     
  Lines        5066     5226     +160     
==========================================
+ Hits         4681     4840     +159     
- Misses        385      386       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

mplatts added a commit that referenced this pull request Aug 12, 2026
@mplatts

mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Screenshots

Branches expanded 3-4 levels so the nesting and guide lines are actually visible.

Light

Tree, light

Dark

Tree, dark

Lazy branch loading

Tree lazy loading

Verified independently

Check Result
mix test 943 tests, 0 failures, 1 skipped (+30)
npm test 181 passing (+24)
mix format / compile -Werror clean
mix credo zero new entries vs main
new dependencies none
slugs : clauses 61 : 61

The guide-line approach is worth noting: a separate aria-hidden absolute layer of 1px columns offset half a step, so alignment holds at any depth with no per-level rules. And roving tabindex over aria-activedescendant is the right read — a tree's nodes are the interactive elements, whereas PetalCommand's virtual model exists because focus has to stay in its input.

Every key in the map was checked against live DOM state (tabindex/aria-expanded/aria-selected after each keystroke), including the tab stop surviving a server round-trip.

@nhobes — one design opinion wanted: the guide lines are deliberately subtle (gray-200 / gray-400/17). Clear at full resolution, easy to lose in a scaled screenshot. Your call whether that's right.

Images live on the pr-assets branch, which exists only to host PR screenshots - it never merges to main and ships in no Hex release.

nhobes and others added 2 commits August 13, 2026 10:14
…n wired, id normalization

Audit round plus two latent bugs the new tests exposed. (1) The chevron
and node icons land on bare hero-* spans, and consumer heroicon CSS
(Tailwind v4 @plugin, every phx.new app) sizes those from the utilities
layer - beating any single components-layer selector, so tree icons
rendered 24px in real apps. Both now use the doubled-selector + bang
contract (see the pagination chevron precedent). (2) The keydown
handler intercepted MODIFIED arrows/Home/End - eating Cmd+ArrowDown
(page-end) and Ctrl+Home (document-start); the APG maps unmodified keys
only, so modifiers now bail out early, spec-pinned. (3) Selection is
now a contract you opt into: with none of selected/select_event/
on_select wired, nodes carry NO aria-selected (APG: a tree that does
not support selection must not announce it - 'not selected' on every
node of a nav tree is noise) and label clicks stop flipping a highlight
the app never asked for and cannot read. Moduledoc states the rule. (4)
NEW BUG the audit's suggested test exposed: node ids stringify
everywhere (DOM ids, phx-value-id, expanded sets) but the selected
comparison and the roving-tabindex start point compared RAW - an app
passing selected={post.id} with integer PKs never saw its highlight.
Both normalize now, test-pinned. Server-mode expanded={:all} also
pinned. 946 Elixir + 182 JS green.
…false} restores chevron-only

Only the chevron toggled a branch, so every folder in a tree had a 16px
hit target while the rest of the row did nothing (or selected, when
selection was wired). Every file tree people already use - VS Code,
Finder, the shadcn/reui trees - expands on a row click, so that is now
the default: `expand_on_click` ships true, and on a branch row a click
anywhere toggles the branch AND selects it in the same click when
selection is wired. `expand_on_click={false}` is exactly today's
behaviour, for rows whose click already has a job (a link tree that
must navigate). Leaves are untouched in both modes.

Click composition, since three handlers now share one row:

(1) The ROW carries select-then-toggle. Client-side model: the select
chain (user JS, highlight move, push) followed by the two toggle_attr
commands. Server-controlled model: the same chain followed by a
JS.push of :on_expand, which picks the node up from the row's own
phx-value-id - the chevron gets away with a bare event name only
because it is the whole handler there. A disabled branch still gets
the toggle and never the select; expansion is not selection, and its
chevron opens it today.

(2) The CHEVRON is unchanged and nested inside that row, which is
safe: LiveView resolves a click to the CLOSEST element carrying
phx-click and stops there (closestPhxBinding), so a chevron click runs
the chevron's toggle and never the row's handler underneath it. Same
rule is why the label had to give up its own phx-click - it would
otherwise swallow the row click and select without expanding.

(3) The KEYBOARD does not move. The hook selects by clicking whatever
carries data-pc-tree-select, and on an expand-on-click row every
visible element is a row click, so the select-only command moves onto
a hidden trigger inside the row: the pointer can never reach it, the
hook always can, and Enter/Space still selects without expanding.
Exactly one element per row wears the marker. Left/Right go on
clicking the chevron. The hook itself is unchanged bar a comment and a
variable name.

Row-expand also switches off when there is no expansion to take over:
server-controlled with no :on_expand has nothing to push, so the row
stays inert and the tree behaves as before.

Also: pc-tree__row--clickable for the cursor out in the indent and
trailing padding, an "expand on click" dial on the playground page so
both modes are one click apart, and the settings showcase example
(where a row click opens a page) now demonstrates expand_on_click=false.

955 Elixir + 183 JS green, format clean.
@nhobes

nhobes commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

New commit from the omnibus eye-test: row-click expansion is now the default, matching reui/shadcn and file-explorer convention (VS Code, Finder).

  • New expand_on_click attr, default true: clicking anywhere on a branch row toggles it, and when selection is wired the same click also selects — one click, both outcomes, selection first so the clicked node stays lit. expand_on_click={false} restores chevron-only expansion, for rows whose click has another job (link trees, rows with their own controls — the :icons showcase example now demonstrates this with its settings-nav framing).
  • The chevron stays a separate hit target that toggles without selecting, in both modes. No stopPropagation games: LiveView dispatches to the closest element carrying phx-click and stops, so the nested chevron simply wins.
  • Keyboard map unchanged: Enter/Space still select without expanding. Since every visible element on an expand-on-click row is now a row click, the select-only command moved to a hidden trigger (data-pc-tree-select) that pointers can't reach and the hook's programmatic click can — exactly one marker per row, pinned by test.
  • Degradations verified: disabled branches expand but never select; server-controlled trees without :on_expand keep an inert row (nothing to push); unselectable trees toggle without highlight.
  • Playground gets an expand_on_click dial next to show_guides for A/B.

Gates: mix test 955/0 (exit 0, +9 specs), vitest 183 (exit 0, +1 spec pinning Enter against the new anatomy), mix format --check-formatted clean repo-wide.

@nhobes

nhobes commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Eye-test follow-up: the chevron's hover wash fused with the row hover (at depth 0 the step-wide pill was literally flush with the row's left edge — 0px gap, one L-shaped wash). New commit gives it breathing room:

  • The painted pill is now one icon square (16x16) centred in the chevron column, with the leftover step split into inline margins — 2px side gaps, 8px top/bottom, so it reads as a chip sitting in the hovered row. The column still measures exactly one step: label/guide/row geometry byte-identical before and after (rect-verified).
  • Shrinking the wash did not shrink the target: the invisible ::before -inset-1 extension (same pattern as the house close buttons) takes the hit area to 24x24 — a strict superset of the old 20x20 box, proven by an elementFromPoint scan and a functional click outside the old bounds. Leaves skip it (spacers, not controls).
  • Verified hovering in light + dark, and against the selected row's primary tint in both themes — the neutral chip stays legible, no selected-state colour rule needed.

mix test 955/0 exit 0, format clean.

The inset chip still read cramped: 2px side gaps against 8px vertical
is what a 20px column inside a 32px row forces on any painted box, and
no file tree convention expects one anyway - VS Code, Finder and
GitHub all highlight the row and sharpen the twisty. So the hover is
now the icon colour deepening (gray-400 -> gray-700, dark gray-500 ->
gray-200), the 24x24 invisible hit target stays, and the leaf spacer
stays inert. The chevron's job is unchanged: expand or collapse
without selecting.
@nhobes

nhobes commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Maintainer eye-test round 2 on the chevron: the inset chip, while better, still read cramped — 2px side gaps vs 8px vertical is what a 20px column inside a 32px row forces on any painted box. And no file-tree convention expects a box there anyway (VS Code, Finder, GitHub: row highlights, twisty glyph sharpens). New commit drops the hover box entirely — hover is now the icon colour deepening — while keeping the 24x24 invisible hit target and the one-step column geometry. The chevron's role is unchanged: expand/collapse without selecting.

nhobes added 3 commits August 21, 2026 15:17
The maintainer asked where tree sits in the sidebar/menu family - the
answer deserves to live where the next asker (human or agent via the
MCP) will look. Cross-referenced against vertical_menu and sidebar the
way modal/alert_dialog and menu/sidebar already are: the shell holds
either, they never hold each other.
# Conflicts:
#	CHANGELOG.md
#	assets/default.css
#	assets/js/petal_components.js
#	dev.exs
@nhobes
nhobes marked this pull request as ready for review August 24, 2026 03:25
@nhobes
nhobes merged commit c1a16e4 into main Aug 24, 2026
3 checks passed
@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown

Greptile Summary

The PR introduces an arbitrary-depth WAI-ARIA tree component with client- and server-controlled expansion, single selection, lazy branches, styling, a keyboard-navigation hook, tests, and showcase/playground integration.

  • Adds recursive tree rendering with ARIA hierarchy metadata and customizable item, empty, and loading slots.
  • Adds the PetalTree roving-tabindex hook and the complete documented keyboard map.
  • Adds tree styles, public exports, showcase examples, playground scenarios, and Elixir/JavaScript coverage.

Confidence Score: 4/5

The PR should not merge until expansion and selection reliably target valid public node IDs containing punctuation.

The component documents arbitrary stringified unique IDs and ships dotted file IDs in its own examples, but its LiveView commands reinterpret those generated IDs as unescaped CSS selectors, preventing the intended node from being expanded or selected.

Files Needing Attention: lib/petal_components/tree.ex

Important Files Changed

Filename Overview
lib/petal_components/tree.ex Adds the recursive public tree component and its event commands, but raw IDs are used as CSS selectors and break interactions for supported dotted node IDs.
assets/js/petal_components.js Adds PetalTree keyboard traversal, focus restoration, selection, and expansion behavior with lifecycle cleanup.
assets/default.css Adds layered tree layout, state, focus, guide, animation, reduced-motion, and protected icon-sizing styles.
test/js/tree.test.js Covers the hook's keyboard and roving-tabindex behavior in jsdom, though its fixture does not expose dotted IDs through the component's selector commands.
test/petal/tree_test.exs Extensively covers recursive rendering, ARIA metadata, expansion models, selection, slots, lazy branches, and attributes.
lib/petal_components/showcase/tree.ex Adds representative tree examples, including dotted file IDs that reveal the selector-escaping defect in the component implementation.

Sequence Diagram

sequenceDiagram
  participant User
  participant Hook as PetalTree hook
  participant DOM as Tree DOM
  participant LV as LiveView
  User->>Hook: "Arrow key / Enter / Space / *"
  Hook->>DOM: Move roving focus
  Hook->>DOM: Click selection or expansion trigger
  alt Client-controlled expansion
    DOM->>DOM: Toggle aria-expanded and data-expanded
  else Server-controlled expansion
    DOM->>LV: Push node ID
    LV->>DOM: Patch expanded tree
    DOM->>Hook: updated()
    Hook->>DOM: Restore active tabindex
  end
Loading

Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile


defp toggle_js(ctx, node_id) do
selector = "#" <> node_dom_id(ctx.id, node_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Raw IDs break command selectors

When a node ID contains CSS-significant punctuation such as the shipped mix.exs and button.ex IDs, toggle_js/2 and selection_js/2 interpolate it into an unescaped CSS selector. The browser interprets the punctuation as selector syntax instead of part of the ID, so expansion or selection fails to update the intended node and can target another matching element.

Knowledge Base Used: Browser hooks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Component: Tree

2 participants