Portal the HP adjust popover out of the dimmed row subtree
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div ref={anchorRef}>
|
||||
{!!open && (
|
||||
<HpAdjustPopover
|
||||
anchorRef={anchorRef}
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<HpAdjustPopover
|
||||
<AnchoredPopover
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={onClose}
|
||||
|
||||
@@ -202,6 +202,7 @@ function ClickableHp({
|
||||
onSetTempHp: (value: number) => void;
|
||||
}>) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
|
||||
if (maxHp === undefined) {
|
||||
@@ -209,7 +210,7 @@ function ClickableHp({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<div ref={anchorRef} className="relative flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPopoverOpen(true)}
|
||||
@@ -230,6 +231,7 @@ function ClickableHp({
|
||||
)}
|
||||
{!!popoverOpen && (
|
||||
<HpAdjustPopover
|
||||
anchorRef={anchorRef}
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={() => setPopoverOpen(false)}
|
||||
|
||||
@@ -6,18 +6,21 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||
|
||||
interface HpAdjustPopoverProps {
|
||||
readonly anchorRef: React.RefObject<HTMLElement | null>;
|
||||
readonly onAdjust: (delta: number) => void;
|
||||
readonly onSetTempHp: (value: number) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export function HpAdjustPopover({
|
||||
anchorRef,
|
||||
onAdjust,
|
||||
onSetTempHp,
|
||||
onClose,
|
||||
@@ -29,10 +32,9 @@ export function HpAdjustPopover({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const parent = el.parentElement;
|
||||
if (!parent) return;
|
||||
const trigger = parent.getBoundingClientRect();
|
||||
const anchor = anchorRef.current;
|
||||
if (!el || !anchor) return;
|
||||
const trigger = anchor.getBoundingClientRect();
|
||||
const popover = el.getBoundingClientRect();
|
||||
const vw = document.documentElement.clientWidth;
|
||||
let left = trigger.left;
|
||||
@@ -43,7 +45,7 @@ export function HpAdjustPopover({
|
||||
left = 8;
|
||||
}
|
||||
setPos({ top: trigger.bottom + 4, left });
|
||||
}, []);
|
||||
}, [anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
@@ -82,10 +84,10 @@ export function HpAdjustPopover({
|
||||
[applyDelta, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
|
||||
className="card-glow fixed z-50 rounded-lg border border-border bg-background p-2"
|
||||
style={
|
||||
pos
|
||||
? { top: pos.top, left: pos.left }
|
||||
@@ -144,6 +146,7 @@ export function HpAdjustPopover({
|
||||
<ShieldPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user