From 7b599e9ad93ea15da8d5d2ffa03d9f8ab88bfcb6 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 5 Aug 2026 14:47:24 +0200 Subject: [PATCH] Portal the HP adjust popover out of the dimmed row subtree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A combatant at 0 HP renders its row sections with opacity-50, and the HP popover lived inside the HP section. CSS opacity applies to the whole subtree, so position: fixed did not escape it — the popover you need to heal a downed creature came up half-transparent. Render it through createPortal into document.body, matching what ConditionPicker and DetailPopover already do. Positioning now takes an anchorRef instead of reading parentElement, since the portal's parent is the body, and the z-index moves to z-50 alongside the other portaled popovers. The standalone popover test has to mount the anchor before the popover: React attaches a parent's ref after its children's layout effects run, so a same-mount anchorRef is still null when the popover measures itself. That matches the app, where the popover only opens on click. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/combatant-row.test.tsx | 20 ++++++++++++ .../__tests__/hp-adjust-popover.test.tsx | 32 ++++++++++++++++++- apps/web/src/components/combatant-row.tsx | 4 ++- apps/web/src/components/hp-adjust-popover.tsx | 19 ++++++----- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/__tests__/combatant-row.test.tsx b/apps/web/src/components/__tests__/combatant-row.test.tsx index fcac89e..933990d 100644 --- a/apps/web/src/components/__tests__/combatant-row.test.tsx +++ b/apps/web/src/components/__tests__/combatant-row.test.tsx @@ -373,6 +373,26 @@ describe("CombatantRow", () => { ).toBeInTheDocument(); }); + it("popover is not dimmed when the combatant is downed", async () => { + const user = userEvent.setup(); + renderRow({ + combatant: { + id: combatantId("1"), + name: "Goblin", + maxHp: 10, + currentHp: 0, + }, + }); + + await user.click(screen.getByLabelText(CURRENT_HP_REGEX)); + // The row dims downed combatants with opacity-50, which would cascade + // to the popover if it rendered inside the dimmed subtree. + const popover = screen + .getByRole("button", { name: "Apply damage" }) + .closest(".opacity-50"); + expect(popover).toBeNull(); + }); + it("HP section is absent when maxHp is undefined", () => { renderRow({ combatant: { diff --git a/apps/web/src/components/__tests__/hp-adjust-popover.test.tsx b/apps/web/src/components/__tests__/hp-adjust-popover.test.tsx index 387a935..d8d8c8d 100644 --- a/apps/web/src/components/__tests__/hp-adjust-popover.test.tsx +++ b/apps/web/src/components/__tests__/hp-adjust-popover.test.tsx @@ -3,11 +3,41 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { useEffect, useRef, useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { HpAdjustPopover } from "../hp-adjust-popover"; afterEach(cleanup); +function AnchoredPopover({ + onAdjust, + onSetTempHp, + onClose, +}: Readonly<{ + onAdjust: (delta: number) => void; + onSetTempHp: (value: number) => void; + onClose: () => void; +}>) { + const anchorRef = useRef(null); + // The popover opens on click in the app, so its anchor is always mounted + // first. Mirror that here — otherwise the anchor ref is still null when the + // popover measures its position and it renders hidden. + const [open, setOpen] = useState(false); + useEffect(() => setOpen(true), []); + return ( +
+ {!!open && ( + + )} +
+ ); +} + function renderPopover( overrides: Partial<{ onAdjust: (delta: number) => void; @@ -19,7 +49,7 @@ function renderPopover( const onSetTempHp = overrides.onSetTempHp ?? vi.fn(); const onClose = overrides.onClose ?? vi.fn(); const result = render( - void; }>) { const [popoverOpen, setPopoverOpen] = useState(false); + const anchorRef = useRef(null); const status = deriveHpStatus(currentHp, maxHp); if (maxHp === undefined) { @@ -209,7 +210,7 @@ function ClickableHp({ } return ( -
+
-
+ , + document.body, ); }