fix(sidebar): unify layout rails and spacing

Keep the primary navigator compact and horizontal while aligning header, content, and footer controls on shared artwork and label rails. Add regression coverage for symmetric popovers, compact hit targets, and mobile drawer accessibility.
This commit is contained in:
johnnyjoygh 2026-09-01 23:24:50 +08:00
parent 673b9df05f
commit 191ec01d11
14 changed files with 326 additions and 133 deletions

View file

@ -54,11 +54,20 @@ import { getSidebarRouteKind, routeSupportsCollectionScope } from "./routes";
import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_FOCUS_CLASSES, SidebarRowIconSlot, sidebarRowStateClasses } from "./SidebarRow";
import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "./SidebarSection";
import SpaceSwitcher from "./SpaceSwitcher";
import {
SIDEBAR_FOOTER_CLASSES,
SIDEBAR_LEADING_SLOT_CLASSES,
SIDEBAR_NAV_LEADING_SLOT_CLASSES,
SIDEBAR_RAIL_CLASSES,
sidebarSurfaceVariants,
} from "./sidebar-layout";
import TagsSection from "./TagsSection";
import ViewsSection from "./ViewsSection";
const SIDEBAR_HORIZONTAL_PADDING = "px-3";
const SIDEBAR_HEADER_ACTION_CLASSES = "size-7 shrink-0 rounded-md text-muted-foreground hover:text-foreground";
const SIDEBAR_HEADER_ACTION_CLASSES =
"size-7 shrink-0 rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50";
const SIDEBAR_HEADER_PRIMARY_ACTION_CLASSES =
"size-7 shrink-0 text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50";
const NewMemoAction = ({ onClick }: { onClick: () => void }) => {
const t = useTranslate();
@ -69,9 +78,9 @@ const NewMemoAction = ({ onClick }: { onClick: () => void }) => {
<TooltipTrigger
render={
<Button
variant="ghost"
variant="outline"
size="icon-sm"
className={SIDEBAR_HEADER_ACTION_CLASSES}
className={SIDEBAR_HEADER_PRIMARY_ACTION_CLASSES}
onClick={onClick}
aria-label={label}
data-new-memo-trigger
@ -304,32 +313,25 @@ interface GlobalNavItem {
}
/**
* Pills keep a constant px so the icon sits exactly where it does in the collapsed 30px
* square; all width change comes from the label column, which animates 0fr -> 1fr. That
* keeps the expand/collapse a single smooth motion with no padding jump.
* The compact navigator is intentionally horizontal. Its 16px glyph plus 8px padding
* on each side makes the collapsed control an exact 32px square. Expanding the
* label only opens the text track, so the artwork and surface never jump.
*/
const navPillClasses = (active: boolean) =>
cn(
"relative flex h-[30px] min-w-0 items-center rounded-md px-[7px] transition-colors",
SIDEBAR_ROW_FOCUS_CLASSES,
sidebarRowStateClasses(active),
);
cn(sidebarSurfaceVariants({ role: "navPill" }), SIDEBAR_ROW_FOCUS_CLASSES, sidebarRowStateClasses(active));
const NavPillLabel = ({ expanded, label, children }: { expanded: boolean; label: ReactNode; children?: ReactNode }) => (
<span
aria-hidden={!expanded || undefined}
className={cn(
// The icon-label gap is padding on this element because overflow-hidden clips
// content but never padding — it must animate to zero with the track, or collapsed
// pills keep an 8px tail.
"grid min-w-0 transition-[grid-template-columns,padding] duration-200 ease-out motion-reduce:transition-none",
expanded ? "grid-cols-[1fr] pl-2" : "grid-cols-[0fr] pl-0",
expanded ? "grid-cols-[1fr] ps-2" : "grid-cols-[0fr] ps-0",
)}
>
{/* Content is shrink-0 so the collapsing track clips it in place — a plain
left-to-right reveal instead of re-truncating the label on every frame. */}
<span className="flex min-w-0 items-center gap-2 overflow-hidden">
<span className="max-w-[5.5rem] shrink-0 truncate text-[12px]">{label}</span>
<span className="flex min-w-0 items-center gap-1.5 overflow-hidden">
<span data-sidebar-label className="max-w-[5.5rem] shrink-0 truncate text-[12px]">
{label}
</span>
{children}
</span>
</span>
@ -401,9 +403,9 @@ const GlobalNavigation = () => {
},
];
// Keep one textual anchor in the compact navigator. A real active destination
// takes precedence; routes outside this navigation default to its first item
// without presenting that fallback as the current page.
// Keep exactly one textual anchor in the compact horizontal navigator. The active
// destination expands; routes outside this navigator fall back to its first control
// without incorrectly marking that fallback as the current page.
const activeNavigatorItemId = currentUser && scopeRouteActive ? "scope" : items.find((item) => item.active)?.id;
const expandedNavigatorItemId = activeNavigatorItemId ?? (currentUser ? "scope" : items[0]?.id);
const scopeExpanded = expandedNavigatorItemId === "scope";
@ -416,10 +418,7 @@ const GlobalNavigation = () => {
<DropdownMenuItem
key={item.id}
aria-current={item.id === resolvedScope ? "page" : undefined}
className={cn(
"h-[30px] shrink-0 py-0 text-[13px]",
item.id === resolvedScope && "bg-accent font-medium text-accent-foreground",
)}
className={cn("h-8 shrink-0 py-0 text-[13px]", item.id === resolvedScope && "bg-accent font-medium text-accent-foreground")}
onClick={() => navigateToScope(item.id)}
>
<Icon className="size-4" strokeWidth={1.8} />
@ -432,12 +431,11 @@ const GlobalNavigation = () => {
return (
<TooltipProvider>
<nav className={cn("flex h-9 items-center gap-1", SIDEBAR_HORIZONTAL_PADDING)} aria-label="Primary">
<nav className={cn("flex h-9 items-center gap-1", SIDEBAR_RAIL_CLASSES)} aria-label="Primary">
{currentUser && (
<DropdownMenu
onOpenChange={(open, eventDetails) => {
// Off the scope routes the pill is a plain navigation button: veto the
// menu and navigate to the scope instead.
// Off the scope routes this is a navigation control, not a menu trigger.
if (open && !scopeRouteActive) {
eventDetails.cancel();
navigateToScope(primaryScope);
@ -445,9 +443,8 @@ const GlobalNavigation = () => {
}}
>
<Tooltip disabled={scopeExpanded}>
{/* The tooltip anchors to a wrapper span rather than the button: a disabled
tooltip stamps data-trigger-disabled on its trigger element, and Base UI's
shared floating logic would read that as the MENU trigger being disabled. */}
{/* Keep the tooltip wrapper separate: Base UI otherwise propagates its
disabled state to the nested dropdown trigger. */}
<TooltipTrigger render={<span className="flex min-w-0" />}>
<DropdownMenuTrigger
render={
@ -459,11 +456,14 @@ const GlobalNavigation = () => {
/>
}
>
<ActiveScopeIcon className="size-4 shrink-0" strokeWidth={1.8} />
<span className={SIDEBAR_NAV_LEADING_SLOT_CLASSES} aria-hidden="true">
<ActiveScopeIcon className="size-4 opacity-75" strokeWidth={1.8} />
</span>
<NavPillLabel expanded={scopeExpanded} label={activeScopeItem.label}>
{scopeRouteActive && (
<ChevronDownIcon
className="-mr-0.5 size-3 shrink-0 opacity-55 transition-transform duration-200 ease-out group-data-[popup-open]/scope:rotate-180 motion-reduce:transition-none"
data-sidebar-trailing
className="size-3 shrink-0 opacity-55 transition-transform duration-200 ease-out group-data-[popup-open]/scope:rotate-180 motion-reduce:transition-none"
strokeWidth={1.8}
/>
)}
@ -491,12 +491,15 @@ const GlobalNavigation = () => {
/>
}
>
<Icon className="size-4 shrink-0" strokeWidth={1.8} />
<span className={SIDEBAR_NAV_LEADING_SLOT_CLASSES} aria-hidden="true">
<Icon className="size-4 opacity-75" strokeWidth={1.8} />
</span>
<NavPillLabel expanded={expanded} label={item.label} />
{item.count != null && (
<span
data-sidebar-trailing
className={cn(
"absolute -right-0.5 -top-0.5 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-semibold leading-4 text-primary-foreground transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none",
"absolute -end-0.5 -top-0.5 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-semibold leading-4 text-primary-foreground transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none",
item.count > 0 ? "scale-100 opacity-100" : "scale-50 opacity-0",
)}
>
@ -514,17 +517,24 @@ const GlobalNavigation = () => {
};
/** The sidebar/header brand slot: collection scope on collection routes, instance brand elsewhere. */
const SidebarBrand = ({ className }: { className?: string }) => {
const SidebarBrand = ({ className, size = "md" }: { className?: string; size?: "md" | "header" }) => {
const currentUser = useCurrentUser();
const location = useLocation();
if (currentUser && routeSupportsCollectionScope(location.pathname)) {
return <SpaceSwitcher className={className} />;
return <SpaceSwitcher className={className} size={size} />;
}
return (
<Link to={currentUser ? ROUTES.HOME : ROUTES.EXPLORE} className={cn("min-w-0 rounded-md focus-visible:outline-none", className)}>
<MemosLogo compact />
<Link
to={currentUser ? ROUTES.HOME : ROUTES.EXPLORE}
className={cn(
"transition-colors hover:bg-sidebar-accent/65 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40",
sidebarSurfaceVariants({ role: size === "header" ? "headerBrand" : "mobileBrand" }),
className,
)}
>
<MemosLogo compact size={size === "header" ? "header" : "md"} />
</Link>
);
};
@ -536,8 +546,8 @@ const AppSidebar = ({ className }: { className?: string }) => {
const { canOpen: canCompose, openEditor } = useGlobalMemoEditor();
return (
<aside className={cn("flex h-full w-full select-none flex-col bg-sidebar text-sidebar-foreground", className)}>
<div className={cn("flex h-13 shrink-0 items-center justify-between gap-2", SIDEBAR_HORIZONTAL_PADDING)}>
<SidebarBrand className="w-full flex-1" />
<div data-sidebar-header className={cn("flex h-13 shrink-0 items-center justify-between gap-2", SIDEBAR_RAIL_CLASSES)}>
<SidebarBrand className="min-w-0" size="header" />
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
@ -556,23 +566,29 @@ const AppSidebar = ({ className }: { className?: string }) => {
</div>
<GlobalNavigation />
<div className="mx-3 mt-2 border-t border-border/70" />
<div className={cn("min-h-0 flex-1 overflow-y-auto overflow-x-hidden pt-2 pb-3 [scrollbar-width:thin]", SIDEBAR_HORIZONTAL_PADDING)}>
<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 />
</div>
<footer className="shrink-0 border-t border-border/70">
<footer className={cn("shrink-0 border-t border-border/70", SIDEBAR_FOOTER_CLASSES)}>
{currentUser ? (
<UserMenu />
) : (
<Link
to={ROUTES.AUTH}
onClick={() => setMobileOpen(false)}
className="group flex h-10 w-full min-w-0 items-center justify-between gap-2 px-3 text-[13px] font-medium 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"
className={cn(
sidebarSurfaceVariants({ role: "account" }),
"group text-[13px] font-medium 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",
)}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
<UserRoundIcon className="size-5 shrink-0 text-muted-foreground" strokeWidth={1.8} />
<span className="truncate">{t("common.sign-in-to-memos")}</span>
<span className={SIDEBAR_LEADING_SLOT_CLASSES}>
<UserRoundIcon className="me-auto size-4 text-muted-foreground" strokeWidth={1.8} />
</span>
<span data-sidebar-label className="min-w-0 flex-1 truncate">
{t("common.sign-in-to-memos")}
</span>
<ArrowRightIcon
data-sidebar-trailing
className="size-3.5 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5 rtl:rotate-180 rtl:group-hover:-translate-x-0.5"
strokeWidth={1.8}
/>
@ -590,14 +606,14 @@ export const MobileAppHeader = () => {
<Button
variant="ghost"
size="icon-sm"
className="size-8"
className="size-8 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"
onClick={() => setMobileOpen(true)}
aria-label="Open navigation"
data-mobile-navigation-trigger
>
<MenuIcon className="size-[18px]" />
</Button>
<SidebarBrand className="max-w-[12rem]" />
<SidebarBrand className="max-w-[12rem]" size="md" />
</header>
);
};
@ -609,7 +625,7 @@ export const MobileAppSidebar = () => {
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent
side={direction === "rtl" ? "right" : "left"}
className="w-[min(18rem,calc(100vw-2rem))] gap-0 border-border p-0 shadow-2xl [&>button]:hidden"
className="w-[min(18rem,calc(100vw-2rem))] gap-0 border-border p-0 shadow-2xl [&>[data-slot=sheet-close]]:sr-only"
>
<SheetTitle className="sr-only">Navigation</SheetTitle>
<AppSidebar />

View file

@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { SIDEBAR_LEADING_SLOT_CLASSES, sidebarSurfaceVariants } from "./sidebar-layout";
/**
* The rail's row rhythm — height, type scale, radius, padding, gap. Every list in the
@ -9,33 +10,33 @@ import { cn } from "@/lib/utils";
* rows that carry a trailing control put the box on a wrapper and focus on the button
* inside it.
*/
export const SIDEBAR_ROW_BOX_CLASSES = "group flex h-[30px] w-full min-w-0 items-center gap-2 rounded-md px-2 text-ui transition-colors";
export const SIDEBAR_ROW_BOX_CLASSES = `${sidebarSurfaceVariants({ role: "row" })} group transition-colors`;
/** Goes on whichever element in a row actually takes focus. */
export const SIDEBAR_ROW_FOCUS_CLASSES = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
export const SIDEBAR_ROW_FOCUS_CLASSES =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50";
export const SIDEBAR_ROW_CLASSES = `${SIDEBAR_ROW_BOX_CLASSES} ${SIDEBAR_ROW_FOCUS_CLASSES}`;
export const SIDEBAR_ROW_ICON_CLASSES = "size-[15px] shrink-0 opacity-75";
export const SIDEBAR_ROW_ICON_CLASSES = "me-auto size-4 shrink-0 opacity-75";
const SIDEBAR_ROW_COUNT_CLASSES = "text-2xs tabular-nums text-muted-foreground/60";
/**
* The focusable body of a split row — rows whose box is a wrapper carrying other controls
* put their label layout and focus ring here. The gap must stay equal to the box's own
* `gap-2` or slot alignment breaks between one-control and split rows.
* `gap-1` or slot alignment breaks between one-control and split rows.
*/
export const SIDEBAR_ROW_LABEL_CLASSES = `flex h-full min-w-0 flex-1 items-center gap-2 text-start ${SIDEBAR_ROW_FOCUS_CLASSES}`;
export const SIDEBAR_ROW_LABEL_CLASSES = `flex h-full min-w-0 flex-1 items-center gap-1 text-start ${SIDEBAR_ROW_FOCUS_CLASSES}`;
/**
* Fixed leading slot: icons and disclosures share one vertical line across every list and
* mode. The box is 24px so a disclosure gets a real hit target, but the icon inside must
* line up with the bare 15px icons of plain rows — the negative margins cancel the extra
* width on both sides so slotted rows keep the same icon and label positions.
* Fixed leading slot: a 20px column begins on the artwork rail. Small glyphs pin to its
* start while 20px marks fill it; the following 4px gap puts every first-level label on
* the same rail without compensating margins.
*/
export const SIDEBAR_ROW_SLOT_CLASSES = "-mx-1 flex size-6 shrink-0 items-center justify-center";
export const SIDEBAR_ROW_SLOT_CLASSES = SIDEBAR_LEADING_SLOT_CLASSES;
/** A slot that is itself a control (disclosure, row menu): same box plus the hover chip. */
export const SIDEBAR_ROW_SLOT_BUTTON_CLASSES = `${SIDEBAR_ROW_SLOT_CLASSES} rounded hover:bg-sidebar-accent ${SIDEBAR_ROW_FOCUS_CLASSES}`;
export const SIDEBAR_ROW_SLOT_BUTTON_CLASSES = `${SIDEBAR_ROW_SLOT_CLASSES} relative rounded hover:bg-sidebar-accent after:absolute after:-inset-0.5 after:content-[''] ${SIDEBAR_ROW_FOCUS_CLASSES}`;
/** Trailing rail for counts, wide enough that digits align down the list. */
export const SIDEBAR_ROW_COUNT_RAIL_CLASSES = `${SIDEBAR_ROW_COUNT_CLASSES} min-w-[3ch] shrink-0 text-end`;
@ -70,7 +71,9 @@ const SidebarRow = ({ active, icon: Icon, label, count, onClick, trailing }: Pro
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(active))}
>
{Icon && <SidebarRowIconSlot icon={Icon} />}
<span className="min-w-0 flex-1 truncate text-start">{label}</span>
<span data-sidebar-label className="min-w-0 flex-1 truncate text-start">
{label}
</span>
{count != null && count > 0 && <span className={SIDEBAR_ROW_COUNT_RAIL_CLASSES}>{count}</span>}
{trailing}
</button>

View file

@ -1,10 +1,10 @@
import type { ReactNode } from "react";
import SidebarSectionHeader from "./SidebarSectionHeader";
export const SIDEBAR_SECTION_STACK_CLASSES = "flex flex-col gap-3.5";
export const SIDEBAR_SECTION_STACK_CLASSES = "flex flex-col gap-3";
export const SIDEBAR_SECTION_CONTENT_CLASSES = "flex flex-col gap-0.5";
export const SIDEBAR_SECTION_ACTION_BUTTON_CLASSES =
"size-5 rounded text-muted-foreground/65 transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
"size-6 rounded text-muted-foreground/65 transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50";
export const SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES = "bg-sidebar-accent text-foreground";
export const SIDEBAR_SECTION_ACTION_ICON_CLASSES = "size-3.5";

View file

@ -6,7 +6,7 @@ interface Props {
}
const SidebarSectionHeader = ({ children, action }: Props) => (
<div className="mb-0.5 flex h-5 min-w-0 items-center justify-between gap-2">
<div className="mb-0.5 flex h-6 min-w-0 items-center justify-between gap-2">
<h2 className="min-w-0 truncate ps-2 text-2xs font-normal uppercase tracking-wide text-muted-foreground/55 select-none">{children}</h2>
{action}
</div>

View file

@ -1,4 +1,4 @@
import { CheckIcon, ChevronsUpDownIcon, LoaderCircleIcon, type LucideIcon, PlusIcon } from "lucide-react";
import { CheckIcon, ChevronDownIcon, LoaderCircleIcon, type LucideIcon, PlusIcon } from "lucide-react";
import { type ReactNode, useRef, useState } from "react";
import CreateSpaceDialog from "@/components/CreateSpaceDialog";
import MemosLogo from "@/components/MemosLogo";
@ -16,6 +16,7 @@ import { useSpaceContext } from "@/contexts/SpaceContext";
import { extractSpaceUidFromName, formatSpaceUidForDisplay } from "@/lib/space-display";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { sidebarSurfaceVariants } from "./sidebar-layout";
// Icons in action and status rows sit in a glyph-width slot so every label in the menu
// starts on the same text rail as the context rows.
@ -50,7 +51,7 @@ const ContextItem = ({
</DropdownMenuItem>
);
function SpaceSwitcher({ className }: { className?: string }) {
function SpaceSwitcher({ className, size = "md" }: { className?: string; size?: "md" | "header" }) {
const t = useTranslate();
const { spaces, duplicateSpaceTitles, selectedSpace, selectedSpaceName, isLoadingSpaces, isSpacesError, selectMemos, selectSpace } =
useSpaceContext();
@ -63,6 +64,8 @@ function SpaceSwitcher({ className }: { className?: string }) {
const currentContextLabel = selectedSpaceName
? `${selectedSpace?.title || t("space.current")}${showSelectedSpaceUid && selectedSpaceUid ? ` (${selectedSpaceUid})` : ""}`
: t("common.memos");
const brandSize = size === "header" ? "header" : "md";
const spaceMarkSize = size === "header" ? "sm" : "md";
const handleMenuOpenChange = (open: boolean) => {
if (!open) return;
@ -71,7 +74,10 @@ function SpaceSwitcher({ className }: { className?: string }) {
const sidebar = trigger?.closest("aside");
if (!trigger || !sidebar) return;
const width = Math.floor(sidebar.getBoundingClientRect().right - trigger.getBoundingClientRect().left);
const sidebarRect = sidebar.getBoundingClientRect();
const triggerRect = trigger.getBoundingClientRect();
const inlineInset = Math.min(Math.abs(triggerRect.left - sidebarRect.left), Math.abs(sidebarRect.right - triggerRect.right));
const width = Math.floor(sidebarRect.width - inlineInset * 2);
if (width > 0) setMenuWidth(width);
};
@ -86,21 +92,27 @@ function SpaceSwitcher({ className }: { className?: string }) {
aria-label={`${t("space.switch")}: ${currentContextLabel}`}
title={currentContextLabel}
className={cn(
"group flex h-9 min-w-0 max-w-full items-center gap-1 rounded-md px-0.5 text-start focus-visible:outline-none",
"group text-start transition-colors hover:bg-sidebar-accent/65 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40",
size === "header" ? sidebarSurfaceVariants({ role: "headerBrand" }) : sidebarSurfaceVariants({ role: "mobileBrand" }),
className,
)}
/>
}
>
<span className="flex min-w-0 flex-1 items-center overflow-hidden">
<span className={cn("flex min-w-0 items-center overflow-hidden", size === "header" ? "gap-1" : "gap-1.5")}>
{selectedSpaceName ? (
<>
<SpaceMark />
<span className="ms-1.5 flex min-w-0 flex-1 flex-col justify-center overflow-hidden">
<span className="block truncate text-[14px] font-medium leading-4 tracking-[-0.01em] text-foreground">
<SpaceMark size={spaceMarkSize} />
<span data-sidebar-label className="flex min-w-0 flex-1 flex-col justify-center overflow-hidden">
<span
className={cn(
"block truncate text-[14px] tracking-[-0.01em] text-foreground",
size === "header" ? "font-semibold leading-5" : "font-medium leading-4",
)}
>
{selectedSpace?.title || t("space.current")}
</span>
{showSelectedSpaceUid && selectedSpaceUid ? (
{size !== "header" && showSelectedSpaceUid && selectedSpaceUid ? (
<span
aria-hidden="true"
title={selectedSpaceUid}
@ -112,10 +124,13 @@ function SpaceSwitcher({ className }: { className?: string }) {
</span>
</>
) : (
<MemosLogo compact />
<MemosLogo compact size={brandSize} />
)}
</span>
<ChevronsUpDownIcon className="size-3.5 shrink-0 text-muted-foreground/70" strokeWidth={1.8} />
<ChevronDownIcon
className="size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 group-data-[popup-open]:rotate-180 motion-reduce:transition-none"
strokeWidth={1.8}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
size="sm"

View file

@ -0,0 +1,35 @@
import { cva } from "class-variance-authority";
/**
* 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
* 20px artwork column; compact navigation reaches the same rails with a 16px glyph and
* an 8px label gap. Keeping those coordinates here prevents independent pill geometry.
*/
export const SIDEBAR_RAIL_CLASSES = "px-3";
// Content rows reserve 20px so 16px icons and 20px marks share a stable label rail.
export const SIDEBAR_LEADING_SLOT_CLASSES = "flex size-5 shrink-0 items-center justify-center";
// Compact nav uses the visible 16px glyph itself as the cell, keeping 8px x/y padding.
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
* fill the shared rail. Heights are fixed from the artwork size, so data cannot change
* a surface's padding or vertical position.
*/
export const sidebarSurfaceVariants = cva("min-w-0 items-center rounded-md", {
variants: {
role: {
row: "flex h-8 w-full gap-1 px-2 text-ui",
navPill: "relative flex h-8 px-2",
headerBrand: "flex h-9 max-w-full gap-1 px-2",
mobileBrand: "flex h-9 max-w-full gap-1.5 px-1",
account: "flex h-9 w-full gap-1 px-2",
},
},
defaultVariants: {
role: "row",
},
});
export const SIDEBAR_FOOTER_CLASSES = `${SIDEBAR_RAIL_CLASSES} py-1.5`;

View file

@ -6,15 +6,16 @@ interface Props {
className?: string;
collapsed?: boolean;
compact?: boolean;
/** Scale of the compact lockup: "md" for headers, "sm" for dense menu rows. */
size?: "sm" | "md";
/** Scale of the compact lockup: "header" for primary chrome, "md" for roomy surfaces, and "sm" for dense menu rows. */
size?: keyof typeof COMPACT_SCALE;
}
// Kept in step with SpaceMark in SpaceSwitcher, so the brand and a Space read as peers
// wherever the two are listed together.
const COMPACT_SCALE = {
md: { mark: "size-7 rounded-[7px]", title: "ml-1.5 text-[14px]" },
sm: { mark: "size-5 rounded-[5px]", title: "ml-1.5 text-ui" },
md: { mark: "size-7 rounded-[7px]", gap: "gap-1.5", title: "text-[14px]", weight: "font-medium" },
header: { mark: "size-5 rounded-[5px]", gap: "gap-1", title: "text-[14px] leading-5", weight: "font-semibold" },
sm: { mark: "size-5 rounded-[5px]", gap: "gap-1.5", title: "text-ui", weight: "font-medium" },
} as const;
function MemosLogo(props: Props) {
@ -25,14 +26,20 @@ function MemosLogo(props: Props) {
const avatarUrl = instanceGeneralSetting.customProfile?.logoUrl || "/full-logo.webp";
return (
<div className={cn("relative w-full h-auto shrink-0", props.className)}>
<div className={cn("relative min-w-0 h-auto", props.className)}>
<div
className={cn("w-auto flex flex-row justify-start items-center text-foreground", compact ? "px-0" : collapsed ? "px-1" : "px-3")}
className={cn(
"flex min-w-0 flex-row items-center justify-start text-foreground",
compact ? cn("px-0", scale.gap) : collapsed ? "px-1" : "gap-2 px-3",
)}
>
<UserAvatar className={cn("shrink-0", compact && scale.mark)} avatarUrl={avatarUrl} />
{!collapsed && (
<span
className={cn("font-medium text-foreground shrink truncate", compact ? cn(scale.title, "tracking-[-0.01em]") : "ml-2 text-lg")}
className={cn(
"shrink truncate text-foreground",
compact ? cn(scale.title, scale.weight, "tracking-[-0.01em]") : "text-lg font-medium",
)}
>
{title}
</span>

View file

@ -94,7 +94,11 @@ const TagMark = ({ className }: { className?: string }) => (
const Chevron = ({ open, className }: { open: boolean; className?: string }) => (
<ChevronRightIcon
aria-hidden="true"
className={cn("size-3.5 shrink-0 text-muted-foreground/70 transition-transform", open ? "rotate-90" : "rtl:rotate-180", className)}
className={cn(
"me-auto size-3.5 shrink-0 text-muted-foreground/70 transition-transform",
open ? "rotate-90" : "rtl:rotate-180",
className,
)}
strokeWidth={1.8}
/>
);

View file

@ -23,6 +23,7 @@ import { Routes } from "@/router";
import { UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
import { getLocaleWithFallback, loadLocale, useTranslate } from "@/utils/i18n";
import { getThemeWithFallback, loadTheme, THEME_OPTIONS } from "@/utils/theme";
import { SIDEBAR_LEADING_SLOT_CLASSES, sidebarSurfaceVariants } from "./AppSidebar/sidebar-layout";
import { LocaleSearchList } from "./LocalePicker";
import UserAvatar from "./UserAvatar";
import {
@ -128,37 +129,40 @@ const UserMenu = (props: Props) => {
disabled={!currentUser}
aria-label={triggerLabel}
className={cn(
"flex h-10 w-full min-w-0 cursor-pointer items-center justify-between gap-2 px-3 text-left 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-auto px-2",
sidebarSurfaceVariants({ role: "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",
collapsed && "w-9",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="relative shrink-0">
{currentUser?.avatarUrl ? (
<UserAvatar className="size-6 rounded-md" avatarUrl={currentUser?.avatarUrl} />
) : (
<User2Icon className="mx-auto size-5 text-muted-foreground" />
)}
{sseStatus !== "connected" && (
<Tooltip>
<TooltipTrigger
render={
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-background",
sseStatus === "connecting" ? "bg-muted-foreground animate-pulse" : "bg-destructive",
)}
/>
}
/>
<TooltipContent side="right">{t(`live-update.${sseStatus}` as Parameters<typeof t>[0])}</TooltipContent>
</Tooltip>
)}
</div>
{!collapsed && <span className="min-w-0 flex-1 truncate text-left text-[13px] font-medium text-foreground">{userLabel}</span>}
<div className={cn(SIDEBAR_LEADING_SLOT_CLASSES, "relative")}>
{currentUser?.avatarUrl ? (
<UserAvatar className="size-5 rounded-[5px]" avatarUrl={currentUser?.avatarUrl} />
) : (
<User2Icon className="me-auto size-4 text-muted-foreground" />
)}
{sseStatus !== "connected" && (
<Tooltip>
<TooltipTrigger
render={
<span
className={cn(
"absolute -bottom-0.5 -end-0.5 size-2.5 rounded-full border-2 border-background",
sseStatus === "connecting" ? "bg-muted-foreground animate-pulse" : "bg-destructive",
)}
/>
}
/>
<TooltipContent side="right">{t(`live-update.${sseStatus}` as Parameters<typeof t>[0])}</TooltipContent>
</Tooltip>
)}
</div>
{!collapsed && (
<span className="relative flex size-5 shrink-0 items-center justify-center">
<span data-sidebar-label className="min-w-0 flex-1 truncate text-start text-[13px] font-medium text-foreground">
{userLabel}
</span>
)}
{!collapsed && (
<span data-sidebar-trailing className="relative flex size-5 shrink-0 items-center justify-center">
<MoreVerticalIcon className="size-4 text-muted-foreground/70" strokeWidth={1.8} />
{unreadCount > 0 && (
<span
@ -170,7 +174,7 @@ const UserMenu = (props: Props) => {
</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[calc(var(--anchor-width)-0.75rem)]">
<DropdownMenuContent align="start" className="w-[var(--anchor-width)]">
<DropdownMenuItem onClick={() => navigateFromMenu(`/u/${encodeURIComponent(currentUser?.username ?? "")}`)}>
<SquareUserIcon className="size-4 text-muted-foreground" />
{t("common.profile")}

View file

@ -58,12 +58,13 @@ const SheetContent = React.forwardRef<
side === "bottom" && "inset-x-0 bottom-0 h-auto border-t data-starting-style:translate-y-full data-ending-style:translate-y-full",
className,
)}
initialFocus={false}
finalFocus={false}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background absolute top-4 end-4 rounded-xs opacity-60 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<SheetPrimitive.Close
data-slot="sheet-close"
className="ring-offset-background absolute top-4 end-4 rounded-xs opacity-60 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
>
<XIcon className="size-5" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>

View file

@ -32,7 +32,7 @@ const filteredStatsHook = vi.hoisted(() => vi.fn());
const tagsSectionHook = vi.hoisted(() => vi.fn());
vi.mock("@/components/MemosLogo", () => ({
default: () => <span>Memos logo</span>,
default: ({ size }: { size?: string }) => <span data-logo-size={size}>Memos logo</span>,
}));
vi.mock("@/components/MemoDisplaySettingMenu", () => ({
@ -158,18 +158,20 @@ const render = (ui: Parameters<typeof testingLibraryRender>[0]) =>
);
const expectCollapsedNavPill = (pill: HTMLElement, label: string) => {
expect(pill).toHaveClass("h-[30px]", "px-[7px]");
const labelTrack = pill.querySelector('span[aria-hidden="true"]');
expect(labelTrack).toHaveClass("grid-cols-[0fr]", "pl-0");
expect(pill).toHaveClass("h-8", "rounded-md", "px-2");
expect(pill.firstElementChild).toHaveClass("size-4");
const labelTrack = pill.querySelector('span.grid[aria-hidden="true"]');
expect(labelTrack).toHaveClass("grid-cols-[0fr]", "ps-0");
expect(labelTrack).toHaveTextContent(label);
};
const expectExpandedNavPill = (pill: HTMLElement, label: string) => {
expect(pill).toHaveClass("h-[30px]", "px-[7px]");
expect(pill).toHaveClass("h-8", "rounded-md", "px-2");
expect(pill.firstElementChild).toHaveClass("size-4");
const labelTrack = pill.querySelector("span.grid");
expect(labelTrack).toHaveClass("grid-cols-[1fr]", "pl-2");
expect(labelTrack).toHaveClass("grid-cols-[1fr]", "ps-2");
expect(labelTrack).not.toHaveAttribute("aria-hidden");
expect(pill).toHaveTextContent(label);
expect(pill.querySelector("[data-sidebar-label]")).toHaveTextContent(label);
};
const expectActiveNavPill = (pill: HTMLElement, label: string) => {
@ -210,8 +212,21 @@ describe("App sidebar logo", () => {
</MemoryRouter>,
);
expect(screen.getByRole("button", { name: "space.switch: common.memos" })).toHaveTextContent("Memos logo");
fireEvent.click(screen.getByRole("button", { name: "editor.new-memo" }));
const switcher = screen.getByRole("button", { name: "space.switch: common.memos" });
const header = switcher.closest("[data-sidebar-header]");
const compose = screen.getByRole("button", { name: "editor.new-memo" });
expect(header).toHaveClass("h-13", "px-3");
expect(switcher).toHaveTextContent("Memos logo");
expect(switcher).toHaveClass("min-w-0", "h-9", "gap-1", "px-2");
expect(switcher).not.toHaveClass("px-1");
expect(switcher.firstElementChild).not.toHaveClass("flex-1");
expect(within(switcher).getByText("Memos logo")).toHaveAttribute("data-logo-size", "header");
expect(switcher.querySelector(".lucide-chevron-down")).not.toBeNull();
expect(compose).toHaveClass("size-7", "rounded-md", "border", "bg-background", "shadow-xs");
expect(compose).not.toHaveClass("rounded-full");
fireEvent.click(compose);
expect(globalEditorState.openEditor).toHaveBeenCalledOnce();
expect(screen.queryByText("common.calendar")).not.toBeInTheDocument();
});
@ -261,7 +276,10 @@ describe("App sidebar logo", () => {
</MemoryRouter>,
);
expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/");
const brand = screen.getByRole("link", { name: "Memos logo" });
expect(brand).toHaveAttribute("href", "/");
expect(brand).toHaveClass("h-9", "gap-1", "px-2");
expect(within(brand).getByText("Memos logo")).toHaveAttribute("data-logo-size", "header");
expect(screen.queryByRole("button", { name: /^space\.switch:/ })).not.toBeInTheDocument();
});
@ -325,6 +343,7 @@ describe("App sidebar logo", () => {
const footer = screen.getByRole("button", { name: "User menu" }).closest("footer");
expect(footer).not.toBeNull();
expect(footer).toHaveClass("px-3", "py-1.5");
expect(footer?.childElementCount).toBe(1);
expect(screen.queryByRole("link", { name: /^common\.inbox/ })).not.toBeInTheDocument();
});
@ -376,7 +395,10 @@ describe("App sidebar logo", () => {
expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/explore");
expect(screen.queryByRole("button", { name: /^space\.switch:/ })).not.toBeInTheDocument();
const navigation = within(screen.getByRole("navigation", { name: "Primary" }));
const primaryNavigation = screen.getByRole("navigation", { name: "Primary" });
expect(primaryNavigation).toHaveClass("h-9", "items-center", "gap-1", "px-3");
expect(primaryNavigation).not.toHaveClass("flex-col");
const navigation = within(primaryNavigation);
expectActiveNavPill(navigation.getByRole("link", { name: "common.explore" }), "common.explore");
const about = navigation.getByRole("link", { name: "common.about" });
expect(about).toHaveAttribute("href", "/about");
@ -602,7 +624,9 @@ describe("App sidebar logo", () => {
);
expect(screen.getByRole("button", { name: "Open navigation" })).toHaveAttribute("data-mobile-navigation-trigger");
expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/");
const mobileBrand = screen.getByRole("link", { name: "Memos logo" });
expect(mobileBrand).toHaveAttribute("href", "/");
expect(mobileBrand).toHaveClass("h-9", "gap-1.5", "px-1");
expect(screen.queryByRole("button", { name: /^space\.switch:/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "common.search" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument();
@ -620,4 +644,20 @@ describe("App sidebar logo", () => {
fireEvent.click(screen.getByRole("button", { name: "editor.new-memo" }));
expect(globalEditorState.openEditor).toHaveBeenCalledOnce();
});
it("keeps an accessible Close control in the mobile navigation drawer", () => {
sidebarState.mobileOpen = true;
render(
<MemoryRouter initialEntries={["/about"]}>
<MobileAppSidebar />
</MemoryRouter>,
);
const dialog = screen.getByRole("dialog");
const close = screen.getByRole("button", { name: "Close" });
expect(dialog).toHaveClass("[&>[data-slot=sheet-close]]:sr-only");
expect(close).toHaveAttribute("data-slot", "sheet-close");
fireEvent.click(close);
expect(sidebarState.setMobileOpen.mock.calls.at(-1)?.[0]).toBe(false);
});
});

View file

@ -4,9 +4,13 @@ import { describe, expect, it, vi } from "vitest";
import SidebarRow, {
SIDEBAR_ROW_BOX_CLASSES,
SIDEBAR_ROW_COUNT_RAIL_CLASSES,
SIDEBAR_ROW_SLOT_BUTTON_CLASSES,
SIDEBAR_ROW_SLOT_CLASSES,
} from "@/components/AppSidebar/SidebarRow";
import SidebarSection, { SIDEBAR_SECTION_CONTENT_CLASSES } from "@/components/AppSidebar/SidebarSection";
import SidebarSection, {
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
SIDEBAR_SECTION_CONTENT_CLASSES,
} from "@/components/AppSidebar/SidebarSection";
import TagTree from "@/components/TagTree";
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
@ -35,12 +39,23 @@ describe("sidebar row grammar", () => {
const row = screen.getByRole("button", { name: "Tasks3" });
expect(row).toHaveClass(...boxClasses);
expect(row).toHaveClass("h-8", "w-full", "gap-1", "rounded-md", "px-2");
expect(row).not.toHaveClass("-mx-1");
// Icon in the shared slot and count in the shared rail, so every list — nav rows,
// views, tags in both modes — keeps its icons and digits on the same vertical lines.
expect(row.firstElementChild).toHaveClass(...SIDEBAR_ROW_SLOT_CLASSES.split(" "));
expect(row.firstElementChild).toHaveClass("size-5");
expect(row.firstElementChild?.firstElementChild).toHaveClass("size-4");
expect(screen.getByText("3")).toHaveClass(...SIDEBAR_ROW_COUNT_RAIL_CLASSES.split(" "));
});
it("keeps compact controls at a 24px hit target without layout margins", () => {
expect(SIDEBAR_ROW_SLOT_BUTTON_CLASSES).toContain("after:-inset-0.5");
expect(SIDEBAR_ROW_SLOT_BUTTON_CLASSES).toContain("after:content-['']");
expect(SIDEBAR_ROW_SLOT_BUTTON_CLASSES).not.toContain("-mx-1");
expect(SIDEBAR_SECTION_ACTION_BUTTON_CLASSES.split(" ")).toContain("size-6");
});
it("gives tag tree rows the same box as a nav row", () => {
render(
<TagTree
@ -56,6 +71,7 @@ describe("sidebar row grammar", () => {
for (const item of screen.getAllByRole("treeitem")) {
expect(item).toHaveClass(...boxClasses);
}
expect(document.querySelector(".lucide-chevron-right")).toHaveClass("me-auto");
});
it("indents nested tags by the same step the memo outline uses", () => {

View file

@ -74,7 +74,7 @@ describe("SpaceSwitcher", () => {
expect(screen.getByRole("menuitem", { name: "space.create" })).toBeInTheDocument();
});
it("fits the popup between the sidebar padding and right edge", async () => {
it("fits the popup symmetrically inside the sidebar rail", async () => {
render(
<aside>
<SpaceSwitcher />
@ -89,7 +89,25 @@ describe("SpaceSwitcher", () => {
fireEvent.click(trigger);
expect(await screen.findByRole("menu")).toHaveStyle({ width: "211px" });
expect(await screen.findByRole("menu")).toHaveStyle({ width: "199px" });
});
it("uses the same symmetric popup rail from the opposite inline edge", async () => {
render(
<aside>
<SpaceSwitcher />
</aside>,
);
const trigger = screen.getByRole("button", { name: "space.switch: common.memos" });
const sidebar = trigger.closest("aside");
expect(sidebar).not.toBeNull();
vi.spyOn(sidebar as HTMLElement, "getBoundingClientRect").mockReturnValue({ left: 0, right: 223, width: 223 } as DOMRect);
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ left: 80, right: 211, width: 131 } as DOMRect);
fireEvent.click(trigger);
expect(await screen.findByRole("menu")).toHaveStyle({ width: "199px" });
});
it("marks exactly one context as active", async () => {
@ -126,6 +144,38 @@ describe("SpaceSwitcher", () => {
expect(within(row).getByText(longTitle)).toHaveClass("max-w-full", "truncate");
});
it("uses the compact lockup in header chrome", () => {
spaceState.selectedSpaceName = "spaces/product";
spaceState.selectedSpace = spaceState.spaces[0];
render(<SpaceSwitcher size="header" />);
const trigger = screen.getByRole("button", { name: "space.switch: Product" });
const title = within(trigger).getByText("Product");
const mark = trigger.querySelector(".lucide-astroid")?.parentElement;
expect(trigger).toHaveClass("h-9", "gap-1", "px-2");
expect(trigger).not.toHaveClass("px-1");
expect(title).toHaveClass("text-[14px]", "font-semibold", "leading-5");
expect(mark).toHaveClass("size-5", "rounded-[5px]");
expect(trigger.querySelector(".lucide-chevron-down")).not.toBeNull();
expect(trigger.querySelector(".lucide-chevrons-up-down")).toBeNull();
});
it("keeps duplicate identity in the header label but out of its geometry", () => {
spaceState.spaces = [
{ name: "spaces/product-notes", title: "Product", description: "" },
{ name: "spaces/product-archive", title: "Product", description: "" },
];
spaceState.selectedSpaceName = spaceState.spaces[0].name;
spaceState.selectedSpace = spaceState.spaces[0];
render(<SpaceSwitcher size="header" />);
const trigger = screen.getByRole("button", { name: "space.switch: Product (product-notes)" });
expect(trigger).toHaveAttribute("title", "Product (product-notes)");
expect(trigger).toHaveClass("h-9", "px-2");
expect(within(trigger).queryByTitle("product-notes")).not.toBeInTheDocument();
});
it("shows UIDs only for Spaces whose titles match", async () => {
const uuid = "123e4567-e89b-12d3-a456-426614174000";
spaceState.spaces = [

View file

@ -88,6 +88,8 @@ describe("User menu", () => {
);
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.firstElementChild).toHaveClass("size-5");
expect(trigger.querySelector(".lucide-ellipsis-vertical")).not.toBeNull();
expect(trigger.querySelector(".lucide-chevrons-up-down")).toBeNull();
expect(trigger.querySelector("[data-inbox-unread-indicator]")).not.toBeNull();
@ -95,7 +97,7 @@ describe("User menu", () => {
const inbox = await screen.findByRole("menuitem", { name: "common.inbox, 2 inbox.unread" });
const menu = screen.getByRole("menu");
expect(menu).toHaveClass("w-[calc(var(--anchor-width)-0.75rem)]");
expect(menu).toHaveClass("w-[var(--anchor-width)]");
expect(menu).not.toHaveClass("min-w-56");
expect(inbox).toHaveAttribute("aria-current", "page");
expect(inbox).toHaveTextContent("common.inbox");