Einzelbeispiel · v1
Formularfehler verständlich anzeigen.
Dies ist das Kontaktformular des Shops mit seiner tatsächlichen Validierung und den simulierten Antworten.
Vorbereiten leert Kontaktentwurf und Ergebnis. Es gibt keine Anmeldung, E-Mail-Zustellung oder Support-Anbindung.
Gemeinsamen Quellcode ansehen
Dies sind die tatsächlichen Quelldateien dieses Beispiels. Der erzeugte Stand wird bei der Build-Validierung mit der Implementierung verglichen. Imports und gemeinsame Hilfsfunktionen bleiben sichtbar; es gibt keine getrennte Lehrkopie.
src/components/features/contact/contact-form.tsx
'use client';
import {useEffect,useRef,useState} from 'react';
import {useTranslations} from 'next-intl';
import {Button} from '@/components/ui/button';
import {FormErrors,FormField} from '@/components/shared/forms/form-field';
import {useDemoState} from '@/lib/state/use-demo-state';
import {readState} from '@/lib/state/registry';
import {beginContact,contactErrors,finishContact,prefillBarrier,sampleContact,type ContactDraft,type ContactField} from '@/lib/contact/model';
import {scenarios} from '@/lib/scenarios/manifest';
import type {DemoRoute} from '@/lib/routes';
export function ContactForm({route,seed,embedded=false}:{embedded?:boolean;route:DemoRoute;seed:{topic:'contact'|'barrier';path:string}}) {
const Root=embedded?'section':'main';const Heading=embedded?'h2':'h1';const PanelHeading=embedded?'h3':'h2';
const submitButton=useRef<HTMLButtonElement>(null);const completeButton=useRef<HTMLButtonElement>(null);const previousResult=useRef('idle');
const t=useTranslations('Contact');const f=useTranslations('Forms');const [state,setContact]=useDemoState('contact');const [errors,setErrors]=useState<ContactField[]>([]);const [pause,setPause]=useState(true);const summary=useRef<HTMLDivElement>(null);const focusErrors=useRef(false);const seeded=useRef(false);const improved=route.variant==='improved';
useEffect(()=>{if(state.result==='loading')completeButton.current?.focus();else if(previousResult.current==='loading')submitButton.current?.focus();previousResult.current=state.result;},[state.result]);
useEffect(()=>{if(seeded.current)return;seeded.current=true;if(seed.topic==='barrier'){const current=readState('contact');const next=prefillBarrier(current,seed.path);if(next!==current)setContact(next);}},[seed,setContact]);
useEffect(()=>{if(focusErrors.current){summary.current?.focus();focusErrors.current=false;}},[errors]);
useEffect(()=>{const reset=()=>{setErrors([]);setPause(true);};window.addEventListener('demo-reset',reset);return()=>window.removeEventListener('demo-reset',reset);},[]);
function update<K extends keyof ContactDraft>(field:K,value:ContactDraft[K]){const current=readState('contact');setContact({...current,draft:{...current.draft,[field]:value},result:'idle'});}
function finish(){setContact(finishContact(readState('contact')));}
const busy=state.result==='loading';const errorFor=(field:ContactField)=>errors.includes(field)?t(`errors.${field}`):undefined;
return <Root id={embedded?'contact-fixture':'main-content'} className={embedded?'embedded-fixture':'page-container purchase-page'}><header className="catalog-page-header"><Heading>{t('title')}</Heading><p>{t('intro')}</p></header><div className="account-layout"><section className="account-form-section" aria-label={t('formLabel')}>
{improved&&<FormErrors headingLevel={embedded?3:2} id="contact-error-summary" ref={summary} title={f('checkFields')} errors={errors.map(field=>({id:`contact-${field}`,text:t(`errors.${field}`)}))}/>}
<form className="demo-form" noValidate onSubmit={event=>{event.preventDefault();const current=readState('contact');const invalid=contactErrors(current.draft);focusErrors.current=improved&&invalid.length>0;setErrors(invalid);if(invalid.length){setContact({...current,result:'idle'});return;}const next=beginContact(current);setContact(pause?next:finishContact(next));}}>
<fieldset disabled={busy}><legend className="sr-only">{t('formLabel')}</legend><div className="form-field"><label htmlFor="contact-topic">{t('topic')}</label><select id="contact-topic" value={state.draft.topic} onChange={event=>{update('topic',event.target.value as 'contact'|'barrier');setErrors([]);}}><option value="contact">{t('general')}</option><option value="barrier">{t('barrier')}</option></select></div>
{(['name','email'] as const).map(field=><FormField key={field} id={`contact-${field}`} label={t(`fields.${field}`)} type={field==='email'?'email':'text'} value={state.draft[field]} onChange={event=>update(field,event.target.value)} maxLength={80} autoComplete="off" required error={improved?errorFor(field):undefined} className={errorFor(field)?'field-invalid':undefined}/>)}
<div id="contact-message-field" className="form-field"><label htmlFor="contact-message">{t('fields.message')}</label><textarea id="contact-message" value={state.draft.message} maxLength={1200} required aria-invalid={improved&&errors.includes('message')?true:undefined} aria-describedby={improved&&errors.includes('message')?'contact-message-error':'contact-message-help'} className={errorFor('message')?'field-invalid':undefined} onChange={event=>update('message',event.target.value)}/><p id="contact-message-help" className="field-help">{t('messageHelp')}</p>{improved&&errorFor('message')&&<p className="field-error" id="contact-message-error">{errorFor('message')}</p>}</div>
{state.draft.topic==='barrier'&&<><FormField id="contact-path" label={t('fields.path')} value={state.draft.path} maxLength={200} onChange={event=>update('path',event.target.value)} error={improved?errorFor('path'):undefined} className={errorFor('path')?'field-invalid':undefined}/><p className="field-help">{t('pathHelp')}</p><div className="form-field"><label htmlFor="contact-scenario">{t('fields.scenario')}</label><select id="contact-scenario" value={state.draft.scenario} onChange={event=>update('scenario',event.target.value)} aria-invalid={improved&&errors.includes('scenario')?true:undefined} aria-describedby={improved&&errors.includes('scenario')?'contact-scenario-error':undefined}><option value="">{t('noScenario')}</option>{scenarios.map(scenario=><option key={scenario.id} value={scenario.id}>{scenario.id}</option>)}</select>{improved&&errorFor('scenario')&&<p className="field-error" id="contact-scenario-error">{errorFor('scenario')}</p>}</div></>}
<Button type="button" variant="outline" size="sm" onClick={()=>{const current=readState('contact');setContact({...current,draft:{...current.draft,name:sampleContact.name,email:sampleContact.email,message:t('sampleMessage')},result:'idle'});setErrors([]);}}>{t('fillSample')}</Button></fieldset>
<fieldset disabled={busy} className="simulation-controls"><legend>{t('demoControls')}</legend><div className="form-field"><label htmlFor="contact-outcome">{t('outcome')}</label><select id="contact-outcome" value={state.choice} onChange={event=>setContact({...readState('contact'),choice:event.target.value as 'success'|'failure',result:'idle'})}><option value="success">{t('successChoice')}</option><option value="failure">{t('failureChoice')}</option></select></div><label className="check-label"><input type="checkbox" checked={pause} onChange={event=>setPause(event.target.checked)}/>{f('pause')}</label></fieldset>
{busy?<div className="submission-paused"><p>{f('paused')}</p><div className="form-actions"><Button ref={completeButton} type="button" onClick={finish}>{t('complete')}</Button><Button type="button" variant="outline" onClick={()=>setContact({...readState('contact'),result:'idle'})}>{t('cancel')}</Button></div></div>:<Button ref={submitButton} type="submit">{t(state.result==='failure'?'retry':'submit')}</Button>}
</form>
{/* contact-status: the barriers result intentionally has no live region. */}
<p id="contact-submit-status" role={improved?(state.result==='failure'?'alert':'status'):undefined} aria-atomic={improved?true:undefined} className={state.result==='failure'?'field-error':'form-status'}>{state.result==='idle'?'':t(`results.${state.result}`)}</p>
</section><aside className="demo-help"><PanelHeading>{t('demoTitle')}</PanelHeading><p>{t('demoCopy')}</p><p className="field-help">{t('privacy')}</p></aside></div></Root>;
}
src/components/shared/forms/form-field.tsx
'use client';
import type {ComponentProps,Ref} from 'react';
export function FormField({id,label,error,...props}:ComponentProps<'input'>&{id:string;label:string;error?:string}) {
return <div className="form-field"><label htmlFor={id}>{label}</label><input {...props} id={id} aria-invalid={error?true:undefined} aria-describedby={error?`${id}-error`:props['aria-describedby']}/>{error&&<p className="field-error" id={`${id}-error`}>{error}</p>}</div>;
}
export function FormErrors({id,errors,title,ref,headingLevel=2}:{headingLevel?:2|3;id:string;errors:{id:string;text:string}[];title:string;ref?:Ref<HTMLDivElement>}) {
const Heading=headingLevel===3?'h3':'h2';
if(!errors.length)return null;
return <div id={id} ref={ref} tabIndex={-1} className="error-summary"><Heading>{title}</Heading><ul>{errors.map(error=><li key={error.id}><a href={`#${error.id}`} onClick={event=>{event.preventDefault();document.getElementById(error.id)?.focus();}}>{error.text}</a></li>)}</ul></div>;
}
src/lib/contact/model.ts
import {z} from 'zod';
import {routeSchema} from '../routes';
import {findProduct} from '../fixtures/catalog';
import {scenarios} from '../scenarios/manifest';
export function isDemoReference(value:string) {
if(!value)return true;
if(value.length>200||/[?#%\\\s]/.test(value))return false;
const parts=value.split('/');if(!routeSchema.safeParse({locale:parts[1],version:parts[2],variant:parts[3]}).success)return false;
const suffix=parts.slice(4).join('/');return ['', 'products','cart','checkout','login','account/settings','contact','preferences'].includes(suffix)||/^products\/[a-z-]+$/.test(suffix)&&Boolean(findProduct(parts[5]));
}
export const contactFields=['name','email','message','path','scenario'] as const;
export type ContactField=typeof contactFields[number];
export const contactDraftSchema=z.object({topic:z.enum(['contact','barrier']),name:z.string().max(80),email:z.string().max(80),message:z.string().max(1200),path:z.string().max(200),scenario:z.string().max(80)}).strict();
export type ContactDraft=z.infer<typeof contactDraftSchema>;
export const emptyContact:ContactDraft={topic:'contact',name:'',email:'',message:'',path:'',scenario:''};
export const sampleContact:ContactDraft={...emptyContact,name:'Alex Beispiel',email:'alex@example.test',message:'Dies ist eine fiktive Nachricht für die Demo.'};
export const validContactSchema=contactDraftSchema.superRefine((data,ctx)=>{
const fail=(field:ContactField)=>ctx.addIssue({code:'custom',path:[field],message:field});
if(data.name.trim().length<2)fail('name');if(!z.email().safeParse(data.email).success)fail('email');if(data.message.trim().length<10)fail('message');
if(data.topic==='barrier'&&!isDemoReference(data.path))fail('path');
if(data.topic==='barrier'&&data.scenario&&!scenarios.some(scenario=>scenario.id===data.scenario))fail('scenario');
});
export function contactErrors(value:unknown):ContactField[]{const result=validContactSchema.safeParse(value);return result.success?[]:contactFields.filter(field=>result.error.issues.some(issue=>issue.path[0]===field));}
export const contactStateSchema=z.object({draft:contactDraftSchema,result:z.enum(['idle','loading','success','failure']),choice:z.enum(['success','failure'])}).strict().refine(state=>state.result==='idle'||validContactSchema.safeParse(state.draft).success,'Invalid submitted draft');
export type ContactState=z.infer<typeof contactStateSchema>;
export const initialContact:ContactState={draft:emptyContact,result:'idle',choice:'success'};
export function beginContact(state:ContactState):ContactState {
validContactSchema.parse(state.draft);return contactStateSchema.parse({...state,result:'loading'});
}
export function finishContact(state:ContactState):ContactState {
const current=contactStateSchema.parse(state);if(current.result!=='loading')throw new Error('Start a submission first');
validContactSchema.parse(current.draft);return {...current,result:current.choice};
}
export function prefillBarrier(state:ContactState,path:string):ContactState {
if(!isDemoReference(path))throw new Error('Unsupported demo path');
if(state.draft.topic==='barrier'&&state.draft.path===path)return state;
return contactStateSchema.parse({...state,draft:{...state.draft,topic:'barrier',path},result:'idle'});
}
src/app/[locale]/globals.css
.form-field .field-invalid {border:2px solid var(--destructive);}
.field-error {color:var(--destructive);font-size:.875rem;margin:.5rem 0 0;}
.error-summary {border:2px solid var(--destructive);padding:1.25rem;margin-top:1.5rem;}