UI Library
フォームバリデーション
入力内容の検証と、その伝え方。エラーは色だけでなく記号と文言でも示し、送信時は最初の該当項目へフォーカスを移します。
components/uilib/ValidatedForm.tsx
'use client'
import { useId, useRef, useState } from 'react'
import s from './ValidatedForm.module.scss'
type Errors = { email?: string; message?: string }
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/**
* フォームバリデーション。
*
* a11y:
* - エラーは aria-describedby で入力欄に紐づけ、aria-invalid を立てる
* - まとめのメッセージは role="alert" の live region に出す
* - 送信時にエラーがあれば「最初の該当項目」へフォーカスを移す
* - 色だけに頼らず、記号(!)と文言でも伝える
* - noValidate でブラウザ既定の吹き出しを止め、文言と表示位置を自分で管理する
* - 一度エラーになった項目は入力のたびに再検証し、直った瞬間に消す
* (送信するまで赤いままだと、直っているのか分からない)
*/
export function ValidatedForm() {
const id = useId()
const [errors, setErrors] = useState<Errors>({})
const [done, setDone] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
const check = (email: string, message: string): Errors => {
const next: Errors = {}
if (!email) next.email = 'メールアドレスを入力してください'
else if (!EMAIL.test(email))
next.email = 'メールアドレスの形式で入力してください(例: name@example.com)'
if (!message) next.message = 'お問い合わせ内容を入力してください'
return next
}
const values = () => {
const form = formRef.current
return {
email: String(new FormData(form!).get('email') ?? ''),
message: String(new FormData(form!).get('message') ?? ''),
}
}
const revalidate = () => {
// まだ一度もエラーを出していないなら、入力中に赤くしない
if (Object.keys(errors).length === 0) return
const { email, message } = values()
setErrors(check(email, message))
}
const onSubmit = (e: React.FormEvent) => {
e.preventDefault()
const { email, message } = values()
const next = check(email, message)
setErrors(next)
if (Object.keys(next).length > 0) {
const first = next.email ? 'email' : 'message'
document.getElementById(`${id}-${first}`)?.focus()
setDone(false)
return
}
setDone(true)
}
const errorId = (name: keyof Errors) => `${id}-${name}-error`
return (
<form className={s.form} ref={formRef} noValidate onSubmit={onSubmit} onInput={revalidate}>
{done && (
<p className={s.ok} role="status">
<span className={s.okMark} aria-hidden="true" />
入力内容を確認しました
</p>
)}
{Object.keys(errors).length > 0 && (
<p className={s.summary} role="alert">
入力内容に{Object.keys(errors).length}件の誤りがあります。該当の項目をご確認ください。
</p>
)}
<div className={[s.field, errors.email && s.hasError].filter(Boolean).join(' ')}>
<label className={s.label} htmlFor={`${id}-email`}>
メールアドレス <span className={s.req}>*必須</span>
</label>
<input
id={`${id}-email`}
name="email"
type="email"
inputMode="email"
autoComplete="email"
spellCheck={false}
aria-invalid={errors.email ? true : undefined}
aria-describedby={errors.email ? errorId('email') : undefined}
/>
{errors.email && (
<p className={s.err} id={errorId('email')}>
{errors.email}
</p>
)}
</div>
<div className={[s.field, errors.message && s.hasError].filter(Boolean).join(' ')}>
<label className={s.label} htmlFor={`${id}-message`}>
お問い合わせ内容 <span className={s.req}>*必須</span>
</label>
<textarea
id={`${id}-message`}
name="message"
rows={3}
aria-invalid={errors.message ? true : undefined}
aria-describedby={errors.message ? errorId('message') : undefined}
/>
{errors.message && (
<p className={s.err} id={errorId('message')}>
{errors.message}
</p>
)}
</div>
<button className={s.submit} type="submit">
送信する
</button>
</form>
)
}このページに表示しているのは、実際にこのサイトで動いているソースそのものです。見本用に書き写したコードではありません。スタイルは同じ階層の ValidatedForm.module.scss にあり、色・余白はすべてデザイントークンの CSS カスタムプロパティを参照しています。
States
状態変化もすべて実装済み
押したとき・入力待ちのとき・エラーのとき——実際の運用で必要になる表示は、あとから作り足す必要がないよう最初から含めてお渡しします。
- default
- hover
- focus
- active
- disabled
- loading
- error
- success