fix: smooth sidebar collapse and expand
This commit is contained in:
parent
821cda64a8
commit
23029b2fce
|
|
@ -1,6 +1,6 @@
|
|||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
|
@ -112,6 +112,8 @@ async function chooseOption(trigger: HTMLElement, optionName: string) {
|
|||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.style.removeProperty("--ao-sidebar-w");
|
||||
getMock.mockReset();
|
||||
getMock.mockResolvedValue({
|
||||
data: {
|
||||
|
|
@ -350,4 +352,36 @@ describe("Sidebar", () => {
|
|||
// Padding is always reserved for the action cluster (not hover-gated)
|
||||
expect(projectRow).toHaveClass("pr-[84px]");
|
||||
});
|
||||
|
||||
it("snaps to the real collapsed rail when dragged past the resize collapse threshold", async () => {
|
||||
renderSidebar();
|
||||
|
||||
const resizeHandle = document.querySelector(".resize-handle--right");
|
||||
if (!(resizeHandle instanceof HTMLElement)) throw new Error("Resize handle not found");
|
||||
|
||||
expect(document.querySelector('[data-slot="sidebar"][data-state="expanded"]')).toBeInTheDocument();
|
||||
|
||||
fireEvent.pointerDown(resizeHandle, { clientX: 240 });
|
||||
fireEvent.pointerMove(window, { clientX: 120 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-slot="sidebar"][data-state="collapsed"]')).toBeInTheDocument();
|
||||
});
|
||||
expect(document.cookie).toContain("sidebar_state=false");
|
||||
expect(window.localStorage.getItem("ao-sidebar-w")).toBe("240");
|
||||
expect(document.documentElement.style.getPropertyValue("--ao-sidebar-w")).toBe("240px");
|
||||
expect(document.body).not.toHaveClass("is-resizing-x");
|
||||
|
||||
const expandRail = document.querySelector('[data-sidebar="rail"]');
|
||||
if (!(expandRail instanceof HTMLElement)) throw new Error("Sidebar rail not found");
|
||||
fireEvent.pointerDown(expandRail, { clientX: 48 });
|
||||
fireEvent.pointerMove(window, { clientX: 128 });
|
||||
fireEvent.pointerUp(window);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-slot="sidebar"][data-state="expanded"]')).toBeInTheDocument();
|
||||
});
|
||||
expect(document.documentElement.style.getPropertyValue("--ao-sidebar-w")).toBe("280px");
|
||||
expect(window.localStorage.getItem("ao-sidebar-w")).toBe("280");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import {
|
|||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubItem,
|
||||
SidebarTrigger,
|
||||
|
|
@ -75,6 +76,10 @@ const HOVER_ACTION_CLASS =
|
|||
// Mirrors the daemon's display-name cap (maxDisplayNameLen) and the spawn
|
||||
// `--name` flag, so inline edits never round-trip a value the API would reject.
|
||||
const MAX_DISPLAY_NAME_LEN = 20;
|
||||
const SIDEBAR_DEFAULT_WIDTH = 240;
|
||||
const SIDEBAR_MIN_WIDTH = 200;
|
||||
const SIDEBAR_MAX_WIDTH = 420;
|
||||
const SIDEBAR_COLLAPSE_THRESHOLD = SIDEBAR_MIN_WIDTH;
|
||||
|
||||
type SidebarProps = {
|
||||
daemonStatus: { state: string; message?: string };
|
||||
|
|
@ -138,7 +143,7 @@ export function Sidebar({
|
|||
}: SidebarProps) {
|
||||
const selection = useSelection();
|
||||
const eventsConnection = useEventsConnection();
|
||||
const { state } = useSidebar();
|
||||
const { state, setOpen } = useSidebar();
|
||||
const isCollapsed = state === "collapsed";
|
||||
const theme = useUiStore((s) => s.theme);
|
||||
const toggleTheme = useUiStore((s) => s.toggleTheme);
|
||||
|
|
@ -164,13 +169,20 @@ export function Sidebar({
|
|||
// agent-orchestrator's sidebar resize: drag the right edge (200-420px,
|
||||
// persisted), double-click to reset to 240px. Drives --ao-sidebar-w on :root,
|
||||
// which the provider forwards into shadcn's --sidebar-width.
|
||||
const { onPointerDown: onResizePointerDown, onDoubleClick: onResizeDoubleClick } = useResizable({
|
||||
const {
|
||||
onPointerDown: onResizePointerDown,
|
||||
onCollapsedPointerDown: onCollapsedResizePointerDown,
|
||||
onDoubleClick: onResizeDoubleClick,
|
||||
} = useResizable({
|
||||
cssVar: "--ao-sidebar-w",
|
||||
storageKey: "ao-sidebar-w",
|
||||
defaultWidth: 240,
|
||||
min: 200,
|
||||
max: 420,
|
||||
defaultWidth: SIDEBAR_DEFAULT_WIDTH,
|
||||
min: SIDEBAR_MIN_WIDTH,
|
||||
max: SIDEBAR_MAX_WIDTH,
|
||||
edge: "right",
|
||||
collapseBelow: SIDEBAR_COLLAPSE_THRESHOLD,
|
||||
onCollapse: () => setOpen(false),
|
||||
onExpand: () => setOpen(true),
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -184,7 +196,7 @@ export function Sidebar({
|
|||
<SidebarHeader className="gap-0 p-0 pl-2.5 pr-[7px] pt-3.5 group-data-[collapsible=icon]:px-1.5">
|
||||
{/* Brand (project-sidebar__brand); in the icon rail it becomes the old
|
||||
36px board button wrapping the 22px accent mark. */}
|
||||
<div className="flex shrink-0 items-center gap-2.5 px-2 pb-[18px] group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:pb-2">
|
||||
<div className="flex shrink-0 items-center gap-2.5 px-2 pb-[18px] group-data-[collapsible=icon]:flex-col group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:gap-1 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:pb-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -206,6 +218,17 @@ export function Sidebar({
|
|||
Orchestrator board
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{!isMac && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SidebarTrigger
|
||||
aria-label="Expand sidebar"
|
||||
className="hidden size-9 shrink-0 rounded-lg text-passive hover:bg-interactive-hover hover:text-foreground group-data-[collapsible=icon]:grid [&_svg]:size-4"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Expand sidebar · ⌘B</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-[14px] font-bold tracking-[-0.015em] text-foreground group-data-[collapsible=icon]:hidden">
|
||||
Agent Orchestrator
|
||||
</span>
|
||||
|
|
@ -224,7 +247,10 @@ export function Sidebar({
|
|||
{!isMac && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SidebarTrigger className="size-[18px] shrink-0 rounded-[4px] p-0 text-passive hover:bg-interactive-hover hover:text-foreground group-data-[collapsible=icon]:hidden [&_svg]:size-[15px]" />
|
||||
<SidebarTrigger
|
||||
aria-label="Collapse sidebar"
|
||||
className="size-[18px] shrink-0 rounded-[4px] p-0 text-passive hover:bg-interactive-hover hover:text-foreground group-data-[collapsible=icon]:hidden [&_svg]:size-[15px]"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Collapse sidebar · ⌘B</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -280,8 +306,8 @@ export function Sidebar({
|
|||
(flex-1) with a uniform 7px footer inset on all sides (reference uses
|
||||
12px top, 0 bottom, content-hugging button). The icon rail keeps the
|
||||
icon-only settings action plus expand toggle (off macOS). */}
|
||||
<SidebarFooter className="mt-auto gap-0 border-t border-border p-[7px] group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:px-1.5 group-data-[collapsible=icon]:pb-0 group-data-[collapsible=icon]:pt-2">
|
||||
<div className="relative flex w-full items-center group-data-[collapsible=icon]:hidden">
|
||||
<SidebarFooter className="relative mt-auto min-h-[51px] gap-0 overflow-hidden border-t border-border p-[7px] transition-[padding] duration-200 ease-linear group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:px-1.5">
|
||||
<div className="relative flex min-h-[37px] w-full min-w-[186px] items-center transition-[opacity,transform] duration-150 ease-out group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-translate-x-2 group-data-[collapsible=icon]:opacity-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
|
@ -341,7 +367,7 @@ export function Sidebar({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="hidden flex-col items-center gap-1 pb-3.5 group-data-[collapsible=icon]:flex">
|
||||
<div className="pointer-events-none absolute inset-x-1.5 top-[7px] flex min-h-[37px] flex-col items-center justify-center gap-1 opacity-0 transition-opacity duration-150 ease-out group-data-[collapsible=icon]:pointer-events-auto group-data-[collapsible=icon]:opacity-100">
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -385,14 +411,6 @@ export function Sidebar({
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!isMac && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SidebarTrigger className="size-9 rounded-lg text-passive hover:bg-interactive-hover hover:text-foreground [&_svg]:size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Expand sidebar · ⌘B</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
|
||||
|
|
@ -402,6 +420,12 @@ export function Sidebar({
|
|||
onDoubleClick={onResizeDoubleClick}
|
||||
style={noDragStyle}
|
||||
/>
|
||||
<SidebarRail
|
||||
aria-label="Expand sidebar"
|
||||
className="group-data-[state=expanded]:hidden hover:after:bg-transparent"
|
||||
onClick={() => setOpen(true)}
|
||||
onPointerDown={onCollapsedResizePointerDown}
|
||||
/>
|
||||
</SidebarRoot>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -265,7 +265,6 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
|||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ interface UseResizableOptions {
|
|||
* handle) grows with leftward drag.
|
||||
*/
|
||||
edge: "left" | "right";
|
||||
/** Optional raw drag width below which the owner should collapse. */
|
||||
collapseBelow?: number;
|
||||
/** Called once when a drag crosses collapseBelow. */
|
||||
onCollapse?: () => void;
|
||||
/** Called once when a collapsed rail drag should reopen the owner. */
|
||||
onExpand?: () => void;
|
||||
/** Pointer movement needed before a collapsed rail drag expands. */
|
||||
expandDragThreshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -22,8 +30,21 @@ interface UseResizableOptions {
|
|||
* on :root (so the consuming layout reads it with `width: var(--cssVar, default)`),
|
||||
* avoiding any inline `style=`.
|
||||
*/
|
||||
export function useResizable({ cssVar, storageKey, defaultWidth, min, max, edge }: UseResizableOptions) {
|
||||
export function useResizable({
|
||||
cssVar,
|
||||
storageKey,
|
||||
defaultWidth,
|
||||
min,
|
||||
max,
|
||||
edge,
|
||||
collapseBelow,
|
||||
onCollapse,
|
||||
onExpand,
|
||||
expandDragThreshold = 8,
|
||||
}: UseResizableOptions) {
|
||||
const widthRef = useRef(defaultWidth);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const pendingWidthRef = useRef<number | null>(null);
|
||||
|
||||
const apply = useCallback(
|
||||
(next: number) => {
|
||||
|
|
@ -34,11 +55,36 @@ export function useResizable({ cssVar, storageKey, defaultWidth, min, max, edge
|
|||
[cssVar, max, min],
|
||||
);
|
||||
|
||||
const applyOnFrame = useCallback(
|
||||
(next: number) => {
|
||||
pendingWidthRef.current = next;
|
||||
if (frameRef.current !== null) return;
|
||||
frameRef.current = window.requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
const pending = pendingWidthRef.current;
|
||||
pendingWidthRef.current = null;
|
||||
if (pending !== null) apply(pending);
|
||||
});
|
||||
},
|
||||
[apply],
|
||||
);
|
||||
|
||||
const flushPending = useCallback(() => {
|
||||
if (frameRef.current !== null) {
|
||||
window.cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
const pending = pendingWidthRef.current;
|
||||
pendingWidthRef.current = null;
|
||||
if (pending !== null) apply(pending);
|
||||
}, [apply]);
|
||||
|
||||
// Restore persisted width on mount.
|
||||
useEffect(() => {
|
||||
const saved = Number(window.localStorage.getItem(storageKey));
|
||||
apply(Number.isFinite(saved) && saved > 0 ? saved : defaultWidth);
|
||||
return () => {
|
||||
if (frameRef.current !== null) window.cancelAnimationFrame(frameRef.current);
|
||||
document.documentElement.style.removeProperty(cssVar);
|
||||
};
|
||||
}, [apply, cssVar, defaultWidth, storageKey]);
|
||||
|
|
@ -49,21 +95,61 @@ export function useResizable({ cssVar, storageKey, defaultWidth, min, max, edge
|
|||
const startX = event.clientX;
|
||||
const startWidth = widthRef.current;
|
||||
const sign = edge === "right" ? 1 : -1;
|
||||
let collapsed = false;
|
||||
document.body.classList.add("is-resizing-x");
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
apply(startWidth + sign * (e.clientX - startX));
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
flushPending();
|
||||
document.body.classList.remove("is-resizing-x");
|
||||
window.localStorage.setItem(storageKey, String(widthRef.current));
|
||||
if (!collapsed) window.localStorage.setItem(storageKey, String(widthRef.current));
|
||||
};
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const nextWidth = startWidth + sign * (e.clientX - startX);
|
||||
if (collapseBelow !== undefined && onCollapse && nextWidth <= collapseBelow) {
|
||||
collapsed = true;
|
||||
flushPending();
|
||||
window.localStorage.setItem(storageKey, String(Math.min(max, Math.max(min, startWidth))));
|
||||
onUp();
|
||||
onCollapse();
|
||||
return;
|
||||
}
|
||||
applyOnFrame(nextWidth);
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
},
|
||||
[apply, edge, storageKey],
|
||||
[applyOnFrame, collapseBelow, edge, flushPending, max, min, onCollapse, storageKey],
|
||||
);
|
||||
|
||||
const onCollapsedPointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLElement>) => {
|
||||
const startX = event.clientX;
|
||||
const sign = edge === "right" ? 1 : -1;
|
||||
let expanded = false;
|
||||
document.body.classList.add("is-resizing-x");
|
||||
|
||||
const onUp = () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
flushPending();
|
||||
document.body.classList.remove("is-resizing-x");
|
||||
if (expanded) window.localStorage.setItem(storageKey, String(widthRef.current));
|
||||
};
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const delta = sign * (e.clientX - startX);
|
||||
if (delta < expandDragThreshold) return;
|
||||
if (!expanded) {
|
||||
expanded = true;
|
||||
onExpand?.();
|
||||
}
|
||||
applyOnFrame(min + delta);
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
},
|
||||
[applyOnFrame, edge, expandDragThreshold, flushPending, min, onExpand, storageKey],
|
||||
);
|
||||
|
||||
/** Double-click the handle to reset to the default width. */
|
||||
|
|
@ -72,5 +158,5 @@ export function useResizable({ cssVar, storageKey, defaultWidth, min, max, edge
|
|||
window.localStorage.setItem(storageKey, String(defaultWidth));
|
||||
}, [apply, defaultWidth, storageKey]);
|
||||
|
||||
return { onPointerDown, onDoubleClick };
|
||||
return { onPointerDown, onCollapsedPointerDown, onDoubleClick };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue