chore(sidebar): move tag layout toggle into a menu

This commit is contained in:
johnnyjoygh 2026-09-05 10:17:19 +08:00
parent bf1f742321
commit 5ad4aea565
5 changed files with 103 additions and 52 deletions

View file

@ -1,6 +1,15 @@
import { HashIcon, ListIcon, ListTreeIcon } from "lucide-react";
import { HashIcon, ListTreeIcon, MoreHorizontalIcon } from "lucide-react";
import { forwardRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useLocalStorage, useOverflowTitle } from "@/hooks";
import { cn } from "@/lib/utils";
@ -13,11 +22,7 @@ import {
sidebarRowStateAttributes,
sidebarRowStateClasses,
} from "./SidebarRow";
import SidebarSection, {
SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES,
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
SIDEBAR_SECTION_ACTION_ICON_CLASSES,
} from "./SidebarSection";
import SidebarSection, { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "./SidebarSection";
interface Props {
tagCount: Record<string, number>;
@ -100,28 +105,42 @@ const TagsSection = ({ tagCount, onSelect, scope }: Props) => {
<SidebarSection
label={t("common.tags")}
action={
<div className="flex items-center gap-0.5" role="group" aria-label={t("common.tags")}>
<Button
variant="ghost"
size="icon-sm"
aria-label={`${t("common.tags")}: ${t("memo.layout-list")}`}
aria-pressed={!treeMode}
className={cn(SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, !treeMode && SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES)}
onClick={() => setTreeMode(false)}
>
<ListIcon className={SIDEBAR_SECTION_ACTION_ICON_CLASSES} strokeWidth={1.8} />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`${t("common.tags")}: ${t("common.tree-mode")}`}
aria-pressed={treeMode}
className={cn(SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, treeMode && SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES)}
onClick={() => setTreeMode(true)}
>
<ListTreeIcon className={SIDEBAR_SECTION_ACTION_ICON_CLASSES} strokeWidth={1.8} />
</Button>
</div>
<DropdownMenu>
<Tooltip>
<TooltipTrigger render={<span className="inline-flex" />}>
<DropdownMenuTrigger
aria-label={`${t("common.tags")}: ${t("common.more")}`}
render={
<Button
variant="ghost"
size="icon-sm"
className={cn(
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
"data-popup-open:bg-sidebar-accent data-popup-open:text-foreground",
)}
/>
}
>
<MoreHorizontalIcon className={SIDEBAR_SECTION_ACTION_ICON_CLASSES} strokeWidth={1.8} />
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">{t("common.more")}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" sideOffset={4} size="sm" className="w-44">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground">{t("common.tags")}</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={treeMode}
onCheckedChange={setTreeMode}
closeOnClick
className="ps-2 pe-7 [&>span]:start-auto [&>span]:end-2"
>
<ListTreeIcon className="text-muted-foreground" strokeWidth={1.8} />
{t("common.tree-mode")}
</DropdownMenuCheckboxItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
}
>
{treeMode ? (

View file

@ -104,7 +104,7 @@
"tags": "Tags",
"title": "Title",
"today": "Today",
"tree-mode": "Tree mode",
"tree-mode": "Show as tree",
"type": "Type",
"unpin": "Unpin",
"update": "Update",

View file

@ -216,7 +216,7 @@
"tasks": "Tasks",
"title": "Title",
"today": "Today",
"tree-mode": "Tree mode",
"tree-mode": "Show as tree",
"type": "Type",
"unlink": "Unlink",
"unpin": "Unpin",

View file

@ -147,7 +147,7 @@
"tasks": "任务",
"title": "标题",
"today": "今天",
"tree-mode": "树模式",
"tree-mode": "树形显示",
"type": "类型",
"unlink": "解绑",
"unpin": "取消置顶",

View file

@ -1,12 +1,8 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SIDEBAR_ROW_BOX_CLASSES, SIDEBAR_ROW_COUNT_RAIL_CLASSES, SIDEBAR_ROW_SLOT_CLASSES } from "@/components/AppSidebar/SidebarRow";
import {
SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES,
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
SIDEBAR_SECTION_ACTION_ICON_CLASSES,
} from "@/components/AppSidebar/SidebarSection";
import { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection";
import TagsSection from "@/components/AppSidebar/TagsSection";
import { MemoFilterProvider } from "@/contexts/MemoFilterContext";
@ -29,22 +25,58 @@ describe("TagsSection", () => {
const heading = screen.getByRole("heading", { name: "common.tags", level: 2 });
expect(heading.parentElement).toHaveTextContent(/^common.tags$/);
const listButton = screen.getByRole("button", { name: "common.tags: memo.layout-list" });
const treeButton = screen.getByRole("button", { name: "common.tags: common.tree-mode" });
const stableActionClasses = SIDEBAR_SECTION_ACTION_BUTTON_CLASSES.split(" ").filter(
(className) => className !== "text-muted-foreground/65",
);
for (const button of [listButton, treeButton]) {
expect(button).toHaveClass(...stableActionClasses);
expect(button.querySelector("svg")).toHaveClass(SIDEBAR_SECTION_ACTION_ICON_CLASSES);
}
expect(listButton).toHaveClass(...SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES.split(" "));
expect(treeButton).toHaveClass("text-muted-foreground/65");
const trigger = screen.getByRole("button", { name: "common.tags: common.more" });
expect(trigger).toHaveClass(...SIDEBAR_SECTION_ACTION_BUTTON_CLASSES.split(" "));
expect(trigger.querySelector("svg")).toHaveClass(SIDEBAR_SECTION_ACTION_ICON_CLASSES);
expect(screen.queryByRole("button", { name: "common.tags: memo.layout-list" })).not.toBeInTheDocument();
expect(screen.queryByRole("menuitemcheckbox")).not.toBeInTheDocument();
});
fireEvent.click(treeButton);
expect(treeButton).toHaveClass(...SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES.split(" "));
expect(listButton).not.toHaveClass(...SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES.split(" "));
expect(listButton).toHaveClass("text-muted-foreground/65");
it("switches layouts from the menu, closes it, and persists the preference", async () => {
const onSelect = vi.fn();
render(
<MemoryRouter>
<MemoFilterProvider>
<TagsSection tagCount={{ a: 2, "a/b": 1 }} scope="home" onSelect={onSelect} />
</MemoFilterProvider>
</MemoryRouter>,
);
const trigger = screen.getByRole("button", { name: "common.tags: common.more" });
fireEvent.click(trigger);
const treeMode = await screen.findByRole("menuitemcheckbox", { name: "common.tree-mode" });
expect(treeMode).toHaveAttribute("aria-checked", "false");
fireEvent.click(treeMode);
expect(screen.getByRole("tree")).toBeInTheDocument();
expect(localStorage.getItem("tag-view-as-tree")).toBe("true");
await waitFor(() => expect(screen.queryByRole("menuitemcheckbox")).not.toBeInTheDocument());
await waitFor(() => expect(trigger).toHaveFocus());
fireEvent.click(trigger);
const checkedTreeMode = await screen.findByRole("menuitemcheckbox", { name: "common.tree-mode" });
expect(checkedTreeMode).toHaveAttribute("aria-checked", "true");
fireEvent.click(checkedTreeMode);
expect(screen.queryByRole("tree")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "#a/b, setting.tags.used-count" })).toBeInTheDocument();
expect(localStorage.getItem("tag-view-as-tree")).toBe("false");
expect(onSelect).not.toHaveBeenCalled();
});
it("reflects an existing tree preference when opening the menu", async () => {
localStorage.setItem("tag-view-as-tree", "true");
render(
<MemoryRouter>
<MemoFilterProvider>
<TagsSection tagCount={{ a: 2, "a/b": 1 }} scope="home" />
</MemoFilterProvider>
</MemoryRouter>,
);
expect(screen.getByRole("tree")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "common.tags: common.more" }));
expect(await screen.findByRole("menuitemcheckbox", { name: "common.tree-mode" })).toHaveAttribute("aria-checked", "true");
});
it("keeps flat rows on the shared row grammar with a trailing count rail", () => {