Use Readonly props and optional chaining/nullish coalescing

Mark component props as Readonly<> across 15 component files and
simplify edit-player-character field access with optional chaining
and nullish coalescing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-14 15:13:39 +01:00
parent 8efba288f7
commit 32b69f8df1
16 changed files with 43 additions and 41 deletions

View File

@@ -9,7 +9,7 @@ import {
Plus, Plus,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { type RefObject, useDeferredValue, useState } from "react"; import React, { type RefObject, useDeferredValue, useState } from "react";
import type { SearchResult } from "../hooks/use-bestiary.js"; import type { SearchResult } from "../hooks/use-bestiary.js";
import { cn } from "../lib/utils.js"; import { cn } from "../lib/utils.js";
import { D20Icon } from "./d20-icon.js"; import { D20Icon } from "./d20-icon.js";
@@ -62,7 +62,7 @@ function AddModeSuggestions({
onConfirmQueued, onConfirmQueued,
onAddFromPlayerCharacter, onAddFromPlayerCharacter,
onClear, onClear,
}: { }: Readonly<{
nameInput: string; nameInput: string;
suggestions: SearchResult[]; suggestions: SearchResult[];
pcMatches: PlayerCharacter[]; pcMatches: PlayerCharacter[];
@@ -75,7 +75,7 @@ function AddModeSuggestions({
onSetQueued: (q: QueuedCreature | null) => void; onSetQueued: (q: QueuedCreature | null) => void;
onConfirmQueued: () => void; onConfirmQueued: () => void;
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void; onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
}) { }>) {
return ( return (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg"> <div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<button <button
@@ -263,7 +263,7 @@ export function ActionBar({
rollAllInitiativeDisabled, rollAllInitiativeDisabled,
onOpenSourceManager, onOpenSourceManager,
autoFocus, autoFocus,
}: ActionBarProps) { }: Readonly<ActionBarProps>) {
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]); const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]); const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);

View File

@@ -18,7 +18,7 @@ export function BulkImportPrompt({
importState, importState,
onStartImport, onStartImport,
onDone, onDone,
}: BulkImportPromptProps) { }: Readonly<BulkImportPromptProps>) {
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL); const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const baseUrlId = useId(); const baseUrlId = useId();
const totalSources = getAllSourceCodes().length; const totalSources = getAllSourceCodes().length;

View File

@@ -11,7 +11,7 @@ export function BulkImportToasts({
state, state,
visible, visible,
onReset, onReset,
}: BulkImportToastsProps) { }: Readonly<BulkImportToastsProps>) {
if (!visible) return null; if (!visible) return null;
if (state.status === "loading") { if (state.status === "loading") {

View File

@@ -9,7 +9,7 @@ interface ColorPaletteProps {
const COLORS = [...VALID_PLAYER_COLORS] as string[]; const COLORS = [...VALID_PLAYER_COLORS] as string[];
export function ColorPalette({ value, onChange }: ColorPaletteProps) { export function ColorPalette({ value, onChange }: Readonly<ColorPaletteProps>) {
return ( return (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{COLORS.map((color) => ( {COLORS.map((color) => (

View File

@@ -50,13 +50,13 @@ function EditableName({
onRename, onRename,
onShowStatBlock, onShowStatBlock,
color, color,
}: { }: Readonly<{
name: string; name: string;
combatantId: CombatantId; combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void; onRename: (id: CombatantId, newName: string) => void;
onShowStatBlock?: () => void; onShowStatBlock?: () => void;
color?: string; color?: string;
}) { }>) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name); const [draft, setDraft] = useState(name);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -154,10 +154,10 @@ function EditableName({
function MaxHpDisplay({ function MaxHpDisplay({
maxHp, maxHp,
onCommit, onCommit,
}: { }: Readonly<{
maxHp: number | undefined; maxHp: number | undefined;
onCommit: (value: number | undefined) => void; onCommit: (value: number | undefined) => void;
}) { }>) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(maxHp?.toString() ?? ""); const [draft, setDraft] = useState(maxHp?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -215,12 +215,12 @@ function ClickableHp({
maxHp, maxHp,
onAdjust, onAdjust,
dimmed, dimmed,
}: { }: Readonly<{
currentHp: number | undefined; currentHp: number | undefined;
maxHp: number | undefined; maxHp: number | undefined;
onAdjust: (delta: number) => void; onAdjust: (delta: number) => void;
dimmed?: boolean; dimmed?: boolean;
}) { }>) {
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const status = deriveHpStatus(currentHp, maxHp); const status = deriveHpStatus(currentHp, maxHp);
@@ -265,10 +265,10 @@ function ClickableHp({
function AcDisplay({ function AcDisplay({
ac, ac,
onCommit, onCommit,
}: { }: Readonly<{
ac: number | undefined; ac: number | undefined;
onCommit: (value: number | undefined) => void; onCommit: (value: number | undefined) => void;
}) { }>) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(ac?.toString() ?? ""); const [draft, setDraft] = useState(ac?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -319,13 +319,13 @@ function InitiativeDisplay({
dimmed, dimmed,
onSetInitiative, onSetInitiative,
onRollInitiative, onRollInitiative,
}: { }: Readonly<{
initiative: number | undefined; initiative: number | undefined;
combatantId: CombatantId; combatantId: CombatantId;
dimmed: boolean; dimmed: boolean;
onSetInitiative: (id: CombatantId, value: number | undefined) => void; onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRollInitiative?: (id: CombatantId) => void; onRollInitiative?: (id: CombatantId) => void;
}) { }>) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(initiative?.toString() ?? ""); const [draft, setDraft] = useState(initiative?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);

View File

@@ -61,7 +61,7 @@ export function ConditionPicker({
activeConditions, activeConditions,
onToggle, onToggle,
onClose, onClose,
}: ConditionPickerProps) { }: Readonly<ConditionPickerProps>) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [flipped, setFlipped] = useState(false); const [flipped, setFlipped] = useState(false);
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined); const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);

View File

@@ -60,7 +60,7 @@ export function ConditionTags({
conditions, conditions,
onRemove, onRemove,
onOpenPicker, onOpenPicker,
}: ConditionTagsProps) { }: Readonly<ConditionTagsProps>) {
return ( return (
<div className="flex flex-wrap items-center gap-0.5"> <div className="flex flex-wrap items-center gap-0.5">
{conditions?.map((condId) => { {conditions?.map((condId) => {

View File

@@ -24,7 +24,7 @@ export function CreatePlayerModal({
onClose, onClose,
onSave, onSave,
playerCharacter, playerCharacter,
}: CreatePlayerModalProps) { }: Readonly<CreatePlayerModalProps>) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [ac, setAc] = useState("10"); const [ac, setAc] = useState("10");
const [maxHp, setMaxHp] = useState("10"); const [maxHp, setMaxHp] = useState("10");

View File

@@ -10,7 +10,7 @@ interface IconGridProps {
const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[]; const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[];
export function IconGrid({ value, onChange }: IconGridProps) { export function IconGrid({ value, onChange }: Readonly<IconGridProps>) {
return ( return (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{ICONS.map((iconId) => { {ICONS.map((iconId) => {

View File

@@ -21,7 +21,7 @@ export function PlayerManagement({
onEdit, onEdit,
onDelete, onDelete,
onCreate, onCreate,
}: PlayerManagementProps) { }: Readonly<PlayerManagementProps>) {
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {

View File

@@ -18,7 +18,7 @@ export function SourceFetchPrompt({
fetchAndCacheSource, fetchAndCacheSource,
onSourceLoaded, onSourceLoaded,
onUploadSource, onUploadSource,
}: SourceFetchPromptProps) { }: Readonly<SourceFetchPromptProps>) {
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode)); const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle"); const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");

View File

@@ -8,7 +8,9 @@ interface SourceManagerProps {
onCacheCleared: () => void; onCacheCleared: () => void;
} }
export function SourceManager({ onCacheCleared }: SourceManagerProps) { export function SourceManager({
onCacheCleared,
}: Readonly<SourceManagerProps>) {
const [sources, setSources] = useState<CachedSourceInfo[]>([]); const [sources, setSources] = useState<CachedSourceInfo[]>([]);
const [optimisticSources, applyOptimistic] = useOptimistic( const [optimisticSources, applyOptimistic] = useOptimistic(
sources, sources,

View File

@@ -46,11 +46,11 @@ function CollapsedTab({
creatureName, creatureName,
side, side,
onToggleCollapse, onToggleCollapse,
}: { }: Readonly<{
creatureName: string; creatureName: string;
side: "left" | "right"; side: "left" | "right";
onToggleCollapse: () => void; onToggleCollapse: () => void;
}) { }>) {
return ( return (
<button <button
type="button" type="button"
@@ -73,13 +73,13 @@ function PanelHeader({
onToggleCollapse, onToggleCollapse,
onPin, onPin,
onUnpin, onUnpin,
}: { }: Readonly<{
panelRole: "browse" | "pinned"; panelRole: "browse" | "pinned";
showPinButton: boolean; showPinButton: boolean;
onToggleCollapse: () => void; onToggleCollapse: () => void;
onPin: () => void; onPin: () => void;
onUnpin: () => void; onUnpin: () => void;
}) { }>) {
return ( return (
<div className="flex items-center justify-between border-border border-b px-4 py-2"> <div className="flex items-center justify-between border-border border-b px-4 py-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -133,7 +133,7 @@ function DesktopPanel({
onPin, onPin,
onUnpin, onUnpin,
children, children,
}: { }: Readonly<{
isCollapsed: boolean; isCollapsed: boolean;
side: "left" | "right"; side: "left" | "right";
creatureName: string; creatureName: string;
@@ -143,7 +143,7 @@ function DesktopPanel({
onPin: () => void; onPin: () => void;
onUnpin: () => void; onUnpin: () => void;
children: ReactNode; children: ReactNode;
}) { }>) {
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l"; const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
const collapsedTranslate = const collapsedTranslate =
side === "right" side === "right"
@@ -179,10 +179,10 @@ function DesktopPanel({
function MobileDrawer({ function MobileDrawer({
onDismiss, onDismiss,
children, children,
}: { }: Readonly<{
onDismiss: () => void; onDismiss: () => void;
children: ReactNode; children: ReactNode;
}) { }>) {
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss); const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
return ( return (
@@ -239,7 +239,7 @@ export function StatBlockPanel({
onStartBulkImport, onStartBulkImport,
onBulkImportDone, onBulkImportDone,
sourceManagerMode, sourceManagerMode,
}: StatBlockPanelProps) { }: Readonly<StatBlockPanelProps>) {
const [isDesktop, setIsDesktop] = useState( const [isDesktop, setIsDesktop] = useState(
() => globalThis.matchMedia("(min-width: 1024px)").matches, () => globalThis.matchMedia("(min-width: 1024px)").matches,
); );

View File

@@ -16,10 +16,10 @@ function abilityMod(score: number): string {
function PropertyLine({ function PropertyLine({
label, label,
value, value,
}: { }: Readonly<{
label: string; label: string;
value: string | undefined; value: string | undefined;
}) { }>) {
if (!value) return null; if (!value) return null;
return ( return (
<div className="text-sm"> <div className="text-sm">
@@ -34,7 +34,7 @@ function SectionDivider() {
); );
} }
export function StatBlock({ creature }: StatBlockProps) { export function StatBlock({ creature }: Readonly<StatBlockProps>) {
const abilities = [ const abilities = [
{ label: "STR", score: creature.abilities.str }, { label: "STR", score: creature.abilities.str },
{ label: "DEX", score: creature.abilities.dex }, { label: "DEX", score: creature.abilities.dex },

View File

@@ -15,7 +15,7 @@ export function TurnNavigation({
onAdvanceTurn, onAdvanceTurn,
onRetreatTurn, onRetreatTurn,
onClearEncounter, onClearEncounter,
}: TurnNavigationProps) { }: Readonly<TurnNavigationProps>) {
const hasCombatants = encounter.combatants.length > 0; const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0; const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
const activeCombatant = encounter.combatants[encounter.activeIndex]; const activeCombatant = encounter.combatants[encounter.activeIndex];

View File

@@ -23,7 +23,7 @@ interface EditFields {
} }
function validateFields(fields: EditFields): DomainError | null { function validateFields(fields: EditFields): DomainError | null {
if (fields.name !== undefined && fields.name.trim() === "") { if (fields.name?.trim() === "") {
return { return {
kind: "domain-error", kind: "domain-error",
code: "invalid-name", code: "invalid-name",
@@ -81,9 +81,9 @@ function applyFields(
): PlayerCharacter { ): PlayerCharacter {
return { return {
id: existing.id, id: existing.id,
name: fields.name === undefined ? existing.name : fields.name.trim(), name: fields.name?.trim() ?? existing.name,
ac: fields.ac === undefined ? existing.ac : fields.ac, ac: fields.ac ?? existing.ac,
maxHp: fields.maxHp === undefined ? existing.maxHp : fields.maxHp, maxHp: fields.maxHp ?? existing.maxHp,
color: color:
fields.color === undefined fields.color === undefined
? existing.color ? existing.color