mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Fix sidebar section header controls
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
cc1628c13a
commit
8ee7bb8e72
6 files changed
+202
-104
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user