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:
parent
d14a542002
commit
6fbc81337f
14 changed files with 569 additions and 332 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "私有",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
56
web/tests/memo-display-setting-menu.test.tsx
Normal file
56
web/tests/memo-display-setting-menu.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
32
web/tests/tag-tree.test.ts
Normal file
32
web/tests/tag-tree.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue