fix(ui): refine space labels and sidebar menus

Keep long Space titles readable without expanding compact surfaces.

Fold Inbox into the user menu and align sidebar popups with the rail.
This commit is contained in:
amblued 2026-08-28 01:01:01 +08:00
parent 9d2b77ced8
commit 22a5f3385b
8 changed files with 187 additions and 103 deletions

View file

@ -658,50 +658,6 @@ const SidebarBrand = ({ className }: { className?: string }) => {
); );
}; };
const InboxFooterLink = () => {
const t = useTranslate();
const location = useLocation();
const { setMobileOpen } = useAppSidebar();
const { data: notifications = [] } = useNotifications();
const unreadCount = notifications.filter((notification) => notification.status === UserNotification_Status.UNREAD).length;
const active = getSidebarRouteKind(location.pathname) === "inbox";
const accessibleLabel = unreadCount > 0 ? `${t("common.inbox")}, ${unreadCount} ${t("inbox.unread")}` : t("common.inbox");
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Link
to={ROUTES.INBOX}
onClick={() => setMobileOpen(false)}
aria-label={accessibleLabel}
aria-current={active ? "page" : undefined}
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-sidebar-accent/65 hover:text-foreground md:size-8",
SIDEBAR_ROW_FOCUS_CLASSES,
active && "bg-sidebar-accent text-sidebar-accent-foreground",
)}
/>
}
>
<span className="relative flex">
<BellIcon className="size-4" strokeWidth={1.8} />
{unreadCount > 0 && (
<span
aria-hidden="true"
data-inbox-unread-indicator
className="absolute -end-0.5 -top-0.5 size-1.5 rounded-full bg-primary ring-2 ring-sidebar"
/>
)}
</span>
</TooltipTrigger>
<TooltipContent side="top">{accessibleLabel}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const AppSidebar = ({ className }: { className?: string }) => { const AppSidebar = ({ className }: { className?: string }) => {
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
@ -734,12 +690,7 @@ const AppSidebar = ({ className }: { className?: string }) => {
</div> </div>
<footer className="shrink-0 border-t border-border/70"> <footer className="shrink-0 border-t border-border/70">
{currentUser ? ( {currentUser ? (
<div className="flex h-10 min-w-0 items-center pe-1"> <UserMenu />
<div className="min-w-0 flex-1">
<UserMenu />
</div>
<InboxFooterLink />
</div>
) : ( ) : (
<Link <Link
to={ROUTES.AUTH} to={ROUTES.AUTH}

View file

@ -1,5 +1,5 @@
import { CheckIcon, ChevronsUpDownIcon, LoaderCircleIcon, type LucideIcon, PlusIcon } from "lucide-react"; import { CheckIcon, ChevronsUpDownIcon, LoaderCircleIcon, type LucideIcon, PlusIcon } from "lucide-react";
import { type ReactNode, useState } from "react"; import { type ReactNode, useRef, useState } from "react";
import CreateSpaceDialog from "@/components/CreateSpaceDialog"; import CreateSpaceDialog from "@/components/CreateSpaceDialog";
import MemosLogo from "@/components/MemosLogo"; import MemosLogo from "@/components/MemosLogo";
import SpaceMark from "@/components/SpaceMark"; import SpaceMark from "@/components/SpaceMark";
@ -40,9 +40,10 @@ const ContextItem = ({
role="menuitemradio" role="menuitemradio"
aria-checked={selected} aria-checked={selected}
aria-label={ariaLabel} aria-label={ariaLabel}
title={ariaLabel}
closeOnClick closeOnClick
onClick={onSelect} onClick={onSelect}
className={cn(selected && "bg-accent/60")} className={cn("min-w-0", selected && "bg-accent/60")}
> >
{children} {children}
{selected && <CheckIcon className="ms-auto size-3.5 shrink-0 text-primary" />} {selected && <CheckIcon className="ms-auto size-3.5 shrink-0 text-primary" />}
@ -54,6 +55,8 @@ function SpaceSwitcher({ className }: { className?: string }) {
const { spaces, duplicateSpaceTitles, selectedSpace, selectedSpaceName, isLoadingSpaces, isSpacesError, selectMemos, selectSpace } = const { spaces, duplicateSpaceTitles, selectedSpace, selectedSpaceName, isLoadingSpaces, isSpacesError, selectMemos, selectSpace } =
useSpaceContext(); useSpaceContext();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [menuWidth, setMenuWidth] = useState<number>();
const triggerRef = useRef<HTMLButtonElement>(null);
const selectedSpaceIdentity = selectedSpace?.name || selectedSpaceName || ""; const selectedSpaceIdentity = selectedSpace?.name || selectedSpaceName || "";
const selectedSpaceUid = selectedSpaceIdentity ? extractSpaceUidFromName(selectedSpaceIdentity) : ""; const selectedSpaceUid = selectedSpaceIdentity ? extractSpaceUidFromName(selectedSpaceIdentity) : "";
const showSelectedSpaceUid = selectedSpace ? duplicateSpaceTitles.has(selectedSpace.title) : Boolean(selectedSpaceName); const showSelectedSpaceUid = selectedSpace ? duplicateSpaceTitles.has(selectedSpace.title) : Boolean(selectedSpaceName);
@ -61,14 +64,27 @@ function SpaceSwitcher({ className }: { className?: string }) {
? `${selectedSpace?.title || t("space.current")}${showSelectedSpaceUid && selectedSpaceUid ? ` (${selectedSpaceUid})` : ""}` ? `${selectedSpace?.title || t("space.current")}${showSelectedSpaceUid && selectedSpaceUid ? ` (${selectedSpaceUid})` : ""}`
: t("common.memos"); : t("common.memos");
const handleMenuOpenChange = (open: boolean) => {
if (!open) return;
const trigger = triggerRef.current;
const sidebar = trigger?.closest("aside");
if (!trigger || !sidebar) return;
const width = Math.floor(sidebar.getBoundingClientRect().right - trigger.getBoundingClientRect().left);
if (width > 0) setMenuWidth(width);
};
return ( return (
<> <>
<DropdownMenu> <DropdownMenu onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
<button <button
ref={triggerRef}
type="button" type="button"
aria-label={`${t("space.switch")}: ${currentContextLabel}`} aria-label={`${t("space.switch")}: ${currentContextLabel}`}
title={currentContextLabel}
className={cn( 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 flex h-9 min-w-0 max-w-full items-center gap-1 rounded-md px-0.5 text-start focus-visible:outline-none",
className, className,
@ -76,11 +92,11 @@ function SpaceSwitcher({ className }: { className?: string }) {
/> />
} }
> >
<span className="flex min-w-0 flex-1 items-center"> <span className="flex min-w-0 flex-1 items-center overflow-hidden">
{selectedSpaceName ? ( {selectedSpaceName ? (
<> <>
<SpaceMark /> <SpaceMark />
<span className="ms-1.5 flex min-w-0 flex-1 flex-col justify-center"> <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"> <span className="block truncate text-[14px] font-medium leading-4 tracking-[-0.01em] text-foreground">
{selectedSpace?.title || t("space.current")} {selectedSpace?.title || t("space.current")}
</span> </span>
@ -101,7 +117,13 @@ function SpaceSwitcher({ className }: { className?: string }) {
</span> </span>
<ChevronsUpDownIcon className="size-3.5 shrink-0 text-muted-foreground/70" strokeWidth={1.8} /> <ChevronsUpDownIcon className="size-3.5 shrink-0 text-muted-foreground/70" strokeWidth={1.8} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent size="sm" align="start" sideOffset={4} className="w-[min(15rem,calc(100vw-1rem))]"> <DropdownMenuContent
size="sm"
align="start"
sideOffset={4}
className="w-[min(15rem,calc(100vw-1rem))] max-w-[calc(100vw-1rem)]"
style={menuWidth ? { width: `${menuWidth}px` } : undefined}
>
<DropdownMenuGroup> <DropdownMenuGroup>
<ContextItem selected={!selectedSpaceName} onSelect={selectMemos}> <ContextItem selected={!selectedSpaceName} onSelect={selectMemos}>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
@ -123,8 +145,8 @@ function SpaceSwitcher({ className }: { className?: string }) {
ariaLabel={showUid && uid ? `${space.title} (${uid})` : space.title} ariaLabel={showUid && uid ? `${space.title} (${uid})` : space.title}
> >
<SpaceMark size="sm" /> <SpaceMark size="sm" />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1 overflow-hidden">
<span className="block truncate font-medium">{space.title}</span> <span className="block max-w-full truncate font-medium">{space.title}</span>
{showUid && uid ? ( {showUid && uid ? (
<span <span
aria-hidden="true" aria-hidden="true"

View file

@ -31,22 +31,24 @@ const MemoSpaceBadge = ({ spaceName }: MemoSpaceBadgeProps) => {
title={accessibleLabel} title={accessibleLabel}
className={cn( className={cn(
"min-w-0 shrink gap-1 border-border/60 bg-muted/30 px-1.5 py-0 text-[11px] font-normal text-muted-foreground", "min-w-0 shrink gap-1 border-border/60 bg-muted/30 px-1.5 py-0 text-[11px] font-normal text-muted-foreground",
showUid && uid ? "max-w-44 sm:max-w-52" : "max-w-24 sm:max-w-32", showUid && uid ? "max-w-52 sm:max-w-64" : "max-w-36 sm:max-w-48",
)} )}
> >
<AstroidIcon aria-hidden="true" className="size-3 shrink-0" strokeWidth={1.8} /> <AstroidIcon aria-hidden="true" className="size-3 shrink-0" strokeWidth={1.8} />
<span className="min-w-0 truncate"> <span className="flex min-w-0 items-baseline overflow-hidden">
{knownTitle && <span className="sr-only">{spaceLabel}: </span>} <span className="min-w-0 flex-1 truncate">
{title} {knownTitle && <span className="sr-only">{spaceLabel}: </span>}
{title}
</span>
{showUid && uid ? (
<>
<span aria-hidden="true" className="max-w-[48%] shrink-0 truncate font-mono">
{` · ${formatSpaceUidForDisplay(identityName)}`}
</span>
<span className="sr-only"> ({uid})</span>
</>
) : null}
</span> </span>
{showUid && uid ? (
<>
<span aria-hidden="true" className="max-w-32 shrink-0 truncate font-mono">
{` · ${formatSpaceUidForDisplay(identityName)}`}
</span>
<span className="sr-only"> ({uid})</span>
</>
) : null}
</Badge> </Badge>
); );
}; };

View file

@ -1,10 +1,11 @@
import { import {
ArchiveIcon, ArchiveIcon,
BellIcon,
CheckIcon, CheckIcon,
ChevronsUpDownIcon,
GlobeIcon, GlobeIcon,
InfoIcon, InfoIcon,
LogOutIcon, LogOutIcon,
MoreVerticalIcon,
PaletteIcon, PaletteIcon,
SettingsIcon, SettingsIcon,
SquareUserIcon, SquareUserIcon,
@ -16,9 +17,10 @@ import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { useSSEConnectionStatus } from "@/hooks/useLiveMemoRefresh"; import { useSSEConnectionStatus } from "@/hooks/useLiveMemoRefresh";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { useUpdateUserGeneralSetting } from "@/hooks/useUserQueries"; import { useNotifications, useUpdateUserGeneralSetting } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Routes } from "@/router"; import { Routes } from "@/router";
import { UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
import { getLocaleWithFallback, loadLocale, useTranslate } from "@/utils/i18n"; import { getLocaleWithFallback, loadLocale, useTranslate } from "@/utils/i18n";
import { getThemeWithFallback, loadTheme, THEME_OPTIONS } from "@/utils/theme"; import { getThemeWithFallback, loadTheme, THEME_OPTIONS } from "@/utils/theme";
import { LocaleSearchList } from "./LocalePicker"; import { LocaleSearchList } from "./LocalePicker";
@ -48,10 +50,16 @@ const UserMenu = (props: Props) => {
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const { userGeneralSetting, refetchSettings, logout } = useAuth(); const { userGeneralSetting, refetchSettings, logout } = useAuth();
const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name); const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name);
const { data: notifications = [] } = useNotifications();
const sseStatus = useSSEConnectionStatus(); const sseStatus = useSSEConnectionStatus();
const currentLocale = getLocaleWithFallback(userGeneralSetting?.locale); const currentLocale = getLocaleWithFallback(userGeneralSetting?.locale);
const currentTheme = getThemeWithFallback(userGeneralSetting?.theme); const currentTheme = getThemeWithFallback(userGeneralSetting?.theme);
const inboxActive = Boolean(matchPath(Routes.INBOX, location.pathname));
const archivedActive = Boolean(matchPath(Routes.ARCHIVED, location.pathname)); const archivedActive = Boolean(matchPath(Routes.ARCHIVED, location.pathname));
const unreadCount = notifications.filter((notification) => notification.status === UserNotification_Status.UNREAD).length;
const userLabel = currentUser?.displayName || currentUser?.username || t("common.profile");
const triggerLabel = `${userLabel}, ${t("common.more")}${unreadCount > 0 ? `, ${unreadCount} ${t("inbox.unread")}` : ""}`;
const inboxLabel = unreadCount > 0 ? `${t("common.inbox")}, ${unreadCount} ${t("inbox.unread")}` : t("common.inbox");
const handleLocaleChange = async (locale: Locale) => { const handleLocaleChange = async (locale: Locale) => {
if (!currentUser) return; if (!currentUser) return;
@ -118,6 +126,7 @@ const UserMenu = (props: Props) => {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={!currentUser} disabled={!currentUser}
aria-label={triggerLabel}
className={cn( 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", "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", collapsed && "w-auto px-2",
@ -146,19 +155,43 @@ const UserMenu = (props: Props) => {
</Tooltip> </Tooltip>
)} )}
</div> </div>
{!collapsed && ( {!collapsed && <span className="min-w-0 flex-1 truncate text-left text-[13px] font-medium text-foreground">{userLabel}</span>}
<span className="min-w-0 flex-1 truncate text-left text-[13px] font-medium text-foreground">
{currentUser?.displayName || currentUser?.username}
</span>
)}
</div> </div>
{!collapsed && <ChevronsUpDownIcon className="size-3.5 shrink-0 text-muted-foreground/70" strokeWidth={1.8} />} {!collapsed && (
<span 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
aria-hidden="true"
data-inbox-unread-indicator
className="absolute end-0 top-0 size-1.5 rounded-full bg-primary ring-2 ring-sidebar"
/>
)}
</span>
)}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start" className="w-[calc(var(--anchor-width)-0.75rem)]">
<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")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
aria-label={inboxLabel}
aria-current={inboxActive ? "page" : undefined}
className={cn(inboxActive && "bg-accent text-accent-foreground")}
onClick={() => navigateFromMenu(Routes.INBOX)}
>
<BellIcon className="size-4 text-muted-foreground" />
<span className="min-w-0 flex-1">{t("common.inbox")}</span>
{unreadCount > 0 && (
<span
aria-hidden="true"
className="ms-auto min-w-5 rounded-full bg-primary/10 px-1.5 text-center text-[10px] font-medium text-primary"
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
aria-current={archivedActive ? "page" : undefined} aria-current={archivedActive ? "page" : undefined}
className={cn(archivedActive && "bg-accent text-accent-foreground")} className={cn(archivedActive && "bg-accent text-accent-foreground")}

View file

@ -39,7 +39,11 @@ vi.mock("@/components/MemoDisplaySettingMenu", () => ({
})); }));
vi.mock("@/components/UserMenu", () => ({ vi.mock("@/components/UserMenu", () => ({
default: () => <div>User menu</div>, default: () => (
<button type="button" className="w-full">
User menu
</button>
),
})); }));
vi.mock("@/components/CreateSpaceDialog", () => ({ vi.mock("@/components/CreateSpaceDialog", () => ({
@ -298,34 +302,27 @@ describe("App sidebar logo", () => {
expect(tagsSectionHook).toHaveBeenCalledWith(expect.objectContaining({ scope: "archived" })); expect(tagsSectionHook).toHaveBeenCalledWith(expect.objectContaining({ scope: "archived" }));
}); });
it("keeps Inbox compact while exposing its unread state accessibly", () => { it("uses one unified signed-in footer surface", () => {
authState.notifications = [{ status: 1 }, { status: 1 }, { status: 2 }];
render( render(
<MemoryRouter initialEntries={["/inbox"]}> <MemoryRouter initialEntries={["/inbox"]}>
<AppSidebar /> <AppSidebar />
</MemoryRouter>, </MemoryRouter>,
); );
const inbox = screen.getByRole("link", { name: "common.inbox, 2 inbox.unread" }); const footer = screen.getByRole("button", { name: "User menu" }).closest("footer");
expect(inbox).toHaveAttribute("aria-current", "page"); expect(footer).not.toBeNull();
expect(inbox).not.toHaveTextContent("common.inbox"); expect(footer?.childElementCount).toBe(1);
expect(inbox).not.toHaveTextContent("2"); expect(screen.queryByRole("link", { name: /^common\.inbox/ })).not.toBeInTheDocument();
expect(inbox.querySelector("[data-inbox-unread-indicator]")).not.toBeNull();
expect(inbox.closest("footer")).not.toBeNull();
}); });
it.each([ it("keeps Attachments active after route normalization", () => {
["/Attachments/", "common.attachments"],
["/Inbox/", "common.inbox"],
])("keeps %s active after route normalization", (path, label) => {
render( render(
<MemoryRouter initialEntries={[path]}> <MemoryRouter initialEntries={["/Attachments/"]}>
<AppSidebar /> <AppSidebar />
</MemoryRouter>, </MemoryRouter>,
); );
expect(screen.getByRole("link", { name: label })).toHaveAttribute("aria-current", "page"); expect(screen.getByRole("link", { name: "common.attachments" })).toHaveAttribute("aria-current", "page");
}); });
it("hides the instance-level unused attachment collection in a Space", () => { it("hides the instance-level unused attachment collection in a Space", () => {
@ -385,7 +382,7 @@ describe("App sidebar logo", () => {
expect(screen.queryByText("Tags")).not.toBeInTheDocument(); expect(screen.queryByText("Tags")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/"); expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/");
expect(screen.getByRole("link", { name: "common.attachments" })).toHaveAttribute("href", "/attachments"); expect(screen.getByRole("link", { name: "common.attachments" })).toHaveAttribute("href", "/attachments");
expect(screen.getByRole("link", { name: "common.inbox" })).toHaveAttribute("href", "/inbox"); expect(screen.queryByRole("link", { name: "common.inbox" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "common.about" })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "common.about" })).not.toBeInTheDocument();
expect(screen.getByText("User menu").closest("footer")).not.toBeNull(); expect(screen.getByText("User menu").closest("footer")).not.toBeNull();
@ -468,7 +465,7 @@ describe("App sidebar logo", () => {
expect(deleteItem).toHaveAttribute("data-variant", "destructive"); expect(deleteItem).toHaveAttribute("data-variant", "destructive");
}); });
it("keeps collection navigation together and places Inbox in the user footer", async () => { it("keeps collection navigation together and uses the unified user footer", async () => {
render( render(
<MemoryRouter initialEntries={["/attachments"]}> <MemoryRouter initialEntries={["/attachments"]}>
<AppSidebar /> <AppSidebar />
@ -478,9 +475,8 @@ describe("App sidebar logo", () => {
const scopeTrigger = screen.getByRole("button", { name: "common.home" }); const scopeTrigger = screen.getByRole("button", { name: "common.home" });
expectCollapsedNavPill(scopeTrigger, "common.home"); expectCollapsedNavPill(scopeTrigger, "common.home");
const inbox = screen.getByRole("link", { name: "common.inbox" }); expect(screen.getByRole("button", { name: "User menu" }).closest("footer")).not.toBeNull();
expect(inbox.closest("footer")).not.toBeNull(); expect(screen.queryByRole("link", { name: "common.inbox" })).not.toBeInTheDocument();
expect(inbox.closest('nav[aria-label="Primary"]')).toBeNull();
const attachments = screen.getByRole("link", { name: "common.attachments" }); const attachments = screen.getByRole("link", { name: "common.attachments" });
expectActiveNavPill(attachments, "common.attachments"); expectActiveNavPill(attachments, "common.attachments");

View file

@ -46,6 +46,16 @@ describe("MemoSpaceBadge", () => {
expect(badge.querySelector(".lucide-astroid")).not.toBeNull(); expect(badge.querySelector(".lucide-astroid")).not.toBeNull();
}); });
it("truncates a long title on one line while preserving its full value", () => {
const longTitle = "A very long product research and planning space title";
state.spaces = [{ name: "spaces/product", title: longTitle }];
render(<MemoSpaceBadge spaceName="spaces/product" />);
const badge = screen.getByTitle(`Space: ${longTitle}`);
expect(badge).toHaveClass("max-w-36", "sm:max-w-48");
expect(screen.getByText(longTitle)).toHaveClass("min-w-0", "flex-1", "truncate");
});
it("adds the full custom UID when known Space titles match", () => { it("adds the full custom UID when known Space titles match", () => {
state.spaces = [ state.spaces = [
{ name: "spaces/product-notes", title: "Product" }, { name: "spaces/product-notes", title: "Product" },
@ -53,7 +63,10 @@ describe("MemoSpaceBadge", () => {
]; ];
render(<MemoSpaceBadge spaceName="spaces/product-notes" />); render(<MemoSpaceBadge spaceName="spaces/product-notes" />);
expect(screen.getByTitle("Space: Product (product-notes)")).toHaveTextContent("Product · product-notes"); const badge = screen.getByTitle("Space: Product (product-notes)");
expect(badge).toHaveClass("max-w-52", "sm:max-w-64");
expect(badge).toHaveTextContent("Product · product-notes");
expect(badge.querySelector(".font-mono")).toHaveClass("max-w-[48%]", "shrink-0", "truncate");
}); });
it("shows eight UUID characters while preserving the full ID in the accessible label", () => { it("shows eight UUID characters while preserving the full ID in the accessible label", () => {

View file

@ -74,6 +74,24 @@ describe("SpaceSwitcher", () => {
expect(screen.getByRole("menuitem", { name: "space.create" })).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: "space.create" })).toBeInTheDocument();
}); });
it("fits the popup between the sidebar padding and right 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: 12, right: 143, width: 131 } as DOMRect);
fireEvent.click(trigger);
expect(await screen.findByRole("menu")).toHaveStyle({ width: "211px" });
});
it("marks exactly one context as active", async () => { it("marks exactly one context as active", async () => {
spaceState.selectedSpaceName = "spaces/product"; spaceState.selectedSpaceName = "spaces/product";
spaceState.selectedSpace = spaceState.spaces[0]; spaceState.selectedSpace = spaceState.spaces[0];
@ -91,6 +109,23 @@ describe("SpaceSwitcher", () => {
expect(screen.getByRole("menuitemradio", { name: "Research" }).querySelector(".lucide-check")).toBeNull(); expect(screen.getByRole("menuitemradio", { name: "Research" }).querySelector(".lucide-check")).toBeNull();
}); });
it("keeps long titles on a truncated rail and exposes the complete value", async () => {
const longTitle = "A very long product research and planning space title";
spaceState.spaces = [{ name: "spaces/product", title: longTitle, description: "" }];
spaceState.selectedSpaceName = spaceState.spaces[0].name;
spaceState.selectedSpace = spaceState.spaces[0];
render(<SpaceSwitcher />);
const trigger = screen.getByRole("button", { name: `space.switch: ${longTitle}` });
expect(trigger).toHaveAttribute("title", longTitle);
expect(within(trigger).getByText(longTitle)).toHaveClass("truncate");
fireEvent.click(trigger);
const row = await screen.findByRole("menuitemradio", { name: longTitle });
expect(row).toHaveAttribute("title", longTitle);
expect(within(row).getByText(longTitle)).toHaveClass("max-w-full", "truncate");
});
it("shows UIDs only for Spaces whose titles match", async () => { it("shows UIDs only for Spaces whose titles match", async () => {
const uuid = "123e4567-e89b-12d3-a456-426614174000"; const uuid = "123e4567-e89b-12d3-a456-426614174000";
spaceState.spaces = [ spaceState.spaces = [

View file

@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
navigateTo: vi.fn(), navigateTo: vi.fn(),
setMobileOpen: vi.fn(), setMobileOpen: vi.fn(),
logout: vi.fn(), logout: vi.fn(),
notifications: [] as Array<{ status: number }>,
})); }));
vi.mock("@/contexts/AppSidebarContext", () => ({ vi.mock("@/contexts/AppSidebarContext", () => ({
@ -34,6 +35,7 @@ vi.mock("@/hooks/useNavigateTo", () => ({
})); }));
vi.mock("@/hooks/useUserQueries", () => ({ vi.mock("@/hooks/useUserQueries", () => ({
useNotifications: () => ({ data: mocks.notifications }),
useUpdateUserGeneralSetting: () => ({ mutate: vi.fn() }), useUpdateUserGeneralSetting: () => ({ mutate: vi.fn() }),
})); }));
@ -52,9 +54,10 @@ describe("User menu", () => {
mocks.navigateTo.mockReset(); mocks.navigateTo.mockReset();
mocks.setMobileOpen.mockReset(); mocks.setMobileOpen.mockReset();
mocks.logout.mockReset(); mocks.logout.mockReset();
mocks.notifications = [];
}); });
it("groups Archived with Profile and marks it active", async () => { it("groups Inbox and Archived with Profile and marks Archived active", async () => {
render( render(
<MemoryRouter initialEntries={["/archived"]}> <MemoryRouter initialEntries={["/archived"]}>
<UserMenu /> <UserMenu />
@ -64,8 +67,10 @@ describe("User menu", () => {
fireEvent.click(screen.getByRole("button", { name: /Steven/ })); fireEvent.click(screen.getByRole("button", { name: /Steven/ }));
const profile = await screen.findByRole("menuitem", { name: "common.profile" }); const profile = await screen.findByRole("menuitem", { name: "common.profile" });
const inbox = screen.getByRole("menuitem", { name: "common.inbox" });
const archived = screen.getByRole("menuitem", { name: "common.archived" }); const archived = screen.getByRole("menuitem", { name: "common.archived" });
expect(profile.compareDocumentPosition(archived) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(profile.compareDocumentPosition(inbox) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(inbox.compareDocumentPosition(archived) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(archived).toHaveAttribute("aria-current", "page"); expect(archived).toHaveAttribute("aria-current", "page");
expect(screen.getAllByRole("separator")).toHaveLength(2); expect(screen.getAllByRole("separator")).toHaveLength(2);
@ -73,4 +78,31 @@ describe("User menu", () => {
expect(mocks.setMobileOpen).toHaveBeenCalledWith(false); expect(mocks.setMobileOpen).toHaveBeenCalledWith(false);
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 () => {
mocks.notifications = [{ status: 1 }, { status: 1 }, { status: 2 }];
render(
<MemoryRouter initialEntries={["/Inbox/"]}>
<UserMenu />
</MemoryRouter>,
);
const trigger = screen.getByRole("button", { name: "Steven, common.more, 2 inbox.unread" });
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();
fireEvent.click(trigger);
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).not.toHaveClass("min-w-56");
expect(inbox).toHaveAttribute("aria-current", "page");
expect(inbox).toHaveTextContent("common.inbox");
expect(inbox).toHaveTextContent("2");
fireEvent.click(inbox);
expect(mocks.setMobileOpen).toHaveBeenCalledWith(false);
expect(mocks.navigateTo).toHaveBeenCalledWith("/inbox");
});
}); });