From 6c2c094f647c74bfd7ab2568203133b566c77c66 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 29 Oct 2026 11:25:10 +0700 Subject: [PATCH] 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. --- client/src/pages/Artifacts.tsx | 159 +++++++++++------- client/src/pages/__tests__/Artifacts.test.tsx | 5 + 2 files changed, 102 insertions(+), 62 deletions(-) diff --git a/client/src/pages/Artifacts.tsx b/client/src/pages/Artifacts.tsx index 047dc2d1..0454432b 100644 --- a/client/src/pages/Artifacts.tsx +++ b/client/src/pages/Artifacts.tsx @@ -48,7 +48,8 @@ * @author Nguyễn Ngọc Trí Vĩ */ -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(null); const box = useRef(null); + const pop = useRef(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 })} - {open && ( -
-

{t("installsTitle")}

- {data === null ? ( -

{t("loading")}

- ) : ( -
    - {data.items.map((i, n) => ( -
  • -
    - - {i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))} - {i.replaced && ( - · {t("installsReplaced")} - )} - - - {new Date(i.at).toLocaleString()} - -
    - {i.paths && ( -
    - {i.paths.length ? ( - i.paths.map((p) => ( -

    - {p} -

    - )) - ) : ( -

    - {t("installsPluginWhere")} -

    - )} + {open && + createPortal( +
    +

    {t("installsTitle")}

    + {data === null ? ( +

    {t("loading")}

    + ) : ( +
      + {data.items.map((i, n) => ( +
    • +
      + + {i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))} + {i.replaced && ( + · {t("installsReplaced")} + )} + + + {new Date(i.at).toLocaleString()} +
      - )} -
    • - ))} - {data.unrecorded > 0 && ( -
    • - {t("installsUnrecorded", { count: data.unrecorded })} -
    • - )} - {data.items.length === 0 && data.unrecorded === 0 && ( -
    • - {t("installsNoneOfYours")} -
    • - )} -
    - )} -
    - )} + {i.paths && ( +
    + {i.paths.length ? ( + i.paths.map((p) => ( +

    + {p} +

    + )) + ) : ( +

    + {t("installsPluginWhere")} +

    + )} +
    + )} +
  • + ))} + {data.unrecorded > 0 && ( +
  • + {t("installsUnrecorded", { count: data.unrecorded })} +
  • + )} + {data.items.length === 0 && data.unrecorded === 0 && ( +
  • + {t("installsNoneOfYours")} +
  • + )} +
+ )} +
, + document.body + )} ); } diff --git a/client/src/pages/__tests__/Artifacts.test.tsx b/client/src/pages/__tests__/Artifacts.test.tsx index 8b66f6e8..a3066713 100644 --- a/client/src/pages/__tests__/Artifacts.test.tsx +++ b/client/src/pages/__tests__/Artifacts.test.tsx @@ -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(); });