Extract shared DetailPopover shell from spell popovers. Normalize weapon/consumable/equipment/armor items from Foundry data into mundane (Items line) and detailed (Equipment section with clickable popovers). Scrolls/wands show embedded spell info. Bump IDB cache v7. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
142 lines
3.9 KiB
TypeScript
142 lines
3.9 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
|
import { useSwipeToDismissDown } from "../hooks/use-swipe-to-dismiss.js";
|
|
import { cn } from "../lib/utils.js";
|
|
|
|
interface DetailPopoverProps {
|
|
readonly anchorRect: DOMRect;
|
|
readonly onClose: () => void;
|
|
readonly ariaLabel: string;
|
|
readonly children: ReactNode;
|
|
}
|
|
|
|
function DesktopPanel({
|
|
anchorRect,
|
|
onClose,
|
|
ariaLabel,
|
|
children,
|
|
}: Readonly<DetailPopoverProps>) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
|
|
|
useLayoutEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const popover = el.getBoundingClientRect();
|
|
const vw = document.documentElement.clientWidth;
|
|
const vh = document.documentElement.clientHeight;
|
|
// Prefer placement to the LEFT of the anchor (panel is on the right edge)
|
|
let left = anchorRect.left - popover.width - 8;
|
|
if (left < 8) {
|
|
left = anchorRect.right + 8;
|
|
}
|
|
if (left + popover.width > vw - 8) {
|
|
left = vw - popover.width - 8;
|
|
}
|
|
let top = anchorRect.top;
|
|
if (top + popover.height > vh - 8) {
|
|
top = vh - popover.height - 8;
|
|
}
|
|
if (top < 8) top = 8;
|
|
setPos({ top, left });
|
|
}, [anchorRect]);
|
|
|
|
useClickOutside(ref, onClose);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className="card-glow fixed z-50 max-h-[calc(100vh-16px)] w-80 max-w-[calc(100vw-16px)] overflow-y-auto rounded-lg border border-border bg-card p-4 shadow-lg"
|
|
style={pos ? { top: pos.top, left: pos.left } : { visibility: "hidden" }}
|
|
role="dialog"
|
|
aria-label={ariaLabel}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MobileSheet({
|
|
onClose,
|
|
ariaLabel,
|
|
children,
|
|
}: Readonly<Omit<DetailPopoverProps, "anchorRect">>) {
|
|
const { offsetY, isSwiping, handlers } = useSwipeToDismissDown(onClose);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
document.addEventListener("keydown", handler);
|
|
return () => document.removeEventListener("keydown", handler);
|
|
}, [onClose]);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50">
|
|
<button
|
|
type="button"
|
|
className="fade-in absolute inset-0 animate-in bg-black/50"
|
|
onClick={onClose}
|
|
aria-label="Close details"
|
|
/>
|
|
<div
|
|
className={cn(
|
|
"panel-glow absolute right-0 bottom-0 left-0 max-h-[80vh] rounded-t-2xl border-border border-t bg-card",
|
|
!isSwiping && "animate-slide-in-bottom",
|
|
)}
|
|
style={
|
|
isSwiping ? { transform: `translateY(${offsetY}px)` } : undefined
|
|
}
|
|
{...handlers}
|
|
role="dialog"
|
|
aria-label={ariaLabel}
|
|
>
|
|
<div className="flex justify-center pt-2 pb-1">
|
|
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
|
|
</div>
|
|
<div className="max-h-[calc(80vh-24px)] overflow-y-auto p-4">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DetailPopover({
|
|
anchorRect,
|
|
onClose,
|
|
ariaLabel,
|
|
children,
|
|
}: Readonly<DetailPopoverProps>) {
|
|
const [isDesktop, setIsDesktop] = useState(
|
|
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
|
);
|
|
|
|
useEffect(() => {
|
|
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
|
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
|
mq.addEventListener("change", handler);
|
|
return () => mq.removeEventListener("change", handler);
|
|
}, []);
|
|
|
|
// Portal to document.body to escape any CSS transforms on ancestors
|
|
// (the side panel uses translate-x for collapse animation, which would
|
|
// otherwise become the containing block for fixed-positioned children).
|
|
const content = isDesktop ? (
|
|
<DesktopPanel
|
|
anchorRect={anchorRect}
|
|
onClose={onClose}
|
|
ariaLabel={ariaLabel}
|
|
>
|
|
{children}
|
|
</DesktopPanel>
|
|
) : (
|
|
<MobileSheet onClose={onClose} ariaLabel={ariaLabel}>
|
|
{children}
|
|
</MobileSheet>
|
|
);
|
|
return createPortal(content, document.body);
|
|
}
|