f4fb69dbc7
Add jsinspect-plus (AST-based structural duplication detector) to pnpm check with threshold 50 / min 3 instances. Fix all findings: - Extract condition icon/color maps to shared condition-styles.ts - Extract useClickOutside hook (5 components) - Extract dispatchAction + resolveAndRename in use-encounter - Extract runEncounterAction in application layer (13 use cases) - Extract findCombatant helper in domain (9 functions) - Extract TraitSection in stat-block (4 trait rendering blocks) - Extract DialogHeader in dialog.tsx (4 dialogs) Net result: -263 lines across 40 files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
113 lines
3.1 KiB
TypeScript
113 lines
3.1 KiB
TypeScript
import {
|
|
type ConditionId,
|
|
getConditionDescription,
|
|
getConditionsForEdition,
|
|
} from "@initiative/domain";
|
|
import { useLayoutEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
|
import { cn } from "../lib/utils";
|
|
import {
|
|
CONDITION_COLOR_CLASSES,
|
|
CONDITION_ICON_MAP,
|
|
} from "./condition-styles.js";
|
|
import { Tooltip } from "./ui/tooltip.js";
|
|
|
|
interface ConditionPickerProps {
|
|
anchorRef: React.RefObject<HTMLElement | null>;
|
|
activeConditions: readonly ConditionId[] | undefined;
|
|
onToggle: (conditionId: ConditionId) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function ConditionPicker({
|
|
anchorRef,
|
|
activeConditions,
|
|
onToggle,
|
|
onClose,
|
|
}: Readonly<ConditionPickerProps>) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [pos, setPos] = useState<{
|
|
top: number;
|
|
left: number;
|
|
maxHeight: number;
|
|
} | null>(null);
|
|
|
|
useLayoutEffect(() => {
|
|
const anchor = anchorRef.current;
|
|
const el = ref.current;
|
|
if (!anchor || !el) return;
|
|
|
|
const anchorRect = anchor.getBoundingClientRect();
|
|
const menuHeight = el.scrollHeight;
|
|
const pad = 8;
|
|
|
|
const spaceBelow = window.innerHeight - anchorRect.bottom - pad;
|
|
const spaceAbove = anchorRect.top - pad;
|
|
const openBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove;
|
|
|
|
const top = openBelow
|
|
? anchorRect.bottom + 4
|
|
: Math.max(pad, anchorRect.top - Math.min(menuHeight, spaceAbove) - 4);
|
|
const maxHeight = openBelow ? spaceBelow : Math.min(menuHeight, spaceAbove);
|
|
|
|
setPos({ top, left: anchorRect.left, maxHeight });
|
|
}, [anchorRef]);
|
|
|
|
useClickOutside(ref, onClose);
|
|
|
|
const { edition } = useRulesEditionContext();
|
|
const conditions = getConditionsForEdition(edition);
|
|
const active = new Set(activeConditions ?? []);
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={ref}
|
|
className="card-glow fixed z-50 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1"
|
|
style={
|
|
pos
|
|
? { top: pos.top, left: pos.left, maxHeight: pos.maxHeight }
|
|
: { visibility: "hidden" as const }
|
|
}
|
|
>
|
|
{conditions.map((def) => {
|
|
const Icon = CONDITION_ICON_MAP[def.iconName];
|
|
if (!Icon) return null;
|
|
const isActive = active.has(def.id);
|
|
const colorClass =
|
|
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
|
return (
|
|
<Tooltip
|
|
key={def.id}
|
|
content={getConditionDescription(def, edition)}
|
|
className="block"
|
|
>
|
|
<button
|
|
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>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|