From 50063ad02fcb91e183a4e38a0f61dbdbb02ca475 Mon Sep 17 00:00:00 2001 From: Xanacas <53140620+Xanacas@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:14:33 +0100 Subject: [PATCH] feat(web): add arrow-key navigation for sidebar project/thread tree Enable keyboard navigation in the sidebar using arrow keys: - ArrowUp/Down to move between visible items - ArrowLeft to collapse a project or jump to parent project from a thread - ArrowRight to expand a project or focus its first thread - Uses aria-expanded for accessibility - Only calls preventDefault when focus is on a nav item Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/src/components/Sidebar.logic.test.ts | 228 +++++++++++++++++- apps/web/src/components/Sidebar.logic.ts | 73 ++++++ apps/web/src/components/Sidebar.tsx | 16 +- 3 files changed, 315 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a8f84d564..741252461 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + handleSidebarArrowNavigation, hasUnseenCompletion, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -199,3 +200,228 @@ describe("resolveThreadRowClassName", () => { expect(className).toContain("hover:bg-accent"); }); }); + +describe("handleSidebarArrowNavigation", () => { + let mockActiveElement: unknown = null; + const originalDocument = globalThis.document; + + function setupMockDocument() { + globalThis.document = { + get activeElement() { + return mockActiveElement; + }, + } as Document; + } + + function teardownMockDocument() { + globalThis.document = originalDocument; + mockActiveElement = null; + } + + function makeMockElement(navItem: string, projectId: string, options?: { expanded?: boolean }) { + const attrs: Record = {}; + if (navItem === "project") { + attrs["aria-expanded"] = options?.expanded ? "true" : "false"; + } + const el = { + dataset: { navItem, projectId }, + focus: vi.fn(), + closest: () => el, + getAttribute: (name: string) => attrs[name] ?? null, + } as unknown as HTMLElement; + return el; + } + + function makeMockContainer( + allItems: HTMLElement[], + querySelectorResults: Record, + ) { + return { + querySelectorAll: () => allItems, + querySelector: (selector: string) => querySelectorResults[selector] ?? null, + } as unknown as HTMLElement; + } + + function makeKeyEvent(key: string) { + const prevented = { value: false }; + return { + event: { + key, + preventDefault: () => { + prevented.value = true; + }, + } as unknown as React.KeyboardEvent, + prevented, + }; + } + + it("moves focus down on ArrowDown", () => { + setupMockDocument(); + const project = makeMockElement("project", "p1", { expanded: true }); + const thread = makeMockElement("thread", "p1"); + const container = makeMockContainer([project, thread], { + '[data-nav-item="project"][data-project-id="p1"]': project, + }); + mockActiveElement = project; + + const { event } = makeKeyEvent("ArrowDown"); + handleSidebarArrowNavigation(event, container, vi.fn()); + + expect(thread.focus).toHaveBeenCalled(); + teardownMockDocument(); + }); + + it("skips collapsed threads on ArrowDown", () => { + setupMockDocument(); + const project1 = makeMockElement("project", "p1", { expanded: false }); + const thread1 = makeMockElement("thread", "p1"); + const project2 = makeMockElement("project", "p2"); + const container = makeMockContainer([project1, thread1, project2], { + '[data-nav-item="project"][data-project-id="p1"]': project1, + }); + mockActiveElement = project1; + + const { event } = makeKeyEvent("ArrowDown"); + handleSidebarArrowNavigation(event, container, vi.fn()); + + expect(project2.focus).toHaveBeenCalled(); + expect(thread1.focus).not.toHaveBeenCalled(); + teardownMockDocument(); + }); + + it("moves focus up on ArrowUp", () => { + setupMockDocument(); + const project = makeMockElement("project", "p1", { expanded: true }); + const thread = makeMockElement("thread", "p1"); + const container = makeMockContainer([project, thread], { + '[data-nav-item="project"][data-project-id="p1"]': project, + }); + mockActiveElement = thread; + + const { event } = makeKeyEvent("ArrowUp"); + handleSidebarArrowNavigation(event, container, vi.fn()); + + expect(project.focus).toHaveBeenCalled(); + teardownMockDocument(); + }); + + it("does not move focus past the last item on ArrowDown", () => { + setupMockDocument(); + const project = makeMockElement("project", "p1"); + const container = makeMockContainer([project], {}); + mockActiveElement = project; + + const { event } = makeKeyEvent("ArrowDown"); + handleSidebarArrowNavigation(event, container, vi.fn()); + + expect(project.focus).not.toHaveBeenCalled(); + teardownMockDocument(); + }); + + it("focuses parent project on ArrowLeft from a thread", () => { + setupMockDocument(); + const project = makeMockElement("project", "p1", { expanded: true }); + const thread = makeMockElement("thread", "p1"); + const container = makeMockContainer([project, thread], { + '[data-nav-item="project"][data-project-id="p1"]': project, + }); + mockActiveElement = thread; + + const { event } = makeKeyEvent("ArrowLeft"); + handleSidebarArrowNavigation(event, container, vi.fn()); + + expect(project.focus).toHaveBeenCalled(); + teardownMockDocument(); + }); + + it("collapses an expanded project on ArrowLeft", () => { + setupMockDocument(); + const project = makeMockElement("project", "p1", { expanded: true }); + const thread = makeMockElement("thread", "p1"); + const container = makeMockContainer([project, thread], { + '[data-nav-item="project"][data-project-id="p1"]': project, + }); + mockActiveElement = project; + + const { event } = makeKeyEvent("ArrowLeft"); + const toggleProject = vi.fn(); + handleSidebarArrowNavigation(event, container, toggleProject); + + expect(toggleProject).toHaveBeenCalledWith("p1"); + teardownMockDocument(); + }); + + it("does not collapse a collapsed project on ArrowLeft", () => { + setupMockDocument(); + const project = makeMockElement("project", "p1", { expanded: false }); + const container = makeMockContainer([project], {}); + mockActiveElement = project; + + const { event } = makeKeyEvent("ArrowLeft"); + const toggleProject = vi.fn(); + handleSidebarArrowNavigation(event, container, toggleProject); + + expect(toggleProject).not.toHaveBeenCalled(); + teardownMockDocument(); + }); + + it("expands a collapsed project on ArrowRight", () => { + setupMockDocument(); + const project = makeMockElement("project", "p1", { expanded: false }); + const container = makeMockContainer([project], {}); + mockActiveElement = project; + + const { event } = makeKeyEvent("ArrowRight"); + const toggleProject = vi.fn(); + handleSidebarArrowNavigation(event, container, toggleProject); + + expect(toggleProject).toHaveBeenCalledWith("p1"); + teardownMockDocument(); + }); + + it("focuses first thread on ArrowRight from an expanded project", () => { + setupMockDocument(); + const project = makeMockElement("project", "p1", { expanded: true }); + const thread = makeMockElement("thread", "p1"); + const container = makeMockContainer([project, thread], { + '[data-nav-item="project"][data-project-id="p1"]': project, + '[data-nav-item="thread"][data-project-id="p1"]': thread, + }); + mockActiveElement = project; + + const { event } = makeKeyEvent("ArrowRight"); + handleSidebarArrowNavigation(event, container, vi.fn()); + + expect(thread.focus).toHaveBeenCalled(); + teardownMockDocument(); + }); + + it("ignores non-arrow keys", () => { + const { event, prevented } = makeKeyEvent("Enter"); + handleSidebarArrowNavigation(event, {} as HTMLElement, vi.fn()); + + expect(prevented.value).toBe(false); + }); + + it("ignores arrow keys when focus is on an input element", () => { + setupMockDocument(); + mockActiveElement = { tagName: "INPUT" }; + + const { event, prevented } = makeKeyEvent("ArrowDown"); + handleSidebarArrowNavigation(event, {} as HTMLElement, vi.fn()); + + expect(prevented.value).toBe(false); + teardownMockDocument(); + }); + + it("ignores arrow keys when focus is on a textarea", () => { + setupMockDocument(); + mockActiveElement = { tagName: "TEXTAREA" }; + + const { event, prevented } = makeKeyEvent("ArrowDown"); + handleSidebarArrowNavigation(event, {} as HTMLElement, vi.fn()); + + expect(prevented.value).toBe(false); + teardownMockDocument(); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f08ed212a..e25cdd40e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,8 +1,11 @@ +import type React from "react"; +import type { ProjectId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { cn } from "../lib/utils"; import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; +export const SIDEBAR_NAV_ITEM_SELECTOR = "[data-nav-item]"; export type SidebarNewThreadEnvMode = "local" | "worktree"; export interface ThreadStatusPill { @@ -145,3 +148,73 @@ export function resolveThreadStatusPill(input: { return null; } + +export function handleSidebarArrowNavigation( + event: React.KeyboardEvent, + container: HTMLElement, + toggleProject: (id: ProjectId) => void, +): void { + const { key } = event; + if (key !== "ArrowUp" && key !== "ArrowDown" && key !== "ArrowLeft" && key !== "ArrowRight") { + return; + } + + const active = document.activeElement; + if (!active) return; + const tagName = (active as HTMLElement).tagName; + if (tagName === "INPUT" || tagName === "TEXTAREA") { + return; + } + + const allItems = container.querySelectorAll(SIDEBAR_NAV_ITEM_SELECTOR); + const items = Array.from(allItems).filter((el) => { + if (el.dataset.navItem !== "thread") return true; + const parentProject = container.querySelector( + `[data-nav-item="project"][data-project-id="${el.dataset.projectId}"]`, + ); + return parentProject?.getAttribute("aria-expanded") === "true"; + }); + const navItemEl = (active as HTMLElement).closest?.(SIDEBAR_NAV_ITEM_SELECTOR); + const currentIndex = navItemEl ? items.indexOf(navItemEl) : -1; + if (currentIndex === -1) return; + + event.preventDefault(); + + const currentItem = items[currentIndex]!; + const navType = currentItem.dataset.navItem; + const projectId = currentItem.dataset.projectId as ProjectId | undefined; + + if (key === "ArrowUp") { + if (currentIndex > 0) { + items[currentIndex - 1]!.focus(); + } + } else if (key === "ArrowDown") { + if (currentIndex < items.length - 1) { + items[currentIndex + 1]!.focus(); + } + } else if (key === "ArrowLeft") { + if (navType === "thread" && projectId) { + const parentProject = container.querySelector( + `[data-nav-item="project"][data-project-id="${projectId}"]`, + ); + parentProject?.focus(); + } else if (navType === "project" && projectId) { + const isExpanded = currentItem.getAttribute("aria-expanded") === "true"; + if (isExpanded) { + toggleProject(projectId); + } + } + } else if (key === "ArrowRight") { + if (navType === "project" && projectId) { + const isExpanded = currentItem.getAttribute("aria-expanded") === "true"; + if (isExpanded) { + const firstThread = container.querySelector( + `[data-nav-item="thread"][data-project-id="${projectId}"]`, + ); + firstThread?.focus(); + } else { + toggleProject(projectId); + } + } + } +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1b43eb4c1..338965421 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -84,6 +84,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { + handleSidebarArrowNavigation, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, @@ -289,6 +290,7 @@ export default function Sidebar() { const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); const addProjectInputRef = useRef(null); + const sidebarMenuRef = useRef(null); const [renamingThreadId, setRenamingThreadId] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< @@ -1287,7 +1289,14 @@ export default function Sidebar() { onDragEnd={handleProjectDragEnd} onDragCancel={handleProjectDragCancel} > - + { + if (sidebarMenuRef.current) { + handleSidebarArrowNavigation(event, sidebarMenuRef.current, toggleProject); + } + }} + > project.id)} strategy={verticalListSortingStrategy} @@ -1319,6 +1328,9 @@ export default function Sidebar() { className="gap-2 px-2 py-1.5 text-left cursor-grab active:cursor-grabbing hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground" {...dragHandleProps.attributes} {...dragHandleProps.listeners} + data-nav-item="project" + data-project-id={project.id} + aria-expanded={project.expanded} onPointerDownCapture={handleProjectTitlePointerDownCapture} onClick={(event) => handleProjectTitleClick(event, project.id)} onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} @@ -1406,6 +1418,8 @@ export default function Sidebar() { render={
} size="sm" isActive={isActive} + data-nav-item="thread" + data-project-id={project.id} className={resolveThreadRowClassName({ isActive, isSelected,