memos/web/tests/app-sidebar-logo.test.tsx
amblued 488b71a00a chore(sidebar): animate global navigator pill transitions
Give the home/attachments/inbox navigator one shared pill anatomy so the
active label expands and collapses as a single smooth motion: constant
px-[7px] padding with the label in a grid column animating 0fr to 1fr,
tooltips kept mounted via Base UI's disabled prop, and the scope control
as one always-mounted uncontrolled DropdownMenu that vetoes the menu and
navigates when off the scope routes. The unread badge scales in and out,
and the scope chevron rotates while its menu is open.

The tooltip anchors to a wrapper span because a disabled tooltip stamps
data-trigger-disabled on its trigger element, which Base UI's shared
floating logic would read as the menu trigger being disabled.
2026-08-19 19:35:37 +08:00

317 lines
12 KiB
TypeScript

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render as testingLibraryRender, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AppSidebar, { MobileAppHeader } from "@/components/AppSidebar";
import { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection";
const authState = vi.hoisted(() => ({
currentUser: { name: "users/test" } as { name: string } | undefined,
memoViews: [] as Array<{ name: string; title: string }>,
}));
const sidebarState = vi.hoisted(() => ({
memoScope: "home" as "home" | "explore" | "archived",
}));
vi.mock("@/components/MemosLogo", () => ({
default: () => <span>Memos logo</span>,
}));
vi.mock("@/components/MemoDisplaySettingMenu", () => ({
default: () => <button type="button">memo.view-options</button>,
}));
vi.mock("@/components/UserMenu", () => ({
default: () => <div>User menu</div>,
}));
vi.mock("@/components/StatisticsView", () => ({
default: () => <div>Calendar</div>,
}));
vi.mock("@/components/AppSidebar/TagsSection", () => ({
default: () => <div>Tags</div>,
}));
vi.mock("@/contexts/AppSidebarContext", () => ({
useAppSidebar: () => ({
attachmentSection: "all",
setAttachmentSection: vi.fn(),
inboxFilter: "all",
setInboxFilter: vi.fn(),
memoDetail: undefined,
setMemoDetail: vi.fn(),
mobileOpen: false,
setMobileOpen: vi.fn(),
quickFindOpen: false,
setQuickFindOpen: vi.fn(),
memoScope: sidebarState.memoScope,
setMemoScope: vi.fn(),
}),
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ isInitialized: true }),
}));
vi.mock("@/contexts/InstanceContext", () => ({
useInstance: () => ({ isInitialized: true }),
}));
vi.mock("@/contexts/MemoFilterContext", () => ({
stringifyFilters: () => "",
useMemoFilterContext: () => ({
filters: [],
memoView: undefined,
setMemoView: vi.fn(),
}),
}));
vi.mock("@/hooks/useCurrentUser", () => ({
default: () => authState.currentUser,
}));
vi.mock("@/hooks/useFilteredMemoStats", () => ({
useFilteredMemoStats: () => ({ statistics: { activityStats: {}, timeBasis: "create_time" }, tags: {} }),
}));
vi.mock("@/hooks/useAttachmentLibrary", () => ({
useAttachmentLibraryStats: () => ({ stats: { media: 0, documents: 0, audio: 0, unused: 0 } }),
}));
vi.mock("@/hooks/useMediaQuery", () => ({
default: () => true,
}));
vi.mock("@/hooks/useUserQueries", () => ({
userKeys: {
memoViews: (parent?: string) => ["users", "memoViews", parent],
},
useMemoViews: () => ({ data: authState.memoViews }),
useNotifications: () => ({ data: [] }),
useUser: () => ({ data: undefined }),
}));
vi.mock("@/i18n", () => ({
default: { language: "en" },
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
const render = (ui: Parameters<typeof testingLibraryRender>[0]) =>
testingLibraryRender(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>{ui}</QueryClientProvider>,
);
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(labelTrack).toHaveTextContent(label);
};
const expectActiveNavPill = (pill: HTMLElement, label: string) => {
expect(pill).toHaveAttribute("aria-current", "page");
expect(pill.querySelector('span[aria-hidden="true"]')).toBeNull();
expect(pill).toHaveTextContent(label);
};
describe("App sidebar logo", () => {
beforeEach(() => {
authState.currentUser = { name: "users/test" };
authState.memoViews = [];
sidebarState.memoScope = "home";
});
it("navigates home instead of opening a global editor", () => {
render(
<MemoryRouter initialEntries={["/"]}>
<AppSidebar />
</MemoryRouter>,
);
const logo = screen.getByRole("link", { name: "Memos logo" });
expect(logo).toHaveAttribute("href", "/");
expect(screen.queryByRole("button", { name: /create.*memos/i })).not.toBeInTheDocument();
expect(screen.queryByText("common.calendar")).not.toBeInTheDocument();
});
it("shows the compact public navigation for a guest", () => {
authState.currentUser = undefined;
render(
<MemoryRouter initialEntries={["/explore"]}>
<AppSidebar />
</MemoryRouter>,
);
expect(screen.getByRole("link", { name: "common.explore" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("href", "/about");
expect(screen.getByRole("link", { name: "common.sign-in-to-memos" }).closest("footer")).not.toBeNull();
expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument();
});
it("falls back to the library content on a route without a specific tenant", () => {
render(
<MemoryRouter initialEntries={["/404"]}>
<AppSidebar />
</MemoryRouter>,
);
expect(screen.getByText("Calendar")).toBeInTheDocument();
expect(screen.getByRole("region", { name: "common.statistics" })).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "common.statistics" })).not.toBeInTheDocument();
expect(screen.getByText("common.views")).toBeInTheDocument();
expect(screen.getByText("Tags")).toBeInTheDocument();
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.home" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "common.about" })).not.toBeInTheDocument();
expect(screen.getByText("User menu").closest("footer")).not.toBeNull();
});
it("uses a visitor sidebar for a guest on a route without contextual content", () => {
authState.currentUser = undefined;
render(
<MemoryRouter initialEntries={["/404"]}>
<AppSidebar />
</MemoryRouter>,
);
expect(screen.getByRole("link", { name: "common.explore" })).toHaveAttribute("href", "/explore");
expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "common.attachments" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "common.inbox" })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("href", "/about");
expect(screen.getByRole("link", { name: "common.sign-in-to-memos" }).closest("footer")).not.toBeNull();
});
it("marks About active for a guest on the About page", () => {
authState.currentUser = undefined;
render(
<MemoryRouter initialEntries={["/about"]}>
<AppSidebar />
</MemoryRouter>,
);
expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("aria-current", "page");
expect(screen.getByText("Calendar")).toBeInTheDocument();
});
it("uses a compact scope menu and places views below the calendar", async () => {
render(
<MemoryRouter initialEntries={["/"]}>
<AppSidebar />
</MemoryRouter>,
);
const calendar = screen.getByText("Calendar");
const views = screen.getByText("common.views");
expect(screen.getByRole("region", { name: "common.statistics" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "common.views", level: 2 })).toBeInTheDocument();
expect(calendar.compareDocumentPosition(views) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
const viewOptions = screen.getByRole("button", { name: "memo.view-options" });
const createView = screen.getByRole("button", { name: "common.create" });
expect(viewOptions.compareDocumentPosition(createView) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(createView).toHaveClass(...SIDEBAR_SECTION_ACTION_BUTTON_CLASSES.split(" "));
expect(createView.querySelector("svg")).toHaveClass(SIDEBAR_SECTION_ACTION_ICON_CLASSES);
const tasksView = screen.getByRole("button", { name: "common.tasks" });
expect(tasksView).toHaveTextContent("common.tasks");
expect(tasksView).not.toHaveTextContent("☑️");
expect(screen.queryByRole("button", { name: "common.all" })).not.toBeInTheDocument();
const scopeTrigger = screen.getByRole("button", { name: "common.home" });
fireEvent.click(scopeTrigger);
expect(await screen.findByRole("menuitem", { name: "common.home" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "common.explore" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "common.archived" })).toBeInTheDocument();
});
it("uses compact text-only actions for a saved view", async () => {
authState.memoViews = [{ name: "memoViews/1", title: "testgp" }];
render(
<MemoryRouter initialEntries={["/"]}>
<AppSidebar />
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("button", { name: "common.edit testgp" }));
const menu = await screen.findByRole("menu");
expect(menu).toHaveAttribute("data-size", "sm");
expect(menu).toHaveClass("min-w-24", "p-0.5");
const editItem = screen.getByRole("menuitem", { name: "common.edit" });
const deleteItem = screen.getByRole("menuitem", { name: "common.delete" });
expect(editItem.querySelector("svg")).toBeNull();
expect(deleteItem.querySelector("svg")).toBeNull();
expect(deleteItem).toHaveAttribute("data-variant", "destructive");
});
it("collapses inactive global destinations and defaults the scope icon to Home", async () => {
render(
<MemoryRouter initialEntries={["/attachments"]}>
<AppSidebar />
</MemoryRouter>,
);
const scopeTrigger = screen.getByRole("button", { name: "common.home" });
expectCollapsedNavPill(scopeTrigger, "common.home");
const inbox = screen.getByRole("link", { name: "common.inbox" });
expectCollapsedNavPill(inbox, "common.inbox");
const attachments = screen.getByRole("link", { name: "common.attachments" });
expectActiveNavPill(attachments, "common.attachments");
fireEvent.click(scopeTrigger);
expect(await screen.findByText("Calendar")).toBeInTheDocument();
expectActiveNavPill(screen.getByRole("button", { name: "common.home" }), "common.home");
expect(screen.queryByRole("menuitem", { name: "common.explore" })).not.toBeInTheDocument();
});
it.each([
["/attachments", "common.attachments"],
["/inbox", "common.inbox"],
["/setting", "common.basic"],
["/u/test", "common.profile"],
])("labels the sidebar content section on %s", (path, label) => {
render(
<MemoryRouter initialEntries={[path]}>
<AppSidebar />
</MemoryRouter>,
);
expect(screen.getByRole("heading", { name: label, level: 2 })).toBeInTheDocument();
});
it.each([
["explore", "common.explore", "/inbox"],
["archived", "common.archived", "/attachments"],
] as const)("keeps the %s scope available from a global destination", async (scope, label, destination) => {
sidebarState.memoScope = scope;
render(
<MemoryRouter initialEntries={[destination]}>
<AppSidebar />
</MemoryRouter>,
);
const scopeTrigger = screen.getByRole("button", { name: label });
expectCollapsedNavPill(scopeTrigger, label);
fireEvent.click(scopeTrigger);
expectActiveNavPill(await screen.findByRole("button", { name: label, current: "page" }), label);
});
it("keeps the mobile brand beside navigation without a duplicate search action", () => {
render(
<MemoryRouter initialEntries={["/"]}>
<MobileAppHeader />
</MemoryRouter>,
);
expect(screen.getByRole("button", { name: "Open navigation" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/");
expect(screen.queryByRole("button", { name: "common.search" })).not.toBeInTheDocument();
});
});