Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 227 additions & 1 deletion apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";

import {
handleSidebarArrowNavigation,
hasUnseenCompletion,
resolveSidebarNewThreadEnvMode,
resolveThreadRowClassName,
Expand Down Expand Up @@ -199,3 +200,228 @@
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<string, string> = {};
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(

Check warning on line 235 in apps/web/src/components/Sidebar.logic.test.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-unicorn(consistent-function-scoping)

Function `makeMockContainer` does not capture any variables from its parent scope
allItems: HTMLElement[],
querySelectorResults: Record<string, HTMLElement | null>,
) {
return {
querySelectorAll: () => allItems,
querySelector: (selector: string) => querySelectorResults[selector] ?? null,
} as unknown as HTMLElement;
}

function makeKeyEvent(key: string) {

Check warning on line 245 in apps/web/src/components/Sidebar.logic.test.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-unicorn(consistent-function-scoping)

Function `makeKeyEvent` does not capture any variables from its parent scope
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();
});
});
73 changes: 73 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<HTMLElement>(SIDEBAR_NAV_ITEM_SELECTOR);
const items = Array.from(allItems).filter((el) => {
if (el.dataset.navItem !== "thread") return true;
const parentProject = container.querySelector<HTMLElement>(
`[data-nav-item="project"][data-project-id="${el.dataset.projectId}"]`,
);
return parentProject?.getAttribute("aria-expanded") === "true";
});
const navItemEl = (active as HTMLElement).closest?.<HTMLElement>(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") {
Copy link
Member

Choose a reason for hiding this comment

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

Should these be configurable and integrated into the centralized keybindigns system?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

from my perspective not. I've never come across a different set of key bindings for walking along a tree view and as the arrow keys are bond to just the tree view, I currently can't come up with a scenario where I would want to bind my arrow keys to something other globally.

But what I'm thinking of:
Add some more global actions for key bindings:

  • sidebar.treeView.focus
  • sidebar.treeView.up
  • sidebar.treeView.down
  • sidebar.treeView.threadNext
  • sidebar.treeView.threadPrev

For those I would immediately activate/load the thread (denounced). But I wanted to see if you're ok with this one 😅

Also I saw PR #69, what are your thoughts on Tanstack keyboard?

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<HTMLElement>(
`[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<HTMLElement>(
`[data-nav-item="thread"][data-project-id="${projectId}"]`,
);
firstThread?.focus();
} else {
toggleProject(projectId);
}
}
}
}
16 changes: 15 additions & 1 deletion apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -289,6 +290,7 @@ export default function Sidebar() {
const [isAddingProject, setIsAddingProject] = useState(false);
const [addProjectError, setAddProjectError] = useState<string | null>(null);
const addProjectInputRef = useRef<HTMLInputElement | null>(null);
const sidebarMenuRef = useRef<HTMLUListElement>(null);
const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
const [renamingTitle, setRenamingTitle] = useState("");
const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
Expand Down Expand Up @@ -1287,7 +1289,14 @@ export default function Sidebar() {
onDragEnd={handleProjectDragEnd}
onDragCancel={handleProjectDragCancel}
>
<SidebarMenu>
<SidebarMenu
ref={sidebarMenuRef}
onKeyDown={(event) => {
if (sidebarMenuRef.current) {
handleSidebarArrowNavigation(event, sidebarMenuRef.current, toggleProject);
}
}}
>
<SortableContext
items={projects.map((project) => project.id)}
strategy={verticalListSortingStrategy}
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -1406,6 +1418,8 @@ export default function Sidebar() {
render={<div role="button" tabIndex={0} />}
size="sm"
isActive={isActive}
data-nav-item="thread"
data-project-id={project.id}
className={resolveThreadRowClassName({
isActive,
isSelected,
Expand Down