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 { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -121,8 +122,8 @@ export function ConditionPicker({
|
|||||||
const isActive = active.has(def.id);
|
const isActive = active.has(def.id);
|
||||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
|
<Tooltip key={def.id} content={def.description} className="block">
|
||||||
<button
|
<button
|
||||||
key={def.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
||||||
@@ -135,11 +136,14 @@ export function ConditionPicker({
|
|||||||
className={isActive ? colorClass : "text-muted-foreground"}
|
className={isActive ? colorClass : "text-muted-foreground"}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={isActive ? "text-foreground" : "text-muted-foreground"}
|
className={
|
||||||
|
isActive ? "text-foreground" : "text-muted-foreground"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{def.label}
|
{def.label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -71,10 +72,9 @@ export function ConditionTags({
|
|||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
|
<Tooltip key={condId} content={`${def.label}: ${def.description}`}>
|
||||||
<button
|
<button
|
||||||
key={condId}
|
|
||||||
type="button"
|
type="button"
|
||||||
title={def.label}
|
|
||||||
aria-label={`Remove ${def.label}`}
|
aria-label={`Remove ${def.label}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||||
@@ -87,6 +87,7 @@ export function ConditionTags({
|
|||||||
>
|
>
|
||||||
<Icon size={14} />
|
<Icon size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<button
|
<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 {
|
export interface ConditionDefinition {
|
||||||
readonly id: ConditionId;
|
readonly id: ConditionId;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
|
readonly description: string;
|
||||||
readonly iconName: string;
|
readonly iconName: string;
|
||||||
readonly color: string;
|
readonly color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||||
{ id: "blinded", label: "Blinded", iconName: "EyeOff", color: "neutral" },
|
{
|
||||||
{ id: "charmed", label: "Charmed", iconName: "Heart", color: "pink" },
|
id: "blinded",
|
||||||
{ id: "deafened", label: "Deafened", iconName: "EarOff", color: "neutral" },
|
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",
|
id: "exhaustion",
|
||||||
label: "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",
|
iconName: "BatteryLow",
|
||||||
color: "amber",
|
color: "amber",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "frightened",
|
id: "frightened",
|
||||||
label: "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",
|
iconName: "Siren",
|
||||||
color: "orange",
|
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",
|
id: "incapacitated",
|
||||||
label: "Incapacitated",
|
label: "Incapacitated",
|
||||||
|
description:
|
||||||
|
"Can't take Actions, Bonus Actions, or Reactions. Concentration is broken.",
|
||||||
iconName: "Ban",
|
iconName: "Ban",
|
||||||
color: "gray",
|
color: "gray",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "invisible",
|
id: "invisible",
|
||||||
label: "Invisible",
|
label: "Invisible",
|
||||||
|
description:
|
||||||
|
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
|
||||||
iconName: "Ghost",
|
iconName: "Ghost",
|
||||||
color: "violet",
|
color: "violet",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "paralyzed",
|
id: "paralyzed",
|
||||||
label: "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",
|
iconName: "ZapOff",
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "petrified",
|
id: "petrified",
|
||||||
label: "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",
|
iconName: "Gem",
|
||||||
color: "slate",
|
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",
|
id: "restrained",
|
||||||
label: "Restrained",
|
label: "Restrained",
|
||||||
|
description:
|
||||||
|
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||||
iconName: "Link",
|
iconName: "Link",
|
||||||
color: "neutral",
|
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",
|
id: "unconscious",
|
||||||
label: "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",
|
iconName: "Moon",
|
||||||
color: "indigo",
|
color: "indigo",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user