fix(web): portal inline-code tooltip to body to prevent clipping (#1187)

This commit is contained in:
Carlos Florêncio
2026-05-31 14:35:30 +01:00
committed by GitHub
parent dea04b2bed
commit f7f4a965a1
2 changed files with 166 additions and 26 deletions
@@ -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>
</>
);
}