Skip to content

[Studio UI] Rewrite tree keyboard navigation with DOM-based traversal - #4048

Open
idaiv wants to merge 8 commits into
2026.xfrom
fix/tree-roving-tabindex
Open

[Studio UI] Rewrite tree keyboard navigation with DOM-based traversal#4048
idaiv wants to merge 8 commits into
2026.xfrom
fix/tree-roving-tabindex

Conversation

@idaiv

@idaiv idaiv commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Complete rewrite of tree keyboard navigation. The previous nodeOrder()-based approach sorted internalKey strings numerically which produced wrong ordering — children appeared after siblings instead of after their parent.

New approach:

  • All navigation uses querySelectorAll('.tree-node__content-inner') on the tree container, guaranteeing correct visual order
  • Uses event.currentTarget instead of nodesRefs lookup, eliminating stale ref mismatches
  • moveFocusToParent() walks the DOM hierarchy directly
  • Roving tabindex: selected (or first) node gets tabIndex=0, all others -1
  • aria-expanded on expandable nodes
  • focus-visible outline ring for keyboard users (:focus:not(:focus-visible) suppresses it for mouse clicks)

Keyboard map:

Key Action
Tab Focus enters tree on selected/first node
ArrowDown Next visible node
ArrowUp Previous visible node
ArrowRight Expand folder (if collapsed) / Move to first child (if expanded)
ArrowLeft Collapse folder (if expanded) / Move to parent (if collapsed)
Home First visible node
End Last visible node
Enter Select/open node

Test plan

  • Tab into the tree — verify focus ring appears on the first/selected node
  • ArrowDown/Up — moves sequentially through all visible nodes across levels
  • ArrowRight on collapsed folder — expands it
  • ArrowRight on expanded folder — focus moves to first child
  • ArrowLeft on child node — focus moves to parent folder
  • ArrowLeft on expanded folder — collapses it
  • Home/End — jumps to first/last visible node
  • Mouse click on a node — no focus ring visible (only :focus-visible)

Supersedes the scope of #4050 and #4051 which are now included here.

🤖 Generated with Claude Code

Relates to https://github.com/pimcore/product-management/issues/372

Tree nodes had tabIndex={-1}, making them unreachable via Tab key.
Apply roving tabindex: the selected node (or the first root node if
nothing is selected) gets tabIndex={0}, all others stay at -1. Once
a node is focused, arrow keys already handle navigation.

Fixes WCAG 2.1 2.1.1 (Keyboard) — tree is now Tab-reachable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings August 31, 2026 13:24
@idaiv idaiv added the Skip Milestone Check Exempts this PR from the mandatory milestone requirement label Aug 31, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Verdict: Needs changes. The PR attempts to make element-tree nodes keyboard-reachable using roving tabindex.

Changes:

  • Assigns tabIndex={0} to selected nodes or a hard-coded fallback node.

Review:

  • The fallback does not match rendered root/top-level nodes, leaving unselected trees unreachable.
  • Selection could create two tabbable nodes instead of one.
  • Shared behavior covers asset, document, and data-object trees, but lacks regression tests.
  • No public API compatibility or documentation impact.
Suppressed comments (1)

assets/js/src/core/components/element-tree/node/tree-node.tsx:226

  • This condition does not produce a valid initial tab stop. With showRoot, ElementTree renders the root at level -1; without it (the default), top-level children are level 0 but their generated keys are 0-<id>, not 0. Thus an unselected tree still has no tabIndex={0}. Moreover, if this fallback ever matched, selecting another node would leave both nodes tabbable because of the ||. Please choose one active key at the tree level from the selected node or the first rendered node, then pass that state to each node.
      tabIndex={ isSelected || (level === 0 && internalKey === '0') ? 0 : -1 }

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

ref={ setRef }
role='button'
tabIndex={ -1 }
tabIndex={ isSelected || (level === 0 && internalKey === '0') ? 0 : -1 }
idaiv and others added 2 commits August 31, 2026 13:31
Replace the broken nodeOrder()-based navigation with direct DOM
querySelectorAll traversal. The previous approach sorted internalKey
strings numerically which produced wrong ordering when parent keys
were prefixes of child keys.

New approach:
- getAllTreeNodes() queries .tree-node__content-inner elements in the
  actual DOM, guaranteeing correct visual order
- Uses event.currentTarget instead of nodesRefs lookup, eliminating
  stale ref mismatches
- moveFocusToParent() walks up the DOM tree (.tree-node → .tree-list
  → parent .tree-node) instead of comparing level numbers
- Roving tabindex: selected or first node gets tabIndex=0
- ArrowRight: expand if collapsed, move to first child if expanded
- ArrowLeft: collapse if expanded, move to parent if collapsed
- ArrowDown/Up: next/previous in visual order
- Home/End: first/last visible node
- aria-expanded on expandable nodes
- focus-visible outline ring for keyboard users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@idaiv idaiv changed the title [Studio UI] Make tree nodes keyboard-reachable via roving tabindex [Studio UI] Rewrite tree keyboard navigation with DOM-based traversal Sep 1, 2026
idaiv and others added 5 commits September 1, 2026 13:50
Refactor from chained if-statements to switch/case to reduce
cognitive complexity flagged by SonarCloud.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove unused imports: KeyboardEvent, INodeRef
- Move getVisibleNodes, moveFocus, moveFocusToParent to outer scope
  (pure DOM functions, no component state dependency)

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

sonarqubecloud Bot commented Sep 2, 2026

Copy link
Copy Markdown

@idaiv
idaiv requested a review from vin0401 September 2, 2026 09:27
@idaiv idaiv added this to the 2026.3.0 milestone Sep 2, 2026
@idaiv
idaiv requested a review from martineiber September 2, 2026 10:28
@idaiv idaiv removed the Skip Milestone Check Exempts this PR from the mandatory milestone requirement label Sep 2, 2026
@idaiv
idaiv removed the request for review from martineiber September 2, 2026 10:35
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.

2 participants