エラーサマリー
フォーム / パターン集トップ
送信時の不備は先頭に一覧で出す。各項目から該当欄へ飛べるようにする
プレビュー#
コピーする#
上のプレビューでつまみを動かし、「生成する」で調整を反映したコードを作ってからコピーしてください。下の欄は生成の元になる型で、直接コピーするものではありません。
<!-- novalidate はブラウザの吹き出しを止めるためのもの。
欄が持っている validity の判定は、付けても残る -->
<form class="error-summary" id="es-form" action="/contact/confirm/" method="post" novalidate>
<!-- ここは入力画面に足す節なので、h1 は持たない。見出しは骨格
(「フォームの基本形」か「フォームの章分け」)が1つだけ持つ -->
<p class="form-note">「必須」の付いた項目は入力が必要です。</p>
<!-- 読み上げ用の器。読み込み時から空のまま置く。ここに文字が入った瞬間が
「変化」として読まれる。中身の入った role="alert" を最初から置いても、
送信のあとに要素ごと足しても、一度も読まれない -->
<p class="visually-hidden" role="alert" id="es-live"></p>
<!-- 見せるほうの一覧。焦点を移して読ませるので、こちらに role は付けない -->
<div class="error-summary__box" id="es-summary" tabindex="-1" hidden>
<h2 class="error-summary__title" id="es-summary-title"></h2>
<ul class="error-summary__list" id="es-summary-list"></ul>
</div>
<div class="field">
<label for="es-name">お名前 <span class="req" aria-hidden="true">必須</span></label>
<input id="es-name" name="name" type="text" autocomplete="name"
aria-describedby="es-name-msg" required>
<!-- エラー文の置き場。空のまま先に置き、不備が出たときだけ文字を書く -->
<p class="field__error" id="es-name-msg" hidden></p>
</div>
<div class="field">
<label for="es-mail">メール <span class="req" aria-hidden="true">必須</span></label>
<input id="es-mail" name="email" type="email" value="mail.example.com" autocomplete="email"
aria-describedby="es-mail-msg" required>
<p class="field__error" id="es-mail-msg" hidden></p>
</div>
<!-- 塗りの送信ボタンは、この画面に1つだけ。基本形・章分け・ボタン行と
重ねて組むときは、残す1つを決めてほかを消す -->
<button class="button button--primary" type="submit">送信する</button>
</form>
<script>
var form = document.getElementById("es-form");
var summary = document.getElementById("es-summary");
var list = document.getElementById("es-summary-list");
// 何を不備とみなすかは案件ごとに違う。ここはブラウザが持っている判定
// (required と type="email")を日本語の文に置き換えただけのもの。
// 項目や規則が増えたら、書き換えるのはこの関数だけでよい
function validate() {
var errors = [];
Array.prototype.forEach.call(form.elements, function (el) {
if (!el.name || el.disabled) return; // ボタンには name を付けていない
if (el.validity.valueMissing) {
errors.push({ id: el.id, message: missingMessage(el) });
} else if (el.validity.typeMismatch && el.type === "email") {
errors.push({ id: el.id, message: labelOf(el) + "は @ を含む形式で入力してください" });
}
});
return errors;
}
// 空のときの文言。選ぶものに「入力されていません」とは言わない。
// 定型に収まらない欄は、欄の側に data-error で文言を持たせる
function missingMessage(el) {
if (el.dataset.error) return el.dataset.error;
return labelOf(el) + (el.tagName === "SELECT" ? "が選ばれていません" : "が入力されていません");
}
// 文言は画面のラベルから作る。ラベルを直せばエラー文も一緒に直る
function labelOf(el) {
var label = form.querySelector('label[for="' + el.id + '"]');
if (!label) return "この項目";
var copy = label.cloneNode(true);
var req = copy.querySelector(".req");
if (req) req.remove(); // 「必須」の印は項目名に含めない
return copy.textContent.trim();
}
// 同じ文言を欄の側にも出す。一覧だけだと、直す場所で迷う
function markFields(errors) {
Array.prototype.forEach.call(form.querySelectorAll(".field"), function (field) {
var input = field.querySelector("input, select, textarea");
var slot = field.querySelector(".field__error");
if (!input || !slot) return;
var hit = null;
errors.forEach(function (error) { if (error.id === input.id) hit = error; });
field.classList.toggle("field--error", Boolean(hit));
input.setAttribute("aria-invalid", hit ? "true" : "false");
slot.textContent = hit ? hit.message : "";
slot.hidden = !hit;
});
}
// errors = [{ id: "es-name", message: "お名前が入力されていません" }, …]
function showErrors(errors) {
var headline = "入力に" + errors.length + "件の不備があります";
document.getElementById("es-summary-title").textContent = headline;
list.textContent = ""; // 器は残し、中身だけ入れ替える
errors.forEach(function (error) {
var link = document.createElement("a");
link.href = "#" + error.id;
link.textContent = error.message;
var item = document.createElement("li");
item.appendChild(link);
list.appendChild(item);
});
summary.hidden = false;
summary.focus(); // tabindex="-1" があるので受けられる
document.getElementById("es-live").textContent = headline; // 空の器に文字だけ書く
}
// 呼び出す側。これが無いと、上の3つは一度も動かない
form.addEventListener("submit", function (event) {
var errors = validate();
markFields(errors);
if (errors.length === 0) {
summary.hidden = true;
return; // 不備が無ければ、そのまま送信させる
}
event.preventDefault();
showErrors(errors);
});
</script>
/* 目には見えないが、読み上げには届く。display: none や visibility: hidden では届かない */
.visually-hidden {
position: absolute;
inline-size: 1px; block-size: 1px;
margin: -1px; padding: 0; border: 0;
clip-path: inset(50%);
overflow: hidden; white-space: nowrap;
}
.error-summary {
display: flex;
flex-direction: column;
gap: var(--space-5);
max-inline-size: 32rem;
}
/* 必須の凡例。印と同じ言葉「必須」で書く */
.form-note {
margin: 0;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.req {
font-weight: 400;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.error-summary__box {
border: 1px solid var(--color-danger);
border-inline-start-width: 4px;
background: var(--color-surface);
padding: var(--space-4) var(--space-5);
}
.error-summary__box:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.error-summary__title {
margin: 0 0 var(--space-2);
font-size: var(--text-base);
color: var(--color-danger);
}
.error-summary__list {
margin: 0;
padding-inline-start: 1.2em;
font-size: var(--text-sm);
}
.error-summary__list li + li {
margin-block-start: var(--space-1);
}
.error-summary__list a {
color: var(--color-danger);
text-underline-offset: .2em;
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.field label {
font-size: var(--text-sm);
font-weight: 700;
}
.field input {
font: inherit;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text);
min-block-size: 2.75rem;
}
/* 不備のある欄だけ枠を替える。色は印であって、知らせは文字が受け持つ */
.field--error input,
.field--error select,
.field--error textarea {
border-color: var(--color-danger);
outline-color: var(--color-danger);
}
.field__error {
margin: 0;
font-size: var(--text-sm);
color: var(--color-danger);
}
/* .button の基底と派生は「出発点のCSS」にある */
.error-summary .button {
align-self: start;
}
確認する#
- 読み上げ用の器は空のまま先に置き、あとから文字だけ書き込む
- 送信時に tabindex=-1 のサマリーへフォーカスを移す
- 一覧の各項目が該当欄へのページ内リンクになっている
- 同じ文言を欄の側にも出す(サマリーだけだと直す場所で迷う)
- showErrors() を呼ぶ submit ハンドラが入っている(貼っただけで箱が空のまま残らない)
- この節は h1 を持たない(入力画面の見出しは骨格が1つだけ持つ)
- 何を不備とみなすかを validate() に書き、項目を足したらここも足している
- form に novalidate があり、ブラウザの吹き出しと自作の一覧が二重に出ない
- 不備が直ったら、欄のエラー文とサマリーの両方が消える