本文へスキップ
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