fix(sidebar): flatten account footer surface
This commit is contained in:
parent
d79b2e1dc3
commit
5a65350400
5 changed files with 41 additions and 24 deletions
|
|
@ -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 />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue