refactor: implement CitationHoverPopover component to enhance inline citation functionality and improve user interaction

This commit is contained in:
Anish Sarkar 2026-05-15 02:00:17 +05:30
parent 56239548c8
commit ea087d1d23
3 changed files with 293 additions and 210 deletions

View file

@ -0,0 +1,142 @@
"use client";
import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react";
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "./_adapter";
type PopoverContentProps = ComponentProps<typeof PopoverContent>;
export type CitationHoverTriggerProps = Pick<
HTMLAttributes<HTMLElement>,
"onBlur" | "onFocus" | "onPointerEnter" | "onPointerLeave"
>;
interface CitationHoverPopoverProps {
id: string;
trigger: (props: CitationHoverTriggerProps) => ReactElement;
children: ReactNode;
contentClassName?: string;
side?: PopoverContentProps["side"];
align?: PopoverContentProps["align"];
sideOffset?: PopoverContentProps["sideOffset"];
onContentClick?: PopoverContentProps["onClick"];
}
const OPEN_DELAY_MS = 80;
const CLOSE_DELAY_MS = 120;
let activeCitationId: string | null = null;
const listeners = new Set<() => void>();
function subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function getSnapshot() {
return activeCitationId;
}
function setActiveCitationId(id: string | null) {
if (activeCitationId === id) return;
activeCitationId = id;
for (const listener of listeners) {
listener();
}
}
function useCitationHoverState(id: string) {
const activeId = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = useCallback(() => {
if (openTimerRef.current) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const open = activeId === id;
const scheduleOpen = useCallback(() => {
clearTimers();
openTimerRef.current = setTimeout(() => {
setActiveCitationId(id);
openTimerRef.current = null;
}, OPEN_DELAY_MS);
}, [clearTimers, id]);
const scheduleClose = useCallback(() => {
clearTimers();
closeTimerRef.current = setTimeout(() => {
if (activeCitationId === id) {
setActiveCitationId(null);
}
closeTimerRef.current = null;
}, CLOSE_DELAY_MS);
}, [clearTimers, id]);
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
clearTimers();
setActiveCitationId(nextOpen ? id : null);
},
[clearTimers, id]
);
useEffect(() => {
return () => {
clearTimers();
if (activeCitationId === id) {
setActiveCitationId(null);
}
};
}, [clearTimers, id]);
return { open, scheduleOpen, scheduleClose, handleOpenChange };
}
export function CitationHoverPopover({
id,
trigger,
children,
contentClassName,
side = "top",
align = "start",
sideOffset = 6,
onContentClick,
}: CitationHoverPopoverProps) {
const { open, scheduleOpen, scheduleClose, handleOpenChange } = useCitationHoverState(id);
const hoverProps = {
onPointerEnter: scheduleOpen,
onPointerLeave: scheduleClose,
onFocus: scheduleOpen,
onBlur: scheduleClose,
} satisfies CitationHoverTriggerProps;
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>{trigger(hoverProps)}</PopoverTrigger>
<PopoverContent
side={side}
align={align}
sideOffset={sideOffset}
className={contentClassName}
onPointerEnter={scheduleOpen}
onPointerLeave={scheduleClose}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onClick={onContentClick}
>
{children}
</PopoverContent>
</Popover>
);
}

View file

@ -2,10 +2,10 @@
import { ExternalLink, Globe } from "lucide-react";
import NextImage from "next/image";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
import { cn } from "./_adapter";
import { CitationHoverPopover } from "./citation-hover-popover";
import type { CitationVariant, SerializableCitation } from "./schema";
import { TYPE_ICONS } from "./type-icons";
@ -32,29 +32,6 @@ function formatDate(isoString: string, locale: string): string {
}
}
function useHoverPopover(delay = 100) {
const [open, setOpen] = React.useState(false);
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const handleMouseEnter = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(true), delay);
}, [delay]);
const handleMouseLeave = React.useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(false), delay);
}, [delay]);
React.useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
return { open, setOpen, handleMouseEnter, handleMouseLeave };
}
export interface CitationProps extends SerializableCitation {
variant?: CitationVariant;
className?: string;
@ -99,13 +76,6 @@ export function Citation(props: CitationProps) {
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (sanitizedHref && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
handleClick();
}
};
const iconElement = favicon ? (
<NextImage
src={favicon}
@ -120,13 +90,14 @@ export function Citation(props: CitationProps) {
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
);
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
// Inline variant: compact chip with hover popover
if (variant === "inline") {
return (
<Popover open={open}>
<PopoverTrigger asChild>
<CitationHoverPopover
id={id}
contentClassName="w-72 cursor-pointer p-0"
onContentClick={handleClick}
trigger={(hoverProps) => (
<Button
variant="ghost"
type="button"
@ -134,8 +105,7 @@ export function Citation(props: CitationProps) {
data-tool-ui-id={id}
data-slot="citation"
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...hoverProps}
className={cn(
"h-auto cursor-pointer gap-1.5 rounded-md px-2 py-1",
"bg-muted/60 text-sm outline-none",
@ -148,34 +118,73 @@ export function Citation(props: CitationProps) {
{iconElement}
<span className="text-muted-foreground">{domain}</span>
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-72 cursor-pointer p-0"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onClick={handleClick}
>
<div className="hover:bg-accent hover:text-accent-foreground flex flex-col gap-2 p-3 transition-colors">
<div className="flex items-start gap-2">
{iconElement}
<span className="text-muted-foreground text-xs">{domain}</span>
</div>
<p className="text-sm leading-snug font-medium">{title}</p>
{snippet && (
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{snippet}
</p>
)}
)}
>
<div className="hover:bg-accent hover:text-accent-foreground flex flex-col gap-2 p-3 transition-colors">
<div className="flex items-start gap-2">
{iconElement}
<span className="text-muted-foreground text-xs">{domain}</span>
</div>
</PopoverContent>
</Popover>
<p className="text-sm leading-snug font-medium">{title}</p>
{snippet && (
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{snippet}
</p>
)}
</div>
</CitationHoverPopover>
);
}
const cardClassName = cn(
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
"border-border bg-card border text-sm shadow-xs",
"transition-colors duration-150",
sanitizedHref && [
"cursor-pointer no-underline",
"hover:border-foreground/25",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
]
);
const cardContent = (
<div className="flex flex-col gap-2 p-4">
<div className="text-muted-foreground flex min-w-0 items-center justify-between gap-1.5 text-xs">
<div className="flex min-w-0 items-center gap-1.5">
{iconElement}
<span className="truncate font-medium">{domain}</span>
{(author || publishedAt) && (
<span className="opacity-70">
<span className="opacity-60"> </span>
{author}
{author && publishedAt && ", "}
{publishedAt && (
<time dateTime={publishedAt} className="tabular-nums">
{formatDate(publishedAt, locale)}
</time>
)}
</span>
)}
</div>
{sanitizedHref && (
<ExternalLink className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
)}
</div>
<h3 className="text-foreground text-[15px] leading-snug font-medium text-pretty">
<span className="group-hover:decoration-foreground/30 line-clamp-2 group-hover:underline group-hover:underline-offset-2">
{title}
</span>
</h3>
{snippet && (
<p className="text-muted-foreground text-[13px] leading-relaxed text-pretty">
<span className="line-clamp-3">{snippet}</span>
</p>
)}
</div>
);
// Default variant: full card
return (
<article
@ -184,59 +193,22 @@ export function Citation(props: CitationProps) {
data-tool-ui-id={id}
data-slot="citation"
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: div receives role="link" conditionally when href is present */}
<div
className={cn(
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
"border-border bg-card border text-sm shadow-xs",
"transition-colors duration-150",
sanitizedHref && [
"cursor-pointer",
"hover:border-foreground/25",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
]
)}
onClick={sanitizedHref ? handleClick : undefined}
role={sanitizedHref ? "link" : undefined}
tabIndex={sanitizedHref ? 0 : undefined}
onKeyDown={sanitizedHref ? handleKeyDown : undefined}
>
<div className="flex flex-col gap-2 p-4">
<div className="text-muted-foreground flex min-w-0 items-center justify-between gap-1.5 text-xs">
<div className="flex min-w-0 items-center gap-1.5">
{iconElement}
<span className="truncate font-medium">{domain}</span>
{(author || publishedAt) && (
<span className="opacity-70">
<span className="opacity-60"> </span>
{author}
{author && publishedAt && ", "}
{publishedAt && (
<time dateTime={publishedAt} className="tabular-nums">
{formatDate(publishedAt, locale)}
</time>
)}
</span>
)}
</div>
{sanitizedHref && (
<ExternalLink className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
)}
</div>
<h3 className="text-foreground text-[15px] leading-snug font-medium text-pretty">
<span className="group-hover:decoration-foreground/30 line-clamp-2 group-hover:underline group-hover:underline-offset-2">
{title}
</span>
</h3>
{snippet && (
<p className="text-muted-foreground text-[13px] leading-relaxed text-pretty">
<span className="line-clamp-3">{snippet}</span>
</p>
)}
</div>
</div>
{sanitizedHref ? (
<a
href={sanitizedHref}
target="_blank"
rel="noopener noreferrer"
className={cardClassName}
onClick={(event) => {
event.preventDefault();
handleClick();
}}
>
{cardContent}
</a>
) : (
<div className={cardClassName}>{cardContent}</div>
)}
</article>
);
}