このページの中身

エラーサマリー

フォーム / パターン集トップ

送信時の不備は先頭に一覧で出す。各項目から該当欄へ飛べるようにする

プレビュー#

この枠の中は出発点のCSSの上で動いています。下のコードは、そちらを先に貼ってから足してください。

コピーする#

上のプレビューでつまみを動かし、「生成する」で調整を反映したコードを作ってからコピーしてください。下の欄は生成の元になる型で、直接コピーするものではありません。

<!-- 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;
}

確認する#

関連#

この資料は、Webサイトを作るときに引くためのものです。 何のために作ったか。作っているのは AK²Lab。