Single-clicking a combatant name now opens the stat block panel instead of entering edit mode. Renaming is triggered by double-click, a hover pencil icon, or long-press on touch. Also fixes condition picker positioning when near viewport edges. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
135 lines
3.1 KiB
TypeScript
135 lines
3.1 KiB
TypeScript
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import {
|
|
ArrowDown,
|
|
Ban,
|
|
BatteryLow,
|
|
Droplet,
|
|
EarOff,
|
|
EyeOff,
|
|
Gem,
|
|
Ghost,
|
|
Hand,
|
|
Heart,
|
|
Link,
|
|
Moon,
|
|
Siren,
|
|
Sparkles,
|
|
ZapOff,
|
|
} from "lucide-react";
|
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { cn } from "../lib/utils";
|
|
|
|
const ICON_MAP: Record<string, LucideIcon> = {
|
|
EyeOff,
|
|
Heart,
|
|
EarOff,
|
|
BatteryLow,
|
|
Siren,
|
|
Hand,
|
|
Ban,
|
|
Ghost,
|
|
ZapOff,
|
|
Gem,
|
|
Droplet,
|
|
ArrowDown,
|
|
Link,
|
|
Sparkles,
|
|
Moon,
|
|
};
|
|
|
|
const COLOR_CLASSES: Record<string, string> = {
|
|
neutral: "text-muted-foreground",
|
|
pink: "text-pink-400",
|
|
amber: "text-amber-400",
|
|
orange: "text-orange-400",
|
|
gray: "text-gray-400",
|
|
violet: "text-violet-400",
|
|
yellow: "text-yellow-400",
|
|
slate: "text-slate-400",
|
|
green: "text-green-400",
|
|
indigo: "text-indigo-400",
|
|
};
|
|
|
|
interface ConditionPickerProps {
|
|
activeConditions: readonly ConditionId[] | undefined;
|
|
onToggle: (conditionId: ConditionId) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function ConditionPicker({
|
|
activeConditions,
|
|
onToggle,
|
|
onClose,
|
|
}: ConditionPickerProps) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [flipped, setFlipped] = useState(false);
|
|
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
|
|
|
useLayoutEffect(() => {
|
|
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);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
onClose();
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [onClose]);
|
|
|
|
const active = new Set(activeConditions ?? []);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={cn(
|
|
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg",
|
|
flipped ? "bottom-full mb-1" : "top-full mt-1",
|
|
)}
|
|
style={maxHeight ? { maxHeight } : undefined}
|
|
>
|
|
{CONDITION_DEFINITIONS.map((def) => {
|
|
const Icon = ICON_MAP[def.iconName];
|
|
if (!Icon) return null;
|
|
const isActive = active.has(def.id);
|
|
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
|
return (
|
|
<button
|
|
key={def.id}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
|
isActive && "bg-card/50",
|
|
)}
|
|
onClick={() => onToggle(def.id)}
|
|
>
|
|
<Icon
|
|
size={14}
|
|
className={isActive ? colorClass : "text-muted-foreground"}
|
|
/>
|
|
<span
|
|
className={isActive ? "text-foreground" : "text-muted-foreground"}
|
|
>
|
|
{def.label}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|