fix(desktop-chrome): preserve balanced layout when sidebar collapses

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-15 21:49:41 -07:00
parent 78cbffeb64
commit e36f660861
5 changed files with 176 additions and 12 deletions
@@ -374,7 +374,7 @@ export function CommunityRail({
return (
<nav
aria-label="Communities"
className="relative z-0 flex w-14 shrink-0 flex-col items-center gap-2.5 overflow-y-auto bg-sidebar px-2.5 pb-5 pt-[calc(var(--buzz-top-chrome-height,40px)+7px)]"
className="relative z-20 flex w-14 shrink-0 flex-col items-center gap-2.5 overflow-y-auto bg-sidebar px-2.5 pb-5 pt-[calc(var(--buzz-top-chrome-height,40px)+7px)]"
data-testid="community-rail"
>
<DndContext
+8 -9
View File
@@ -24,7 +24,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/shared/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH_STORAGE_KEY = "buzz-sidebar-width";
@@ -52,7 +51,6 @@ type SidebarContextProps = {
setSidebarWidth: (width: number | ((width: number) => number)) => void;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
@@ -133,7 +131,6 @@ function readSidebarWidth() {
? clampSidebarWidth(storedWidth)
: SIDEBAR_WIDTH_DEFAULT;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
@@ -220,8 +217,6 @@ const SidebarProvider = React.forwardRef<
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
@@ -365,7 +360,7 @@ const Sidebar = React.forwardRef<
/>
<div
className={cn(
"absolute inset-y-0 z-10 hidden h-full w-(--sidebar-width) transition-[left,right,width,visibility] duration-200 ease-linear md:flex",
"absolute inset-y-0 z-10 hidden h-full w-(--sidebar-width) overflow-hidden transition-[left,right,width,visibility] duration-200 ease-linear md:flex",
"group-data-[resizing=true]:transition-none group-data-[collapsible=offcanvas]:pointer-events-none group-data-[collapsible=offcanvas]:invisible",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
@@ -374,6 +369,7 @@ const Sidebar = React.forwardRef<
? "p-[8px] group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_18px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
className,
"group-data-[collapsible=offcanvas]:pointer-events-none",
)}
{...props}
>
@@ -381,7 +377,12 @@ const Sidebar = React.forwardRef<
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=sidebar]:pr-px group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
<div
className="flex h-full w-full origin-top flex-col transition-[opacity,scale,translate] duration-200 ease-linear group-data-[collapsible=offcanvas]:translate-x-6 group-data-[collapsible=offcanvas]:scale-95 group-data-[collapsible=offcanvas]:opacity-0"
data-sidebar-transition-content
>
{children}
</div>
</div>
</div>
</div>
@@ -707,7 +708,6 @@ const SidebarGroupAction = React.forwardRef<
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 z-10 flex size-6 items-center justify-center rounded-[4px] p-1 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-colors hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className,
@@ -853,7 +853,6 @@ const SidebarMenuAction = React.forwardRef<
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
+28
View File
@@ -23,6 +23,24 @@ const COMMUNITY_B = {
addedAt: "2026-01-02T00:00:00.000Z",
};
async function expectContentSurfaceHorizontalGutters(
page: import("@playwright/test").Page,
) {
const [mainInsetBox, contentBox] = await Promise.all([
page.locator("[data-buzz-glass-inset]").boundingBox(),
page.locator("[data-buzz-content-surface]").first().boundingBox(),
]);
expect(mainInsetBox).not.toBeNull();
expect(contentBox).not.toBeNull();
const leftGutter = (contentBox?.x ?? 0) - (mainInsetBox?.x ?? 0);
const rightGutter =
(mainInsetBox?.x ?? 0) +
(mainInsetBox?.width ?? 0) -
((contentBox?.x ?? 0) + (contentBox?.width ?? 0));
expect(Math.abs(leftGutter - 1)).toBeLessThan(0.5);
expect(Math.abs(rightGutter - 8)).toBeLessThan(0.5);
}
async function seedCommunities(
page: import("@playwright/test").Page,
communities: Array<Record<string, unknown>>,
@@ -137,6 +155,7 @@ test.describe("community rail", () => {
// The add-community affordance lives at the bottom of the rail.
await expect(page.getByTestId("community-rail-add")).toBeVisible();
await expectContentSurfaceHorizontalGutters(page);
});
test("restores pointer events after dismissing community settings", async ({
@@ -1142,6 +1161,14 @@ test.describe("community rail", () => {
// adds nothing).
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await expect(page.getByTestId("community-rail")).toHaveCount(0);
await page
.getByRole("button", { name: "Toggle Sidebar", exact: true })
.click();
await expect(
page.locator('[data-side="left"][data-state="collapsed"]'),
).toBeVisible();
await expectContentSurfaceHorizontalGutters(page);
});
test("keeps the rail visible when the sidebar is collapsed", async ({
@@ -1174,6 +1201,7 @@ test.describe("community rail", () => {
page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`),
).toBeVisible();
await expect(page.getByTestId("community-rail-add")).toBeVisible();
await expectContentSurfaceHorizontalGutters(page);
});
test("clears the macOS traffic lights", async ({ page }) => {
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
const SHOTS = "test-results/sidebar-offcanvas-rail";
const THEME_STORAGE_KEY = "buzz-theme";
@@ -20,7 +21,7 @@ const COMMUNITY_B = {
};
async function setup(page: Page, theme: string) {
await page.setViewportSize({ width: 1280, height: 800 });
await page.setViewportSize({ width: 960, height: 540 });
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, value);
@@ -52,15 +53,100 @@ for (const theme of ["buzz", "buzz-dark", "vesper"]) {
page,
}) => {
await setup(page, theme);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/${theme}-expanded.png` });
const communityRail = page.getByTestId("community-rail");
const communityButton = page.getByTestId(
`community-rail-button-${COMMUNITY_B.id}`,
);
const railBoxBeforeCollapse = await communityRail.boundingBox();
expect(railBoxBeforeCollapse).not.toBeNull();
await page.locator('[data-sidebar="trigger"]').first().click();
// Sample every rendered frame while the sidebar crosses the rail. The
// community list must remain the painted hit target without moving or
// fading at any point in the transition, while the sidebar content visibly
// recedes in place before the shell finishes closing.
const transitionFrames = await communityButton.evaluate(async (button) => {
const rail = button.closest('[data-testid="community-rail"]');
const sidebarContent = document.querySelector<HTMLElement>(
"[data-sidebar-transition-content]",
);
if (!(rail instanceof HTMLElement) || !sidebarContent) return [];
const frames: Array<{
elapsed: number;
hitRail: boolean;
opacity: string;
sidebarOpacity: number;
sidebarScale: string;
sidebarTranslateX: number;
visibility: string;
x: number;
y: number;
}> = [];
const startedAt = performance.now();
while (performance.now() - startedAt < 250) {
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
const buttonBox = button.getBoundingClientRect();
const railBox = rail.getBoundingClientRect();
const hit = document.elementFromPoint(
buttonBox.x + buttonBox.width / 2,
buttonBox.y + buttonBox.height / 2,
);
const style = getComputedStyle(rail);
const sidebarStyle = getComputedStyle(sidebarContent);
frames.push({
elapsed: performance.now() - startedAt,
hitRail: hit === rail || rail.contains(hit),
opacity: style.opacity,
sidebarOpacity: Number.parseFloat(sidebarStyle.opacity),
sidebarScale: sidebarStyle.scale,
sidebarTranslateX: Number.parseFloat(sidebarStyle.translate),
visibility: style.visibility,
x: railBox.x,
y: railBox.y,
});
}
return frames;
});
expect(transitionFrames.length).toBeGreaterThan(1);
const shell = page.locator(
'[data-state="collapsed"][data-collapsible="offcanvas"]',
);
await expect(shell).toHaveCount(1);
expect(
transitionFrames.every(
(frame) =>
frame.hitRail &&
frame.opacity === "1" &&
frame.visibility === "visible" &&
frame.x === railBoxBeforeCollapse?.x &&
frame.y === railBoxBeforeCollapse?.y,
),
).toBe(true);
const midTransitionFrames = transitionFrames.filter(
(frame) =>
frame.sidebarOpacity > 0 &&
frame.sidebarOpacity < 1 &&
frame.sidebarScale !== "none" &&
frame.sidebarScale !== "0.95" &&
frame.sidebarTranslateX > 0 &&
frame.sidebarTranslateX < 24,
);
expect(midTransitionFrames.length).toBeGreaterThan(1);
expect(
transitionFrames.some(
(frame) => frame.elapsed >= 100 && frame.sidebarOpacity > 0.05,
),
).toBe(true);
// Let the 200ms slide finish; visibility flips at the transition's end.
await page.waitForTimeout(500);
await page.waitForTimeout(250);
// Second direct child = the sliding sidebar container (first is the gap).
const offscreenSidebar = shell.locator("> div").nth(1);
@@ -72,6 +158,7 @@ for (const theme of ["buzz", "buzz-dark", "vesper"]) {
await expect(
page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/${theme}-collapsed.png` });
});
}
+50
View File
@@ -531,6 +531,56 @@ test("aligns the sidebar search with the channel title outside the Buzz theme",
expect(Math.abs(searchCenter - channelTitleCenter)).toBeLessThanOrEqual(2);
});
test("scales the sidebar backward while its chrome closes", async ({
page,
}) => {
await page.goto("/");
const sidebar = page.getByTestId("app-sidebar");
const sidebarSurface = sidebar.locator("[data-sidebar-transition-content]");
await expect(sidebarSurface).toHaveCSS("opacity", "1");
await expect(sidebarSurface).toHaveCSS("scale", "none");
await page.getByRole("button", { name: "Toggle Sidebar" }).click();
await expect(sidebarSurface).toHaveCSS("opacity", "0");
await expect(sidebar).toHaveCSS("pointer-events", "none");
await expect(sidebar).toHaveCSS("overflow", "hidden");
await expect(sidebar.locator(':scope > [data-sidebar="sidebar"]')).toHaveCSS(
"background-color",
await sidebarSurface.evaluate((element) => {
const sidebarElement = element.closest('[data-sidebar="sidebar"]');
if (!(sidebarElement instanceof HTMLElement)) return "";
return getComputedStyle(sidebarElement).backgroundColor;
}),
);
await expect(sidebarSurface).toHaveCSS("scale", "0.95");
await expect(sidebarSurface).toHaveCSS("translate", "24px");
const transformOrigin = await sidebarSurface.evaluate(
(element) => getComputedStyle(element).transformOrigin,
);
const [originX, originY] = transformOrigin.split(" ").map(Number.parseFloat);
const surfaceWidth = await sidebarSurface.evaluate(
(element) => element.clientWidth,
);
expect(Math.abs(originX - surfaceWidth / 2)).toBeLessThan(0.5);
expect(originY).toBe(0);
await expect(sidebarSurface).toHaveCSS(
"transition-property",
"opacity, scale, translate",
);
await expect(sidebarSurface).toHaveCSS("transition-duration", "0.2s");
await expect(sidebarSurface).toHaveCSS(
"transition-timing-function",
"linear",
);
await page.getByRole("button", { name: "Toggle Sidebar" }).click();
await expect(sidebarSurface).toHaveCSS("opacity", "1");
await expect(sidebar).toHaveCSS("pointer-events", "auto");
await expect(sidebarSurface).toHaveCSS("scale", "none");
});
test("sidebar rail resizes without toggling the sidebar", async ({ page }) => {
await page.goto("/");
const rail = page.getByRole("button", { name: "Resize sidebar" });