Add PF2e equipment display with detail popovers in stat blocks
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>
This commit is contained in:
141
apps/web/src/components/detail-popover.tsx
Normal file
141
apps/web/src/components/detail-popover.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user