chore(sidebar): improve view and tag controls

- expose display settings from the Views header and refine layout controls
- add explicit tag list/tree navigation with accessible expansion behavior
- cover view persistence, menu behavior, and tag tree construction
This commit is contained in:
johnnyjoygh 2026-08-05 23:12:49 +08:00
parent d14a542002
commit 6fbc81337f
14 changed files with 569 additions and 332 deletions

View file

@ -28,6 +28,7 @@ import toast from "react-hot-toast";
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 { SETTINGS_SECTIONS } from "@/components/Settings/settingSections";
import StatisticsView from "@/components/StatisticsView";
import UserMenu from "@/components/UserMenu";
@ -101,15 +102,18 @@ const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => {
<SidebarSectionHeader
action={
!manageActive && (
<Button
variant="ghost"
size="icon-sm"
className="size-5 rounded text-muted-foreground"
onClick={handleCreate}
aria-label={t("common.create")}
>
<PlusIcon className="size-3.5" />
</Button>
<div className="flex items-center gap-0.5">
<MemoDisplaySettingMenu />
<Button
variant="ghost"
size="icon-sm"
className="size-5 rounded text-muted-foreground"
onClick={handleCreate}
aria-label={t("common.create")}
>
<PlusIcon className="size-3.5" />
</Button>
</div>
)
}
>

View file

@ -3,7 +3,7 @@ import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export const SIDEBAR_ROW_CLASSES =
"group flex h-[30px] w-full min-w-0 items-center gap-2 rounded-md px-2 text-[13px] leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
"group flex h-[30px] w-full min-w-0 items-center gap-2 rounded-md px-2 text-[13px] leading-[18px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
export const SIDEBAR_ROW_ICON_CLASSES = "size-[15px] shrink-0 opacity-75";
export const SIDEBAR_ROW_COUNT_CLASSES = "text-[11px] tabular-nums text-muted-foreground/60";

View file

@ -1,12 +1,10 @@
import { HashIcon, MoreHorizontalIcon } from "lucide-react";
import { useMemo } from "react";
import { Switch } from "@/components/ui/switch";
import { ListIcon, ListTreeIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useLocalStorage } from "@/hooks";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import TagTree from "../TagTree";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import SidebarRow from "./SidebarRow";
import SidebarSectionHeader from "./SidebarSectionHeader";
interface Props {
@ -14,13 +12,66 @@ interface Props {
onSelect?: () => void;
}
const TagPath = ({ tag }: { tag: string }) => {
const segments = tag.split("/");
return (
<span className="min-w-0 truncate text-left">
{segments.map((segment, index) => (
<span key={`${segment}-${index}`}>
{index > 0 && <span className="px-0.5 font-mono text-[10.5px] text-muted-foreground/45">/</span>}
<span className={index === segments.length - 1 ? "text-current" : "text-muted-foreground/75"}>{segment}</span>
</span>
))}
</span>
);
};
const getExpandableTagPaths = (tags: [tag: string, amount: number][]) => {
const paths = new Set<string>();
for (const [tag] of tags) {
const segments = tag.split("/");
for (let index = 1; index < segments.length; index++) {
paths.add(segments.slice(0, index).join("/"));
}
}
return paths;
};
const getParentTagPaths = (tag: string) => {
const segments = tag.split("/");
return segments.slice(0, -1).map((_, index) => segments.slice(0, index + 1).join("/"));
};
const TagsSection = ({ tagCount, onSelect }: Props) => {
const t = useTranslate();
const { getFiltersByFactor, addFilter, removeFilter } = useMemoFilterContext();
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false);
const [expandedTagPaths, setExpandedTagPaths] = useState<Set<string>>(() => new Set());
const activeTags = new Set(getFiltersByFactor("tagSearch").map((filter) => filter.value));
const activeTag = activeTags.values().next().value as string | undefined;
const tags = useMemo(() => Object.entries(tagCount).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])), [tagCount]);
const expandableTagPaths = useMemo(() => getExpandableTagPaths(tags), [tags]);
useEffect(() => {
setExpandedTagPaths((current) => {
const next = new Set(Array.from(current).filter((path) => expandableTagPaths.has(path)));
return next.size === current.size ? current : next;
});
}, [expandableTagPaths]);
useEffect(() => {
if (!treeMode || !activeTag) {
return;
}
setExpandedTagPaths((current) => {
const next = new Set([...current, ...getParentTagPaths(activeTag)]);
return next.size === current.size ? current : next;
});
}, [activeTag, treeMode]);
if (tags.length === 0) {
return null;
@ -37,44 +88,93 @@ const TagsSection = ({ tagCount, onSelect }: Props) => {
onSelect?.();
};
const handleToggleBranch = (tag: string) => {
setExpandedTagPaths((current) => {
const next = new Set(current);
if (next.has(tag)) {
next.delete(tag);
} else {
next.add(tag);
}
return next;
});
};
return (
<section>
<SidebarSectionHeader
action={
<Popover>
<PopoverTrigger
render={
<button
type="button"
className="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground"
aria-label={t("common.settings")}
/>
}
<div className="flex gap-px" role="group" aria-label={t("common.tags")}>
<button
type="button"
aria-label={t("common.tags")}
aria-pressed={!treeMode}
className={cn(
"flex size-5 items-center justify-center rounded-[4px] text-muted-foreground/65 transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:bg-sidebar-accent focus-visible:text-foreground",
!treeMode && "bg-sidebar-accent text-foreground",
)}
onClick={() => setTreeMode(false)}
>
<MoreHorizontalIcon className="size-3.5" />
</PopoverTrigger>
<PopoverContent align="end" alignOffset={-12} className="w-48">
<div className="flex items-center justify-between gap-2 p-1">
<span className="text-sm">{t("common.tree-mode")}</span>
<Switch checked={treeMode} onCheckedChange={setTreeMode} />
</div>
<div className="flex items-center justify-between gap-2 p-1">
<span className="text-sm">{t("common.auto-expand")}</span>
<Switch disabled={!treeMode} checked={treeAutoExpand} onCheckedChange={setTreeAutoExpand} />
</div>
</PopoverContent>
</Popover>
<ListIcon className="size-3" strokeWidth={1.8} />
</button>
<button
type="button"
aria-label={`${t("common.tags")}: ${t("common.tree-mode")}`}
aria-pressed={treeMode}
className={cn(
"flex size-5 items-center justify-center rounded-[4px] text-muted-foreground/65 transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:bg-sidebar-accent focus-visible:text-foreground",
treeMode && "bg-sidebar-accent text-foreground",
)}
onClick={() => setTreeMode(true)}
>
<ListTreeIcon className="size-3" strokeWidth={1.8} />
</button>
</div>
}
>
{t("common.tags")}
<span className="inline-flex items-baseline gap-1.5">
{t("common.tags")}
<span className="font-mono text-[9px] font-normal tracking-normal text-muted-foreground/45">{tags.length}</span>
</span>
</SidebarSectionHeader>
{treeMode ? (
<TagTree tagAmounts={tags} expandSubTags={!!treeAutoExpand} />
<TagTree
tagAmounts={tags}
activeTag={activeTag}
expandedTagPaths={expandedTagPaths}
onTagClick={handleTagClick}
onToggleBranch={handleToggleBranch}
/>
) : (
<div className="space-y-0.5">
<div className="space-y-px">
{tags.map(([tag, amount]) => {
const active = activeTags.has(tag);
return <SidebarRow key={tag} active={active} icon={HashIcon} label={tag} count={amount} onClick={() => handleTagClick(tag)} />;
return (
<button
key={tag}
type="button"
aria-pressed={active || undefined}
title={`#${tag}`}
className={cn(
"group flex h-7 w-full min-w-0 items-center gap-1.5 rounded-[5px] px-2 text-[13px] leading-[18px] text-muted-foreground transition-colors hover:bg-sidebar-accent/65 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring/40",
active && "bg-sidebar-accent font-medium text-sidebar-accent-foreground hover:bg-sidebar-accent",
)}
onClick={() => handleTagClick(tag)}
>
<span aria-hidden="true" className="w-3 shrink-0 text-center font-mono text-[11px] font-medium text-muted-foreground/70">
#
</span>
<TagPath tag={tag} />
<span
className={cn(
"ml-auto shrink-0 font-mono text-[10.5px] tabular-nums text-muted-foreground/55",
active && "text-sidebar-accent-foreground/65",
)}
>
{amount}
</span>
</button>
);
})}
</div>
)}

View file

@ -67,7 +67,7 @@ const MemoOutline = ({ headings }: MemoOutlineProps) => {
onClick={(e) => handleClick(e, heading.slug)}
aria-current={active ? "location" : undefined}
className={cn(
"relative flex h-[30px] min-w-0 items-center rounded-md pr-2 text-[13px] leading-none transition-colors",
"relative flex h-[30px] min-w-0 items-center rounded-md pr-2 text-[13px] leading-[18px] transition-colors",
heading.level === minLevel && "font-medium",
active ? "text-foreground" : "text-muted-foreground/70 hover:bg-sidebar-accent/65 hover:text-foreground",
)}

View file

@ -1,5 +1,5 @@
import { Columns2Icon, Columns3Icon, InfinityIcon, type LucideIcon, Rows3Icon, Settings2Icon } from "lucide-react";
import { useMemo } from "react";
import { Columns2Icon, Columns3Icon, InfinityIcon, type LucideIcon, Rows3Icon, SlidersHorizontalIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -12,6 +12,12 @@ interface Props {
className?: string;
}
interface SettingRowProps {
label: string;
description?: string;
children: ReactNode;
}
// Keyed by the context's canonical value list, so adding a column option forces an icon and
// wording here at compile time. The i18n param is deliberately named `n`, not `count` —
// i18next would route `count` through plural-form lookup.
@ -22,7 +28,17 @@ const LAYOUT_OPTIONS: Record<MemoMaxColumns, { icon: LucideIcon; key: "layout-li
0: { icon: InfinityIcon, key: "layout-auto" },
};
function MemoDisplaySettingMenu({ className }: Props) {
const SettingRow = ({ label, description, children }: SettingRowProps) => (
<div className="flex min-h-7 items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[13px] leading-5 text-foreground">{label}</p>
{description && <p className="text-[11px] leading-4 text-muted-foreground">{description}</p>}
</div>
{children}
</div>
);
function MemoDisplaySettingsContent() {
const t = useTranslate();
const {
orderByTimeAsc,
@ -31,7 +47,7 @@ function MemoDisplaySettingMenu({ className }: Props) {
linkPreview,
maxColumns,
setTimeBasis,
toggleSortOrder,
setOrderByTimeAsc,
setCompactMode,
setLinkPreview,
setMaxColumns,
@ -40,140 +56,145 @@ function MemoDisplaySettingMenu({ className }: Props) {
// there; it only becomes a real choice at a single column.
const compactLocked = maxColumns !== 1;
const timeBasisOptions = useMemo(
() => [
{ value: "create_time", label: t("common.created-at") },
{ value: "update_time", label: t("common.last-updated-at") },
],
[t],
);
const sortOrderOptions = useMemo(
() => [
{ value: "false", label: t("memo.newest-first") },
{ value: "true", label: t("memo.oldest-first") },
],
[t],
const timeBasisOptions = [
{ value: "create_time", label: t("common.created-at") },
{ value: "update_time", label: t("common.last-updated-at") },
];
const sortOrderOptions = [
{ value: "desc", label: t("memo.newest-first") },
{ value: "asc", label: t("memo.oldest-first") },
];
return (
<div>
<section className="px-3 py-2.5">
<p className="mb-2 text-[10px] font-semibold uppercase tracking-[0.09em] text-muted-foreground/65">{t("memo.layout")}</p>
<div
role="radiogroup"
aria-label={t("memo.layout")}
className="grid grid-cols-4 gap-0.5 rounded-lg bg-muted/55 p-0.5"
onKeyDown={(event) => {
const delta =
event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
if (delta === 0) return;
event.preventDefault();
const index = MAX_COLUMNS_VALUES.indexOf(maxColumns);
const next = MAX_COLUMNS_VALUES[(index + delta + MAX_COLUMNS_VALUES.length) % MAX_COLUMNS_VALUES.length];
setMaxColumns(next);
event.currentTarget.querySelector<HTMLButtonElement>(`[data-value="${next}"]`)?.focus();
}}
>
{MAX_COLUMNS_VALUES.map((value) => {
const { icon: Icon, key } = LAYOUT_OPTIONS[value];
const label = t(`memo.${key}`, { n: value });
const description = t(`memo.${key}-description`, { n: value });
const shortLabel = value > 1 ? value.toString() : label;
const active = maxColumns === value;
return (
<button
key={value}
type="button"
role="radio"
aria-checked={active}
aria-label={label}
title={description}
tabIndex={active ? 0 : -1}
data-value={value}
onClick={() => setMaxColumns(value)}
className={cn(
"flex h-8 min-w-0 items-center justify-center gap-1 rounded-md px-1 text-[11px] transition-[background-color,color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
active ? "bg-background text-foreground shadow-xs" : "text-muted-foreground hover:bg-background/60 hover:text-foreground",
)}
>
<Icon className="size-3.5 shrink-0" strokeWidth={1.8} />
<span className="truncate">{shortLabel}</span>
</button>
);
})}
</div>
</section>
<section className="space-y-2 border-t border-border/60 px-3 py-2.5">
<p className="text-[10px] font-semibold uppercase tracking-[0.09em] text-muted-foreground/65">{t("memo.order")}</p>
<SettingRow label={t("memo.order-by")}>
<Select
value={timeBasis}
items={timeBasisOptions}
onValueChange={(value) => setTimeBasis(value === "update_time" ? "update_time" : "create_time")}
>
<SelectTrigger size="sm" className="w-32" aria-label={t("memo.order-by")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{timeBasisOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow label={t("memo.direction")}>
<Select
value={orderByTimeAsc ? "asc" : "desc"}
items={sortOrderOptions}
onValueChange={(value) => setOrderByTimeAsc(value === "asc")}
>
<SelectTrigger size="sm" className="w-32" aria-label={t("memo.direction")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOrderOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
</section>
<section className="space-y-2 border-t border-border/60 px-3 py-2.5">
<SettingRow label={t("memo.compact-mode")} description={compactLocked ? t("memo.grid-compact-hint") : undefined}>
<Switch
aria-label={t("memo.compact-mode")}
checked={compactLocked || compactMode}
onCheckedChange={setCompactMode}
disabled={compactLocked}
/>
</SettingRow>
<SettingRow label={t("memo.link-preview")}>
<Switch aria-label={t("memo.link-preview")} checked={linkPreview} onCheckedChange={setLinkPreview} />
</SettingRow>
</section>
</div>
);
}
function MemoDisplaySettingMenu({ className }: Props) {
const t = useTranslate();
return (
<Popover>
<PopoverTrigger className={cn(className, "opacity-40 hover:opacity-100 transition-opacity")}>
<Settings2Icon className="w-4 h-auto shrink-0" />
</PopoverTrigger>
<PopoverContent align="end" alignOffset={-12} sideOffset={14}>
<div className="flex flex-col gap-2 p-1">
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.shown-time")}</span>
<Select
value={timeBasis}
items={timeBasisOptions}
onValueChange={(value) => setTimeBasis(value === "update_time" ? "update_time" : "create_time")}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{timeBasisOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.order")}</span>
<Select
value={orderByTimeAsc.toString()}
items={sortOrderOptions}
onValueChange={(value) => {
if ((value === "true") !== orderByTimeAsc) {
toggleSortOrder();
}
}}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOrderOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className={cn("text-sm shrink-0 mr-3", compactLocked ? "text-muted-foreground" : "text-foreground")}>
{t("memo.compact-mode")}
</span>
<Switch checked={compactLocked || compactMode} onCheckedChange={setCompactMode} disabled={compactLocked} />
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.link-preview")}</span>
<Switch checked={linkPreview} onCheckedChange={setLinkPreview} />
</div>
<div className="w-full flex flex-row justify-between items-center border-t border-border/50 pt-2">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.layout")}</span>
{/* A quiet muted track (28px tall, borderless); only the active option carries the accent
fill. A radiogroup with roving tabindex, since the options are mutually exclusive. */}
<div
role="radiogroup"
aria-label={t("memo.layout")}
className="flex items-center gap-0.5 rounded-lg bg-muted/50 p-0.5"
onKeyDown={(event) => {
const delta =
event.key === "ArrowRight" || event.key === "ArrowDown"
? 1
: event.key === "ArrowLeft" || event.key === "ArrowUp"
? -1
: 0;
if (delta === 0) return;
event.preventDefault();
const index = MAX_COLUMNS_VALUES.indexOf(maxColumns);
const next = MAX_COLUMNS_VALUES[(index + delta + MAX_COLUMNS_VALUES.length) % MAX_COLUMNS_VALUES.length];
setMaxColumns(next);
event.currentTarget.querySelector<HTMLButtonElement>(`[data-value="${next}"]`)?.focus();
}}
>
{MAX_COLUMNS_VALUES.map((value) => {
const { icon: Icon, key } = LAYOUT_OPTIONS[value];
const label = t(`memo.${key}`, { n: value });
const active = maxColumns === value;
return (
<Tooltip key={value}>
<TooltipTrigger
render={
<button
type="button"
role="radio"
aria-checked={active}
aria-label={label}
tabIndex={active ? 0 : -1}
data-value={value}
onClick={() => setMaxColumns(value)}
className={cn(
"grid h-6 w-7 place-items-center rounded-md transition-colors",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground/70 hover:bg-accent/50 hover:text-foreground",
)}
/>
}
>
<Icon className="w-3.5 h-3.5" />
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">{label}</p>
<p className="text-primary-foreground/70">{t(`memo.${key}-description`, { n: value })}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
<Tooltip>
<TooltipTrigger render={<span className="inline-flex" />}>
<PopoverTrigger
aria-label={t("memo.view-options")}
className={cn(
"flex size-5 items-center justify-center rounded-[4px] text-muted-foreground/65 transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:bg-sidebar-accent focus-visible:text-foreground data-popup-open:bg-sidebar-accent data-popup-open:text-foreground",
className,
)}
>
<SlidersHorizontalIcon className="size-3" strokeWidth={1.8} />
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top">{t("memo.view-options")}</TooltipContent>
</Tooltip>
<PopoverContent align="end" sideOffset={6} className="w-64 p-0">
<div className="border-b border-border/60 px-3 py-2.5">
<p className="text-[13px] font-medium text-foreground">{t("memo.view-options")}</p>
</div>
<MemoDisplaySettingsContent />
</PopoverContent>
</Popover>
);

View file

@ -1,172 +1,176 @@
import { ChevronRightIcon, HashIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_COUNT_CLASSES, SIDEBAR_ROW_ICON_CLASSES } from "@/components/AppSidebar/SidebarRow";
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { ChevronRightIcon } from "lucide-react";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
interface Tag {
interface TagTreeNode {
key: string;
text: string;
amount: number;
subTags: Tag[];
amount?: number;
subTags: TagTreeNode[];
}
interface Props {
tagAmounts: [tag: string, amount: number][];
expandSubTags: boolean;
activeTag?: string;
expandedTagPaths: ReadonlySet<string>;
onTagClick: (tag: string) => void;
onToggleBranch: (tag: string) => void;
}
const TagTree = ({ tagAmounts: rawTagAmounts, expandSubTags }: Props) => {
const [tags, setTags] = useState<Tag[]>([]);
export const buildTagTree = (tagAmounts: [tag: string, amount: number][]) => {
const root: TagTreeNode = {
key: "",
text: "",
subTags: [],
};
useEffect(() => {
const sortedTagAmounts = Array.from(rawTagAmounts).sort();
const root: Tag = {
key: "",
text: "",
amount: 0,
subTags: [],
};
for (const [tag, amount] of [...tagAmounts].sort(([left], [right]) => left.localeCompare(right))) {
const segments = tag.split("/");
let parent = root;
let path = "";
for (const tagAmount of sortedTagAmounts) {
const subtags = tagAmount[0].split("/");
let tempObj = root;
let tagText = "";
segments.forEach((segment, index) => {
path = path ? `${path}/${segment}` : segment;
let node = parent.subTags.find((item) => item.key === segment);
for (let i = 0; i < subtags.length; i++) {
const key = subtags[i];
let amount: number = 0;
if (i === 0) {
tagText += key;
} else {
tagText += "/" + key;
}
if (sortedTagAmounts.some(([tag, amount]) => tag === tagText && amount > 1)) {
amount = tagAmount[1];
}
let obj = null;
for (const t of tempObj.subTags) {
if (t.text === tagText) {
obj = t;
break;
}
}
if (!obj) {
obj = {
key,
text: tagText,
amount: amount,
subTags: [],
};
tempObj.subTags.push(obj);
}
tempObj = obj;
if (!node) {
node = {
key: segment,
text: path,
subTags: [],
};
parent.subTags.push(node);
}
}
setTags(root.subTags as Tag[]);
}, [rawTagAmounts]);
if (index === segments.length - 1) {
node.amount = amount;
}
parent = node;
});
}
return root.subTags;
};
const TagMark = () => (
<span aria-hidden="true" className="w-3 shrink-0 text-center font-mono text-[11px] font-medium text-muted-foreground/70">
#
</span>
);
interface TagItemProps {
tag: TagTreeNode;
depth: number;
activeTag?: string;
expandedTagPaths: ReadonlySet<string>;
onTagClick: (tag: string) => void;
onToggleBranch: (tag: string) => void;
}
const TagItem = ({ tag, depth, activeTag, expandedTagPaths, onTagClick, onToggleBranch }: TagItemProps) => {
const t = useTranslate();
const isTag = tag.amount !== undefined;
const isActive = activeTag === tag.text;
const isAncestorOfActiveTag = activeTag?.startsWith(`${tag.text}/`) ?? false;
const hasSubTags = tag.subTags.length > 0;
const showSubTags = expandedTagPaths.has(tag.text);
return (
<div className="relative flex h-auto w-full flex-col flex-nowrap items-start justify-start gap-0.5">
{tags.map((t, idx) => (
<TagItemContainer key={t.text + "-" + idx} tag={t} expandSubTags={expandSubTags} />
<div className="w-full min-w-0">
<div
role="treeitem"
aria-expanded={hasSubTags ? showSubTags : undefined}
aria-selected={isActive || undefined}
className={cn(
"relative flex h-7 w-full min-w-0 items-center rounded-[5px] pr-2 text-[13px] leading-[18px] text-muted-foreground transition-colors hover:bg-sidebar-accent/65 hover:text-foreground",
isActive && "bg-sidebar-accent font-medium text-sidebar-accent-foreground hover:bg-sidebar-accent",
isAncestorOfActiveTag && !isActive && "text-foreground/75",
!isTag && "text-[12.5px] text-muted-foreground/75",
)}
style={{ paddingInlineStart: 4 + depth * 14 }}
>
{hasSubTags ? (
<button
type="button"
aria-label={`${showSubTags ? t("common.collapse") : t("common.expand")} ${tag.key}`}
aria-expanded={showSubTags}
className="mr-0.5 flex size-5 shrink-0 items-center justify-center rounded-[4px] text-muted-foreground/75 transition-colors hover:bg-background/70 hover:text-foreground focus-visible:bg-background/70 focus-visible:text-foreground focus-visible:outline-none"
onClick={() => onToggleBranch(tag.text)}
>
<ChevronRightIcon className={cn("size-3 transition-transform duration-150", showSubTags && "rotate-90")} strokeWidth={1.8} />
</button>
) : (
<span className="mr-0.5 size-5 shrink-0" />
)}
{isTag ? (
<button
type="button"
aria-pressed={isActive || undefined}
title={`#${tag.text}`}
className="flex h-full min-w-0 flex-1 items-center gap-1.5 rounded-sm text-left focus-visible:text-foreground focus-visible:underline focus-visible:decoration-muted-foreground focus-visible:underline-offset-2 focus-visible:outline-none"
onClick={() => onTagClick(tag.text)}
>
<TagMark />
<span className="min-w-0 flex-1 truncate">{tag.key}</span>
</button>
) : (
<span className="min-w-0 flex-1 truncate" title={tag.text}>
{tag.key}
</span>
)}
{isTag && (
<span
className={cn(
"ml-1.5 shrink-0 font-mono text-[10.5px] tabular-nums text-muted-foreground/55",
isActive && "text-sidebar-accent-foreground/65",
)}
>
{tag.amount}
</span>
)}
</div>
{hasSubTags && showSubTags && (
<div className="w-full min-w-0" role="group">
{tag.subTags.map((subTag) => (
<TagItem
key={subTag.text}
tag={subTag}
depth={depth + 1}
activeTag={activeTag}
expandedTagPaths={expandedTagPaths}
onTagClick={onTagClick}
onToggleBranch={onToggleBranch}
/>
))}
</div>
)}
</div>
);
};
const TagTree = ({ tagAmounts, activeTag, expandedTagPaths, onTagClick, onToggleBranch }: Props) => {
const t = useTranslate();
const tags = useMemo(() => buildTagTree(tagAmounts), [tagAmounts]);
return (
<div className="relative flex h-auto w-full flex-col items-stretch gap-px" role="tree" aria-label={t("common.tags")}>
{tags.map((tag) => (
<TagItem
key={tag.text}
tag={tag}
depth={0}
activeTag={activeTag}
expandedTagPaths={expandedTagPaths}
onTagClick={onTagClick}
onToggleBranch={onToggleBranch}
/>
))}
</div>
);
};
interface TagItemContainerProps {
tag: Tag;
expandSubTags: boolean;
}
const TagItemContainer = (props: TagItemContainerProps) => {
const { tag, expandSubTags } = props;
const t = useTranslate();
const { getFiltersByFactor, addFilter, removeFilter } = useMemoFilterContext();
const tagFilters = getFiltersByFactor("tagSearch");
const isActive = tagFilters.some((f: MemoFilter) => f.value === tag.text);
const hasSubTags = tag.subTags.length > 0;
const [showSubTags, setShowSubTags] = useState(false);
useEffect(() => {
setShowSubTags(expandSubTags);
}, [expandSubTags]);
const handleTagClick = () => {
if (isActive) {
removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag.text);
} else {
// Remove all existing tag filters first, then add the new one
removeFilter((f: MemoFilter) => f.factor === "tagSearch");
addFilter({
factor: "tagSearch",
value: tag.text,
});
}
};
const handleToggleBtnClick = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
setShowSubTags((current) => !current);
}, []);
return (
<>
<div
className={cn(
SIDEBAR_ROW_CLASSES,
"shrink-0 select-none",
isActive
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
: "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground",
)}
>
<button
type="button"
aria-pressed={isActive || 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"
onClick={handleTagClick}
>
<HashIcon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
<span className="min-w-0 flex-1 truncate">{tag.key}</span>
</button>
{tag.amount > 1 && <span className={SIDEBAR_ROW_COUNT_CLASSES}>{tag.amount}</span>}
{hasSubTags && (
<button
type="button"
aria-label={`${showSubTags ? t("common.collapse") : t("common.expand")} ${tag.key}`}
aria-expanded={showSubTags}
className={cn(
"-mr-1 flex size-6 shrink-0 items-center justify-center rounded transition-colors hover:bg-background/70",
showSubTags && "[&>svg]:rotate-90",
)}
onClick={handleToggleBtnClick}
>
<ChevronRightIcon className="size-3.5 transition-transform" />
</button>
)}
</div>
{hasSubTags ? (
<div
className={`w-[calc(100%-0.5rem)] flex flex-col justify-start items-start h-auto ml-2 pl-2 border-l-2 border-l-border ${
!showSubTags && "hidden"
}`}
>
{tag.subTags.map((st, idx) => (
<TagItemContainer key={st.text + "-" + idx} tag={st} expandSubTags={expandSubTags} />
))}
</div>
) : null}
</>
);
};
export default TagTree;

View file

@ -21,7 +21,7 @@ interface ViewContextValue {
compactMode: boolean;
linkPreview: boolean;
maxColumns: MemoMaxColumns;
toggleSortOrder: () => void;
setOrderByTimeAsc: (value: boolean) => void;
setTimeBasis: (field: MemoTimeBasis) => void;
setCompactMode: (value: boolean) => void;
setLinkPreview: (value: boolean) => void;
@ -78,7 +78,7 @@ export function ViewProvider({ children }: { children: ReactNode }) {
});
};
const toggleSortOrder = () => updateState((prev) => ({ orderByTimeAsc: !prev.orderByTimeAsc }));
const setOrderByTimeAsc = (value: boolean) => updateState({ orderByTimeAsc: value });
const setTimeBasis = (field: MemoTimeBasis) => updateState({ timeBasis: field });
const setCompactMode = (value: boolean) => updateState({ compactMode: value });
const setLinkPreview = (value: boolean) => updateState({ linkPreview: value });
@ -92,7 +92,7 @@ export function ViewProvider({ children }: { children: ReactNode }) {
compactMode: viewState.compactMode,
linkPreview: viewState.linkPreview,
maxColumns: viewState.maxColumns,
toggleSortOrder,
setOrderByTimeAsc,
setTimeBasis,
setCompactMode,
setLinkPreview,

View file

@ -309,6 +309,7 @@
"label": "Filters"
},
"compact-mode": "Compact mode",
"grid-compact-hint": "Grid layouts always use compact cards.",
"layout": "Layout",
"layout-auto": "Auto",
"layout-auto-description": "As many columns as fit the screen",
@ -373,6 +374,7 @@
"updated": "Tasks updated"
},
"view-detail": "View Detail",
"view-options": "View options",
"visibility": {
"disabled": "Public memos are disabled",
"private": "Private",

View file

@ -268,6 +268,7 @@
"label": "过滤器"
},
"compact-mode": "紧凑模式",
"grid-compact-hint": "网格布局始终使用紧凑卡片。",
"layout": "布局",
"layout-auto": "自动",
"layout-auto-description": "根据屏幕宽度显示尽可能多的列",
@ -291,6 +292,7 @@
"show-more": "查看更多",
"to-do": "待办",
"view-detail": "查看详情",
"view-options": "视图选项",
"visibility": {
"disabled": "已禁用公开备忘录",
"private": "私有",

View file

@ -12,6 +12,10 @@ 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>,
}));
@ -172,6 +176,9 @@ describe("App sidebar logo", () => {
const calendar = screen.getByText("Calendar");
const views = screen.getByText("common.views");
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(screen.getByRole("button", { name: "common.tasks" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "common.all" })).not.toBeInTheDocument();

View file

@ -23,8 +23,8 @@ vi.mock("@/contexts/ViewContext", async () => {
};
});
import { useAllUserStats, useUserStats } from "@/hooks/useUserQueries";
import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
import { useAllUserStats, useUserStats } from "@/hooks/useUserQueries";
const wrapper = ({ children }: { children: ReactNode }) => children as never;
@ -57,7 +57,7 @@ describe("useFilteredMemoStats", () => {
mockUseView.mockReturnValue({
timeBasis: "create_time",
orderByTimeAsc: false,
toggleSortOrder: vi.fn(),
setOrderByTimeAsc: vi.fn(),
setTimeBasis: vi.fn(),
});
@ -71,7 +71,7 @@ describe("useFilteredMemoStats", () => {
mockUseView.mockReturnValue({
timeBasis: "update_time",
orderByTimeAsc: false,
toggleSortOrder: vi.fn(),
setOrderByTimeAsc: vi.fn(),
setTimeBasis: vi.fn(),
});
@ -95,7 +95,7 @@ describe("useFilteredMemoStats", () => {
mockUseView.mockReturnValue({
timeBasis: "update_time",
orderByTimeAsc: false,
toggleSortOrder: vi.fn(),
setOrderByTimeAsc: vi.fn(),
setTimeBasis: vi.fn(),
});
@ -111,7 +111,7 @@ describe("useFilteredMemoStats", () => {
mockUseView.mockReturnValue({
timeBasis: "create_time",
orderByTimeAsc: false,
toggleSortOrder: vi.fn(),
setOrderByTimeAsc: vi.fn(),
setTimeBasis: vi.fn(),
});

View file

@ -0,0 +1,56 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import MemoDisplaySettingMenu from "@/components/MemoDisplaySettingMenu";
import { ViewProvider } from "@/contexts/ViewContext";
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string, params?: Record<string, number>) => {
const labels: Record<string, string> = {
"common.created-at": "Created",
"common.last-updated-at": "Last updated",
"memo.compact-mode": "Compact mode",
"memo.direction": "Direction",
"memo.grid-compact-hint": "Grid layouts always use compact cards.",
"memo.layout": "Layout",
"memo.layout-auto": "Auto",
"memo.layout-auto-description": "As many columns as fit",
"memo.layout-columns-description": `Up to ${params?.n ?? ""} columns`,
"memo.layout-list": "List",
"memo.layout-list-description": "A single column",
"memo.link-preview": "Link preview",
"memo.newest-first": "Newest first",
"memo.oldest-first": "Oldest first",
"memo.order": "Order",
"memo.order-by": "Order by",
"memo.view-options": "View options",
};
if (key === "memo.layout-columns") return `${params?.n ?? ""} columns`;
return labels[key] ?? key;
},
}));
describe("MemoDisplaySettingMenu", () => {
beforeEach(() => {
localStorage.clear();
});
it("opens from an accessible trigger and explains the compact grid constraint", () => {
render(
<ViewProvider>
<MemoDisplaySettingMenu />
</ViewProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "View options" }));
const compactMode = screen.getByRole("switch", { name: "Compact mode" });
expect(compactMode).not.toBeChecked();
expect(compactMode).toBeEnabled();
fireEvent.click(screen.getByRole("radio", { name: "2 columns" }));
expect(compactMode).toBeChecked();
expect(compactMode).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("Grid layouts always use compact cards.")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { buildTagTree } from "@/components/TagTree";
describe("tag tree", () => {
it("preserves exact counts, including one", () => {
const tree = buildTagTree([
["a", 2],
["a/b", 1],
]);
expect(tree).toMatchObject([
{
key: "a",
text: "a",
amount: 2,
subTags: [{ key: "b", text: "a/b", amount: 1, subTags: [] }],
},
]);
});
it("keeps generated parent paths structural", () => {
const tree = buildTagTree([["personal/travel/singapore", 2]]);
expect(tree[0].amount).toBeUndefined();
expect(tree[0].subTags[0].amount).toBeUndefined();
expect(tree[0].subTags[0].subTags[0]).toMatchObject({
key: "singapore",
text: "personal/travel/singapore",
amount: 2,
});
});
});

View file

@ -28,6 +28,15 @@ describe("ViewContext maxColumns setting", () => {
expect(persisted().maxColumns).toBe(0);
});
it("sets and persists the sort direction explicitly", () => {
const { result } = renderHook(() => useView(), { wrapper });
act(() => result.current.setOrderByTimeAsc(true));
expect(result.current.orderByTimeAsc).toBe(true);
expect(persisted().orderByTimeAsc).toBe(true);
});
it("restores a persisted column count on init", () => {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify({ maxColumns: 2 }));