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:
@@ -541,6 +541,288 @@ describe("normalizeFoundryCreature", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("equipment normalization", () => {
|
||||||
|
it("normalizes a weapon with traits and description", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "w1",
|
||||||
|
name: "Flaming Longsword",
|
||||||
|
type: "weapon",
|
||||||
|
system: {
|
||||||
|
level: { value: 5 },
|
||||||
|
traits: { value: ["magical", "fire"] },
|
||||||
|
description: {
|
||||||
|
value: "<p>This sword blazes with fire.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.equipment).toHaveLength(1);
|
||||||
|
const item = creature.equipment?.[0];
|
||||||
|
expect(item?.name).toBe("Flaming Longsword");
|
||||||
|
expect(item?.level).toBe(5);
|
||||||
|
expect(item?.traits).toEqual(["magical", "fire"]);
|
||||||
|
expect(item?.description).toBe("This sword blazes with fire.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a consumable potion with description", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "c1",
|
||||||
|
name: "Healing Potion (Moderate)",
|
||||||
|
type: "consumable",
|
||||||
|
system: {
|
||||||
|
level: { value: 6 },
|
||||||
|
traits: { value: ["consumable", "healing", "magical"] },
|
||||||
|
description: {
|
||||||
|
value: "<p>Restores 3d8+10 Hit Points.</p>",
|
||||||
|
},
|
||||||
|
category: "potion",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.equipment).toHaveLength(1);
|
||||||
|
const item = creature.equipment?.[0];
|
||||||
|
expect(item?.name).toBe("Healing Potion (Moderate)");
|
||||||
|
expect(item?.category).toBe("potion");
|
||||||
|
expect(item?.description).toBe("Restores 3d8+10 Hit Points.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts scroll embedded spell name and rank", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Scroll of Teleport (Rank 6)",
|
||||||
|
type: "consumable",
|
||||||
|
system: {
|
||||||
|
level: { value: 11 },
|
||||||
|
traits: { value: ["consumable", "magical", "scroll"] },
|
||||||
|
description: { value: "<p>A scroll.</p>" },
|
||||||
|
category: "scroll",
|
||||||
|
spell: {
|
||||||
|
name: "Teleport",
|
||||||
|
system: { level: { value: 6 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const item = creature.equipment?.[0];
|
||||||
|
expect(item?.spellName).toBe("Teleport");
|
||||||
|
expect(item?.spellRank).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts wand embedded spell name and rank", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "w1",
|
||||||
|
name: "Wand of Dispel Magic (Rank 2)",
|
||||||
|
type: "consumable",
|
||||||
|
system: {
|
||||||
|
level: { value: 5 },
|
||||||
|
traits: { value: ["consumable", "magical", "wand"] },
|
||||||
|
description: { value: "<p>A wand.</p>" },
|
||||||
|
category: "wand",
|
||||||
|
spell: {
|
||||||
|
name: "Dispel Magic",
|
||||||
|
system: { level: { value: 2 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const item = creature.equipment?.[0];
|
||||||
|
expect(item?.spellName).toBe("Dispel Magic");
|
||||||
|
expect(item?.spellRank).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters magical equipment into equipment field", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "e1",
|
||||||
|
name: "Ring of Energy Resistance (Fire)",
|
||||||
|
type: "equipment",
|
||||||
|
system: {
|
||||||
|
level: { value: 6 },
|
||||||
|
traits: { value: ["magical", "invested"] },
|
||||||
|
description: {
|
||||||
|
value: "<p>Grants fire resistance 5.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.equipment).toHaveLength(1);
|
||||||
|
expect(creature.equipment?.[0]?.name).toBe(
|
||||||
|
"Ring of Energy Resistance (Fire)",
|
||||||
|
);
|
||||||
|
expect(creature.items).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters mundane items into items string", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "w1",
|
||||||
|
name: "Longsword",
|
||||||
|
type: "weapon",
|
||||||
|
system: {
|
||||||
|
level: { value: 0 },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: { value: "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "a1",
|
||||||
|
name: "Leather Armor",
|
||||||
|
type: "armor",
|
||||||
|
system: {
|
||||||
|
level: { value: 0 },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: { value: "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.items).toBe("Longsword, Leather Armor");
|
||||||
|
expect(creature.equipment).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits equipment when no detailed items exist", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "w1",
|
||||||
|
name: "Dagger",
|
||||||
|
type: "weapon",
|
||||||
|
system: {
|
||||||
|
level: { value: 0 },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: { value: "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.equipment).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits items when no mundane items exist", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "c1",
|
||||||
|
name: "Giant Wasp Venom",
|
||||||
|
type: "consumable",
|
||||||
|
system: {
|
||||||
|
level: { value: 7 },
|
||||||
|
traits: { value: ["consumable", "poison"] },
|
||||||
|
description: {
|
||||||
|
value: "<p>A deadly poison.</p>",
|
||||||
|
},
|
||||||
|
category: "poison",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.items).toBeUndefined();
|
||||||
|
expect(creature.equipment).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes armor with special material in equipment", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "a1",
|
||||||
|
name: "Adamantine Full Plate",
|
||||||
|
type: "armor",
|
||||||
|
system: {
|
||||||
|
level: { value: 0 },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: {
|
||||||
|
value: "<p>Full plate made of adamantine.</p>",
|
||||||
|
},
|
||||||
|
material: { type: "adamantine", grade: "standard" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.equipment).toHaveLength(1);
|
||||||
|
expect(creature.equipment?.[0]?.name).toBe("Adamantine Full Plate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes mundane armor from equipment (goes to items)", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "a1",
|
||||||
|
name: "Chain Mail",
|
||||||
|
type: "armor",
|
||||||
|
system: {
|
||||||
|
level: { value: 0 },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: { value: "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.equipment).toBeUndefined();
|
||||||
|
expect(creature.items).toBe("Chain Mail");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips Foundry HTML tags from equipment descriptions", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "c1",
|
||||||
|
name: "Potion of Speed",
|
||||||
|
type: "consumable",
|
||||||
|
system: {
|
||||||
|
level: { value: 10 },
|
||||||
|
traits: { value: ["consumable", "magical"] },
|
||||||
|
description: {
|
||||||
|
value:
|
||||||
|
"<p>Gain @UUID[Compendium.pf2e.conditionitems.Item.Quickened]{quickened} for 1 minute.</p>",
|
||||||
|
},
|
||||||
|
category: "potion",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const desc = creature.equipment?.[0]?.description;
|
||||||
|
expect(desc).toBe("Gain quickened for 1 minute.");
|
||||||
|
expect(desc).not.toContain("@UUID");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("spellcasting normalization", () => {
|
describe("spellcasting normalization", () => {
|
||||||
it("normalizes prepared spells by rank", () => {
|
it("normalizes prepared spells by rank", () => {
|
||||||
const creature = normalizeFoundryCreature(
|
const creature = normalizeFoundryCreature(
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { type IDBPDatabase, openDB } from "idb";
|
|||||||
|
|
||||||
const DB_NAME = "initiative-bestiary";
|
const DB_NAME = "initiative-bestiary";
|
||||||
const STORE_NAME = "sources";
|
const STORE_NAME = "sources";
|
||||||
// v6 (2026-04-09): SpellReference per-spell data added; old caches are cleared
|
// v7 (2026-04-10): Equipment items added to PF2e creatures; old caches are cleared
|
||||||
const DB_VERSION = 6;
|
const DB_VERSION = 7;
|
||||||
|
|
||||||
interface CachedSourceInfo {
|
interface CachedSourceInfo {
|
||||||
readonly sourceCode: string;
|
readonly sourceCode: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
CreatureId,
|
CreatureId,
|
||||||
|
EquipmentItem,
|
||||||
Pf2eCreature,
|
Pf2eCreature,
|
||||||
SpellcastingBlock,
|
SpellcastingBlock,
|
||||||
SpellReference,
|
SpellReference,
|
||||||
@@ -114,6 +115,73 @@ interface SpellSystem {
|
|||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConsumableSystem {
|
||||||
|
level?: { value: number };
|
||||||
|
traits?: { value: string[] };
|
||||||
|
description?: { value: string };
|
||||||
|
category?: string;
|
||||||
|
spell?: {
|
||||||
|
name: string;
|
||||||
|
system?: { level?: { value: number } };
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EQUIPMENT_TYPES = new Set(["weapon", "consumable", "equipment", "armor"]);
|
||||||
|
|
||||||
|
/** Items shown in the Equipment section with popovers. */
|
||||||
|
function isDetailedEquipment(item: RawFoundryItem): boolean {
|
||||||
|
if (!EQUIPMENT_TYPES.has(item.type)) return false;
|
||||||
|
const sys = item.system;
|
||||||
|
const level = (sys.level as { value: number } | undefined)?.value ?? 0;
|
||||||
|
const traits = (sys.traits as { value: string[] } | undefined)?.value ?? [];
|
||||||
|
// All consumables are tactically relevant (potions, scrolls, poisons, etc.)
|
||||||
|
if (item.type === "consumable") return true;
|
||||||
|
// Magical/invested items
|
||||||
|
if (traits.includes("magical") || traits.includes("invested")) return true;
|
||||||
|
// Special material armor/equipment
|
||||||
|
const material = sys.material as { type: string | null } | undefined;
|
||||||
|
if (material?.type) return true;
|
||||||
|
// Higher-level items
|
||||||
|
if (level > 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Items shown on the "Items" line as plain names. */
|
||||||
|
function isMundaneItem(item: RawFoundryItem): boolean {
|
||||||
|
return EQUIPMENT_TYPES.has(item.type) && !isDetailedEquipment(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEquipmentItem(item: RawFoundryItem): EquipmentItem {
|
||||||
|
const sys = item.system;
|
||||||
|
const level = (sys.level as { value: number } | undefined)?.value ?? 0;
|
||||||
|
const traits = (sys.traits as { value: string[] } | undefined)?.value;
|
||||||
|
const rawDesc = (sys.description as { value: string } | undefined)?.value;
|
||||||
|
const description = rawDesc
|
||||||
|
? stripFoundryTags(rawDesc) || undefined
|
||||||
|
: undefined;
|
||||||
|
const category = sys.category as string | undefined;
|
||||||
|
|
||||||
|
let spellName: string | undefined;
|
||||||
|
let spellRank: number | undefined;
|
||||||
|
if (item.type === "consumable") {
|
||||||
|
const spell = (sys as unknown as ConsumableSystem).spell;
|
||||||
|
if (spell) {
|
||||||
|
spellName = spell.name;
|
||||||
|
spellRank = spell.system?.level?.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
level,
|
||||||
|
category: category || undefined,
|
||||||
|
traits: traits && traits.length > 0 ? traits : undefined,
|
||||||
|
description,
|
||||||
|
spellName,
|
||||||
|
spellRank,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const SIZE_MAP: Record<string, string> = {
|
const SIZE_MAP: Record<string, string> = {
|
||||||
tiny: "tiny",
|
tiny: "tiny",
|
||||||
sm: "small",
|
sm: "small",
|
||||||
@@ -633,6 +701,14 @@ export function normalizeFoundryCreature(
|
|||||||
),
|
),
|
||||||
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
|
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
|
||||||
spellcasting: orUndefined(normalizeSpellcasting(items)),
|
spellcasting: orUndefined(normalizeSpellcasting(items)),
|
||||||
|
items:
|
||||||
|
items
|
||||||
|
.filter(isMundaneItem)
|
||||||
|
.map((i) => i.name)
|
||||||
|
.join(", ") || undefined,
|
||||||
|
equipment: orUndefined(
|
||||||
|
items.filter(isDetailedEquipment).map(normalizeEquipmentItem),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { EquipmentItem } from "@initiative/domain";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { EquipmentDetailPopover } from "../equipment-detail-popover.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const POISON: EquipmentItem = {
|
||||||
|
name: "Giant Wasp Venom",
|
||||||
|
level: 7,
|
||||||
|
category: "poison",
|
||||||
|
traits: ["consumable", "poison", "injury"],
|
||||||
|
description: "A deadly poison extracted from giant wasps.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCROLL: EquipmentItem = {
|
||||||
|
name: "Scroll of Teleport",
|
||||||
|
level: 11,
|
||||||
|
category: "scroll",
|
||||||
|
traits: ["consumable", "magical", "scroll"],
|
||||||
|
description: "A scroll containing Teleport.",
|
||||||
|
spellName: "Teleport",
|
||||||
|
spellRank: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANCHOR: DOMRect = new DOMRect(100, 100, 50, 20);
|
||||||
|
const SCROLL_SPELL_REGEX = /Teleport \(Rank 6\)/;
|
||||||
|
const DIALOG_LABEL_REGEX = /Equipment details: Giant Wasp Venom/;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation(() => ({
|
||||||
|
matches: true,
|
||||||
|
media: "(min-width: 1024px)",
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EquipmentDetailPopover", () => {
|
||||||
|
it("renders item name, level, traits, and description", () => {
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={POISON}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Giant Wasp Venom")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("7")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("consumable")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("poison")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("injury")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("A deadly poison extracted from giant wasps."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders scroll/wand spell info", () => {
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={SCROLL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(SCROLL_SPELL_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when Escape is pressed", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={POISON}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the dialog role with the item name as label", () => {
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={POISON}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", {
|
||||||
|
name: DIALOG_LABEL_REGEX,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,6 +31,7 @@ const RK_DC_25_REGEX = /DC 25/;
|
|||||||
const RK_HUMANOID_SOCIETY_REGEX = /Humanoid \(Society\)/;
|
const RK_HUMANOID_SOCIETY_REGEX = /Humanoid \(Society\)/;
|
||||||
const RK_UNDEAD_RELIGION_REGEX = /Undead \(Religion\)/;
|
const RK_UNDEAD_RELIGION_REGEX = /Undead \(Religion\)/;
|
||||||
const RK_BEAST_SKILLS_REGEX = /Beast \(Arcana\/Nature\)/;
|
const RK_BEAST_SKILLS_REGEX = /Beast \(Arcana\/Nature\)/;
|
||||||
|
const SCROLL_NAME_REGEX = /Scroll of Teleport/;
|
||||||
|
|
||||||
const GOBLIN_WARRIOR: Pf2eCreature = {
|
const GOBLIN_WARRIOR: Pf2eCreature = {
|
||||||
system: "pf2e",
|
system: "pf2e",
|
||||||
@@ -338,6 +339,79 @@ describe("Pf2eStatBlock", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("equipment section", () => {
|
||||||
|
const CREATURE_WITH_EQUIPMENT: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
id: creatureId("test:equipped"),
|
||||||
|
name: "Equipped NPC",
|
||||||
|
items: "longsword, leather armor",
|
||||||
|
equipment: [
|
||||||
|
{
|
||||||
|
name: "Giant Wasp Venom",
|
||||||
|
level: 7,
|
||||||
|
category: "poison",
|
||||||
|
traits: ["consumable", "poison"],
|
||||||
|
description: "A deadly poison extracted from giant wasps.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Scroll of Teleport",
|
||||||
|
level: 11,
|
||||||
|
category: "scroll",
|
||||||
|
traits: ["consumable", "magical", "scroll"],
|
||||||
|
description: "A scroll containing Teleport.",
|
||||||
|
spellName: "Teleport",
|
||||||
|
spellRank: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Plain Talisman",
|
||||||
|
level: 1,
|
||||||
|
traits: ["magical"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders Equipment section with item names", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Equipment" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Giant Wasp Venom")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders scroll name as-is from Foundry data", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(screen.getByText(SCROLL_NAME_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render Equipment section when creature has no equipment", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "Equipment" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders equipment items with descriptions as clickable buttons", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Giant Wasp Venom" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders equipment items without descriptions as plain text", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Plain Talisman" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Plain Talisman")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Items line with mundane item names", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(screen.getByText("Items")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("longsword, leather armor")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("clickable spells", () => {
|
describe("clickable spells", () => {
|
||||||
const SPELLCASTER: Pf2eCreature = {
|
const SPELLCASTER: Pf2eCreature = {
|
||||||
...NAUNET,
|
...NAUNET,
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
70
apps/web/src/components/equipment-detail-popover.tsx
Normal file
70
apps/web/src/components/equipment-detail-popover.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { EquipmentItem } from "@initiative/domain";
|
||||||
|
import { DetailPopover } from "./detail-popover.js";
|
||||||
|
|
||||||
|
interface EquipmentDetailPopoverProps {
|
||||||
|
readonly item: EquipmentItem;
|
||||||
|
readonly anchorRect: DOMRect;
|
||||||
|
readonly onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EquipmentDetailContent({ item }: Readonly<{ item: EquipmentItem }>) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<h3 className="font-bold text-lg text-stat-heading">{item.name}</h3>
|
||||||
|
{item.traits && item.traits.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{item.traits.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="rounded border border-border bg-card px-1.5 py-0.5 text-foreground text-xs"
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-0.5 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Level</span> {item.level}
|
||||||
|
</div>
|
||||||
|
{item.category ? (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Category</span>{" "}
|
||||||
|
{item.category.charAt(0).toUpperCase() + item.category.slice(1)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item.spellName ? (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Spell</span> {item.spellName}
|
||||||
|
{item.spellRank === undefined ? "" : ` (Rank ${item.spellRank})`}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{item.description ? (
|
||||||
|
<p className="whitespace-pre-line text-foreground">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground italic">
|
||||||
|
No description available.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquipmentDetailPopover({
|
||||||
|
item,
|
||||||
|
anchorRect,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<EquipmentDetailPopoverProps>) {
|
||||||
|
return (
|
||||||
|
<DetailPopover
|
||||||
|
anchorRect={anchorRect}
|
||||||
|
onClose={onClose}
|
||||||
|
ariaLabel={`Equipment details: ${item.name}`}
|
||||||
|
>
|
||||||
|
<EquipmentDetailContent item={item} />
|
||||||
|
</DetailPopover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { Pf2eCreature, SpellReference } from "@initiative/domain";
|
import type {
|
||||||
|
EquipmentItem,
|
||||||
|
Pf2eCreature,
|
||||||
|
SpellReference,
|
||||||
|
} from "@initiative/domain";
|
||||||
import { formatInitiativeModifier, recallKnowledge } from "@initiative/domain";
|
import { formatInitiativeModifier, recallKnowledge } from "@initiative/domain";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { EquipmentDetailPopover } from "./equipment-detail-popover.js";
|
||||||
import { SpellDetailPopover } from "./spell-detail-popover.js";
|
import { SpellDetailPopover } from "./spell-detail-popover.js";
|
||||||
import {
|
import {
|
||||||
PropertyLine,
|
PropertyLine,
|
||||||
@@ -102,6 +107,35 @@ function SpellListLine({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EquipmentLinkProps {
|
||||||
|
readonly item: EquipmentItem;
|
||||||
|
readonly onOpen: (item: EquipmentItem, rect: DOMRect) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EquipmentLink({ item, onOpen }: Readonly<EquipmentLinkProps>) {
|
||||||
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (!item.description) return;
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
if (rect) onOpen(item, rect);
|
||||||
|
}, [item, onOpen]);
|
||||||
|
|
||||||
|
if (!item.description) {
|
||||||
|
return <span>{item.name}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className="cursor-pointer text-foreground underline decoration-dotted underline-offset-2 hover:text-hover-neutral"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||||
const [openSpell, setOpenSpell] = useState<{
|
const [openSpell, setOpenSpell] = useState<{
|
||||||
spell: SpellReference;
|
spell: SpellReference;
|
||||||
@@ -112,6 +146,15 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const handleCloseSpell = useCallback(() => setOpenSpell(null), []);
|
const handleCloseSpell = useCallback(() => setOpenSpell(null), []);
|
||||||
|
const [openEquipment, setOpenEquipment] = useState<{
|
||||||
|
item: EquipmentItem;
|
||||||
|
rect: DOMRect;
|
||||||
|
} | null>(null);
|
||||||
|
const handleOpenEquipment = useCallback(
|
||||||
|
(item: EquipmentItem, rect: DOMRect) => setOpenEquipment({ item, rect }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleCloseEquipment = useCallback(() => setOpenEquipment(null), []);
|
||||||
|
|
||||||
const rk = recallKnowledge(creature.level, creature.traits);
|
const rk = recallKnowledge(creature.level, creature.traits);
|
||||||
|
|
||||||
@@ -256,6 +299,19 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{creature.equipment && creature.equipment.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionDivider />
|
||||||
|
<h3 className="font-bold text-base text-stat-heading">Equipment</h3>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
{creature.equipment.map((item) => (
|
||||||
|
<div key={item.name}>
|
||||||
|
<EquipmentLink item={item} onOpen={handleOpenEquipment} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{openSpell ? (
|
{openSpell ? (
|
||||||
<SpellDetailPopover
|
<SpellDetailPopover
|
||||||
spell={openSpell.spell}
|
spell={openSpell.spell}
|
||||||
@@ -263,6 +319,13 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
onClose={handleCloseSpell}
|
onClose={handleCloseSpell}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{openEquipment ? (
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={openEquipment.item}
|
||||||
|
anchorRect={openEquipment.rect}
|
||||||
|
onClose={handleCloseEquipment}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { ActivityCost, SpellReference } from "@initiative/domain";
|
import type { ActivityCost, SpellReference } from "@initiative/domain";
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { DetailPopover } from "./detail-popover.js";
|
||||||
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";
|
|
||||||
import { ActivityIcon } from "./stat-block-parts.js";
|
import { ActivityIcon } from "./stat-block-parts.js";
|
||||||
|
|
||||||
interface SpellDetailPopoverProps {
|
interface SpellDetailPopoverProps {
|
||||||
@@ -178,119 +174,18 @@ function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesktopPopover({
|
|
||||||
spell,
|
|
||||||
anchorRect,
|
|
||||||
onClose,
|
|
||||||
}: Readonly<SpellDetailPopoverProps>) {
|
|
||||||
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={`Spell details: ${spell.name}`}
|
|
||||||
>
|
|
||||||
<SpellDetailContent spell={spell} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileSheet({
|
|
||||||
spell,
|
|
||||||
onClose,
|
|
||||||
}: Readonly<{ spell: SpellReference; onClose: () => void }>) {
|
|
||||||
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 spell 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={`Spell details: ${spell.name}`}
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<SpellDetailContent spell={spell} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SpellDetailPopover({
|
export function SpellDetailPopover({
|
||||||
spell,
|
spell,
|
||||||
anchorRect,
|
anchorRect,
|
||||||
onClose,
|
onClose,
|
||||||
}: Readonly<SpellDetailPopoverProps>) {
|
}: Readonly<SpellDetailPopoverProps>) {
|
||||||
const [isDesktop, setIsDesktop] = useState(
|
return (
|
||||||
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
<DetailPopover
|
||||||
|
anchorRect={anchorRect}
|
||||||
|
onClose={onClose}
|
||||||
|
ariaLabel={`Spell details: ${spell.name}`}
|
||||||
|
>
|
||||||
|
<SpellDetailContent spell={spell} />
|
||||||
|
</DetailPopover>
|
||||||
);
|
);
|
||||||
|
|
||||||
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 ? (
|
|
||||||
<DesktopPopover spell={spell} anchorRect={anchorRect} onClose={onClose} />
|
|
||||||
) : (
|
|
||||||
<MobileSheet spell={spell} onClose={onClose} />
|
|
||||||
);
|
|
||||||
return createPortal(content, document.body);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,19 @@ export interface SpellReference {
|
|||||||
readonly usesPerDay?: number;
|
readonly usesPerDay?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A carried equipment item on a PF2e creature (weapon, consumable, magic item, etc.). */
|
||||||
|
export interface EquipmentItem {
|
||||||
|
readonly name: string;
|
||||||
|
readonly level: number;
|
||||||
|
readonly category?: string;
|
||||||
|
readonly traits?: readonly string[];
|
||||||
|
readonly description?: string;
|
||||||
|
/** For scrolls/wands: the embedded spell name. */
|
||||||
|
readonly spellName?: string;
|
||||||
|
/** For scrolls/wands: the embedded spell rank. */
|
||||||
|
readonly spellRank?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DailySpells {
|
export interface DailySpells {
|
||||||
readonly uses: number;
|
readonly uses: number;
|
||||||
readonly each: boolean;
|
readonly each: boolean;
|
||||||
@@ -201,6 +214,7 @@ export interface Pf2eCreature {
|
|||||||
readonly abilitiesMid?: readonly TraitBlock[];
|
readonly abilitiesMid?: readonly TraitBlock[];
|
||||||
readonly abilitiesBot?: readonly TraitBlock[];
|
readonly abilitiesBot?: readonly TraitBlock[];
|
||||||
readonly spellcasting?: readonly SpellcastingBlock[];
|
readonly spellcasting?: readonly SpellcastingBlock[];
|
||||||
|
readonly equipment?: readonly EquipmentItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AnyCreature = Creature | Pf2eCreature;
|
export type AnyCreature = Creature | Pf2eCreature;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export {
|
|||||||
type CreatureId,
|
type CreatureId,
|
||||||
creatureId,
|
creatureId,
|
||||||
type DailySpells,
|
type DailySpells,
|
||||||
|
type EquipmentItem,
|
||||||
type LegendaryBlock,
|
type LegendaryBlock,
|
||||||
type Pf2eBestiaryIndex,
|
type Pf2eBestiaryIndex,
|
||||||
type Pf2eBestiaryIndexEntry,
|
type Pf2eBestiaryIndexEntry,
|
||||||
|
|||||||
Reference in New Issue
Block a user