fix(artifacts): the installs list floats over the page, never cut off

It was an absolute box inside the card list, whose overflow-hidden clipped it
at the last card, and it opened to the right off a button near the edge. It
is now a portal at the button: below it, above when there is no room, kept
inside the window, following scroll and resize.
This commit is contained in:
2026-10-29 11:25:10 +07:00
parent 609a35f251
commit 6c2c094f64
2 changed files with 102 additions and 62 deletions
+97 -62
View File
@@ -48,7 +48,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
ChevronRight,
@@ -1629,12 +1630,41 @@ function DetailDialog({
);
}
/** "N installs" on a card; a click lists who installed it, when, and where. */
/** "N installs" on a card; a click lists who installed it, when, and where.
* The list floats over the page (a portal at the button, below it or above
* when there is no room, kept inside the window): inside the card list's
* `overflow-hidden` it was cut off at the last card. */
function InstallsButton({ item }: { item: Artifact }) {
const { t } = useTranslation("artifacts");
const [open, setOpen] = useState(false);
const [data, setData] = useState<ArtifactInstalls | null>(null);
const box = useRef<HTMLSpanElement>(null);
const pop = useRef<HTMLDivElement>(null);
const [at, setAt] = useState<{ left: number; top: number } | null>(null);
useLayoutEffect(() => {
if (!open) return setAt(null);
const place = () => {
const b = box.current?.getBoundingClientRect();
const p = pop.current;
if (!b || !p) return;
const w = p.offsetWidth;
const h = p.offsetHeight;
const gap = 4;
const below = b.bottom + gap + h <= window.innerHeight - 8 || b.top - gap - h < 8;
setAt({
left: Math.max(8, Math.min(b.left, window.innerWidth - w - 8)),
top: below ? b.bottom + gap : b.top - gap - h,
});
};
place();
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [open, data]);
useEffect(() => {
if (!open) return;
@@ -1647,7 +1677,8 @@ function InstallsButton({ item }: { item: Artifact }) {
toast.error(err);
});
const onDown = (e: MouseEvent) => {
if (!box.current?.contains(e.target as Node)) setOpen(false);
const n = e.target as Node;
if (!box.current?.contains(n) && !pop.current?.contains(n)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
@@ -1670,66 +1701,70 @@ function InstallsButton({ item }: { item: Artifact }) {
>
{t("installs", { count: item.installs })}
</button>
{open && (
<div
role="dialog"
aria-label={t("installsTitle")}
data-testid="artifact-installs"
className="absolute left-0 z-30 mt-1 w-80 max-w-[80vw] rounded-lg border border-border bg-surface-1 p-2 shadow-xl"
>
<p className="mb-1.5 px-1 text-xs font-medium text-fg-primary">{t("installsTitle")}</p>
{data === null ? (
<p className="px-1 text-xs text-fg-muted">{t("loading")}</p>
) : (
<ul className="max-h-64 space-y-1 overflow-auto">
{data.items.map((i, n) => (
<li key={n} className="rounded px-1.5 py-1 text-xs hover:bg-surface-2">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-fg-primary">
{i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))}
{i.replaced && (
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
)}
</span>
<span className="shrink-0 text-[11px] text-fg-muted">
{new Date(i.at).toLocaleString()}
</span>
</div>
{i.paths && (
<div className="mt-0.5 space-y-0.5">
{i.paths.length ? (
i.paths.map((p) => (
<p
key={p}
className="truncate font-mono text-[10px] text-fg-muted"
title={p}
>
{p}
</p>
))
) : (
<p className="font-mono text-[10px] text-fg-muted">
{t("installsPluginWhere")}
</p>
)}
{open &&
createPortal(
<div
ref={pop}
role="dialog"
aria-label={t("installsTitle")}
data-testid="artifact-installs"
style={at ? { left: at.left, top: at.top } : { left: 0, top: 0, visibility: "hidden" }}
className="fixed z-[60] w-80 max-w-[80vw] rounded-lg border border-border bg-surface-1 p-2 shadow-xl"
>
<p className="mb-1.5 px-1 text-xs font-medium text-fg-primary">{t("installsTitle")}</p>
{data === null ? (
<p className="px-1 text-xs text-fg-muted">{t("loading")}</p>
) : (
<ul className="max-h-64 space-y-1 overflow-auto">
{data.items.map((i, n) => (
<li key={n} className="rounded px-1.5 py-1 text-xs hover:bg-surface-2">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-fg-primary">
{i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))}
{i.replaced && (
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
)}
</span>
<span className="shrink-0 text-[11px] text-fg-muted">
{new Date(i.at).toLocaleString()}
</span>
</div>
)}
</li>
))}
{data.unrecorded > 0 && (
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
{t("installsUnrecorded", { count: data.unrecorded })}
</li>
)}
{data.items.length === 0 && data.unrecorded === 0 && (
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
{t("installsNoneOfYours")}
</li>
)}
</ul>
)}
</div>
)}
{i.paths && (
<div className="mt-0.5 space-y-0.5">
{i.paths.length ? (
i.paths.map((p) => (
<p
key={p}
className="truncate font-mono text-[10px] text-fg-muted"
title={p}
>
{p}
</p>
))
) : (
<p className="font-mono text-[10px] text-fg-muted">
{t("installsPluginWhere")}
</p>
)}
</div>
)}
</li>
))}
{data.unrecorded > 0 && (
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
{t("installsUnrecorded", { count: data.unrecorded })}
</li>
)}
{data.items.length === 0 && data.unrecorded === 0 && (
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
{t("installsNoneOfYours")}
</li>
)}
</ul>
)}
</div>,
document.body
)}
</span>
);
}
@@ -362,6 +362,11 @@ describe("Artifacts", () => {
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
expect(within(box).getByText(/khoa/)).toBeInTheDocument();
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
// Over the page, outside the card list that clips its overflow — and a
// click inside it keeps it open.
expect(box.parentElement).toBe(document.body);
await user.click(within(box).getByText(/khoa/));
expect(screen.getByTestId("artifact-installs")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByTestId("artifact-installs")).toBeNull();
});