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.
This commit is contained in:
boojack 2026-08-31 23:40:47 +08:00
parent 5b2bd74bdb
commit 04be0d9ee6
9 changed files with 357 additions and 208 deletions

View file

@ -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<MemoView>();
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 (
<SidebarSection
label={t("common.views")}
action={
!manageActive && (
<div className="flex items-center gap-0.5">
<MemoDisplaySettingMenu />
<Button
variant="ghost"
size="icon-sm"
className={SIDEBAR_SECTION_ACTION_BUTTON_CLASSES}
onClick={handleCreate}
aria-label={t("common.create")}
>
<PlusIcon className={SIDEBAR_SECTION_ACTION_ICON_CLASSES} strokeWidth={1.8} />
</Button>
</div>
)
}
>
<SidebarRow
active={!manageActive && selectedMemoView === BUILTIN_TASKS_VIEW_ID}
label={t("common.tasks")}
onClick={() => handleView(BUILTIN_TASKS_VIEW_ID)}
/>
{memoViews.map((memoView) => {
const id = getMemoViewId(memoView.name);
const active = !manageActive && selectedMemoView === id;
return (
<div key={memoView.name} className={cn(SIDEBAR_ROW_CLASSES, "group/view", sidebarRowStateClasses(active))}>
<button
type="button"
onClick={() => handleView(id)}
aria-pressed={active || undefined}
className="flex h-full min-w-0 flex-1 items-center gap-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<span className="min-w-0 flex-1 truncate">{memoView.title}</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger
nativeButton={false}
render={
<span
role="button"
tabIndex={0}
aria-label={`${t("common.edit")} ${memoView.title}`}
className="-mr-1 flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-opacity hover:bg-background/70 md:opacity-0 md:group-hover/view:opacity-100 md:focus-visible:opacity-100 data-popup-open:opacity-100"
/>
}
>
<MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={2} size="sm">
<DropdownMenuItem
onClick={() => {
navigate(ROUTES.VIEWS, { state: { memoView } });
setMobileOpen(false);
}}
>
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={() => setDeleteTarget(memoView)}>
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
{manageActive && <SidebarRow active icon={MoreHorizontalIcon} label={t("common.manage")} />}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !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"
/>
</SidebarSection>
);
};
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))}
>
<section.icon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
<SidebarRowIconSlot icon={section.icon} />
<span className="truncate">{t(section.labelKey)}</span>
</Link>
));

View file

@ -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 }) => (
<span className={SIDEBAR_ROW_SLOT_CLASSES} aria-hidden="true">
<Icon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
</span>
);
/** 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 && <Icon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />}
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
{count != null && count > 0 && <span className={SIDEBAR_ROW_COUNT_CLASSES}>{count}</span>}
{Icon && <SidebarRowIconSlot icon={Icon} />}
<span className="min-w-0 flex-1 truncate text-start">{label}</span>
{count != null && count > 0 && <span className={SIDEBAR_ROW_COUNT_RAIL_CLASSES}>{count}</span>}
{trailing}
</button>
);

View file

@ -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<HTMLSpanElement, { tag: string }>(({ tag }, ref) => {
const segments = tag.split("/");
return (
<span ref={ref} className="min-w-0 flex-1 truncate text-left">
<span ref={ref} className="min-w-0 flex-1 truncate text-start">
{segments.map((segment, index) => (
<span key={`${segment}-${index}`}>
{index > 0 && <span className="px-0.5 text-muted-foreground/40">/</span>}
@ -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<HTMLSpanElement>(`#${tag}`);
return (
<button
type="button"
aria-label={ariaLabel}
aria-pressed={active || undefined}
title={title}
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(active))}
onClick={onClick}
>
<HashIcon aria-hidden="true" className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
{/* Same leading slot as the tree, so the # marks hold their line when switching modes. */}
<SidebarRowIconSlot icon={HashIcon} />
<TagPath ref={ref} tag={tag} />
<span className={SIDEBAR_ROW_COUNT_CLASSES}>{amount}</span>
<span className={SIDEBAR_ROW_COUNT_RAIL_CLASSES}>{amount}</span>
</button>
);
};
@ -128,7 +132,14 @@ const TagsSection = ({ tagCount, onSelect, navigationTarget, scope }: Props) =>
) : (
<>
{tags.map(([tag, amount]) => (
<FlatTagRow key={tag} tag={tag} amount={amount} active={activeTags.has(tag)} onClick={() => handleTagClick(tag)} />
<FlatTagRow
key={tag}
tag={tag}
amount={amount}
active={activeTags.has(tag)}
ariaLabel={tagRowAriaLabel(t, tag, amount)}
onClick={() => handleTagClick(tag)}
/>
))}
</>
)}

View file

@ -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<MemoView>();
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 (
<SidebarSection
label={t("common.views")}
action={
!manageActive && (
<div className="flex items-center gap-0.5">
<MemoDisplaySettingMenu />
<Button
variant="ghost"
size="icon-sm"
className={SIDEBAR_SECTION_ACTION_BUTTON_CLASSES}
onClick={handleCreate}
aria-label={t("common.create")}
>
<PlusIcon className={SIDEBAR_SECTION_ACTION_ICON_CLASSES} strokeWidth={1.8} />
</Button>
</div>
)
}
>
<SidebarRow
active={!manageActive && selectedMemoView === BUILTIN_TASKS_VIEW_ID}
icon={SquareCheckIcon}
label={t("common.tasks")}
onClick={() => handleView(BUILTIN_TASKS_VIEW_ID)}
/>
{memoViews.map((memoView) => {
const id = getMemoViewId(memoView.name);
const active = !manageActive && selectedMemoView === id;
return (
<div key={memoView.name} className={cn(SIDEBAR_ROW_BOX_CLASSES, "group/view", sidebarRowStateClasses(active))}>
<button type="button" onClick={() => handleView(id)} aria-pressed={active || undefined} className={SIDEBAR_ROW_LABEL_CLASSES}>
<SidebarRowIconSlot icon={ParenthesesIcon} />
<span className="min-w-0 flex-1 truncate">{memoView.title}</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger
nativeButton={false}
render={
<span
role="button"
tabIndex={0}
aria-label={`${t("common.edit")} ${memoView.title}`}
className={VIEW_MENU_TRIGGER_CLASSES}
/>
}
>
<MoreHorizontalIcon className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={2} size="sm">
<DropdownMenuItem
onClick={() => {
navigate(ROUTES.VIEWS, { state: { memoView } });
setMobileOpen(false);
}}
>
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={() => setDeleteTarget(memoView)}>
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
{manageActive && <SidebarRow active icon={MoreHorizontalIcon} label={t("common.manage")} />}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !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"
/>
</SidebarSection>
);
};
export default ViewsSection;

View file

@ -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
>
<LinkIcon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
<span ref={ref} className="min-w-0 flex-1 truncate text-left">
<SidebarRowIconSlot icon={LinkIcon} />
<span ref={ref} className="min-w-0 flex-1 truncate text-start">
{snippet}
</span>
</Link>
@ -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",
)}
>
<Share2Icon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
<span className="min-w-0 flex-1 truncate text-left">{t("common.share")}</span>
<SidebarRowIconSlot icon={Share2Icon} />
<span className="min-w-0 flex-1 truncate text-start">{t("common.share")}</span>
<ChevronDownIcon className="size-3.5 shrink-0 opacity-55" strokeWidth={1.8} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={4} className="w-48">

View file

@ -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<typeof useTranslate>, 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 = () => <HashIcon aria-hidden="true" className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />;
const TagMark = ({ className }: { className?: string }) => (
<HashIcon aria-hidden="true" className={cn(SIDEBAR_ROW_ICON_CLASSES, className)} strokeWidth={1.8} />
);
const Chevron = ({ open }: { open: boolean }) => (
const Chevron = ({ open, className }: { open: boolean; className?: string }) => (
<ChevronRightIcon
aria-hidden="true"
className={cn("size-3.5 shrink-0 text-muted-foreground/70 transition-transform", open ? "rotate-90" : "rtl:rotate-180")}
className={cn("size-3.5 shrink-0 text-muted-foreground/70 transition-transform", open ? "rotate-90" : "rtl:rotate-180", className)}
strokeWidth={1.8}
/>
);
@ -104,6 +116,7 @@ const TagItem = ({ tag, depth, activeTag, expanded, onTagClick, onToggle }: TagI
const hasSubTags = tag.subTags.length > 0;
const open = hasSubTags && expanded.has(tag.text);
const { ref: labelRef, title } = useOverflowTitle<HTMLSpanElement>(isTag ? `#${tag.text}` : tag.text);
const tagLabel = tag.amount !== undefined ? tagRowAriaLabel(t, tag.text, tag.amount) : undefined;
return (
<div className="w-full min-w-0">
@ -114,57 +127,61 @@ const TagItem = ({ tag, depth, activeTag, expanded, onTagClick, onToggle }: TagI
aria-expanded={hasSubTags ? open : undefined}
className={cn(
SIDEBAR_ROW_BOX_CLASSES,
isTag ? sidebarRowStateClasses(isActive) : "font-medium text-muted-foreground/65",
isTag ? sidebarRowStateClasses(isActive) : STRUCTURAL_ROW_CLASSES,
isAncestorOfActiveTag && !isActive && "text-foreground/75",
)}
// Overrides the start half of the box's `px-2`, leaving the trailing 8px intact.
// Same 12px step the memo outline indents by.
style={{ paddingInlineStart: 8 + depth * INDENT_STEP }}
>
{isTag ? (
<button
type="button"
aria-pressed={isActive || undefined}
title={title}
className={cn("flex h-full min-w-0 flex-1 items-center gap-2 text-left", SIDEBAR_ROW_FOCUS_CLASSES)}
onClick={() => onTagClick(tag.text)}
>
<TagMark />
<span ref={labelRef} className="min-w-0 flex-1 truncate">
{tag.key}
</span>
<span className={SIDEBAR_ROW_COUNT_CLASSES}>{tag.amount}</span>
</button>
) : (
// A structural segment only exists to reach its children, so the whole row is the
// disclosure — one control, rather than a row and a chevron doing the same thing.
// Aligns with the tag rows below it: past the 15px mark plus the row's 8px gap.
<button
type="button"
aria-label={`${open ? t("common.collapse") : t("common.expand")} ${tag.text}`}
aria-expanded={open}
className={cn("flex h-full min-w-0 flex-1 items-center gap-2 text-left", SIDEBAR_ROW_FOCUS_CLASSES)}
onClick={() => onToggle(tag.text)}
>
<span ref={labelRef} className="min-w-0 flex-1 truncate ps-[23px]" title={title}>
{tag.key}
</span>
<Chevron open={open} />
</button>
)}
{isTag && hasSubTags && (
// A tag row's label filters, so its disclosure needs a control of its own.
// At rest the slot holds the row's # mark like any other tag; pointing at the row
// (or tabbing into it) swaps in the chevron, so branches only look different while
// the disclosure is actually reachable.
<button
type="button"
aria-label={`${open ? t("common.collapse") : t("common.expand")} #${tag.text}`}
aria-expanded={open}
className={cn(
"-mr-1 flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground/70 hover:bg-background/70 hover:text-foreground",
SIDEBAR_ROW_FOCUS_CLASSES,
)}
className={SIDEBAR_ROW_SLOT_BUTTON_CLASSES}
onClick={() => onToggle(tag.text)}
>
<TagMark className="group-hover:hidden group-has-[:focus-visible]:hidden" />
<Chevron open={open} className="hidden group-hover:block group-has-[:focus-visible]:block" />
</button>
)}
{isTag ? (
<button
type="button"
aria-label={tagLabel}
aria-pressed={isActive || undefined}
title={title}
className={SIDEBAR_ROW_LABEL_CLASSES}
onClick={() => onTagClick(tag.text)}
>
{!hasSubTags && <SidebarRowIconSlot icon={HashIcon} />}
<span ref={labelRef} className="min-w-0 flex-1 truncate">
{tag.key}
</span>
<span className={SIDEBAR_ROW_COUNT_RAIL_CLASSES}>{tag.amount}</span>
</button>
) : (
// A structural segment only exists to reach its children, so the whole row is the
// disclosure — one control, rather than a row and a chevron doing the same thing.
<button
type="button"
aria-label={`${open ? t("common.collapse") : t("common.expand")} ${tag.text}`}
aria-expanded={open}
className={SIDEBAR_ROW_LABEL_CLASSES}
onClick={() => onToggle(tag.text)}
>
<span className={SIDEBAR_ROW_SLOT_CLASSES} aria-hidden="true">
<Chevron open={open} />
</span>
<span ref={labelRef} className="min-w-0 flex-1 truncate" title={title}>
{tag.key}
</span>
{/* Empty count rail keeps structural labels truncating at the same edge as tag labels. */}
<span aria-hidden="true" className={SIDEBAR_ROW_COUNT_RAIL_CLASSES} />
</button>
)}
</div>

View file

@ -1,7 +1,11 @@
import { render, screen } from "@testing-library/react";
import { HashIcon } from "lucide-react";
import { describe, expect, it, vi } from "vitest";
import SidebarRow, { SIDEBAR_ROW_BOX_CLASSES } from "@/components/AppSidebar/SidebarRow";
import SidebarRow, {
SIDEBAR_ROW_BOX_CLASSES,
SIDEBAR_ROW_COUNT_RAIL_CLASSES,
SIDEBAR_ROW_SLOT_CLASSES,
} from "@/components/AppSidebar/SidebarRow";
import SidebarSection, { SIDEBAR_SECTION_CONTENT_CLASSES } from "@/components/AppSidebar/SidebarSection";
import TagTree from "@/components/TagTree";
@ -27,9 +31,14 @@ describe("sidebar row grammar", () => {
});
it("gives a nav row the shared row box", () => {
render(<SidebarRow icon={HashIcon} label="Tasks" />);
render(<SidebarRow icon={HashIcon} label="Tasks" count={3} />);
expect(screen.getByRole("button", { name: "Tasks" })).toHaveClass(...boxClasses);
const row = screen.getByRole("button", { name: "Tasks3" });
expect(row).toHaveClass(...boxClasses);
// Icon in the shared slot and count in the shared rail, so every list — nav rows,
// views, tags in both modes — keeps its icons and digits on the same vertical lines.
expect(row.firstElementChild).toHaveClass(...SIDEBAR_ROW_SLOT_CLASSES.split(" "));
expect(screen.getByText("3")).toHaveClass(...SIDEBAR_ROW_COUNT_RAIL_CLASSES.split(" "));
});
it("gives tag tree rows the same box as a nav row", () => {

View file

@ -1,5 +1,6 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SIDEBAR_ROW_COUNT_RAIL_CLASSES } from "@/components/AppSidebar/SidebarRow";
import TagTree from "@/components/TagTree";
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
@ -74,12 +75,58 @@ describe("TagTree rendering", () => {
expect(screen.queryByText("b")).not.toBeInTheDocument();
});
it("puts disclosures before labels and keeps counts in a fixed trailing rail", () => {
const onTagClick = vi.fn();
render(
<TagTree
tagAmounts={[
["a", 12],
["a/b", 1],
["getting-started", 3],
]}
scope="home"
onTagClick={onTagClick}
/>,
);
const branch = screen.getByText("a").closest('[role="treeitem"]') as HTMLElement;
const branchLabelButton = screen.getByText("a").closest("button") as HTMLButtonElement;
const disclosure = screen.getByLabelText("common.expand #a");
const branchCount = within(branch).getByText("12");
expect(disclosure.compareDocumentPosition(branchLabelButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(branchLabelButton).toHaveClass("text-start");
expect(branchLabelButton.querySelector("svg")).not.toBeInTheDocument();
// A branch reads as a plain tag at rest: the slot holds the # mark and only swaps in the
// chevron while the row is hovered or holds focus.
const [restMark, hoverChevron] = Array.from(disclosure.querySelectorAll("svg"));
expect(restMark).toHaveClass("group-hover:hidden", "group-has-[:focus-visible]:hidden");
expect(hoverChevron).toHaveClass("hidden", "group-hover:block", "group-has-[:focus-visible]:block");
fireEvent.click(disclosure);
expect(onTagClick).not.toHaveBeenCalled();
fireEvent.click(branchCount);
expect(onTagClick).toHaveBeenCalledWith("a");
const leaf = screen.getByText("getting-started").closest('[role="treeitem"]') as HTMLElement;
const leafCount = within(leaf).getByText("3");
expect(within(leaf).getAllByRole("button")).toHaveLength(1);
expect(screen.getByText("getting-started").closest("button")?.querySelector("svg")).toBeInTheDocument();
for (const count of [branchCount, leafCount]) {
expect(count).toHaveClass(...SIDEBAR_ROW_COUNT_RAIL_CLASSES.split(" "));
}
});
it("gives a structural row one control that both labels and toggles it", () => {
render(<TagTree tagAmounts={[["personal/travel/singapore", 1]]} scope="home" onTagClick={vi.fn()} />);
// The row is the disclosure, so it is the only tab stop on that line.
const personalDisclosure = screen.getByLabelText(/common\.(expand|collapse) personal$/);
expect(personalDisclosure).toHaveAttribute("aria-expanded", "false");
const personalLabel = screen.getByText("personal");
const chevron = personalDisclosure.querySelector("svg") as SVGElement;
expect(chevron.compareDocumentPosition(personalLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
fireEvent.click(personalDisclosure);
expect(screen.getByText("travel")).toBeVisible();

View file

@ -1,6 +1,7 @@
import { fireEvent, render, screen } 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,
@ -45,4 +46,26 @@ describe("TagsSection", () => {
expect(listButton).not.toHaveClass(...SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES.split(" "));
expect(listButton).toHaveClass("text-muted-foreground/65");
});
it("keeps flat rows on the shared row grammar with a trailing count rail", () => {
render(
<MemoryRouter>
<MemoFilterProvider>
<TagsSection tagCount={{ alpha: 2, "a/very-long-tag-path": 1 }} scope="home" />
</MemoFilterProvider>
</MemoryRouter>,
);
const alpha = screen.getByText("alpha");
const alphaButton = alpha.closest("button") as HTMLButtonElement;
const path = alpha.parentElement?.parentElement as HTMLSpanElement;
const count = screen.getByText("2");
expect(alphaButton).toHaveClass(...SIDEBAR_ROW_BOX_CLASSES.split(" "));
// The # sits in the same fixed slot the tree uses, so switching modes keeps it in place.
expect(alphaButton.firstElementChild).toHaveClass(...SIDEBAR_ROW_SLOT_CLASSES.split(" "));
expect(path).toHaveClass("truncate", "text-start");
expect(path).not.toContainElement(count);
expect(count).toHaveClass(...SIDEBAR_ROW_COUNT_RAIL_CLASSES.split(" "));
});
});