Add condition tooltips with 5.5e descriptions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
@@ -121,25 +122,28 @@ export function ConditionPicker({
|
||||
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"}
|
||||
<Tooltip key={def.id} content={def.description} 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)}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
</button>
|
||||
<Icon
|
||||
size={14}
|
||||
className={isActive ? colorClass : "text-muted-foreground"}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
isActive ? "text-foreground" : "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
@@ -71,22 +72,22 @@ export function ConditionTags({
|
||||
if (!Icon) return null;
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<button
|
||||
key={condId}
|
||||
type="button"
|
||||
title={def.label}
|
||||
aria-label={`Remove ${def.label}`}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||
colorClass,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(condId);
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
<Tooltip key={condId} content={`${def.label}: ${def.description}`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${def.label}`}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||
colorClass,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(condId);
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
|
||||
55
apps/web/src/components/ui/tooltip.tsx
Normal file
55
apps/web/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { type ReactNode, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface TooltipProps {
|
||||
content: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
content,
|
||||
children,
|
||||
className,
|
||||
}: Readonly<TooltipProps>) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
function show() {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setPos({
|
||||
top: rect.top - 4,
|
||||
left: rect.left + rect.width / 2,
|
||||
});
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setPos(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={ref}
|
||||
onPointerEnter={show}
|
||||
onPointerLeave={hide}
|
||||
className={className ?? "inline-flex"}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{pos !== null &&
|
||||
createPortal(
|
||||
<div
|
||||
role="tooltip"
|
||||
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,63 +18,127 @@ export type ConditionId =
|
||||
export interface ConditionDefinition {
|
||||
readonly id: ConditionId;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly iconName: string;
|
||||
readonly color: string;
|
||||
}
|
||||
|
||||
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
{ id: "blinded", label: "Blinded", iconName: "EyeOff", color: "neutral" },
|
||||
{ id: "charmed", label: "Charmed", iconName: "Heart", color: "pink" },
|
||||
{ id: "deafened", label: "Deafened", iconName: "EarOff", color: "neutral" },
|
||||
{
|
||||
id: "blinded",
|
||||
label: "Blinded",
|
||||
description:
|
||||
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
||||
iconName: "EyeOff",
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "charmed",
|
||||
label: "Charmed",
|
||||
description:
|
||||
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
|
||||
iconName: "Heart",
|
||||
color: "pink",
|
||||
},
|
||||
{
|
||||
id: "deafened",
|
||||
label: "Deafened",
|
||||
description: "Can't hear. Auto-fail hearing checks.",
|
||||
iconName: "EarOff",
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "exhaustion",
|
||||
label: "Exhaustion",
|
||||
description:
|
||||
"Subtract exhaustion level from D20 Tests and Spell save DCs. Speed reduced by 5 ft. \u00D7 level. Removed by long rest (1 level) or death at 10 levels.",
|
||||
iconName: "BatteryLow",
|
||||
color: "amber",
|
||||
},
|
||||
{
|
||||
id: "frightened",
|
||||
label: "Frightened",
|
||||
description:
|
||||
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
||||
iconName: "Siren",
|
||||
color: "orange",
|
||||
},
|
||||
{ id: "grappled", label: "Grappled", iconName: "Hand", color: "neutral" },
|
||||
{
|
||||
id: "grappled",
|
||||
label: "Grappled",
|
||||
description:
|
||||
"Speed is 0 and can't benefit from bonuses to speed. Ends if grappler is Incapacitated or moved out of reach.",
|
||||
iconName: "Hand",
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "incapacitated",
|
||||
label: "Incapacitated",
|
||||
description:
|
||||
"Can't take Actions, Bonus Actions, or Reactions. Concentration is broken.",
|
||||
iconName: "Ban",
|
||||
color: "gray",
|
||||
},
|
||||
{
|
||||
id: "invisible",
|
||||
label: "Invisible",
|
||||
description:
|
||||
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
|
||||
iconName: "Ghost",
|
||||
color: "violet",
|
||||
},
|
||||
{
|
||||
id: "paralyzed",
|
||||
label: "Paralyzed",
|
||||
description:
|
||||
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||
iconName: "ZapOff",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
id: "petrified",
|
||||
label: "Petrified",
|
||||
description:
|
||||
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
||||
iconName: "Gem",
|
||||
color: "slate",
|
||||
},
|
||||
{ id: "poisoned", label: "Poisoned", iconName: "Droplet", color: "green" },
|
||||
{ id: "prone", label: "Prone", iconName: "ArrowDown", color: "neutral" },
|
||||
{
|
||||
id: "poisoned",
|
||||
label: "Poisoned",
|
||||
description: "Disadvantage on attack rolls and ability checks.",
|
||||
iconName: "Droplet",
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
id: "prone",
|
||||
label: "Prone",
|
||||
description:
|
||||
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
||||
iconName: "ArrowDown",
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "restrained",
|
||||
label: "Restrained",
|
||||
description:
|
||||
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||
iconName: "Link",
|
||||
color: "neutral",
|
||||
},
|
||||
{ id: "stunned", label: "Stunned", iconName: "Sparkles", color: "yellow" },
|
||||
{
|
||||
id: "stunned",
|
||||
label: "Stunned",
|
||||
description:
|
||||
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
||||
iconName: "Sparkles",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
id: "unconscious",
|
||||
label: "Unconscious",
|
||||
description:
|
||||
"Incapacitated. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||
iconName: "Moon",
|
||||
color: "indigo",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user