From e04b54f6252be56d54cb01da5541ead6642f1cab Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Wed, 13 May 2026 20:07:19 +0100 Subject: [PATCH] feat(tree): keyboard arrow navigation between rows --- .../page/tree/components/doc-tree.tsx | 108 +++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/apps/client/src/features/page/tree/components/doc-tree.tsx b/apps/client/src/features/page/tree/components/doc-tree.tsx index cd8bec270..0ddb905f0 100644 --- a/apps/client/src/features/page/tree/components/doc-tree.tsx +++ b/apps/client/src/features/page/tree/components/doc-tree.tsx @@ -115,6 +115,9 @@ function DocTreeInner( const scrollRef = useRef(null); const rowElementsRef = useRef(new Map()); + // Set by the keyboard handler when the navigation target hasn't been + // virtualized yet. Consumed by registerRowElement when the row mounts. + const pendingFocusIdRef = useRef(null); const contextId = useMemo( () => uniqueContextId ?? Symbol('doc-tree'), [uniqueContextId], @@ -122,8 +125,17 @@ function DocTreeInner( const registerRowElement = useCallback( (id: string, el: HTMLElement | null) => { - if (el) rowElementsRef.current.set(id, el); - else rowElementsRef.current.delete(id); + if (el) { + rowElementsRef.current.set(id, el); + if (pendingFocusIdRef.current === id) { + pendingFocusIdRef.current = null; + // rAF lets the virtualizer settle layout/transform before focus, + // so the freshly-scrolled-in row is actually painted in view. + requestAnimationFrame(() => el.focus()); + } + } else { + rowElementsRef.current.delete(id); + } }, [], ); @@ -215,6 +227,96 @@ function DocTreeInner( lastScrolledIdRef.current = selectedId; }, [selectedId, flat, virtualizer]); + // Keyboard navigation handler — single delegated listener on the
    . + // The focused row is identified by walking up the DOM to the nearest element + // carrying data-row-id, so this works whether the user has focused the row + // itself or one of its inner buttons (chevron, +). No per-row re-renders; + // focus is moved via .focus() on the registered element, with a pending-id + // hand-off when the target row is currently virtualized out of view. + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; + if ( + e.key !== 'ArrowDown' && + e.key !== 'ArrowUp' && + e.key !== 'ArrowLeft' && + e.key !== 'ArrowRight' + ) { + return; + } + + const target = e.target as HTMLElement; + if (target.matches('input, textarea, [contenteditable="true"]')) return; + const rowEl = target.closest('[data-row-id]'); + if (!rowEl) return; + const id = rowEl.getAttribute('data-row-id'); + if (!id) return; + + const idx = flat.findIndex((r) => r.node.id === id); + if (idx < 0) return; + + const focusByIndex = (targetIdx: number) => { + if (targetIdx < 0 || targetIdx >= flat.length) return; + const targetId = flat[targetIdx].node.id; + const existing = rowElementsRef.current.get(targetId); + if (existing) { + existing.focus(); + } else { + pendingFocusIdRef.current = targetId; + virtualizer.scrollToIndex(targetIdx, { align: 'auto' }); + } + }; + + const row = flat[idx]; + const hasChildren = + (row.node.children && row.node.children.length > 0) || + (row.node as { hasChildren?: boolean }).hasChildren === true; + const isOpen = openIds.has(row.node.id); + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + focusByIndex(idx + 1); + break; + case 'ArrowUp': + e.preventDefault(); + focusByIndex(idx - 1); + break; + case 'ArrowRight': + e.preventDefault(); + if (hasChildren && !isOpen) { + onToggle(row.node.id, true); + } else if ( + isOpen && + row.node.children && + row.node.children.length > 0 + ) { + focusByIndex(idx + 1); + } + break; + case 'ArrowLeft': { + e.preventDefault(); + if (isOpen && hasChildren) { + onToggle(row.node.id, false); + } else { + // Move to parent — first preceding row with smaller level. + // Bounded by sibling-count to parent in the flat list; tree depth + // and sibling counts are small in practice. + const currentLevel = row.level; + for (let i = idx - 1; i >= 0; i--) { + if (flat[i].level < currentLevel) { + focusByIndex(i); + break; + } + } + } + break; + } + } + }, + [flat, openIds, onToggle, virtualizer], + ); + if (data.length === 0 && emptyState) { return
    {emptyState}
    ; } @@ -226,6 +328,7 @@ function DocTreeInner(
      ( key={row.node.id} role="treeitem" aria-level={row.level + 1} + data-row-id={row.node.id} style={{ position: 'absolute', top: 0,