refactor(sidebar): reduce tooltip noise and refine tags

- Centralize tooltip timing and limit hints to useful content.\n- Simplify tag hierarchy presentation and align compact tag rows.
This commit is contained in:
johnnyjoygh 2026-08-05 23:52:13 +08:00
parent 6fbc81337f
commit b930b1090a
11 changed files with 187 additions and 183 deletions

View file

@ -71,7 +71,7 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
</button>
);
const shouldShowTooltip = tooltipText && !disableTooltip;
const shouldShowTooltip = day.count > 0 && tooltipText && !disableTooltip;
if (!shouldShowTooltip) {
return button;

View file

@ -1,7 +1,7 @@
import { ListIcon, ListTreeIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { HashIcon, ListIcon, ListTreeIcon } from "lucide-react";
import { forwardRef, useMemo } from "react";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useLocalStorage } from "@/hooks";
import { useLocalStorage, useOverflowTitle } from "@/hooks";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import TagTree from "../TagTree";
@ -12,66 +12,59 @@ interface Props {
onSelect?: () => void;
}
const TagPath = ({ tag }: { tag: string }) => {
const TagPath = forwardRef<HTMLSpanElement, { tag: string }>(({ tag }, ref) => {
const segments = tag.split("/");
return (
<span className="min-w-0 truncate text-left">
<span ref={ref} 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>}
{index > 0 && <span className="px-0.5 text-muted-foreground/40">/</span>}
<span className={index === segments.length - 1 ? "text-current" : "text-muted-foreground/75"}>{segment}</span>
</span>
))}
</span>
);
};
});
TagPath.displayName = "TagPath";
const getExpandableTagPaths = (tags: [tag: string, amount: number][]) => {
const paths = new Set<string>();
interface FlatTagRowProps {
tag: string;
amount: number;
active: boolean;
onClick: () => void;
}
for (const [tag] of tags) {
const segments = tag.split("/");
for (let index = 1; index < segments.length; index++) {
paths.add(segments.slice(0, index).join("/"));
}
}
const FlatTagRow = ({ tag, amount, active, onClick }: FlatTagRowProps) => {
const { ref, title } = useOverflowTitle<HTMLSpanElement>(`#${tag}`);
return paths;
};
const getParentTagPaths = (tag: string) => {
const segments = tag.split("/");
return segments.slice(0, -1).map((_, index) => segments.slice(0, index + 1).join("/"));
return (
<button
type="button"
aria-pressed={active || undefined}
title={title}
className={cn(
"group grid h-[26px] w-full min-w-0 grid-cols-[12px_minmax(0,1fr)_auto] items-center gap-x-1.5 rounded-[5px] px-2 text-xs leading-4 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={onClick}
>
<HashIcon aria-hidden="true" className="size-3 text-muted-foreground/65" strokeWidth={1.75} />
<TagPath ref={ref} tag={tag} />
<span className={cn("shrink-0 leading-none tabular-nums text-muted-foreground/50", active && "text-sidebar-accent-foreground/65")}>
{amount}
</span>
</button>
);
};
const TagsSection = ({ tagCount, onSelect }: Props) => {
const t = useTranslate();
const { getFiltersByFactor, addFilter, removeFilter } = useMemoFilterContext();
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", 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;
@ -88,94 +81,50 @@ 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={
<div className="flex gap-px" role="group" aria-label={t("common.tags")}>
<div className="flex items-center gap-0.5" 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",
"flex size-[22px] items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/40",
!treeMode && "bg-sidebar-accent text-foreground",
)}
onClick={() => setTreeMode(false)}
>
<ListIcon className="size-3" strokeWidth={1.8} />
<ListIcon className="size-3.5" strokeWidth={1.7} />
</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",
"flex size-[22px] items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/40",
treeMode && "bg-sidebar-accent text-foreground",
)}
onClick={() => setTreeMode(true)}
>
<ListTreeIcon className="size-3" strokeWidth={1.8} />
<ListTreeIcon className="size-3.5" strokeWidth={1.7} />
</button>
</div>
}
>
<span className="inline-flex items-baseline gap-1.5">
<span className="inline-flex items-center gap-1.5 leading-none">
{t("common.tags")}
<span className="font-mono text-[9px] font-normal tracking-normal text-muted-foreground/45">{tags.length}</span>
<span className="font-normal leading-none tracking-normal text-muted-foreground/45">{tags.length}</span>
</span>
</SidebarSectionHeader>
{treeMode ? (
<TagTree
tagAmounts={tags}
activeTag={activeTag}
expandedTagPaths={expandedTagPaths}
onTagClick={handleTagClick}
onToggleBranch={handleToggleBranch}
/>
<TagTree tagAmounts={tags} activeTag={activeTag} onTagClick={handleTagClick} />
) : (
<div className="space-y-px">
{tags.map(([tag, amount]) => {
const active = activeTags.has(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>
);
})}
{tags.map(([tag, amount]) => (
<FlatTagRow key={tag} tag={tag} amount={amount} active={activeTags.has(tag)} onClick={() => handleTagClick(tag)} />
))}
</div>
)}
</section>

View file

@ -9,6 +9,7 @@ import { getRelationBuckets, getRelationMemo } from "@/components/MemoMetadata/R
import { useResolvedRelationMemos } from "@/components/MemoMetadata/Relation/useResolvedRelationMemos";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { useInstance } from "@/contexts/InstanceContext";
import { useOverflowTitle } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useUpdateMemo } from "@/hooks/useMemoQueries";
import { cn } from "@/lib/utils";
@ -35,6 +36,7 @@ const Section = ({ label, children }: { label: string; children: React.ReactNode
);
const BacklinkRow = ({ relation, snippet }: { relation: MemoRelation; snippet: string }) => {
const { ref, title } = useOverflowTitle<HTMLSpanElement>(snippet);
const relatedMemo = getRelationMemo(relation, "referenced");
if (!relatedMemo) {
return null;
@ -44,11 +46,13 @@ const BacklinkRow = ({ relation, snippet }: { relation: MemoRelation; snippet: s
<Link
className={cn(SIDEBAR_ROW_CLASSES, "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground")}
to={`/${relatedMemo.name}`}
title={snippet}
title={title}
viewTransition
>
<LinkIcon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
<span className="min-w-0 flex-1 truncate text-left">{snippet}</span>
<span ref={ref} className="min-w-0 flex-1 truncate text-left">
{snippet}
</span>
</Link>
);
};

View file

@ -1,5 +1,6 @@
import { ChevronRightIcon } from "lucide-react";
import { HashIcon } from "lucide-react";
import { useMemo } from "react";
import { useOverflowTitle } from "@/hooks";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
@ -13,9 +14,7 @@ interface TagTreeNode {
interface Props {
tagAmounts: [tag: string, amount: number][];
activeTag?: string;
expandedTagPaths: ReadonlySet<string>;
onTagClick: (tag: string) => void;
onToggleBranch: (tag: string) => void;
}
export const buildTagTree = (tagAmounts: [tag: string, amount: number][]) => {
@ -53,98 +52,65 @@ export const buildTagTree = (tagAmounts: [tag: string, amount: number][]) => {
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>
);
const TagMark = () => <HashIcon aria-hidden="true" className="size-3 text-muted-foreground/65" strokeWidth={1.75} />;
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 TagItem = ({ tag, depth, activeTag, onTagClick }: TagItemProps) => {
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);
const { ref: labelRef, title } = useOverflowTitle<HTMLSpanElement>(isTag ? `#${tag.text}` : tag.text);
return (
<div className="w-full min-w-0">
<div
role="treeitem"
aria-expanded={hasSubTags ? showSubTags : undefined}
aria-level={depth + 1}
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",
"relative flex h-[26px] w-full min-w-0 items-center rounded-[5px] pr-2 text-xs leading-4 transition-colors",
isTag ? "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground" : "font-medium text-muted-foreground/65",
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 }}
style={{ paddingInlineStart: 8 + 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"
title={title}
className="grid h-full min-w-0 flex-1 grid-cols-[12px_minmax(0,1fr)_auto] items-center gap-x-1.5 rounded-sm text-left focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/40"
onClick={() => onTagClick(tag.text)}
>
<TagMark />
<span className="min-w-0 flex-1 truncate">{tag.key}</span>
<span ref={labelRef} className="min-w-0 flex-1 truncate">
{tag.key}
</span>
<span
className={cn("shrink-0 leading-none tabular-nums text-muted-foreground/50", isActive && "text-sidebar-accent-foreground/65")}
>
{tag.amount}
</span>
</button>
) : (
<span className="min-w-0 flex-1 truncate" title={tag.text}>
<span ref={labelRef} className="min-w-0 flex-1 truncate ps-[18px]" title={title}>
{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">
{hasSubTags && (
<div className="mt-px flex w-full min-w-0 flex-col gap-px" role="group">
{tag.subTags.map((subTag) => (
<TagItem
key={subTag.text}
tag={subTag}
depth={depth + 1}
activeTag={activeTag}
expandedTagPaths={expandedTagPaths}
onTagClick={onTagClick}
onToggleBranch={onToggleBranch}
/>
<TagItem key={subTag.text} tag={subTag} depth={depth + 1} activeTag={activeTag} onTagClick={onTagClick} />
))}
</div>
)}
@ -152,22 +118,14 @@ const TagItem = ({ tag, depth, activeTag, expandedTagPaths, onTagClick, onToggle
);
};
const TagTree = ({ tagAmounts, activeTag, expandedTagPaths, onTagClick, onToggleBranch }: Props) => {
const TagTree = ({ tagAmounts, activeTag, onTagClick }: 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}
/>
<TagItem key={tag.text} tag={tag} depth={0} activeTag={activeTag} onTagClick={onTagClick} />
))}
</div>
);

View file

@ -3,16 +3,12 @@ import * as React from "react";
import { cn } from "@/lib/utils";
import { popupMotionClasses } from "./popup";
const TooltipProvider = ({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) => {
return <TooltipPrimitive.Provider delay={delay} {...props} />;
const TooltipProvider = ({ delay = 600, timeout = 400, ...props }: TooltipPrimitive.Provider.Props) => {
return <TooltipPrimitive.Provider delay={delay} timeout={timeout} {...props} />;
};
const Tooltip = ({ ...props }: TooltipPrimitive.Root.Props) => {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
};
const TooltipTrigger = React.forwardRef<HTMLButtonElement, TooltipPrimitive.Trigger.Props>(({ ...props }, ref) => {

View file

@ -10,5 +10,6 @@ export * from "./useMediaQuery";
export * from "./useMemoFilters";
export * from "./useMemoSorting";
export * from "./useNavigateTo";
export * from "./useOverflowTitle";
export * from "./useUserLocale";
export * from "./useUserTheme";

View file

@ -0,0 +1,21 @@
import { useEffect, useRef, useState } from "react";
export const useOverflowTitle = <T extends HTMLElement>(text: string) => {
const ref = useRef<T>(null);
const [isOverflowing, setIsOverflowing] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const checkOverflow = () => setIsOverflowing(element.scrollWidth > element.clientWidth);
checkOverflow();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(checkOverflow);
observer.observe(element);
return () => observer.disconnect();
}, [text]);
return { ref, title: isOverflowing ? text : undefined };
};

View file

@ -8,6 +8,7 @@ import { RouterProvider } from "react-router-dom";
import "./i18n";
import "./index.css";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { TooltipProvider } from "@/components/ui/tooltip";
import { refreshAccessToken } from "@/connect";
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
import { InstanceProvider, useInstance } from "@/contexts/InstanceContext";
@ -64,12 +65,14 @@ function Main() {
<QueryClientProvider client={queryClient}>
<InstanceProvider>
<AuthProvider>
<ViewProvider>
<AppInitializer>
<RouterProvider router={router} />
<Toaster position="top-right" />
</AppInitializer>
</ViewProvider>
<TooltipProvider>
<ViewProvider>
<AppInitializer>
<RouterProvider router={router} />
<Toaster position="top-right" />
</AppInitializer>
</ViewProvider>
</TooltipProvider>
</AuthProvider>
</InstanceProvider>
<ReactQueryDevtools initialIsOpen={false} />

View file

@ -30,6 +30,7 @@ describe("CalendarCell empty-day clickability", () => {
const button = screen.getByRole("button", { name: /May 1, 2025/ });
expect(button).toHaveAttribute("tabindex", "0");
expect(button).toHaveAttribute("aria-disabled", "false");
expect(button).not.toHaveAttribute("data-slot", "tooltip-trigger");
expect(button).toHaveClass("bg-transparent");
});
@ -37,7 +38,9 @@ describe("CalendarCell empty-day clickability", () => {
const onClick = vi.fn();
render(<CalendarCell day={makeDay({ count: 3 })} maxCount={5} tooltipText="May 1, 2025" onClick={onClick} />);
fireEvent.click(screen.getByRole("button", { name: /May 1, 2025/ }));
const button = screen.getByRole("button", { name: /May 1, 2025/ });
expect(button).toHaveAttribute("data-slot", "tooltip-trigger");
fireEvent.click(button);
expect(onClick).toHaveBeenCalledWith("2025-05-01");
});
@ -58,9 +61,7 @@ describe("CalendarCell empty-day clickability", () => {
});
it("does not render out-of-month days as interactive (no role=button)", () => {
render(
<CalendarCell day={makeDay({ isCurrentMonth: false })} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />,
);
render(<CalendarCell day={makeDay({ isCurrentMonth: false })} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />);
expect(screen.queryByRole("button")).toBeNull();
});

View file

@ -0,0 +1,42 @@
import { render, screen } from "@testing-library/react";
import { useLayoutEffect } from "react";
import { describe, expect, it } from "vitest";
import { useOverflowTitle } from "@/hooks/useOverflowTitle";
interface HarnessProps {
text: string;
scrollWidth: number;
clientWidth: number;
}
const Harness = ({ text, scrollWidth, clientWidth }: HarnessProps) => {
const { ref, title } = useOverflowTitle<HTMLSpanElement>(text);
useLayoutEffect(() => {
if (!ref.current) return;
Object.defineProperties(ref.current, {
scrollWidth: { configurable: true, value: scrollWidth },
clientWidth: { configurable: true, value: clientWidth },
});
}, [clientWidth, ref, scrollWidth]);
return (
<div data-testid="trigger" title={title}>
<span ref={ref}>{text}</span>
</div>
);
};
describe("useOverflowTitle", () => {
it("omits the title when the text fits", () => {
render(<Harness text="Short label" scrollWidth={80} clientWidth={120} />);
expect(screen.getByTestId("trigger")).not.toHaveAttribute("title");
});
it("adds the title when the text is truncated", async () => {
render(<Harness text="A label that is too long" scrollWidth={180} clientWidth={80} />);
expect(await screen.findByTitle("A label that is too long")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,29 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import TagTree from "@/components/TagTree";
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
describe("TagTree rendering", () => {
it("shows every nested tag without disclosure icons", () => {
const onTagClick = vi.fn();
const { container } = render(
<TagTree
tagAmounts={[
["a", 2],
["a/b", 1],
["getting-started", 1],
]}
onTagClick={onTagClick}
/>,
);
expect(screen.getAllByRole("treeitem")).toHaveLength(3);
expect(screen.getByText("b")).toBeVisible();
expect(container.querySelectorAll("svg.lucide-hash")).toHaveLength(3);
expect(container.querySelector("svg.lucide-chevron-right")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("b").closest("button") as HTMLButtonElement);
expect(onTagClick).toHaveBeenCalledWith("a/b");
});
});