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:
parent
6fbc81337f
commit
b930b1090a
11 changed files with 187 additions and 183 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
21
web/src/hooks/useOverflowTitle.ts
Normal file
21
web/src/hooks/useOverflowTitle.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
42
web/tests/overflow-title.test.tsx
Normal file
42
web/tests/overflow-title.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
29
web/tests/tag-tree-render.test.tsx
Normal file
29
web/tests/tag-tree-render.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue