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
158 changes: 158 additions & 0 deletions packages/ui/src/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// @vitest-environment happy-dom

import { act, type ReactNode } from "react";
// @ts-expect-error This package does not ship @types/react-dom; the test only
// needs createRoot's render/unmount surface.
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Sidebar } from "./Sidebar";

type Root = {
render(children: ReactNode): void;
unmount(): void;
};

const roots: Root[] = [];
const expandedStorageKey = "hubble-sidebar-expanded-folders:test";

(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;

afterEach(() => {
act(() => {
for (const root of roots) root.unmount();
});
roots.length = 0;
localStorage.clear();
document.body.replaceChildren();
vi.restoreAllMocks();
});

describe("Sidebar folder clicks", () => {
it("selects an expanded folder before collapsing it on a second click", () => {
seedExpanded("project/");
const onSelectFile = vi.fn();
renderSidebar(onSelectFile);

clickRow("note.md");
expect(onSelectFile).toHaveBeenCalledWith("/workspace/project/note.md");

clickRow("project");
expect(folderRow("project").getAttribute("aria-expanded")).toBe("true");
expect(folderRow("project").getAttribute("aria-selected")).toBe("true");
expect(row("note.md")).not.toBeNull();

clickRow("project");
expect(folderRow("project").getAttribute("aria-expanded")).toBe("false");
expect(row("note.md")).toBeNull();
expect(onSelectFile).toHaveBeenCalledTimes(1);
});

it("expands a collapsed folder on the first row click", () => {
renderSidebar();

clickRow("archive");
expect(folderRow("archive").getAttribute("aria-expanded")).toBe("true");
expect(folderRow("archive").getAttribute("aria-selected")).toBe("true");
expect(row("old.md")).not.toBeNull();
});

it("keeps folders expanded after row and chevron double clicks", () => {
renderSidebar();

clickRow("archive");
clickRow("archive", { detail: 2 });
expect(folderRow("archive").getAttribute("aria-expanded")).toBe("true");

clickFolderToggle("project");
clickFolderToggle("project", { detail: 2 });
expect(folderRow("project").getAttribute("aria-expanded")).toBe("true");
});

it("collapses an expanded folder immediately from its chevron", () => {
seedExpanded("project/");
renderSidebar();

clickRow("note.md");
clickFolderToggle("project");

expect(folderRow("project").getAttribute("aria-expanded")).toBe("false");
expect(row("note.md")).toBeNull();
});

it("reduces a multi-selection before collapsing a folder", () => {
seedExpanded("project/", "archive/");
renderSidebar();

clickRow("project");
clickRow("archive", { ctrlKey: true });
expect(folderRow("archive").getAttribute("aria-expanded")).toBe("true");

clickRow("project");
expect(folderRow("project").getAttribute("aria-expanded")).toBe("true");
expect(folderRow("archive").getAttribute("aria-selected")).toBe("false");

clickRow("project");
expect(folderRow("project").getAttribute("aria-expanded")).toBe("false");
});
});

function seedExpanded(...folderIds: string[]) {
localStorage.setItem(expandedStorageKey, JSON.stringify(folderIds));
}

function renderSidebar(onSelectFile: (path: string) => void = () => {}) {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(
<Sidebar
files={[
{ path: "/workspace/project/note.md" },
{ path: "/workspace/archive/old.md" },
]}
currentPath={null}
getDisplayPath={(path) => path.replace("/workspace/", "")}
onSelectFile={onSelectFile}
onSortModeChange={() => {}}
sortMode="alpha"
storageScope="test"
/>,
);
});
}

function row(label: string) {
return document.querySelector<HTMLElement>(
`[role="treeitem"][title="${label}"]`,
);
}

function folderRow(label: string) {
const element = row(label);
if (!element) throw new Error(`Missing folder row ${label}`);
return element;
}

function clickRow(label: string, init: MouseEventInit = {}) {
const button = row(label)?.querySelector("button");
if (!button) throw new Error(`Missing row button ${label}`);
dispatchClick(button, init);
}

function clickFolderToggle(label: string, init: MouseEventInit = {}) {
const toggle = row(label)?.querySelector("[data-sidebar-folder-toggle]");
if (!toggle) throw new Error(`Missing folder toggle ${label}`);
dispatchClick(toggle, init);
}

function dispatchClick(target: Element, init: MouseEventInit) {
act(() => {
target.dispatchEvent(
new MouseEvent("click", { bubbles: true, detail: 1, ...init }),
);
});
}
24 changes: 20 additions & 4 deletions packages/ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,12 @@ export function Sidebar({
row: SidebarSelectableRow,
event: React.MouseEvent<HTMLButtonElement>,
) => {
const rowKey = sidebarRowKey(row);
const wasOnlySelectedRow =
rowKey !== null && selectedKeys.size === 1 && selectedKeys.has(rowKey);
const clickedFolderToggle =
event.target instanceof Element &&
event.target.closest("[data-sidebar-folder-toggle]") !== null;
const mode: SidebarSelectionMode = event.shiftKey
? "range"
: event.metaKey || event.ctrlKey
Expand All @@ -595,8 +601,12 @@ export function Sidebar({
event.preventDefault();
return;
}
if (row.kind === "file" && event.detail > 1) return;
activateRow(row);
if (event.detail > 1) return;
// Expanded folders select first; the chevron toggles immediately.
const wouldCollapseFolder = row.kind === "folder" && row.expanded;
if (!wouldCollapseFolder || clickedFolderToggle || wasOnlySelectedRow) {
activateRow(row);
}
requestAnimationFrame(() => navRef.current?.focus());
};
const enterRowEdit = (row: SidebarRow) => {
Expand Down Expand Up @@ -957,7 +967,12 @@ export function Sidebar({
paddingInlineStart: `${0.5 + row.depth * 0.75}rem`,
} as React.CSSProperties;
const chevron = (
<span className="inline-flex size-3 shrink-0 items-center justify-center text-muted-foreground">
<span
className="-m-1 inline-flex size-5 shrink-0 items-center justify-center text-muted-foreground"
data-sidebar-folder-toggle={
row.kind === "folder" ? "" : undefined
}
>
{row.kind === "folder" && (
<MingcuteRightLine
className={cn(
Expand Down Expand Up @@ -1001,7 +1016,8 @@ export function Sidebar({
data-selected={isSelected ? "true" : undefined}
className={cn(
"group/sidebar-row relative flex w-full items-center text-sidebar-foreground",
"transition-[background-color,opacity,filter] duration-150 ease-out motion-reduce:transition-none",
// Background transitions leave a ghost highlight when expansion reorders rows.
"transition-[opacity,filter] duration-150 ease-out motion-reduce:transition-none",
isSelected &&
"bg-sidebar-accent text-sidebar-accent-foreground",
isOpen && "font-medium",
Expand Down
Loading