mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop-chrome): restore collapsed content gutter
Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -894,6 +894,7 @@ export function AppShell() {
|
||||
/>
|
||||
) : null}
|
||||
<AppShellChannelSurface
|
||||
hasCommunityRail={hasCommunityRail}
|
||||
isHuddleRoom={isHuddleRoom}
|
||||
isHuddleRoomStarting={isHuddleRoomStarting}
|
||||
mainInsetRef={mainInsetRef}
|
||||
|
||||
@@ -4,10 +4,11 @@ import { HuddleRoomHeader, HuddleStartingView } from "@/features/huddle";
|
||||
import { MainInsetProvider } from "@/shared/layout/MainInsetContext";
|
||||
import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { SidebarInset } from "@/shared/ui/sidebar";
|
||||
import { SidebarInset, useSidebar } from "@/shared/ui/sidebar";
|
||||
|
||||
type AppShellChannelSurfaceProps = {
|
||||
children: React.ReactNode;
|
||||
hasCommunityRail: boolean;
|
||||
isHuddleRoom: boolean;
|
||||
isHuddleRoomStarting: boolean;
|
||||
mainInsetRef: React.RefObject<HTMLElement | null>;
|
||||
@@ -16,11 +17,18 @@ type AppShellChannelSurfaceProps = {
|
||||
|
||||
export function AppShellChannelSurface({
|
||||
children,
|
||||
hasCommunityRail,
|
||||
isHuddleRoom,
|
||||
isHuddleRoomStarting,
|
||||
mainInsetRef,
|
||||
terminal,
|
||||
}: AppShellChannelSurfaceProps) {
|
||||
const { isMobile, openMobile, state: sidebarState } = useSidebar();
|
||||
const hasCollapsedSidebarGutter =
|
||||
!isHuddleRoom &&
|
||||
!hasCommunityRail &&
|
||||
(isMobile ? !openMobile : sidebarState === "collapsed");
|
||||
|
||||
return (
|
||||
<MainInsetProvider mainInsetRef={mainInsetRef}>
|
||||
<SidebarInset
|
||||
@@ -28,6 +36,7 @@ export function AppShellChannelSurface({
|
||||
className={cn(
|
||||
"isolate z-0 min-h-0 min-w-0 overflow-hidden",
|
||||
isHuddleRoom ? "bg-background" : "bg-sidebar",
|
||||
hasCollapsedSidebarGutter && "pl-2",
|
||||
)}
|
||||
data-buzz-content-surface={isHuddleRoom ? true : undefined}
|
||||
data-buzz-content-unframed={isHuddleRoom ? true : undefined}
|
||||
@@ -35,6 +44,12 @@ export function AppShellChannelSurface({
|
||||
data-buzz-shadow-viewport
|
||||
style={chromeCssVarDefaults as React.CSSProperties}
|
||||
>
|
||||
{hasCollapsedSidebarGutter ? (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 w-2 bg-sidebar"
|
||||
data-collapsed-content-gutter
|
||||
/>
|
||||
) : null}
|
||||
{isHuddleRoom && !isHuddleRoomStarting ? <HuddleRoomHeader /> : null}
|
||||
<BuzzTheme.ContentSurface terminal={terminal} unframed={isHuddleRoom}>
|
||||
{isHuddleRoomStarting ? <HuddleStartingView /> : children}
|
||||
|
||||
@@ -36,7 +36,8 @@ const SIDEBAR_WIDTH_MAX = 420;
|
||||
const SIDEBAR_WIDTH_MOBILE = "288px";
|
||||
const SIDEBAR_WIDTH_ICON = "48px";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "s";
|
||||
|
||||
// Increases button hit areas on mobile without changing their layout.
|
||||
const MOBILE_ACTION_HIT_AREA = "after:absolute after:-inset-2 after:md:hidden";
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
@@ -217,8 +218,8 @@ const SidebarProvider = React.forwardRef<
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// Expose semantic state so Tailwind descendants can style both modes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
@@ -360,8 +361,8 @@ const Sidebar = React.forwardRef<
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"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",
|
||||
"absolute inset-y-0 z-10 hidden h-full w-(--sidebar-width) transition-[left,right,width,visibility] duration-200 ease-linear md:flex",
|
||||
"group-data-[resizing=true]:transition-none group-data-[collapsible=offcanvas]:invisible",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
@@ -378,7 +379,7 @@ const Sidebar = React.forwardRef<
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
className="flex h-full w-full origin-top flex-col transition-[opacity,scale,translate] duration-200 ease-linear motion-reduce:transition-none motion-reduce:duration-0 group-data-[collapsible=offcanvas]:translate-x-6 group-data-[collapsible=offcanvas]:scale-95 group-data-[collapsible=offcanvas]:opacity-0"
|
||||
data-sidebar-transition-content
|
||||
>
|
||||
{children}
|
||||
@@ -708,7 +709,7 @@ 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",
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
MOBILE_ACTION_HIT_AREA,
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
@@ -853,7 +854,7 @@ 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",
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
MOBILE_ACTION_HIT_AREA,
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { installMockBridge } from "../helpers/bridge";
|
||||
import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features";
|
||||
|
||||
const RELAY_URL = "ws://localhost:3000";
|
||||
const THEME_STORAGE_KEY = "buzz-theme";
|
||||
const OWNER_PUBKEY = "deadbeef".repeat(8);
|
||||
|
||||
function snapshotKey(relayUrl: string) {
|
||||
@@ -25,6 +26,7 @@ const COMMUNITY_B = {
|
||||
|
||||
async function expectContentSurfaceHorizontalGutters(
|
||||
page: import("@playwright/test").Page,
|
||||
expectedLeftGutter = 1,
|
||||
) {
|
||||
const [mainInsetBox, contentBox] = await Promise.all([
|
||||
page.locator("[data-buzz-glass-inset]").boundingBox(),
|
||||
@@ -37,7 +39,7 @@ async function expectContentSurfaceHorizontalGutters(
|
||||
(mainInsetBox?.x ?? 0) +
|
||||
(mainInsetBox?.width ?? 0) -
|
||||
((contentBox?.x ?? 0) + (contentBox?.width ?? 0));
|
||||
expect(Math.abs(leftGutter - 1)).toBeLessThan(0.5);
|
||||
expect(Math.abs(leftGutter - expectedLeftGutter)).toBeLessThan(0.5);
|
||||
expect(Math.abs(rightGutter - 8)).toBeLessThan(0.5);
|
||||
}
|
||||
|
||||
@@ -82,7 +84,7 @@ test.describe("community rail", () => {
|
||||
"overflow",
|
||||
"visible",
|
||||
);
|
||||
await expect(rail).toHaveCSS("z-index", "0");
|
||||
await expect(rail).toHaveCSS("z-index", "20");
|
||||
|
||||
const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`);
|
||||
const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`);
|
||||
@@ -1152,7 +1154,36 @@ test.describe("community rail", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the gutter when the mobile sidebar closes without a rail", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 740, height: 516 });
|
||||
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
||||
await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id);
|
||||
await page.goto("/");
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Toggle Sidebar", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(
|
||||
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
|
||||
).toBeHidden();
|
||||
await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS(
|
||||
"width",
|
||||
"8px",
|
||||
);
|
||||
await expectContentSurfaceHorizontalGutters(page, 9);
|
||||
});
|
||||
|
||||
test("hides the rail with a single community", async ({ page }) => {
|
||||
await page.addInitScript((themeStorageKey) => {
|
||||
window.localStorage.setItem(themeStorageKey, "buzz-dark");
|
||||
}, THEME_STORAGE_KEY);
|
||||
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
||||
await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id);
|
||||
await page.goto("/");
|
||||
@@ -1168,7 +1199,18 @@ test.describe("community rail", () => {
|
||||
await expect(
|
||||
page.locator('[data-side="left"][data-state="collapsed"]'),
|
||||
).toBeVisible();
|
||||
await expectContentSurfaceHorizontalGutters(page);
|
||||
await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS(
|
||||
"width",
|
||||
"8px",
|
||||
);
|
||||
const sidebarBackground = await page
|
||||
.locator("[data-buzz-glass-inset]")
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS(
|
||||
"background-color",
|
||||
sidebarBackground,
|
||||
);
|
||||
await expectContentSurfaceHorizontalGutters(page, 9);
|
||||
});
|
||||
|
||||
test("keeps the rail visible when the sidebar is collapsed", async ({
|
||||
@@ -1201,6 +1243,9 @@ test.describe("community rail", () => {
|
||||
page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("community-rail-add")).toBeVisible();
|
||||
await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expectContentSurfaceHorizontalGutters(page);
|
||||
});
|
||||
|
||||
|
||||
@@ -63,87 +63,82 @@ for (const theme of ["buzz", "buzz-dark", "vesper"]) {
|
||||
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) => {
|
||||
// Observe the transition before triggering it, then hold every animated
|
||||
// sidebar-content property at its midpoint. This keeps the regression
|
||||
// causal without making its assertions depend on Playwright or rAF
|
||||
// scheduler latency.
|
||||
const transition = await communityButton.evaluate(async (button) => {
|
||||
const rail = button.closest('[data-testid="community-rail"]');
|
||||
const trigger = document.querySelector<HTMLElement>(
|
||||
'[data-sidebar="trigger"]',
|
||||
);
|
||||
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,
|
||||
});
|
||||
if (!(rail instanceof HTMLElement) || !trigger || !sidebarContent) {
|
||||
return null;
|
||||
}
|
||||
return frames;
|
||||
|
||||
const transitionStarted = new Promise<void>((resolve) => {
|
||||
sidebarContent.addEventListener("transitionrun", () => resolve(), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
trigger.click();
|
||||
await transitionStarted;
|
||||
|
||||
const animations = sidebarContent.getAnimations();
|
||||
await Promise.all(animations.map((animation) => animation.ready));
|
||||
for (const animation of animations) {
|
||||
animation.pause();
|
||||
animation.currentTime = 100;
|
||||
}
|
||||
|
||||
const buttonBox = button.getBoundingClientRect();
|
||||
const railBox = rail.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(
|
||||
buttonBox.x + buttonBox.width / 2,
|
||||
buttonBox.y + buttonBox.height / 2,
|
||||
);
|
||||
const railStyle = getComputedStyle(rail);
|
||||
const sidebarStyle = getComputedStyle(sidebarContent);
|
||||
const result = {
|
||||
durations: animations.map(
|
||||
(animation) => animation.effect?.getTiming().duration,
|
||||
),
|
||||
hitRail: hit === rail || rail.contains(hit),
|
||||
opacity: railStyle.opacity,
|
||||
sidebarOpacity: Number.parseFloat(sidebarStyle.opacity),
|
||||
sidebarScale: sidebarStyle.scale,
|
||||
sidebarTranslateX: Number.parseFloat(sidebarStyle.translate),
|
||||
visibility: railStyle.visibility,
|
||||
x: railBox.x,
|
||||
y: railBox.y,
|
||||
};
|
||||
|
||||
for (const animation of animations) animation.finish();
|
||||
return result;
|
||||
});
|
||||
expect(transitionFrames.length).toBeGreaterThan(1);
|
||||
expect(transition).not.toBeNull();
|
||||
expect(transition?.durations).toEqual([200, 200, 200]);
|
||||
expect(transition).toMatchObject({
|
||||
hitRail: true,
|
||||
opacity: "1",
|
||||
visibility: "visible",
|
||||
x: railBoxBeforeCollapse?.x,
|
||||
y: railBoxBeforeCollapse?.y,
|
||||
});
|
||||
expect(transition?.sidebarOpacity).toBeGreaterThan(0);
|
||||
expect(transition?.sidebarOpacity).toBeLessThan(1);
|
||||
expect(transition?.sidebarScale).not.toBe("none");
|
||||
expect(transition?.sidebarScale).not.toBe("0.95");
|
||||
expect(transition?.sidebarTranslateX).toBeGreaterThan(0);
|
||||
expect(transition?.sidebarTranslateX).toBeLessThan(24);
|
||||
|
||||
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(250);
|
||||
|
||||
@@ -545,7 +545,7 @@ test("scales the sidebar backward while its chrome closes", async ({
|
||||
|
||||
await expect(sidebarSurface).toHaveCSS("opacity", "0");
|
||||
await expect(sidebar).toHaveCSS("pointer-events", "none");
|
||||
await expect(sidebar).toHaveCSS("overflow", "hidden");
|
||||
await expect(sidebar).toHaveCSS("overflow", "visible");
|
||||
await expect(sidebar.locator(':scope > [data-sidebar="sidebar"]')).toHaveCSS(
|
||||
"background-color",
|
||||
await sidebarSurface.evaluate((element) => {
|
||||
@@ -581,6 +581,25 @@ test("scales the sidebar backward while its chrome closes", async ({
|
||||
await expect(sidebarSurface).toHaveCSS("scale", "none");
|
||||
});
|
||||
|
||||
test("disables the sidebar collapse transition for reduced motion", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/");
|
||||
|
||||
const sidebarSurface = page
|
||||
.getByTestId("app-sidebar")
|
||||
.locator("[data-sidebar-transition-content]");
|
||||
await expect(sidebarSurface).toHaveCSS("transition-duration", "0s");
|
||||
|
||||
await page.getByRole("button", { name: "Toggle Sidebar" }).click();
|
||||
|
||||
await expect(sidebarSurface).toHaveCSS("opacity", "0");
|
||||
await expect(sidebarSurface).toHaveCSS("scale", "0.95");
|
||||
await expect(sidebarSurface).toHaveCSS("translate", "24px");
|
||||
await expect(sidebarSurface).toHaveCSS("transition-duration", "0s");
|
||||
});
|
||||
|
||||
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