fix(web): portal inline-code tooltip to body to prevent clipping (#1187)
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { InlineCode } from "./inline-code";
|
||||
|
||||
const { copyMock } = vi.hoisted(() => ({
|
||||
copyMock: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-copy-to-clipboard", () => ({
|
||||
useCopyToClipboard: () => ({ copy: copyMock }),
|
||||
}));
|
||||
|
||||
describe("InlineCode", () => {
|
||||
beforeEach(() => {
|
||||
copyMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders the inline code content", () => {
|
||||
render(<InlineCode>npm install</InlineCode>);
|
||||
expect(screen.getByText("npm install")).toBeDefined();
|
||||
});
|
||||
|
||||
it("portals the hover tooltip to document.body so overflow-hidden ancestors cannot clip it", () => {
|
||||
// Mirrors the user-message bubble with nested overflow-hidden layers that previously clipped the tooltip.
|
||||
const { container } = render(
|
||||
<div className="overflow-hidden">
|
||||
<div className="overflow-hidden">
|
||||
<InlineCode>git status</InlineCode>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText("git status"));
|
||||
|
||||
const tooltip = screen.getByRole("tooltip");
|
||||
expect(tooltip.textContent).toBe("Copy to clipboard");
|
||||
// Tooltip must escape the clipping wrapper: lives on document.body, not inside the container.
|
||||
expect(container.contains(tooltip)).toBe(false);
|
||||
expect(document.body.contains(tooltip)).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the tooltip when the pointer leaves", () => {
|
||||
render(<InlineCode>ls</InlineCode>);
|
||||
const code = screen.getByText("ls");
|
||||
|
||||
fireEvent.mouseEnter(code);
|
||||
expect(screen.queryByRole("tooltip")).not.toBeNull();
|
||||
|
||||
fireEvent.mouseLeave(code);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("copies the content and shows a Copied! confirmation on click", async () => {
|
||||
render(<InlineCode>echo hi</InlineCode>);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("echo hi"));
|
||||
});
|
||||
|
||||
expect(copyMock).toHaveBeenCalledWith("echo hi");
|
||||
expect(screen.getByRole("tooltip").textContent).toBe("Copied!");
|
||||
});
|
||||
|
||||
it("wires the trigger to the tooltip via aria-describedby only while visible", () => {
|
||||
render(<InlineCode>pwd</InlineCode>);
|
||||
const code = screen.getByText("pwd");
|
||||
|
||||
expect(code.getAttribute("aria-describedby")).toBeNull();
|
||||
|
||||
fireEvent.mouseEnter(code);
|
||||
const tooltip = screen.getByRole("tooltip");
|
||||
expect(code.getAttribute("aria-describedby")).toBe(tooltip.id);
|
||||
expect(tooltip.id).not.toBe("");
|
||||
|
||||
fireEvent.mouseLeave(code);
|
||||
expect(code.getAttribute("aria-describedby")).toBeNull();
|
||||
});
|
||||
|
||||
it("dismisses the tooltip on scroll so it cannot drift from the anchored code", () => {
|
||||
render(<InlineCode>cd</InlineCode>);
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText("cd"));
|
||||
expect(screen.queryByRole("tooltip")).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
|
||||
|
||||
@@ -8,45 +9,89 @@ type InlineCodeProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const COPIED_RESET_MS = 1500;
|
||||
|
||||
type TooltipAnchor = { left: number; top: number };
|
||||
|
||||
// Tooltip is portaled to document.body so overflow-hidden ancestors (e.g. the user-message bubble) can't clip it.
|
||||
export function InlineCode({ children }: InlineCodeProps) {
|
||||
const { copy } = useCopyToClipboard();
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [tooltipText, setTooltipText] = useState("Copy to clipboard");
|
||||
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const tooltipId = useId();
|
||||
|
||||
const handleClick = async () => {
|
||||
const text = String(children);
|
||||
await copy(text);
|
||||
setTooltipText("Copied!");
|
||||
setShowTooltip(true);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
setShowTooltip(false);
|
||||
setTimeout(() => setTooltipText("Copy to clipboard"), 200);
|
||||
}, 1500);
|
||||
// Viewport snapshot; dismiss on scroll/resize rather than repositioning.
|
||||
const visible = hovered || copied;
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const dismiss = () => {
|
||||
setHovered(false);
|
||||
setCopied(false);
|
||||
};
|
||||
window.addEventListener("scroll", dismiss, true);
|
||||
window.addEventListener("resize", dismiss);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", dismiss, true);
|
||||
window.removeEventListener("resize", dismiss);
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
const anchorTo = (el: HTMLElement) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setAnchor({ left: rect.left + rect.width / 2, top: rect.top });
|
||||
};
|
||||
|
||||
const handleClick = async (event: React.MouseEvent<HTMLElement>) => {
|
||||
anchorTo(event.currentTarget);
|
||||
await copy(String(children));
|
||||
setCopied(true);
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
resetTimer.current = setTimeout(() => setCopied(false), COPIED_RESET_MS);
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="relative inline-block group/inline-code">
|
||||
<>
|
||||
<code
|
||||
onClick={handleClick}
|
||||
onMouseEnter={(event) => {
|
||||
anchorTo(event.currentTarget);
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
aria-describedby={visible ? tooltipId : undefined}
|
||||
className="cursor-pointer hover:bg-foreground/10 transition-colors"
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
|
||||
{/* Tooltip */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-full left-1/2 -translate-x-1/2 mb-1",
|
||||
"px-2 py-1 text-xs text-popover-foreground bg-popover border border-border rounded shadow-md whitespace-nowrap",
|
||||
"pointer-events-none select-none transition-opacity duration-200",
|
||||
"opacity-0 group-hover/inline-code:opacity-100",
|
||||
showTooltip && "opacity-100",
|
||||
{visible &&
|
||||
anchor &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<span
|
||||
role="tooltip"
|
||||
id={tooltipId}
|
||||
style={{ left: anchor.left, top: anchor.top - 4 }}
|
||||
className={cn(
|
||||
"fixed z-50 -translate-x-1/2 -translate-y-full",
|
||||
"rounded border border-border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md",
|
||||
"pointer-events-none select-none whitespace-nowrap",
|
||||
"animate-in fade-in-0 duration-150",
|
||||
)}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy to clipboard"}
|
||||
</span>,
|
||||
document.body,
|
||||
)}
|
||||
>
|
||||
{tooltipText}
|
||||
<span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-border" />
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user