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:
parent
9d2b77ced8
commit
22a5f3385b
8 changed files with 187 additions and 103 deletions
|
|
@ -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 t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
|
|
@ -734,12 +690,7 @@ const AppSidebar = ({ className }: { className?: string }) => {
|
|||
</div>
|
||||
<footer className="shrink-0 border-t border-border/70">
|
||||
{currentUser ? (
|
||||
<div className="flex h-10 min-w-0 items-center pe-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<UserMenu />
|
||||
</div>
|
||||
<InboxFooterLink />
|
||||
</div>
|
||||
<UserMenu />
|
||||
) : (
|
||||
<Link
|
||||
to={ROUTES.AUTH}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 MemosLogo from "@/components/MemosLogo";
|
||||
import SpaceMark from "@/components/SpaceMark";
|
||||
|
|
@ -40,9 +40,10 @@ const ContextItem = ({
|
|||
role="menuitemradio"
|
||||
aria-checked={selected}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
closeOnClick
|
||||
onClick={onSelect}
|
||||
className={cn(selected && "bg-accent/60")}
|
||||
className={cn("min-w-0", selected && "bg-accent/60")}
|
||||
>
|
||||
{children}
|
||||
{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 } =
|
||||
useSpaceContext();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [menuWidth, setMenuWidth] = useState<number>();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const selectedSpaceIdentity = selectedSpace?.name || selectedSpaceName || "";
|
||||
const selectedSpaceUid = selectedSpaceIdentity ? extractSpaceUidFromName(selectedSpaceIdentity) : "";
|
||||
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})` : ""}`
|
||||
: 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 (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu onOpenChange={handleMenuOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
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",
|
||||
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 ? (
|
||||
<>
|
||||
<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">
|
||||
{selectedSpace?.title || t("space.current")}
|
||||
</span>
|
||||
|
|
@ -101,7 +117,13 @@ function SpaceSwitcher({ className }: { className?: string }) {
|
|||
</span>
|
||||
<ChevronsUpDownIcon className="size-3.5 shrink-0 text-muted-foreground/70" strokeWidth={1.8} />
|
||||
</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>
|
||||
<ContextItem selected={!selectedSpaceName} onSelect={selectMemos}>
|
||||
<span className="min-w-0 flex-1">
|
||||
|
|
@ -123,8 +145,8 @@ function SpaceSwitcher({ className }: { className?: string }) {
|
|||
ariaLabel={showUid && uid ? `${space.title} (${uid})` : space.title}
|
||||
>
|
||||
<SpaceMark size="sm" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{space.title}</span>
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
<span className="block max-w-full truncate font-medium">{space.title}</span>
|
||||
{showUid && uid ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
|
|
|
|||
|
|
@ -31,22 +31,24 @@ const MemoSpaceBadge = ({ spaceName }: MemoSpaceBadgeProps) => {
|
|||
title={accessibleLabel}
|
||||
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",
|
||||
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} />
|
||||
<span className="min-w-0 truncate">
|
||||
{knownTitle && <span className="sr-only">{spaceLabel}: </span>}
|
||||
{title}
|
||||
<span className="flex min-w-0 items-baseline overflow-hidden">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{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>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import {
|
||||
ArchiveIcon,
|
||||
BellIcon,
|
||||
CheckIcon,
|
||||
ChevronsUpDownIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
LogOutIcon,
|
||||
MoreVerticalIcon,
|
||||
PaletteIcon,
|
||||
SettingsIcon,
|
||||
SquareUserIcon,
|
||||
|
|
@ -16,9 +17,10 @@ import { useAuth } from "@/contexts/AuthContext";
|
|||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useSSEConnectionStatus } from "@/hooks/useLiveMemoRefresh";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { useUpdateUserGeneralSetting } from "@/hooks/useUserQueries";
|
||||
import { useNotifications, useUpdateUserGeneralSetting } from "@/hooks/useUserQueries";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 { LocaleSearchList } from "./LocalePicker";
|
||||
|
|
@ -48,10 +50,16 @@ const UserMenu = (props: Props) => {
|
|||
const currentUser = useCurrentUser();
|
||||
const { userGeneralSetting, refetchSettings, logout } = useAuth();
|
||||
const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name);
|
||||
const { data: notifications = [] } = useNotifications();
|
||||
const sseStatus = useSSEConnectionStatus();
|
||||
const currentLocale = getLocaleWithFallback(userGeneralSetting?.locale);
|
||||
const currentTheme = getThemeWithFallback(userGeneralSetting?.theme);
|
||||
const inboxActive = Boolean(matchPath(Routes.INBOX, 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) => {
|
||||
if (!currentUser) return;
|
||||
|
|
@ -118,6 +126,7 @@ const UserMenu = (props: Props) => {
|
|||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
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",
|
||||
|
|
@ -146,19 +155,43 @@ const UserMenu = (props: Props) => {
|
|||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="min-w-0 flex-1 truncate text-left text-[13px] font-medium text-foreground">
|
||||
{currentUser?.displayName || currentUser?.username}
|
||||
</span>
|
||||
)}
|
||||
{!collapsed && <span className="min-w-0 flex-1 truncate text-left text-[13px] font-medium text-foreground">{userLabel}</span>}
|
||||
</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>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuContent align="start" className="w-[calc(var(--anchor-width)-0.75rem)]">
|
||||
<DropdownMenuItem onClick={() => navigateFromMenu(`/u/${encodeURIComponent(currentUser?.username ?? "")}`)}>
|
||||
<SquareUserIcon className="size-4 text-muted-foreground" />
|
||||
{t("common.profile")}
|
||||
</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
|
||||
aria-current={archivedActive ? "page" : undefined}
|
||||
className={cn(archivedActive && "bg-accent text-accent-foreground")}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,11 @@ vi.mock("@/components/MemoDisplaySettingMenu", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/components/UserMenu", () => ({
|
||||
default: () => <div>User menu</div>,
|
||||
default: () => (
|
||||
<button type="button" className="w-full">
|
||||
User menu
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/CreateSpaceDialog", () => ({
|
||||
|
|
@ -298,34 +302,27 @@ describe("App sidebar logo", () => {
|
|||
expect(tagsSectionHook).toHaveBeenCalledWith(expect.objectContaining({ scope: "archived" }));
|
||||
});
|
||||
|
||||
it("keeps Inbox compact while exposing its unread state accessibly", () => {
|
||||
authState.notifications = [{ status: 1 }, { status: 1 }, { status: 2 }];
|
||||
|
||||
it("uses one unified signed-in footer surface", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/inbox"]}>
|
||||
<AppSidebar />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const inbox = screen.getByRole("link", { name: "common.inbox, 2 inbox.unread" });
|
||||
expect(inbox).toHaveAttribute("aria-current", "page");
|
||||
expect(inbox).not.toHaveTextContent("common.inbox");
|
||||
expect(inbox).not.toHaveTextContent("2");
|
||||
expect(inbox.querySelector("[data-inbox-unread-indicator]")).not.toBeNull();
|
||||
expect(inbox.closest("footer")).not.toBeNull();
|
||||
const footer = screen.getByRole("button", { name: "User menu" }).closest("footer");
|
||||
expect(footer).not.toBeNull();
|
||||
expect(footer?.childElementCount).toBe(1);
|
||||
expect(screen.queryByRole("link", { name: /^common\.inbox/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/Attachments/", "common.attachments"],
|
||||
["/Inbox/", "common.inbox"],
|
||||
])("keeps %s active after route normalization", (path, label) => {
|
||||
it("keeps Attachments active after route normalization", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<MemoryRouter initialEntries={["/Attachments/"]}>
|
||||
<AppSidebar />
|
||||
</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", () => {
|
||||
|
|
@ -385,7 +382,7 @@ describe("App sidebar logo", () => {
|
|||
expect(screen.queryByText("Tags")).not.toBeInTheDocument();
|
||||
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.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.about" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("User menu").closest("footer")).not.toBeNull();
|
||||
|
|
@ -468,7 +465,7 @@ describe("App sidebar logo", () => {
|
|||
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(
|
||||
<MemoryRouter initialEntries={["/attachments"]}>
|
||||
<AppSidebar />
|
||||
|
|
@ -478,9 +475,8 @@ describe("App sidebar logo", () => {
|
|||
const scopeTrigger = screen.getByRole("button", { name: "common.home" });
|
||||
expectCollapsedNavPill(scopeTrigger, "common.home");
|
||||
|
||||
const inbox = screen.getByRole("link", { name: "common.inbox" });
|
||||
expect(inbox.closest("footer")).not.toBeNull();
|
||||
expect(inbox.closest('nav[aria-label="Primary"]')).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "User menu" }).closest("footer")).not.toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "common.inbox" })).not.toBeInTheDocument();
|
||||
|
||||
const attachments = screen.getByRole("link", { name: "common.attachments" });
|
||||
expectActiveNavPill(attachments, "common.attachments");
|
||||
|
|
|
|||
|
|
@ -46,6 +46,16 @@ describe("MemoSpaceBadge", () => {
|
|||
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", () => {
|
||||
state.spaces = [
|
||||
{ name: "spaces/product-notes", title: "Product" },
|
||||
|
|
@ -53,7 +63,10 @@ describe("MemoSpaceBadge", () => {
|
|||
];
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -74,6 +74,24 @@ describe("SpaceSwitcher", () => {
|
|||
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 () => {
|
||||
spaceState.selectedSpaceName = "spaces/product";
|
||||
spaceState.selectedSpace = spaceState.spaces[0];
|
||||
|
|
@ -91,6 +109,23 @@ describe("SpaceSwitcher", () => {
|
|||
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 () => {
|
||||
const uuid = "123e4567-e89b-12d3-a456-426614174000";
|
||||
spaceState.spaces = [
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
|
|||
navigateTo: vi.fn(),
|
||||
setMobileOpen: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
notifications: [] as Array<{ status: number }>,
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/AppSidebarContext", () => ({
|
||||
|
|
@ -34,6 +35,7 @@ vi.mock("@/hooks/useNavigateTo", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/hooks/useUserQueries", () => ({
|
||||
useNotifications: () => ({ data: mocks.notifications }),
|
||||
useUpdateUserGeneralSetting: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
|
|
@ -52,9 +54,10 @@ describe("User menu", () => {
|
|||
mocks.navigateTo.mockReset();
|
||||
mocks.setMobileOpen.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(
|
||||
<MemoryRouter initialEntries={["/archived"]}>
|
||||
<UserMenu />
|
||||
|
|
@ -64,8 +67,10 @@ describe("User menu", () => {
|
|||
fireEvent.click(screen.getByRole("button", { name: /Steven/ }));
|
||||
|
||||
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" });
|
||||
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(screen.getAllByRole("separator")).toHaveLength(2);
|
||||
|
||||
|
|
@ -73,4 +78,31 @@ describe("User menu", () => {
|
|||
expect(mocks.setMobileOpen).toHaveBeenCalledWith(false);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue