From 4f852d790279e2f695082a7b02169ca4a82ba78f Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Tue, 26 May 2026 23:59:45 -0500 Subject: [PATCH] Add player setup form with validation and field error highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dynamic name fields (3–6 players) with add/remove controls - Start button disabled until all names are non-blank and unique; uniqueness check normalizes internal whitespace so "Bob Smith" and "Bob Smith" are treated as the same name - Names passed to game state are edge-trimmed only, preserving internal spacing as the user typed it - Failing fields highlighted with red border and light red background - PrimaryButton gains disabled prop with faded/flat appearance; active press state suppressed when disabled - All round buttons now use consistent shadow and translateY values Co-Authored-By: Claude Sonnet 4.6 --- src/components/PrimaryButton.module.css | 8 ++- src/components/PrimaryButton.tsx | 5 +- src/pages/PlayerSetup.module.css | 71 +++++++++++++++++++++++++ src/pages/PlayerSetup.tsx | 59 ++++++++++++++++++-- 4 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 src/pages/PlayerSetup.module.css diff --git a/src/components/PrimaryButton.module.css b/src/components/PrimaryButton.module.css index f4cc75b..67b6e59 100644 --- a/src/components/PrimaryButton.module.css +++ b/src/components/PrimaryButton.module.css @@ -11,7 +11,13 @@ transition: transform 80ms ease, box-shadow 80ms ease; } -.button:active { +.button:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); } + +.button:disabled { + opacity: 0.45; + box-shadow: none; + cursor: not-allowed; +} diff --git a/src/components/PrimaryButton.tsx b/src/components/PrimaryButton.tsx index 26e34f2..7abc014 100644 --- a/src/components/PrimaryButton.tsx +++ b/src/components/PrimaryButton.tsx @@ -3,11 +3,12 @@ import styles from './PrimaryButton.module.css' interface Props { onClick: () => void children: React.ReactNode + disabled?: boolean } -export default function PrimaryButton({ onClick, children }: Props) { +export default function PrimaryButton({ onClick, children, disabled }: Props) { return ( - ) diff --git a/src/pages/PlayerSetup.module.css b/src/pages/PlayerSetup.module.css new file mode 100644 index 0000000..b39c2aa --- /dev/null +++ b/src/pages/PlayerSetup.module.css @@ -0,0 +1,71 @@ +.row { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.nameInput { + flex: 1; + padding: 0.85rem 1rem; + font-size: 1.1rem; + font-family: inherit; + border: 1.5px solid #c8b99a; + border-radius: 0.75rem; + background: #fdfaf4; +} + +.nameInputError { + border-color: #c0392b; + background: #fff5f5; +} + +.removeBtn { + flex-shrink: 0; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + border: none; + background: #d94f4f; + color: white; + font-size: 1.25rem; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + transition: transform 80ms ease, box-shadow 80ms ease; +} + +.removeBtn:active { + transform: translateY(2px); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); +} + +.addRow { + display: flex; + justify-content: center; + margin-top: 0.5rem; +} + +.addBtn { + width: 3rem; + height: 3rem; + border-radius: 50%; + border: none; + background: #a8d5e8; + font-size: 1.75rem; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + transition: transform 80ms ease, box-shadow 80ms ease; +} + +.addBtn:active { + transform: translateY(2px); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); +} diff --git a/src/pages/PlayerSetup.tsx b/src/pages/PlayerSetup.tsx index 41e257f..256d6e5 100644 --- a/src/pages/PlayerSetup.tsx +++ b/src/pages/PlayerSetup.tsx @@ -1,14 +1,67 @@ +import { useState } from 'react' import PageFrame from '../components/PageFrame' import PrimaryButton from '../components/PrimaryButton' +import styles from './PlayerSetup.module.css' interface Props { - onStart: () => void + onStart: (players: string[]) => void +} + +function normalize(s: string) { + return s.trim().replace(/\s+/g, ' ') } export default function PlayerSetup({ onStart }: Props) { + const [names, setNames] = useState(['', '', '']) + + function updateName(i: number, value: string) { + setNames(n => n.map((v, idx) => (idx === i ? value : v))) + } + + function addName() { + setNames(n => [...n, '']) + } + + function removeName(i: number) { + setNames(n => n.filter((_, idx) => idx !== i)) + } + + const normalized = names.map(normalize) + const counts = new Map() + normalized.forEach(n => counts.set(n, (counts.get(n) ?? 0) + 1)) + const fieldErrors = normalized.map(n => n === '' || (counts.get(n) ?? 0) > 1) + const isValid = fieldErrors.every(e => !e) + return ( - Start}> - {null} + onStart(names.map(n => n.trim()))} disabled={!isValid}> + Start + + } + > + {names.map((name, i) => ( +
+ updateName(i, e.target.value)} + /> + {i >= 3 && ( + + )} +
+ ))} + {names.length < 6 && ( +
+ +
+ )}
) }