refactor: streamline new-game player selection ux
Consolidate new-game wizard steps into reusable components and remove legacy duplicate files. Replace inline player inputs with a chip-first flow and minimal name-entry modal, while improving compact-screen spacing and step-specific selection behavior. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
width: min(420px, 100%);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.modalInput {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modalActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
min-height: var(--touch-target-min);
|
||||
}
|
||||
|
||||
.actionButton:hover,
|
||||
.actionButton:focus {
|
||||
border-color: var(--color-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.confirmButton {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import styles from './NameEntryModal.module.css';
|
||||
import newGameStyles from '../NewGame.module.css';
|
||||
|
||||
interface NameEntryModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
placeholder: string;
|
||||
inputId: string;
|
||||
initialValue?: string;
|
||||
enterKeyHint?: 'next' | 'done';
|
||||
onClose: () => void;
|
||||
onConfirm: (name: string) => string | null;
|
||||
}
|
||||
|
||||
export function NameEntryModal({
|
||||
open,
|
||||
title,
|
||||
placeholder,
|
||||
inputId,
|
||||
initialValue = '',
|
||||
enterKeyHint = 'done',
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: NameEntryModalProps) {
|
||||
const [name, setName] = useState(initialValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(initialValue);
|
||||
setError(null);
|
||||
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, [open, initialValue]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.modalOverlay} onClick={onClose} role="dialog" aria-modal="true" aria-labelledby={`${inputId}-title`}>
|
||||
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
||||
<h3 id={`${inputId}-title`} className={styles.modalTitle}>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<input
|
||||
id={inputId}
|
||||
ref={inputRef}
|
||||
className={`${newGameStyles['name-input']} ${styles.modalInput}`}
|
||||
value={name}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
autoCapitalize="words"
|
||||
spellCheck={false}
|
||||
enterKeyHint={enterKeyHint}
|
||||
onInput={(e: Event) => {
|
||||
setName((e.target as HTMLInputElement).value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
const validationError = onConfirm(name);
|
||||
if (validationError) setError(validationError);
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <p className={styles.errorText}>{error}</p>}
|
||||
|
||||
<div className={styles.modalActions}>
|
||||
<button type="button" className={styles.actionButton} onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.actionButton} ${styles.confirmButton}`}
|
||||
onClick={() => {
|
||||
const validationError = onConfirm(name);
|
||||
if (validationError) setError(validationError);
|
||||
}}
|
||||
>
|
||||
Übernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import styles from '../NewGame.module.css';
|
||||
import {
|
||||
ARIA_LABELS,
|
||||
ERROR_STYLES,
|
||||
FORM_CONFIG,
|
||||
UI_CONSTANTS,
|
||||
} from '@lib/domain/constants';
|
||||
import { PlayerSelectModal } from '../steps/PlayerSelectModal';
|
||||
import { NameEntryModal } from './NameEntryModal';
|
||||
import { WizardNav } from './WizardNav';
|
||||
import { WizardStepForm } from './WizardStepForm';
|
||||
|
||||
interface PlayerNameStepProps {
|
||||
stepNumber: number;
|
||||
title: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
inputId: string;
|
||||
playerInputClassName: string;
|
||||
playerNameHistory: string[];
|
||||
onNext: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
initialValue?: string;
|
||||
required?: boolean;
|
||||
requiredErrorMessage?: string;
|
||||
enterKeyHint: 'next' | 'done';
|
||||
showSkipButton?: boolean;
|
||||
skipLabel?: string;
|
||||
showMorePlayersModal?: boolean;
|
||||
prefillNameEntry?: boolean;
|
||||
onCreateTemporaryName?: (name: string) => void;
|
||||
}
|
||||
|
||||
export function PlayerNameStep({
|
||||
stepNumber,
|
||||
title,
|
||||
label,
|
||||
placeholder,
|
||||
inputId,
|
||||
playerInputClassName,
|
||||
playerNameHistory,
|
||||
onNext,
|
||||
onCancel,
|
||||
initialValue = '',
|
||||
required = true,
|
||||
requiredErrorMessage,
|
||||
enterKeyHint,
|
||||
showSkipButton = false,
|
||||
skipLabel = 'Überspringen',
|
||||
showMorePlayersModal = false,
|
||||
prefillNameEntry = true,
|
||||
onCreateTemporaryName,
|
||||
}: PlayerNameStepProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPlayerListOpen, setIsPlayerListOpen] = useState(false);
|
||||
const [isNameEntryOpen, setIsNameEntryOpen] = useState(false);
|
||||
|
||||
const validate = (name: string): string | null => {
|
||||
const trimmedName = name.trim();
|
||||
|
||||
if (required && !trimmedName) {
|
||||
return requiredErrorMessage ?? 'Bitte Namen eingeben';
|
||||
}
|
||||
|
||||
if (trimmedName.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH) {
|
||||
return `Spielername darf maximal ${FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const trimmedValue = value.trim();
|
||||
const validationError = validate(trimmedValue);
|
||||
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
onNext(trimmedValue);
|
||||
};
|
||||
|
||||
const handleQuickPick = (name: string) => {
|
||||
setValue(name);
|
||||
setError(null);
|
||||
onNext(name);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
setValue('');
|
||||
setError(null);
|
||||
onNext('');
|
||||
};
|
||||
|
||||
const handleManualNameSubmit = (name: string): string | null => {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
return 'Bitte Namen eingeben';
|
||||
}
|
||||
|
||||
const manualNameError = validate(trimmedName);
|
||||
if (manualNameError) {
|
||||
return manualNameError;
|
||||
}
|
||||
|
||||
setValue(trimmedName);
|
||||
setError(null);
|
||||
setIsNameEntryOpen(false);
|
||||
onCreateTemporaryName?.(trimmedName);
|
||||
onNext(trimmedName);
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<WizardStepForm
|
||||
ariaLabel={ARIA_LABELS.PLAYER_INPUT(label)}
|
||||
title={title}
|
||||
currentStep={stepNumber}
|
||||
onSubmit={handleSubmit}
|
||||
footer={
|
||||
<WizardNav
|
||||
onBack={onCancel}
|
||||
nextDisabled={!value.trim()}
|
||||
middleContent={
|
||||
showSkipButton ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className={styles['quick-pick-btn']}
|
||||
>
|
||||
{skipLabel}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`${styles['player-input']} ${playerInputClassName}`}
|
||||
style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_LARGE }}
|
||||
>
|
||||
<div className={styles['quick-picks-row']}>
|
||||
{showMorePlayersModal && playerNameHistory.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles['quick-pick-btn']} ${styles['quick-pick-action-btn']}`}
|
||||
onClick={() => setIsPlayerListOpen(true)}
|
||||
aria-label="Liste anzeigen"
|
||||
>
|
||||
Liste anzeigen
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles['quick-pick-btn']} ${styles['quick-pick-action-btn']}`}
|
||||
onClick={() => setIsNameEntryOpen(true)}
|
||||
aria-label={`Name für ${label} eingeben`}
|
||||
>
|
||||
Name eingeben
|
||||
</button>
|
||||
|
||||
{playerNameHistory.length > 0 && (
|
||||
<span className={styles['quick-pick-actions-separator']} aria-hidden="true" />
|
||||
)}
|
||||
|
||||
{playerNameHistory.slice(0, UI_CONSTANTS.MAX_QUICK_PICKS).map((name, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name + idx}
|
||||
className={`${styles['quick-pick-btn']} ${value === name ? styles['selected'] : ''}`}
|
||||
onClick={() => handleQuickPick(name)}
|
||||
aria-label={ARIA_LABELS.QUICK_PICK(name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className={styles['validation-error']}
|
||||
style={{ marginBottom: 16, ...ERROR_STYLES.CONTAINER }}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span style={ERROR_STYLES.ICON}>⚠️</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPlayerListOpen && (
|
||||
<PlayerSelectModal
|
||||
players={playerNameHistory}
|
||||
onSelect={(name) => {
|
||||
setIsPlayerListOpen(false);
|
||||
handleQuickPick(name);
|
||||
}}
|
||||
onClose={() => setIsPlayerListOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NameEntryModal
|
||||
open={isNameEntryOpen}
|
||||
title={`Name für ${label}`}
|
||||
placeholder={placeholder}
|
||||
inputId={inputId}
|
||||
initialValue={prefillNameEntry ? value : ''}
|
||||
enterKeyHint={enterKeyHint}
|
||||
onClose={() => setIsNameEntryOpen(false)}
|
||||
onConfirm={handleManualNameSubmit}
|
||||
/>
|
||||
|
||||
{value.length > FORM_CONFIG.CHARACTER_COUNT_WARNING_THRESHOLD && (
|
||||
<div
|
||||
className={styles['character-counter']}
|
||||
style={{ color: value.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH ? '#f44336' : '#ff9800' }}
|
||||
>
|
||||
{value.length}/{FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen
|
||||
</div>
|
||||
)}
|
||||
</WizardStepForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { h } from 'preact';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import styles from '../NewGame.module.css';
|
||||
import { ARIA_LABELS } from '@lib/domain/constants';
|
||||
|
||||
interface WizardNavProps {
|
||||
onBack: () => void;
|
||||
nextDisabled?: boolean;
|
||||
nextType?: 'submit' | 'button';
|
||||
nextButtonAriaLabel?: string;
|
||||
middleContent?: ComponentChildren;
|
||||
}
|
||||
|
||||
export function WizardNav({
|
||||
onBack,
|
||||
nextDisabled = false,
|
||||
nextType = 'submit',
|
||||
nextButtonAriaLabel = ARIA_LABELS.NEXT,
|
||||
middleContent,
|
||||
}: WizardNavProps) {
|
||||
return (
|
||||
<div className={styles['arrow-nav']}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label={ARIA_LABELS.BACK}
|
||||
onClick={onBack}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
|
||||
<div className={styles['arrow-nav-actions']}>
|
||||
{middleContent}
|
||||
<button
|
||||
type={nextType}
|
||||
className={styles['arrow-btn']}
|
||||
aria-label={nextButtonAriaLabel}
|
||||
disabled={nextDisabled}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { h } from 'preact';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { ProgressIndicator } from '../ProgressIndicator';
|
||||
import styles from '../NewGame.module.css';
|
||||
import { UI_CONSTANTS } from '@lib/domain/constants';
|
||||
|
||||
interface WizardStepFormProps {
|
||||
ariaLabel: string;
|
||||
title: string;
|
||||
currentStep: number;
|
||||
onSubmit?: (e: Event) => void;
|
||||
children: ComponentChildren;
|
||||
footer: ComponentChildren;
|
||||
}
|
||||
|
||||
export function WizardStepForm({
|
||||
ariaLabel,
|
||||
title,
|
||||
currentStep,
|
||||
onSubmit,
|
||||
children,
|
||||
footer,
|
||||
}: WizardStepFormProps) {
|
||||
return (
|
||||
<form className={styles['new-game-form']} onSubmit={onSubmit} aria-label={ariaLabel} autoComplete="off">
|
||||
<div className={styles['form-header']}>
|
||||
<div className={styles['screen-title']}>{title}</div>
|
||||
<ProgressIndicator currentStep={currentStep} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_MEDIUM }} />
|
||||
</div>
|
||||
|
||||
<div className={styles['form-content']}>{children}</div>
|
||||
|
||||
<div className={styles['form-footer']}>{footer}</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user