mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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` });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user