forked from phenom/healer-man
147 lines
6.1 KiB
TypeScript
147 lines
6.1 KiB
TypeScript
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 <>
|
|
<header><strong>{ability.name}</strong>{bindingLabel && <kbd>{bindingLabel}</kbd>}</header>
|
|
<p>{ability.description || "No description available for this ability."}</p>
|
|
<footer>{abilityTooltipFacts(ability).map((fact) => <span key={fact}>{fact}</span>)}</footer>
|
|
{ability.lockedUntilLevel !== undefined && <p className="ability-tooltip__lock">Unlocks at level {ability.lockedUntilLevel}</p>}
|
|
{ability.talentLocked && <p className="ability-tooltip__lock">Learn in talents</p>}
|
|
</>;
|
|
}
|
|
|
|
/** 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<ButtonHTMLAttributes<HTMLButtonElement>>;
|
|
}) {
|
|
const id = useId();
|
|
const [anchor, setAnchor] = useState<HTMLButtonElement | null>(null);
|
|
const [position, setPosition] = useState({ left: 12, top: 12 });
|
|
const tooltip = useRef<HTMLDivElement>(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<string>).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(
|
|
<div ref={tooltip} id={id} role="tooltip" className="ability-tooltip" style={position}>
|
|
<AbilityTooltipContent ability={ability!} bindingLabel={bindingLabel} />
|
|
</div>, document.body,
|
|
)}
|
|
</>;
|
|
}
|