From 04be0d9ee61809f099a7a4e1884ba60f8a82a2d9 Mon Sep 17 00:00:00 2001 From: boojack Date: Mon, 31 Aug 2026 23:40:47 +0800 Subject: [PATCH] chore(web): unify sidebar rows on a shared row grammar Align every sidebar list (tags in both modes, views, settings, memo detail) on shared slot and count-rail primitives so icons, labels, and digits sit on the same vertical lines. Tree branches show their # mark at rest and swap in the chevron on hover or keyboard focus; views rows gain glyphs and move onto the same grammar, with ViewsSection extracted to its own module. --- web/src/components/AppSidebar/AppSidebar.tsx | 152 +----------------- web/src/components/AppSidebar/SidebarRow.tsx | 36 ++++- web/src/components/AppSidebar/TagsSection.tsx | 25 ++- .../components/AppSidebar/ViewsSection.tsx | 152 ++++++++++++++++++ .../MemoDetailSidebar/MemoDetailSidebar.tsx | 10 +- web/src/components/TagTree.tsx | 103 +++++++----- web/tests/sidebar-row-grammar.test.tsx | 15 +- web/tests/tag-tree-render.test.tsx | 49 +++++- web/tests/tags-section.test.tsx | 23 +++ 9 files changed, 357 insertions(+), 208 deletions(-) create mode 100644 web/src/components/AppSidebar/ViewsSection.tsx diff --git a/web/src/components/AppSidebar/AppSidebar.tsx b/web/src/components/AppSidebar/AppSidebar.tsx index 047568c6..90c2cc4f 100644 --- a/web/src/components/AppSidebar/AppSidebar.tsx +++ b/web/src/components/AppSidebar/AppSidebar.tsx @@ -1,5 +1,4 @@ import { useDirection } from "@base-ui/react/direction-provider"; -import { useQueryClient } from "@tanstack/react-query"; import { ArchiveIcon, ArrowRightIcon, @@ -16,20 +15,15 @@ import { type LucideIcon, MapIcon, MenuIcon, - MoreHorizontalIcon, PaperclipIcon, - PlusIcon, SearchIcon, SquarePenIcon, Trash2Icon, UserRoundIcon, } from "lucide-react"; -import { type ReactNode, useEffect, useState } from "react"; -import toast from "react-hot-toast"; +import { type ReactNode, useEffect } from "react"; import { Link, matchPath, useLocation, useNavigate, useSearchParams } from "react-router-dom"; -import ConfirmDialog from "@/components/ConfirmDialog"; import { MemoDetailSidebar } from "@/components/MemoDetailSidebar"; -import MemoDisplaySettingMenu from "@/components/MemoDisplaySettingMenu"; import { DEFAULT_SETTING_SECTION, SETTINGS_SECTIONS } from "@/components/Settings/settingSections"; import StatisticsView from "@/components/StatisticsView"; import UserMenu from "@/components/UserMenu"; @@ -37,7 +31,6 @@ import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { memoViewServiceClient } from "@/connect"; import { type AttachmentSection, type InboxFilter, useAppSidebar } from "@/contexts/AppSidebarContext"; import { useAuth } from "@/contexts/AuthContext"; import { useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext"; @@ -48,32 +41,20 @@ import { useAttachmentLibraryStats } from "@/hooks/useAttachmentLibrary"; import useCurrentUser from "@/hooks/useCurrentUser"; import { type MemoStatsContext, useFilteredMemoStats } from "@/hooks/useFilteredMemoStats"; import useMediaQuery from "@/hooks/useMediaQuery"; -import { useMemoViews, useNotifications, userKeys, useUser } from "@/hooks/useUserQueries"; -import { handleError } from "@/lib/error"; -import { - BUILTIN_TASKS_VIEW_ID, - getMemoScopePath, - getMemoViewId, - isMemoScopeRoute, - type PrimaryMemoScope, - resolveMemoScope, -} from "@/lib/memo-views"; +import { useNotifications, useUser } from "@/hooks/useUserQueries"; +import { getMemoScopePath, isMemoScopeRoute, type PrimaryMemoScope, resolveMemoScope } from "@/lib/memo-views"; import { cn } from "@/lib/utils"; import { ROUTES } from "@/router/routes"; import { State } from "@/types/proto/api/v1/common_pb"; -import type { MemoView } from "@/types/proto/api/v1/memo_view_service_pb"; import { User_Role, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; import MemosLogo from "../MemosLogo"; import { getSidebarRouteKind, routeSupportsCollectionScope } from "./routes"; -import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_FOCUS_CLASSES, SIDEBAR_ROW_ICON_CLASSES, sidebarRowStateClasses } from "./SidebarRow"; -import SidebarSection, { - SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, - SIDEBAR_SECTION_ACTION_ICON_CLASSES, - SIDEBAR_SECTION_STACK_CLASSES, -} from "./SidebarSection"; +import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_FOCUS_CLASSES, SidebarRowIconSlot, sidebarRowStateClasses } from "./SidebarRow"; +import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "./SidebarSection"; import SpaceSwitcher from "./SpaceSwitcher"; import TagsSection from "./TagsSection"; +import ViewsSection from "./ViewsSection"; const SIDEBAR_HORIZONTAL_PADDING = "px-3"; const SIDEBAR_HEADER_ACTION_CLASSES = "size-7 shrink-0 rounded-md text-muted-foreground hover:text-foreground"; @@ -103,125 +84,6 @@ const NewMemoAction = ({ onClick }: { onClick: () => void }) => { ); }; -const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => { - const t = useTranslate(); - const navigate = useNavigate(); - const currentUser = useCurrentUser(); - const queryClient = useQueryClient(); - const { data: memoViews = [] } = useMemoViews(currentUser?.name); - const { memoView: selectedMemoView, setMemoView } = useMemoFilterContext(); - const { setMobileOpen } = useAppSidebar(); - const [deleteTarget, setDeleteTarget] = useState(); - const location = useLocation(); - - const handleView = (viewId: string) => { - setMemoView(selectedMemoView === viewId ? undefined : viewId); - if (!isMemoScopeRoute(location.pathname)) navigate(ROUTES.HOME); - setMobileOpen(false); - }; - - const handleCreate = () => { - navigate(ROUTES.VIEWS, { state: { openCreate: true } }); - setMobileOpen(false); - }; - - const handleDelete = async () => { - if (!deleteTarget) return; - try { - await memoViewServiceClient.deleteMemoView({ name: deleteTarget.name }); - await queryClient.invalidateQueries({ queryKey: userKeys.memoViews(currentUser?.name) }); - if (selectedMemoView === getMemoViewId(deleteTarget.name)) setMemoView(undefined); - toast.success(t("setting.memo-view.delete-success", { title: deleteTarget.title })); - } catch (error: unknown) { - handleError(error, toast.error, { context: "Delete memo view" }); - } finally { - setDeleteTarget(undefined); - } - }; - - return ( - - - - - ) - } - > - handleView(BUILTIN_TASKS_VIEW_ID)} - /> - {memoViews.map((memoView) => { - const id = getMemoViewId(memoView.name); - const active = !manageActive && selectedMemoView === id; - return ( -
- - - - } - > - - - - { - navigate(ROUTES.VIEWS, { state: { memoView } }); - setMobileOpen(false); - }} - > - {t("common.edit")} - - setDeleteTarget(memoView)}> - {t("common.delete")} - - - -
- ); - })} - {manageActive && } - !open && setDeleteTarget(undefined)} - title={t("setting.memo-view.delete-confirm", { title: deleteTarget?.title ?? "" })} - confirmLabel={t("common.delete")} - cancelLabel={t("common.cancel")} - onConfirm={handleDelete} - confirmVariant="destructive" - /> -
- ); -}; - const ProfileMode = () => { const t = useTranslate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -389,7 +251,7 @@ const SettingsSidebarContent = () => { onClick={() => setMobileOpen(false)} className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(currentSection === section.key))} > - + {t(section.labelKey)} )); diff --git a/web/src/components/AppSidebar/SidebarRow.tsx b/web/src/components/AppSidebar/SidebarRow.tsx index f22644ba..3decca98 100644 --- a/web/src/components/AppSidebar/SidebarRow.tsx +++ b/web/src/components/AppSidebar/SidebarRow.tsx @@ -17,7 +17,35 @@ export const SIDEBAR_ROW_FOCUS_CLASSES = "focus-visible:outline-none focus-visib export const SIDEBAR_ROW_CLASSES = `${SIDEBAR_ROW_BOX_CLASSES} ${SIDEBAR_ROW_FOCUS_CLASSES}`; export const SIDEBAR_ROW_ICON_CLASSES = "size-[15px] shrink-0 opacity-75"; -export const SIDEBAR_ROW_COUNT_CLASSES = "text-2xs tabular-nums text-muted-foreground/60"; +const SIDEBAR_ROW_COUNT_CLASSES = "text-2xs tabular-nums text-muted-foreground/60"; + +/** + * The focusable body of a split row — rows whose box is a wrapper carrying other controls + * put their label layout and focus ring here. The gap must stay equal to the box's own + * `gap-2` or slot alignment breaks between one-control and split rows. + */ +export const SIDEBAR_ROW_LABEL_CLASSES = `flex h-full min-w-0 flex-1 items-center gap-2 text-start ${SIDEBAR_ROW_FOCUS_CLASSES}`; + +/** + * Fixed leading slot: icons and disclosures share one vertical line across every list and + * mode. The box is 24px so a disclosure gets a real hit target, but the icon inside must + * line up with the bare 15px icons of plain rows — the negative margins cancel the extra + * width on both sides so slotted rows keep the same icon and label positions. + */ +export const SIDEBAR_ROW_SLOT_CLASSES = "-mx-1 flex size-6 shrink-0 items-center justify-center"; + +/** A slot that is itself a control (disclosure, row menu): same box plus the hover chip. */ +export const SIDEBAR_ROW_SLOT_BUTTON_CLASSES = `${SIDEBAR_ROW_SLOT_CLASSES} rounded hover:bg-sidebar-accent ${SIDEBAR_ROW_FOCUS_CLASSES}`; + +/** Trailing rail for counts, wide enough that digits align down the list. */ +export const SIDEBAR_ROW_COUNT_RAIL_CLASSES = `${SIDEBAR_ROW_COUNT_CLASSES} min-w-[3ch] shrink-0 text-end`; + +/** The standard icon-in-slot pairing, so every list renders its glyphs identically. */ +export const SidebarRowIconSlot = ({ icon: Icon }: { icon: LucideIcon }) => ( + +); /** Idle and selected colouring for a row box, kept in one place so lists cannot drift apart. */ export const sidebarRowStateClasses = (active?: boolean) => @@ -41,9 +69,9 @@ const SidebarRow = ({ active, icon: Icon, label, count, onClick, trailing }: Pro aria-pressed={active || undefined} className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(active))} > - {Icon && } - {label} - {count != null && count > 0 && {count}} + {Icon && } + {label} + {count != null && count > 0 && {count}} {trailing} ); diff --git a/web/src/components/AppSidebar/TagsSection.tsx b/web/src/components/AppSidebar/TagsSection.tsx index f7cee5d0..9bfa2133 100644 --- a/web/src/components/AppSidebar/TagsSection.tsx +++ b/web/src/components/AppSidebar/TagsSection.tsx @@ -6,8 +6,8 @@ import { replaceFiltersByFactor, stringifyFilters, useMemoFilterContext } from " import { useLocalStorage, useOverflowTitle } from "@/hooks"; import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; -import TagTree from "../TagTree"; -import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_COUNT_CLASSES, SIDEBAR_ROW_ICON_CLASSES, sidebarRowStateClasses } from "./SidebarRow"; +import TagTree, { tagRowAriaLabel } from "../TagTree"; +import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_COUNT_RAIL_CLASSES, SidebarRowIconSlot, sidebarRowStateClasses } from "./SidebarRow"; import SidebarSection, { SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES, SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, @@ -27,7 +27,7 @@ const TagPath = forwardRef(({ tag }, ref) => { const segments = tag.split("/"); return ( - + {segments.map((segment, index) => ( {index > 0 && /} @@ -43,23 +43,27 @@ interface FlatTagRowProps { tag: string; amount: number; active: boolean; + /** Computed by the parent, which already holds the translator — rows stay subscription-free. */ + ariaLabel: string; onClick: () => void; } -const FlatTagRow = ({ tag, amount, active, onClick }: FlatTagRowProps) => { +const FlatTagRow = ({ tag, amount, active, ariaLabel, onClick }: FlatTagRowProps) => { const { ref, title } = useOverflowTitle(`#${tag}`); return ( ); }; @@ -128,7 +132,14 @@ const TagsSection = ({ tagCount, onSelect, navigationTarget, scope }: Props) => ) : ( <> {tags.map(([tag, amount]) => ( - handleTagClick(tag)} /> + handleTagClick(tag)} + /> ))} )} diff --git a/web/src/components/AppSidebar/ViewsSection.tsx b/web/src/components/AppSidebar/ViewsSection.tsx new file mode 100644 index 00000000..fef38330 --- /dev/null +++ b/web/src/components/AppSidebar/ViewsSection.tsx @@ -0,0 +1,152 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { MoreHorizontalIcon, ParenthesesIcon, PlusIcon, SquareCheckIcon } from "lucide-react"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { useLocation, useNavigate } from "react-router-dom"; +import ConfirmDialog from "@/components/ConfirmDialog"; +import MemoDisplaySettingMenu from "@/components/MemoDisplaySettingMenu"; +import { Button } from "@/components/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { memoViewServiceClient } from "@/connect"; +import { useAppSidebar } from "@/contexts/AppSidebarContext"; +import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; +import useCurrentUser from "@/hooks/useCurrentUser"; +import { useMemoViews, userKeys } from "@/hooks/useUserQueries"; +import { handleError } from "@/lib/error"; +import { BUILTIN_TASKS_VIEW_ID, getMemoViewId, isMemoScopeRoute } from "@/lib/memo-views"; +import { cn } from "@/lib/utils"; +import { ROUTES } from "@/router/routes"; +import type { MemoView } from "@/types/proto/api/v1/memo_view_service_pb"; +import { useTranslate } from "@/utils/i18n"; +import SidebarRow, { + SIDEBAR_ROW_BOX_CLASSES, + SIDEBAR_ROW_LABEL_CLASSES, + SIDEBAR_ROW_SLOT_BUTTON_CLASSES, + SidebarRowIconSlot, + sidebarRowStateClasses, +} from "./SidebarRow"; +import SidebarSection, { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "./SidebarSection"; + +/** The row's ⋯ menu: a trailing slot control that stays hidden until the row is engaged. */ +const VIEW_MENU_TRIGGER_CLASSES = cn( + SIDEBAR_ROW_SLOT_BUTTON_CLASSES, + "text-muted-foreground transition-opacity md:opacity-0 md:group-hover/view:opacity-100 md:focus-visible:opacity-100 data-popup-open:opacity-100", +); + +const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => { + const t = useTranslate(); + const navigate = useNavigate(); + const currentUser = useCurrentUser(); + const queryClient = useQueryClient(); + const { data: memoViews = [] } = useMemoViews(currentUser?.name); + const { memoView: selectedMemoView, setMemoView } = useMemoFilterContext(); + const { setMobileOpen } = useAppSidebar(); + const [deleteTarget, setDeleteTarget] = useState(); + const location = useLocation(); + + const handleView = (viewId: string) => { + setMemoView(selectedMemoView === viewId ? undefined : viewId); + if (!isMemoScopeRoute(location.pathname)) navigate(ROUTES.HOME); + setMobileOpen(false); + }; + + const handleCreate = () => { + navigate(ROUTES.VIEWS, { state: { openCreate: true } }); + setMobileOpen(false); + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + try { + await memoViewServiceClient.deleteMemoView({ name: deleteTarget.name }); + await queryClient.invalidateQueries({ queryKey: userKeys.memoViews(currentUser?.name) }); + if (selectedMemoView === getMemoViewId(deleteTarget.name)) setMemoView(undefined); + toast.success(t("setting.memo-view.delete-success", { title: deleteTarget.title })); + } catch (error: unknown) { + handleError(error, toast.error, { context: "Delete memo view" }); + } finally { + setDeleteTarget(undefined); + } + }; + + return ( + + + + + ) + } + > + handleView(BUILTIN_TASKS_VIEW_ID)} + /> + {memoViews.map((memoView) => { + const id = getMemoViewId(memoView.name); + const active = !manageActive && selectedMemoView === id; + return ( +
+ + + + } + > + + + + { + navigate(ROUTES.VIEWS, { state: { memoView } }); + setMobileOpen(false); + }} + > + {t("common.edit")} + + setDeleteTarget(memoView)}> + {t("common.delete")} + + + +
+ ); + })} + {manageActive && } + !open && setDeleteTarget(undefined)} + title={t("setting.memo-view.delete-confirm", { title: deleteTarget?.title ?? "" })} + confirmLabel={t("common.delete")} + cancelLabel={t("common.cancel")} + onConfirm={handleDelete} + confirmVariant="destructive" + /> +
+ ); +}; + +export default ViewsSection; diff --git a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx index fa2b17c6..31e26033 100644 --- a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx +++ b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx @@ -3,7 +3,7 @@ import { BookmarkCheckIcon, BookmarkIcon, ChevronDownIcon, ImageIcon, LinkIcon, import { useMemo, useState } from "react"; import toast from "react-hot-toast"; import { Link } from "react-router-dom"; -import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_ICON_CLASSES } from "@/components/AppSidebar/SidebarRow"; +import SidebarRow, { SIDEBAR_ROW_CLASSES, SidebarRowIconSlot } from "@/components/AppSidebar/SidebarRow"; import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "@/components/AppSidebar/SidebarSection"; import { extractHeadings } from "@/components/MemoContent/pipeline"; import { getRelationBuckets, getRelationMemo } from "@/components/MemoMetadata/Relation/relationHelpers"; @@ -56,8 +56,8 @@ const BacklinkRow = ({ title={title} viewTransition > - - + + {snippet} @@ -125,8 +125,8 @@ const MemoDetailSidebar = ({ memo, parentPage, parentScope, className, onShareIm "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground data-popup-open:bg-sidebar-accent/65 data-popup-open:text-foreground", )} > - - {t("common.share")} + + {t("common.share")} diff --git a/web/src/components/TagTree.tsx b/web/src/components/TagTree.tsx index 7319ef85..e52f8df6 100644 --- a/web/src/components/TagTree.tsx +++ b/web/src/components/TagTree.tsx @@ -2,9 +2,12 @@ import { ChevronRightIcon, HashIcon } from "lucide-react"; import { useEffect, useMemo } from "react"; import { SIDEBAR_ROW_BOX_CLASSES, - SIDEBAR_ROW_COUNT_CLASSES, - SIDEBAR_ROW_FOCUS_CLASSES, + SIDEBAR_ROW_COUNT_RAIL_CLASSES, SIDEBAR_ROW_ICON_CLASSES, + SIDEBAR_ROW_LABEL_CLASSES, + SIDEBAR_ROW_SLOT_BUTTON_CLASSES, + SIDEBAR_ROW_SLOT_CLASSES, + SidebarRowIconSlot, sidebarRowStateClasses, } from "@/components/AppSidebar/SidebarRow"; import { useLocalStorage, useOverflowTitle } from "@/hooks"; @@ -37,6 +40,13 @@ interface TagTreeExpansion { const EMPTY_EXPANSION: TagTreeExpansion = { expanded: [] }; +// A structural row toggles like any other, so it hovers like one too — just quieter at rest. +const STRUCTURAL_ROW_CLASSES = cn(sidebarRowStateClasses(false), "font-medium text-muted-foreground/65"); + +/** One announcement for a tag row in either layout, so tree and flat mode never drift apart. */ +export const tagRowAriaLabel = (t: ReturnType, tag: string, amount: number) => + `#${tag}, ${t("setting.tags.used-count", { count: amount })}`; + export const buildTagTree = (tagAmounts: [tag: string, amount: number][]) => { const root: TagTreeNode = { key: "", @@ -77,12 +87,14 @@ const parentPathsOf = (tag: string): string[] => { return segments.slice(0, -1).map((_, index) => segments.slice(0, index + 1).join("/")); }; -const TagMark = () =>