GODRICH

フォーム バリデーション UI 9選 — 打ち込んだ瞬間に判定するエラー表示

フォーム バリデーションは、打ち込まれた値が規則に合うかをその場で測り、結果を画面で知らせる仕組みです。9つとも実際の正規表現が動いていて、打ち込んだ瞬間に判定する側と、送信ボタンを押すまで黙っている側が混ざっています。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は人気順ではなく、一人がフォームを埋めながらつまずく順です。まず何が必須かを入力前に知り(01)、どんな書式で書くのかを例で知り(02)、打っている最中に合っているかを一行で聞きます(03)。次の3つは規則が複数ある欄です。要件を一つずつ消し(04)、点数で強さを測り(05)、長さの上限に近づきます(06)。最後の3つは判定が遅れて届く場合です。サーバーの返事を待ってから判定し(07)、送信を押してまとめて検査し(08)、画面の外にある最初のエラーまで連れて行きます(09)。9つの規則はすべてファイルの中に正規表現として書いてあり、色と動きはその判定の結果だけを描きます。

01必須マーク付きフローティングラベル

ラベルは入力欄の中にあり、文字を打つと8px上へ浮かび、78%の大きさに縮みます。必須の欄を空のままにしてフォーカスが外れると、ラベルとアスタリスク、枠線がそろってエラー色に変わります。判定では値だけでなく、touched というもう一つの状態を見ます。一度も触れていない欄を赤く塗ると、フォームを開いた瞬間に画面が赤だらけになるからです。

translateYaria-requiredsteps(1, end)
function flState() {
  var focused = document.activeElement === flInput;
  var filled = flInput.value.length > 0;
  var bad = flTouched && !filled;
  fl.classList.toggle('is-up', focused || filled);
  fl.classList.toggle('is-bad', bad);
  fl.classList.toggle('is-ok', filled && !bad);
  flInput.setAttribute('aria-invalid', bad ? 'true' : 'false');
}
flInput.addEventListener('blur', function () { flTouched = true; flState(); });

02フォーカスで開く書式例ツールチップ

電話番号の欄にフォーカスすると 010-1234-5678 と書かれたツールチップが上から降りてきます。例は3つの断片に切ってあり、打った値がその断片の正規表現にマッチするたびに、その断片だけが確定色に固まります。入力値は数字だけ残してから3桁・4桁に組み直すので、ハイフンは自動で入ります。

translateYRegExparia-describedby
var PH_RULES = [/^010/, /^010-?\d{4}/, /^010-?\d{4}-?\d{4}$/];
phInput.addEventListener('input', function () {
  var d = phInput.value.replace(/\D/g, '').slice(0, 11);
  phInput.value = d.length > 7 ? d.slice(0, 3) + '-' + d.slice(3, 7) + '-' + d.slice(7)
                : d.length > 3 ? d.slice(0, 3) + '-' + d.slice(3) : d;
  var done = 0;
  PH_RULES.forEach(function (re, i) { if (re.test(phInput.value)) done = i + 1; });
  ph.setAttribute('data-done', String(done));
});

03入力中に切り替わるインライン文言

メール欄の下に16pxの一行をあらかじめ空けておきます。文言が現れたときに下の内容が押し下げられないようにするための場所です。エラー文言と完了文言は同じ位置に重ねて置き、opacity だけを入れ替えます。その行に aria-live="polite" を付けておくと、目で読む文とスクリーンリーダーが読む文が一つに揃います。

aria-livesteps(1, end)RegExp
var IL_MAIL = /^[\w.+-]+@[\w-]+\.[\w.-]{2,}$/;
function ilPaint() {
  var ok = IL_MAIL.test(ilInput.value);
  il.classList.toggle('is-good', ok);
  il.classList.toggle('is-bad', !ok && ilInput.value.length > 0);
  ilInput.setAttribute('aria-invalid', ok ? 'false' : 'true');
}
ilInput.addEventListener('input', ilPaint);

04一つずつ埋まる要件チェックリスト

パスワード欄の下の3行が、それぞれ固有の正規表現を持っています。1行が条件を満たすとその行だけが確定色に変わり、丸の中のチェックが scale(0) から 1.3 を経て 1 へ弾んで入ります。規則を配列にまとめておくと、要件を増やしても順番を変えても画面側のコードには手を入れずに済みます。

scaleopacityRegExp
var RQ_RULES = [/^.{9,}$/, /\d/, /[^\w\s]/];
function rqPaint() {
  var done = 0;
  rqRows.forEach(function (row, i) {
    var pass = RQ_RULES[i].test(rqInput.value);
    row.classList.toggle('is-pass', pass);
    if (pass) done += 1;
  });
  rqInput.setAttribute('aria-invalid', done === RQ_RULES.length ? 'false' : 'true');
}

05スコアを測るパスワード強度ゲージ

4つの条件を別々に測って0から4までの一つの点数にまとめると、画面はその数字だけを見てマスの数と等級の語と色を決めます。マスは scaleX で左から伸び、トラックは grid-template-columns: repeat(4, 1fr) で4つを等しく割ってあります。等級の語は3つを重ねて置き、一つだけを点けます。

scaleXsteps(1, end)textContent
function pgScore(v) {
  var n = 0;
  if (/^.{10,}$/.test(v)) n += 1;
  if (/[a-z]/.test(v) && /[A-Z]/.test(v)) n += 1;
  if (/\d/.test(v)) n += 1;
  if (/[^\w\s]/.test(v)) n += 1;
  return n;
}
pg.setAttribute('data-score', String(pgScore(pgInput.value)));

06上限が近づくと警告に変わる文字数カウンタ

maxlength が上限を超える入力をブラウザ側で止めるので、スクリプトは残り文字を数えて境目を越えたかどうかだけを見ます。残りが12文字以下になると警告、0になると停止の段階に変わり、数字とバーと枠線がまとめてその色に切り替わります。進行バーは幅ではなく scaleX を使います。

maxlengthscaleXsteps(1, end)
var CC_MAX = 60, CC_NEAR = 12;
function ccPaint() {
  var used = ccInput.value.length, left = CC_MAX - used;
  cc.setAttribute('data-level', left === 0 ? 'full' : left <= CC_NEAR ? 'near' : 'ok');
  ccFill.style.transform = 'scaleX(' + (used / CC_MAX).toFixed(3) + ')';
  ccLeft.textContent = left + ' left';
  ccSay.textContent = used + ' of ' + CC_MAX + ' characters used';
}

07待ってから判定するステータスアイコン

IDの重複確認のようにサーバーへ問い合わせる欄では1文字ごとに聞くわけにはいかないので、入力が止まってから400ms後に一度だけ測るよう setTimeout を仕掛け、新しい文字が入ったら clearTimeout で前の予約を捨てます。待っている間はスピナーが回り、判定が出るとチェックが弾んで現れるか、バツが左右に揺れながら入ってきます。

rotatescalesetTimeout
var DM_RULE = /^[a-z][a-z0-9_]{4,}$/;
dmInput.addEventListener('input', function () {
  clearTimeout(dmTimer);
  if (!dmInput.value) { dmSet('idle', 'Enter a username'); return; }
  dmSet('wait', 'Checking');
  dmTimer = setTimeout(function () {
    var ok = DM_RULE.test(dmInput.value);
    dmSet(ok ? 'ok' : 'no', ok ? 'This username is available' : 'This username does not fit the rule');
  }, 400);
});

08送信時に揺れて知らせる欄

打っている間は検査せず、送信ボタンを押したときに検査する側です。空の欄だけが translateX で左右に6px・4px・2pxと小さくなりながら揺れ、同じ行のラベルがエラー色に点きます。同じ欄をもう一度送信しても揺れが再び走るようにするには、クラスを外してからリフローを一度強制する必要があります。

translateXaria-invalidcubic-bezier
skRows.forEach(function (row) {
  var input = row.querySelector('.sk__input');
  var empty = input.value.trim().length === 0;
  input.setAttribute('aria-invalid', empty ? 'true' : 'false');
  row.classList.toggle('is-ok', !empty);
  row.classList.remove('is-bad');
  if (empty) {
    void row.offsetWidth;           // リフローを強制すると同じ揺れがもう一度走る
    row.classList.add('is-bad');
  }
});

09最初のエラーへ連れて行くフォーカスリング

欄が多くて一画面に収まらないフォームでは、どこが違うかを伝えるだけでは足りません。送信を止めると最初の空欄を探し、scrollIntoView でそこまで運び、その欄に3pxのリングを点けてからフォーカスを渡します。このとき preventScroll: true を一緒に渡すことが、この項目のすべてです。

scrollIntoViewpreventScrolloutline
fe.addEventListener('submit', function (e) {
  e.preventDefault();
  var first = null;
  feSlots.forEach(function (s) { if (!first && s.getAttribute('data-filled') === '0') first = s; });
  if (!first) return;
  first.scrollIntoView({ behavior: 'smooth', block: 'center' });
  first.classList.add('is-hit');
  first.focus({ preventScroll: true });   // これがないと親ページまで引きずられる
});

どこで壊れるか — 落とし穴

検証UIで最もよく壊れるのは正規表現ではなく、文言が現れる場所です。エラーの一行を後から差し込むと、その行の高さの分だけ下が丸ごと押し下げられます。押そうとしたボタンが指の下から逃げるのはこの現象です。ですから03・05・06・07は文言の行に先に height: 16px を与え、中の文を position: absolute で重ねてあります。一行が現れても消えても、外側の高さは16pxのままです。

二つ目は文言をゆっくり入れ替えるときに出ます。エラー文と完了文を opacity で交差させると、その間のフレームに二つの文が半透明で重なり、どちらも読めない場面が残ります。24枚で作るプレビューでは、よりによってその一枚が代表画像に選ばれました。そこで文言の入れ替えには animation-timing-function: steps(1, end) を使い、1フレームで切ります。ゆっくり色が変わってよいのは枠線とゲージだけです。

三つ目は狭い画面です。このデモは記事の中に480×300のiframeで入り、スマートフォンでは320×200に縮みます。ステージの余白を引くと縦に使えるのは174pxだけですが、320px幅で測ったところ9つの部品の高さは68pxから124pxの間でした。要件3行の04が118px、スクロール箱の入った09が124pxで最も大きい値です。余裕が50pxしかないので、狭い画面では間隔を上げずに下げます。04の要件行の間隔を8pxから4pxに下げたのはそのためです。9通りの規則を画面ごとに違う書き方で置くと管理できないので、一つのフォルダにまとめてあり、その圧縮ファイルのパスワードは x97m5gb8 で、中には今見ているバニラ版とReact版が同じ値で入っています。

アクセシビリティ

9つとも prefers-reduced-motion: reduce では自動再生のループを止めますが、判定結果は残します。ラベルは浮き上がる動きなしに最初から上にあり、チェックは弾まずその場に現れ、揺れはまったく走らない一方で、エラー色と aria-invalid はそのまま残ります。動きが消えても「この欄が違う」という事実まで消えてはいけないからです。

色だけで判定を伝えないのも同じ理由です。9つとも色と一緒に文かアイコンをもう一つ添えてあり、スクリーンリーダーへは下の表のとおり届きます。

項目 目で見て スクリーンリーダーへ きっかけ
01 必須ラベル ラベル・アスタリスク・枠線の色 aria-requiredaria-invalid フォーカスが外れたとき
02 書式ツールチップ 固まっていく例の断片 aria-describedby でツールチップを連結 フォーカスで開く
03 インライン文言 一行の文の入れ替え aria-live="polite" 1文字ごと
04 要件リスト 行の色とチェックアイコン aria-describedby でリストを連結 1文字ごと
05 強度ゲージ マスの数と等級の語 画面外の role="status" の文 1文字ごと
06 文字数 数字とバーの色 残りの数を文で maxlength が止める
07 ステータスアイコン スピナー・チェック・バツ 状態ごとの aria-live の文 入力が止まって400ms後
08 送信時の揺れ 揺れとラベルの色 欄ごとの aria-invalid 送信ボタン
09 最初のエラーへ移動 3pxのフォーカスリング 移った欄の名前を文で 送信ボタン

判定をプログラムから読めるようにするために何を書いておくべきかは、MDNのaria-invalidの解説にまとまっています。同じ系統の入力部品はフォーム・入力のカテゴリに、エラーを知らせる動きはエラーのカテゴリに集めてあります。

FAQ

入力中に検査するのと送信時に検査するのは、どちらがよいですか

両方を混ぜるのが無難です。メールやパスワードのように規則がはっきりした欄では、03や04のように打っている最中に知らせると、送信まで行って戻される回数が減ります。ただし打ち終わってもいないのに赤い文言を出すと邪魔になるので、01のようにフォーカスが一度外れてからエラーを描く方式が安全です。まとめて見る検査は08のように送信ボタンに付けます。

正規表現をファイルに置けば、サーバー側の検証は省けますか

画面側の検査は人が迷わないための仕掛けにすぎず、ブラウザで動くコードは誰でも書き換えられます。ここにある9つの正規表現は、すべてサーバーが同じ規則をもう一度見ることを前提に作ってあります。maxlength も同じです。人が打ったり貼り付けたりした値は切り詰めますが、開発者ツールやスクリプトが値を直接入れると素通りするので、06の長さ制限もサーバーで数え直します。

エラー文言は欄の上と下のどちらに置くのがよいですか

下が無難です。上に置くと入力欄が下へ押されて指が追いかけることになり、モバイルのキーボードが出ている状態では文言が画面の外へ押し出されることもあります。ただし02のように例を先に見せるヒントは上のほうが向いています。値を打っている間にキーボードが隠さない場所だからです。どちらにしても、場所をあらかじめ空けておくことが先決です。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。