From 5ad4aea56583455f641073dd88aa54c05f56ecfe Mon Sep 17 00:00:00 2001 From: johnnyjoygh Date: Sat, 5 Sep 2026 10:17:19 +0800 Subject: [PATCH] chore(sidebar): move tag layout toggle into a menu --- web/src/components/AppSidebar/TagsSection.tsx | 75 ++++++++++++------- web/src/locales/en-GB.json | 2 +- web/src/locales/en.json | 2 +- web/src/locales/zh-Hans.json | 2 +- web/tests/tags-section.test.tsx | 74 ++++++++++++------ 5 files changed, 103 insertions(+), 52 deletions(-) diff --git a/web/src/components/AppSidebar/TagsSection.tsx b/web/src/components/AppSidebar/TagsSection.tsx index bf5bff4f..a6c8e848 100644 --- a/web/src/components/AppSidebar/TagsSection.tsx +++ b/web/src/components/AppSidebar/TagsSection.tsx @@ -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; @@ -100,28 +105,42 @@ const TagsSection = ({ tagCount, onSelect, scope }: Props) => { - - - + + + }> + + } + > + + + + {t("common.more")} + + + + {t("common.tags")} + + + {t("common.tree-mode")} + + + + } > {treeMode ? ( diff --git a/web/src/locales/en-GB.json b/web/src/locales/en-GB.json index 6d878d40..7d443bea 100644 --- a/web/src/locales/en-GB.json +++ b/web/src/locales/en-GB.json @@ -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", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 3b0df8b2..12df83bd 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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", diff --git a/web/src/locales/zh-Hans.json b/web/src/locales/zh-Hans.json index 0fda0def..54df922c 100644 --- a/web/src/locales/zh-Hans.json +++ b/web/src/locales/zh-Hans.json @@ -147,7 +147,7 @@ "tasks": "任务", "title": "标题", "today": "今天", - "tree-mode": "树模式", + "tree-mode": "树形显示", "type": "类型", "unlink": "解绑", "unpin": "取消置顶", diff --git a/web/tests/tags-section.test.tsx b/web/tests/tags-section.test.tsx index 658bc2eb..358e3152 100644 --- a/web/tests/tags-section.test.tsx +++ b/web/tests/tags-section.test.tsx @@ -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( + + + + + , + ); + + 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( + + + + + , + ); + + 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", () => {