Fix sidebar section header controls

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-09 18:27:03 -05:00
1 parent cc1628c13a
commit 8ee7bb8e72
6 files changed
+202 -104

No files matched your search

+13 -8
View File
@@ -237,23 +237,28 @@ describe("SidebarAgents", () => {
expect(agentLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
});
it("moves agent creation into the section menu and links browse agents", async () => {
it("uses the heading for section menu and the plus button for agent creation", async () => {
await renderSidebarAgents();
expect(container.querySelector('button[aria-label="New agent"]')).toBeNull();
const sectionMenuTrigger = container.querySelector('button[aria-label="Agents section actions"]');
expect(sectionMenuTrigger?.textContent).toContain("Agents");
expect(sectionMenuTrigger?.querySelector("svg")).toBeNull();
const newAgentButton = container.querySelector('button[aria-label="New agent"]');
expect(newAgentButton).toBeTruthy();
await act(async () => {
newAgentButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockOpenNewAgent).toHaveBeenCalledTimes(1);
await openAgentsSectionMenu();
const newAgentItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("New agent"));
expect(newAgentItem).toBeTruthy();
expect(newAgentItem).toBeFalsy();
const browseLink = Array.from(document.body.querySelectorAll("a"))
.find((element) => element.textContent?.includes("Browse agents"));
expect(browseLink?.getAttribute("href")).toBe("/agents/all");
await act(async () => {
newAgentItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockOpenNewAgent).toHaveBeenCalledTimes(1);
});
it("sorts alphabetically and persists the selected mode per company and user", async () => {
+5 -1
View File
@@ -351,10 +351,14 @@ export function SidebarAgents() {
<SidebarSection
label="Agents"
collapsible={{ open, onOpenChange: setOpen }}
headerAction={{
ariaLabel: "New agent",
icon: Plus,
onClick: openNewAgent,
}}
menu={{
ariaLabel: "Agents section actions",
actions: [
{ type: "item", label: "New agent", icon: Plus, onSelect: openNewAgent },
{ type: "item", label: "Browse agents", icon: Users, href: "/agents/all" },
{ type: "separator" },
],
+13 -8
View File
@@ -256,23 +256,28 @@ describe("SidebarProjects", () => {
expect(container.querySelector('[data-testid="project-slot-project-b"]')).toBeTruthy();
});
it("moves project creation into the section menu and links browse projects", async () => {
it("uses the heading for section menu and the plus button for project creation", async () => {
await renderSidebarProjects();
expect(container.querySelector('button[aria-label="New project"]')).toBeNull();
const sectionMenuTrigger = container.querySelector('button[aria-label="Projects section actions"]');
expect(sectionMenuTrigger?.textContent).toContain("Projects");
expect(sectionMenuTrigger?.querySelector("svg")).toBeNull();
const newProjectButton = container.querySelector('button[aria-label="New project"]');
expect(newProjectButton).toBeTruthy();
await act(async () => {
newProjectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockOpenNewProject).toHaveBeenCalledTimes(1);
await openProjectsMenu(container);
const newProjectItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("New project"));
expect(newProjectItem).toBeTruthy();
expect(newProjectItem).toBeFalsy();
const browseLink = Array.from(document.body.querySelectorAll("a"))
.find((element) => element.textContent?.includes("Browse projects"));
expect(browseLink?.getAttribute("href")).toBe("/projects");
await act(async () => {
newProjectItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockOpenNewProject).toHaveBeenCalledTimes(1);
});
it("sorts alphabetically and persists the selected mode per company and user", async () => {
+5 -1
View File
@@ -297,10 +297,14 @@ export function SidebarProjects() {
<SidebarSection
label="Projects"
collapsible={{ open, onOpenChange: setOpen }}
headerAction={{
ariaLabel: "New project",
icon: Plus,
onClick: openNewProject,
}}
menu={{
ariaLabel: "Projects section actions",
actions: [
{ type: "item", label: "New project", icon: Plus, onSelect: openNewProject },
{ type: "item", label: "Browse projects", icon: FolderOpen, href: "/projects" },
{ type: "separator" },
],
+49 -7
View File
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarSection } from "./SidebarSection";
import { Plus } from "lucide-react";
const sidebarState = vi.hoisted(() => ({
isMobile: false,
@@ -92,9 +93,44 @@ describe("SidebarSection", () => {
const projectsLabel = Array.from(container.querySelectorAll("span"))
.find((element) => element.textContent === "Projects");
expect(workLabel?.previousElementSibling?.className).toContain("w-3");
expect(projectsLabel?.previousElementSibling?.tagName.toLowerCase()).toBe("svg");
expect(projectsLabel?.previousElementSibling?.getAttribute("class")).toContain("w-3");
expect(workLabel?.parentElement?.textContent).toBe("Work");
expect(projectsLabel?.parentElement?.textContent).toBe("Projects");
expect(projectsLabel?.parentElement?.querySelector("svg")).toBeNull();
expect(container.querySelector('button[aria-label="Collapse Projects"] svg')).toBeTruthy();
});
it("keeps collapse on the caret and opens the menu from the heading", async () => {
const onOpenChange = vi.fn();
const currentRoot = createRoot(container);
root = currentRoot;
await act(async () => {
currentRoot.render(
<SidebarSection
label="Projects"
collapsible={{ open: true, onOpenChange }}
menu={{
ariaLabel: "Projects section actions",
actions: [{ type: "item", label: "Browse projects", href: "/projects" }],
}}
>
<a href="/projects">Projects</a>
</SidebarSection>,
);
});
await flushReact();
await openSectionMenu(container);
expect(onOpenChange).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Browse projects");
const caret = container.querySelector('button[aria-label="Collapse Projects"]');
await act(async () => {
caret?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("renders configured menu actions and radio choices", async () => {
@@ -170,6 +206,11 @@ describe("SidebarSection", () => {
ariaLabel: "Projects section actions",
actions: [{ type: "item", label: "New project" }],
}}
headerAction={{
ariaLabel: "New project",
icon: Plus,
onClick: vi.fn(),
}}
>
<a href="/projects">Projects</a>
</SidebarSection>,
@@ -179,12 +220,13 @@ describe("SidebarSection", () => {
const projectsLabel = Array.from(container.querySelectorAll("span"))
.find((element) => element.textContent === "Projects");
const caret = projectsLabel?.previousElementSibling;
const trigger = container.querySelector('button[aria-label="Projects section actions"]');
const caret = container.querySelector('button[aria-label="Expand Projects"] svg');
const action = container.querySelector('button[aria-label="New project"]');
expect(caret?.getAttribute("class")).toContain("opacity-100");
expect(caret?.getAttribute("class")).not.toContain("opacity-0");
expect(trigger?.getAttribute("class")).toContain("opacity-100");
expect(trigger?.getAttribute("class")).not.toContain("opacity-0");
expect(projectsLabel?.parentElement?.textContent).toBe("Projects");
expect(action?.getAttribute("class")).toContain("opacity-100");
expect(action?.getAttribute("class")).not.toContain("opacity-0");
});
});
+117 -79
View File
@@ -1,6 +1,6 @@
import { useState, type ComponentType, type ReactNode } from "react";
import { Link } from "@/lib/router";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useSidebar } from "../context/SidebarContext";
import {
@@ -45,6 +45,12 @@ type SidebarSectionMenu = {
onRadioValueChange?: (value: string) => void;
};
type SidebarSectionHeaderAction = {
ariaLabel: string;
icon: SidebarSectionIcon;
onClick: () => void;
};
interface SidebarSectionProps {
label: string;
children: ReactNode;
@@ -53,16 +59,20 @@ interface SidebarSectionProps {
onOpenChange: (open: boolean) => void;
};
menu?: SidebarSectionMenu;
headerAction?: SidebarSectionHeaderAction;
}
function SidebarSectionHeader({
collapsible,
headerAction,
label,
menu,
}: Pick<SidebarSectionProps, "collapsible" | "label" | "menu">) {
}: Pick<SidebarSectionProps, "collapsible" | "headerAction" | "label" | "menu">) {
const { isMobile } = useSidebar();
const [menuOpen, setMenuOpen] = useState(false);
const hasMenu = Boolean(menu && ((menu.actions?.length ?? 0) > 0 || (menu.radioChoices?.length ?? 0) > 0));
const hasMenu = Boolean(
menu && ((menu.actions?.length ?? 0) > 0 || (menu.radioChoices?.length ?? 0) > 0),
);
const labelClassName = "text-[10px] font-medium uppercase tracking-widest font-mono text-muted-foreground/60";
const headerControlVisibilityClassName = isMobile
? "opacity-100"
@@ -73,95 +83,123 @@ function SidebarSectionHeader({
collapsible?.open && "rotate-90",
menuOpen && "opacity-100",
);
const actionClassName = cn(
"h-5 w-5 shrink-0 text-muted-foreground/60 transition-opacity hover:text-foreground data-[state=open]:opacity-100",
headerControlVisibilityClassName,
);
const headerContent = <span className={labelClassName}>{label}</span>;
const HeaderActionIcon = headerAction?.icon;
const labelContent = (
<>
{collapsible ? (
<ChevronRight className={caretClassName} aria-hidden="true" />
) : (
<span className="h-3 w-3 shrink-0" aria-hidden="true" />
)}
<span className={labelClassName}>{label}</span>
</>
const headingControl = hasMenu ? (
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex min-w-0 flex-1 items-center py-0.5 text-left outline-none"
aria-label={menu?.ariaLabel ?? `${label} actions`}
>
{headerContent}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
{menu?.actions?.map((action, index) => {
if (action.type === "separator") {
return <DropdownMenuSeparator key={`separator-${index}`} />;
}
const Icon = action.icon;
const content = (
<>
{Icon ? <Icon className="size-4" /> : null}
<span>{action.label}</span>
</>
);
if (action.href) {
return (
<DropdownMenuItem key={`${action.label}-${index}`} asChild>
<Link to={action.href}>{content}</Link>
</DropdownMenuItem>
);
}
return (
<DropdownMenuItem key={`${action.label}-${index}`} onSelect={action.onSelect}>
{content}
</DropdownMenuItem>
);
})}
{menu?.radioChoices && menu.radioChoices.length > 0 ? (
<DropdownMenuRadioGroup
value={menu.radioValue}
onValueChange={menu.onRadioValueChange}
aria-label={menu.radioLabel}
>
{menu.radioChoices.map((choice) => (
<DropdownMenuRadioItem key={choice.value} value={choice.value}>
{choice.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="flex min-w-0 flex-1 items-center py-0.5">{headerContent}</div>
);
return (
<div className="group/sidebar-section flex items-center px-3 py-1.5">
{collapsible ? (
<CollapsibleTrigger className="flex min-w-0 flex-1 items-center gap-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1">
{labelContent}
</CollapsibleTrigger>
) : (
<div className="flex min-w-0 flex-1 items-center gap-1">{labelContent}</div>
)}
{hasMenu ? (
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className={cn(
"h-5 w-5 text-muted-foreground/60 transition-opacity hover:text-foreground data-[state=open]:opacity-100",
headerControlVisibilityClassName,
)}
aria-label={menu?.ariaLabel ?? `${label} actions`}
<div className="group/sidebar-section px-3 py-1.5">
<div
className={cn(
"relative flex min-h-6 items-center gap-1 rounded-md px-1 transition-colors",
"hover:bg-accent/50 focus-within:bg-accent/50",
menuOpen && "bg-accent/50",
)}
>
{collapsible ? (
<CollapsibleTrigger asChild>
<button
type="button"
className="absolute -left-4 flex h-5 w-5 items-center justify-center rounded-sm outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
aria-label={collapsible.open ? `Collapse ${label}` : `Expand ${label}`}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{menu?.actions?.map((action, index) => {
if (action.type === "separator") {
return <DropdownMenuSeparator key={`separator-${index}`} />;
}
const Icon = action.icon;
const content = (
<>
{Icon ? <Icon className="size-4" /> : null}
<span>{action.label}</span>
</>
);
if (action.href) {
return (
<DropdownMenuItem key={`${action.label}-${index}`} asChild>
<Link to={action.href}>{content}</Link>
</DropdownMenuItem>
);
}
return (
<DropdownMenuItem key={`${action.label}-${index}`} onSelect={action.onSelect}>
{content}
</DropdownMenuItem>
);
})}
{menu?.radioChoices && menu.radioChoices.length > 0 ? (
<DropdownMenuRadioGroup
value={menu.radioValue}
onValueChange={menu.onRadioValueChange}
aria-label={menu.radioLabel}
>
{menu.radioChoices.map((choice) => (
<DropdownMenuRadioItem key={choice.value} value={choice.value}>
{choice.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<ChevronRight className={caretClassName} aria-hidden="true" />
</button>
</CollapsibleTrigger>
) : null}
{headingControl}
{headerAction && HeaderActionIcon ? (
<Button
variant="ghost"
size="icon-xs"
className={actionClassName}
aria-label={headerAction.ariaLabel}
onClick={headerAction.onClick}
>
<HeaderActionIcon className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
</div>
);
}
export function SidebarSection({ label, children, collapsible, menu }: SidebarSectionProps) {
export function SidebarSection({
label,
children,
collapsible,
menu,
headerAction,
}: SidebarSectionProps) {
const content = <div className="flex flex-col gap-0.5 mt-0.5">{children}</div>;
if (collapsible) {
return (
<Collapsible open={collapsible.open} onOpenChange={collapsible.onOpenChange}>
<SidebarSectionHeader label={label} collapsible={collapsible} menu={menu} />
<SidebarSectionHeader
label={label}
collapsible={collapsible}
menu={menu}
headerAction={headerAction}
/>
<CollapsibleContent>{content}</CollapsibleContent>
</Collapsible>
);
@@ -169,7 +207,7 @@ export function SidebarSection({ label, children, collapsible, menu }: SidebarSe
return (
<div>
<SidebarSectionHeader label={label} menu={menu} />
<SidebarSectionHeader label={label} menu={menu} headerAction={headerAction} />
{content}
</div>
);