fix(sidebar): flatten account footer surface

This commit is contained in:
johnnyjoygh 2026-09-02 20:45:43 +08:00
parent d79b2e1dc3
commit 5a65350400
5 changed files with 41 additions and 24 deletions

View file

@ -55,7 +55,6 @@ import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_FOCUS_CLASSES, SidebarRowI
import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "./SidebarSection"; import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "./SidebarSection";
import SpaceSwitcher from "./SpaceSwitcher"; import SpaceSwitcher from "./SpaceSwitcher";
import { import {
SIDEBAR_FOOTER_CLASSES,
SIDEBAR_LEADING_SLOT_CLASSES, SIDEBAR_LEADING_SLOT_CLASSES,
SIDEBAR_NAV_LEADING_SLOT_CLASSES, SIDEBAR_NAV_LEADING_SLOT_CLASSES,
SIDEBAR_RAIL_CLASSES, SIDEBAR_RAIL_CLASSES,
@ -576,7 +575,7 @@ const AppSidebar = ({ className }: { className?: string }) => {
<div className={cn("min-h-0 flex-1 overflow-y-auto overflow-x-hidden pt-2 pb-3 [scrollbar-width:thin]", SIDEBAR_RAIL_CLASSES)}> <div className={cn("min-h-0 flex-1 overflow-y-auto overflow-x-hidden pt-2 pb-3 [scrollbar-width:thin]", SIDEBAR_RAIL_CLASSES)}>
<RouteSidebarContent /> <RouteSidebarContent />
</div> </div>
<footer className={cn("shrink-0 border-t border-border/70", SIDEBAR_FOOTER_CLASSES)}> <footer className="shrink-0 border-t border-border/70">
{currentUser ? ( {currentUser ? (
<UserMenu /> <UserMenu />
) : ( ) : (

View file

@ -2,9 +2,9 @@ import { cva } from "class-variance-authority";
/** /**
* The sidebar is drawn on one shared rail: inset surfaces start 12px from the shell, * The sidebar is drawn on one shared rail: inset surfaces start 12px from the shell,
* visible artwork starts at 20px, and first-level labels start at 44px. Rows reserve a * visible artwork starts at 20px, and first-level labels start at 44px. The full-bleed
* 20px artwork column; compact navigation reaches the same rails with a 16px glyph and * footer action pads its content directly onto those same rails. Rows reserve a 20px
* an 8px label gap. Keeping those coordinates here prevents independent pill geometry. * artwork column; compact navigation reaches the label rail with an 8px gap.
*/ */
export const SIDEBAR_RAIL_CLASSES = "px-3"; export const SIDEBAR_RAIL_CLASSES = "px-3";
// Content rows reserve 20px so 16px icons and 20px marks share a stable label rail. // Content rows reserve 20px so 16px icons and 20px marks share a stable label rail.
@ -13,23 +13,22 @@ export const SIDEBAR_LEADING_SLOT_CLASSES = "flex size-5 shrink-0 items-center j
export const SIDEBAR_NAV_LEADING_SLOT_CLASSES = "flex size-4 shrink-0 items-center justify-center"; export const SIDEBAR_NAV_LEADING_SLOT_CLASSES = "flex size-4 shrink-0 items-center justify-center";
/** /**
* Only two width behaviours are allowed: brand controls hug their content while rows * Brand controls hug their content, ordinary rows fill the inset rail, and the account
* fill the shared rail. Heights are fixed from the artwork size, so data cannot change * action fills the footer shell (or becomes a rail-aligned square when collapsed).
* a surface's padding or vertical position. * Heights stay fixed so data cannot change a surface's padding or vertical position.
*/ */
export const sidebarSurfaceVariants = cva("min-w-0 items-center rounded-md", { export const sidebarSurfaceVariants = cva("min-w-0 items-center", {
variants: { variants: {
role: { role: {
row: "flex h-8 w-full gap-1 px-2 text-ui", row: "flex h-8 w-full gap-1 rounded-md px-2 text-ui",
navPill: "relative flex h-8 px-2", navPill: "relative flex h-8 rounded-md px-2",
headerBrand: "flex h-9 max-w-full gap-1 px-2", headerBrand: "flex h-9 max-w-full gap-1 rounded-md px-2",
mobileBrand: "flex h-9 max-w-full gap-1.5 px-1", mobileBrand: "flex h-9 max-w-full gap-1.5 rounded-md px-1",
account: "flex h-9 w-full gap-1 px-2", account: "flex h-9 w-full gap-1 rounded-none px-5",
accountCollapsed: "ms-3 flex size-9 rounded-md p-2",
}, },
}, },
defaultVariants: { defaultVariants: {
role: "row", role: "row",
}, },
}); });
export const SIDEBAR_FOOTER_CLASSES = `${SIDEBAR_RAIL_CLASSES} py-1.5`;

View file

@ -129,9 +129,8 @@ const UserMenu = (props: Props) => {
disabled={!currentUser} disabled={!currentUser}
aria-label={triggerLabel} aria-label={triggerLabel}
className={cn( className={cn(
sidebarSurfaceVariants({ role: "account" }), sidebarSurfaceVariants({ role: collapsed ? "accountCollapsed" : "account" }),
"cursor-pointer text-start text-foreground transition-colors hover:bg-sidebar-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50 data-popup-open:bg-sidebar-accent", "cursor-pointer text-start text-foreground transition-colors hover:bg-sidebar-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50 data-popup-open:bg-sidebar-accent",
collapsed && "w-9",
)} )}
> >
<div className={cn(SIDEBAR_LEADING_SLOT_CLASSES, "relative")}> <div className={cn(SIDEBAR_LEADING_SLOT_CLASSES, "relative")}>
@ -174,7 +173,7 @@ const UserMenu = (props: Props) => {
</span> </span>
)} )}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[var(--anchor-width)]"> <DropdownMenuContent align={collapsed ? "start" : "center"} className={cn(!collapsed && "w-[calc(var(--anchor-width)-1.5rem)]")}>
<DropdownMenuItem onClick={() => navigateFromMenu(`/u/${encodeURIComponent(currentUser?.username ?? "")}`)}> <DropdownMenuItem onClick={() => navigateFromMenu(`/u/${encodeURIComponent(currentUser?.username ?? "")}`)}>
<SquareUserIcon className="size-4 text-muted-foreground" /> <SquareUserIcon className="size-4 text-muted-foreground" />
{t("common.profile")} {t("common.profile")}

View file

@ -343,7 +343,9 @@ describe("App sidebar logo", () => {
const footer = screen.getByRole("button", { name: "User menu" }).closest("footer"); const footer = screen.getByRole("button", { name: "User menu" }).closest("footer");
expect(footer).not.toBeNull(); expect(footer).not.toBeNull();
expect(footer).toHaveClass("px-3", "py-1.5"); expect(footer).not.toHaveClass("px-3");
expect(footer).not.toHaveClass("py-1");
expect(footer).not.toHaveClass("py-1.5");
expect(footer?.childElementCount).toBe(1); expect(footer?.childElementCount).toBe(1);
expect(screen.queryByRole("link", { name: /^common\.inbox/ })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: /^common\.inbox/ })).not.toBeInTheDocument();
}); });
@ -403,7 +405,10 @@ describe("App sidebar logo", () => {
const about = navigation.getByRole("link", { name: "common.about" }); const about = navigation.getByRole("link", { name: "common.about" });
expect(about).toHaveAttribute("href", "/about"); expect(about).toHaveAttribute("href", "/about");
expectCollapsedNavPill(about, "common.about"); expectCollapsedNavPill(about, "common.about");
expect(screen.getByRole("link", { name: "common.sign-in-to-memos" }).closest("footer")).not.toBeNull(); const signIn = screen.getByRole("link", { name: "common.sign-in-to-memos" });
expect(signIn).toHaveClass("w-full", "px-5");
expect(signIn).not.toHaveClass("rounded-md");
expect(signIn.closest("footer")).not.toBeNull();
expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument();
}); });

View file

@ -79,7 +79,7 @@ describe("User menu", () => {
expect(mocks.navigateTo).toHaveBeenCalledWith("/archived"); expect(mocks.navigateTo).toHaveBeenCalledWith("/archived");
}); });
it("uses a vertical ellipsis trigger, inset menu width, and preserves the Inbox unread state", async () => { it("uses a full-bleed footer trigger, inset menu width, and preserves the Inbox unread state", async () => {
mocks.notifications = [{ status: 1 }, { status: 1 }, { status: 2 }]; mocks.notifications = [{ status: 1 }, { status: 1 }, { status: 2 }];
render( render(
<MemoryRouter initialEntries={["/Inbox/"]}> <MemoryRouter initialEntries={["/Inbox/"]}>
@ -88,7 +88,8 @@ describe("User menu", () => {
); );
const trigger = screen.getByRole("button", { name: "Steven, common.more, 2 inbox.unread" }); const trigger = screen.getByRole("button", { name: "Steven, common.more, 2 inbox.unread" });
expect(trigger).toHaveClass("h-9", "w-full", "gap-1", "rounded-md", "px-2"); expect(trigger).toHaveClass("h-9", "w-full", "gap-1", "px-5");
expect(trigger).not.toHaveClass("rounded-md");
expect(trigger.firstElementChild).toHaveClass("size-5"); expect(trigger.firstElementChild).toHaveClass("size-5");
expect(trigger.querySelector(".lucide-ellipsis-vertical")).not.toBeNull(); expect(trigger.querySelector(".lucide-ellipsis-vertical")).not.toBeNull();
expect(trigger.querySelector(".lucide-chevrons-up-down")).toBeNull(); expect(trigger.querySelector(".lucide-chevrons-up-down")).toBeNull();
@ -97,7 +98,7 @@ describe("User menu", () => {
const inbox = await screen.findByRole("menuitem", { name: "common.inbox, 2 inbox.unread" }); const inbox = await screen.findByRole("menuitem", { name: "common.inbox, 2 inbox.unread" });
const menu = screen.getByRole("menu"); const menu = screen.getByRole("menu");
expect(menu).toHaveClass("w-[var(--anchor-width)]"); expect(menu).toHaveClass("w-[calc(var(--anchor-width)-1.5rem)]");
expect(menu).not.toHaveClass("min-w-56"); expect(menu).not.toHaveClass("min-w-56");
expect(inbox).toHaveAttribute("aria-current", "page"); expect(inbox).toHaveAttribute("aria-current", "page");
expect(inbox).toHaveTextContent("common.inbox"); expect(inbox).toHaveTextContent("common.inbox");
@ -107,4 +108,18 @@ describe("User menu", () => {
expect(mocks.setMobileOpen).toHaveBeenCalledWith(false); expect(mocks.setMobileOpen).toHaveBeenCalledWith(false);
expect(mocks.navigateTo).toHaveBeenCalledWith("/inbox"); expect(mocks.navigateTo).toHaveBeenCalledWith("/inbox");
}); });
it("keeps the collapsed trigger on the artwork rail", () => {
render(
<MemoryRouter initialEntries={["/"]}>
<UserMenu collapsed />
</MemoryRouter>,
);
const trigger = screen.getByRole("button", { name: /Steven/ });
expect(trigger).toHaveClass("ms-3", "size-9", "rounded-md", "p-2");
expect(trigger).not.toHaveClass("w-full");
expect(trigger).not.toHaveClass("px-5");
expect(trigger).not.toHaveClass("rounded-none");
});
}); });