diff --git a/apps/web/src/components/__tests__/condition-picker.test.tsx b/apps/web/src/components/__tests__/condition-picker.test.tsx index 8aa0bd2..4b94731 100644 --- a/apps/web/src/components/__tests__/condition-picker.test.tsx +++ b/apps/web/src/components/__tests__/condition-picker.test.tsx @@ -4,6 +4,7 @@ import "@testing-library/jest-dom/vitest"; import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain"; import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { createRef, type RefObject } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ConditionPicker } from "../condition-picker"; @@ -18,8 +19,13 @@ function renderPicker( ) { const onToggle = overrides.onToggle ?? vi.fn(); const onClose = overrides.onClose ?? vi.fn(); + const anchorRef = createRef() as RefObject; + const anchor = document.createElement("div"); + document.body.appendChild(anchor); + (anchorRef as { current: HTMLElement }).current = anchor; const result = render( (null); const prevHpRef = useRef(currentHp); const [isPulsing, setIsPulsing] = useState(false); @@ -567,13 +568,16 @@ export function CombatantRow({ color={pcColor} onToggleStatBlock={onToggleStatBlock} /> - toggleCondition(id, conditionId)} - onOpenPicker={() => setPickerOpen((prev) => !prev)} - /> +
+ toggleCondition(id, conditionId)} + onOpenPicker={() => setPickerOpen((prev) => !prev)} + /> +
{!!pickerOpen && ( toggleCondition(id, conditionId)} onClose={() => setPickerOpen(false)} diff --git a/apps/web/src/components/condition-picker.tsx b/apps/web/src/components/condition-picker.tsx index 324170d..110049e 100644 --- a/apps/web/src/components/condition-picker.tsx +++ b/apps/web/src/components/condition-picker.tsx @@ -18,6 +18,7 @@ import { ZapOff, } from "lucide-react"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { cn } from "../lib/utils"; const ICON_MAP: Record = { @@ -52,34 +53,45 @@ const COLOR_CLASSES: Record = { }; interface ConditionPickerProps { + anchorRef: React.RefObject; activeConditions: readonly ConditionId[] | undefined; onToggle: (conditionId: ConditionId) => void; onClose: () => void; } export function ConditionPicker({ + anchorRef, activeConditions, onToggle, onClose, }: Readonly) { const ref = useRef(null); - const [flipped, setFlipped] = useState(false); - const [maxHeight, setMaxHeight] = useState(undefined); + const [pos, setPos] = useState<{ + top: number; + left: number; + maxHeight: number; + } | null>(null); useLayoutEffect(() => { + const anchor = anchorRef.current; const el = ref.current; - if (!el) return; - const rect = el.getBoundingClientRect(); - const spaceBelow = window.innerHeight - rect.top; - const spaceAbove = rect.bottom; - const shouldFlip = - rect.bottom > window.innerHeight && spaceAbove > spaceBelow; - setFlipped(shouldFlip); - const available = shouldFlip ? spaceAbove : spaceBelow; - if (rect.height > available) { - setMaxHeight(available - 16); - } - }, []); + if (!anchor || !el) return; + + const anchorRect = anchor.getBoundingClientRect(); + const menuHeight = el.scrollHeight; + const pad = 8; + + const spaceBelow = window.innerHeight - anchorRect.bottom - pad; + const spaceAbove = anchorRect.top - pad; + const openBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove; + + const top = openBelow + ? anchorRect.bottom + 4 + : Math.max(pad, anchorRect.top - Math.min(menuHeight, spaceAbove) - 4); + const maxHeight = openBelow ? spaceBelow : Math.min(menuHeight, spaceAbove); + + setPos({ top, left: anchorRect.left, maxHeight }); + }, [anchorRef]); useEffect(() => { function handleClickOutside(e: MouseEvent) { @@ -93,14 +105,15 @@ export function ConditionPicker({ const active = new Set(activeConditions ?? []); - return ( + return createPortal(
{CONDITION_DEFINITIONS.map((def) => { const Icon = ICON_MAP[def.iconName]; @@ -129,6 +142,7 @@ export function ConditionPicker({ ); })} -
+ , + document.body, ); }