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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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