import { cloneElement, useEffect, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type ReactElement, } from "react"; import { createPortal } from "react-dom"; import type { AbilityTarget } from "../game/abilityCatalog"; const INSPECT_EVENT = "healer-man:ability-tooltip-inspect"; export interface AbilityTooltipData { name: string; description?: string; resourceCost?: number; resourceLabel?: string; cooldownSeconds?: number; castSeconds?: number; range?: number; target?: AbilityTarget; passive?: boolean; rank?: number; lockedUntilLevel?: number; talentLocked?: boolean; } export function abilityTooltipFacts(ability: AbilityTooltipData): string[] { const facts: string[] = []; if (ability.passive) facts.push("Passive"); if (ability.rank !== undefined) facts.push(`Rank ${ability.rank}`); if (!ability.passive) { if (ability.resourceCost !== undefined) facts.push(ability.resourceCost > 0 ? `${Number(ability.resourceCost.toFixed(2))} ${ability.resourceLabel ?? "resource"}` : "No cost"); if (ability.castSeconds !== undefined) facts.push(ability.castSeconds > 0 ? `${ability.castSeconds} sec cast` : "Instant"); if (ability.cooldownSeconds !== undefined) facts.push(ability.cooldownSeconds > 0 ? `${ability.cooldownSeconds} sec cooldown` : "No cooldown"); if (ability.range !== undefined && ability.range > 0) facts.push(`${ability.range} m range`); if (ability.target) facts.push({ friendly: "Selected ally or self", hostile: "Enemy target", any: "Enemy or ally", self: "Self" }[ability.target]); } return facts; } export function abilityTooltipPosition( anchor: { left: number; right: number; top: number; bottom: number }, size: { width: number; height: number }, viewport: { width: number; height: number }, ): { left: number; top: number } { const margin = 12; const gap = 8; const clamp = (value: number, maximum: number) => Math.max(margin, Math.min(value, maximum - margin)); const above = anchor.top - size.height - gap; return { left: clamp((anchor.left + anchor.right - size.width) / 2, viewport.width - size.width), top: clamp(above >= margin ? above : anchor.bottom + gap, viewport.height - size.height), }; } export function AbilityTooltipContent({ ability, bindingLabel }: { ability: AbilityTooltipData; bindingLabel?: string }) { return <>
{ability.name}{bindingLabel && {bindingLabel}}

{ability.description || "No description available for this ability."}

{ability.lockedUntilLevel !== undefined &&

Unlocks at level {ability.lockedUntilLevel}

} {ability.talentLocked &&

Learn in talents

} ; } /** Adds inspection without a layout wrapper or changing the control's activation behavior. */ export function AbilityTooltipTarget({ ability, bindingLabel, children }: { ability: AbilityTooltipData | null | undefined; bindingLabel?: string; children: ReactElement>; }) { const id = useId(); const [anchor, setAnchor] = useState(null); const [position, setPosition] = useState({ left: 12, top: 12 }); const tooltip = useRef(null); const hovering = useRef(false); const keyboardFocused = useRef(false); const visible = Boolean(anchor && ability); const inspect = (element: HTMLButtonElement) => { window.dispatchEvent(new CustomEvent(INSPECT_EVENT, { detail: id })); setAnchor(element); }; useLayoutEffect(() => { if (!anchor || !tooltip.current || !ability) return; setPosition(abilityTooltipPosition(anchor.getBoundingClientRect(), tooltip.current.getBoundingClientRect(), { width: window.innerWidth, height: window.innerHeight, })); }, [anchor, ability, bindingLabel]); useEffect(() => { if (!visible) return; const dismiss = () => { hovering.current = false; keyboardFocused.current = false; setAnchor(null); }; const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") dismiss(); }; const onInspect = (event: Event) => { if ((event as CustomEvent).detail !== id) dismiss(); }; window.addEventListener(INSPECT_EVENT, onInspect); window.addEventListener("keydown", onKey); window.addEventListener("blur", dismiss); window.addEventListener("resize", dismiss); window.addEventListener("scroll", dismiss, true); return () => { window.removeEventListener(INSPECT_EVENT, onInspect); window.removeEventListener("keydown", onKey); window.removeEventListener("blur", dismiss); window.removeEventListener("resize", dismiss); window.removeEventListener("scroll", dismiss, true); }; }, [visible, id]); const original = children.props; return <> {cloneElement(children, { title: ability ? undefined : original.title, "aria-describedby": [original["aria-describedby"], visible ? id : undefined].filter(Boolean).join(" ") || undefined, onPointerEnter: (event) => { original.onPointerEnter?.(event); if (event.pointerType === "touch" || !ability) return; hovering.current = true; inspect(event.currentTarget); }, onPointerLeave: (event) => { original.onPointerLeave?.(event); hovering.current = false; if (!keyboardFocused.current) setAnchor(null); }, onFocus: (event) => { original.onFocus?.(event); keyboardFocused.current = event.currentTarget.matches(":focus-visible"); if (keyboardFocused.current && ability) inspect(event.currentTarget); }, onBlur: (event) => { original.onBlur?.(event); keyboardFocused.current = false; if (!hovering.current) setAnchor(null); }, })} {visible && createPortal( , document.body, )} ; }