フォーム

入力検証付きお問い合わせフォーム

入力項目ごとの修正方法と送信時のエラー要約を示し、二重送信を防ぎ、送信失敗後も入力を保持するお問い合わせフォームです。

動きを試す

プレビュー

プレビュー設定

プレビューを準備しています。

必要なファイルを選ぶ

コード

index.html

利用前に変更する箇所

  • 項目名、選択肢、補足、送信先の実運用要件へ置き換える
  • 実送信を追加する場合はサーバー側の認可・CSRF・入力検証・エラー処理を別途実装する

<!doctype html>
<html lang="ja" data-theme="light">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>入力確認付き問い合わせフォーム</title>
    <link rel="stylesheet" href="./styles.css" />
    <script src="./validation.js" defer></script>
  </head>
  <body>
    <main>
      <section class="contact-demo" aria-labelledby="contact-form-title" data-form-root data-state="idle">
        <header class="demo-header">
          <p class="eyebrow">このフォームから内容は送信されません</p>
          <h1 id="contact-form-title">お問い合わせ</h1>
          <p class="lead">必要な項目を入力し、内容を確認してから送信してください。</p>
        </header>

        <section class="error-summary" data-error-summary tabindex="-1" aria-labelledby="error-summary-title" hidden>
          <h2 id="error-summary-title">入力内容を確認してください</h2>
          <p data-error-summary-count></p>
          <ul data-error-summary-list></ul>
        </section>

        <section class="submission-notice submission-notice--error" data-submit-error role="alert" hidden>
          <h2>送信できませんでした</h2>
          <p>入力内容は保持されています。内容を確認して、もう一度送信してください。</p>
        </section>

        <section class="submission-notice submission-notice--success" data-success hidden aria-labelledby="success-title" tabindex="-1">
          <h2 id="success-title">お問い合わせを受け付けました</h2>
          <p>入力内容を確認しました。このフォームから外部へ情報は送信されていません。</p>
          <button type="button" data-action="restart">新しいお問い合わせを入力する</button>
        </section>

        <form
          id="contact-form"
          novalidate
          data-contact-form
          aria-labelledby="contact-form-title"
          aria-describedby="form-required-note"
        >
          <p id="form-required-note" class="required-note"><span aria-hidden="true">*</span> は必須項目です。</p>

          <div class="field" data-field="name">
            <label for="name">お名前 <span aria-hidden="true">*</span><span class="visually-hidden">(必須)</span></label>
            <p id="name-hint" class="hint">1〜80文字で入力してください。</p>
            <input id="name" name="name" type="text" required minlength="1" maxlength="80" aria-describedby="name-hint name-error" autocomplete="name" />
            <p id="name-error" class="field-error" data-error-for="name" data-field-error="name" hidden></p>
          </div>

          <div class="field" data-field="email">
            <label for="email">メールアドレス <span aria-hidden="true">*</span><span class="visually-hidden">(必須)</span></label>
            <p id="email-hint" class="hint">確認のために使用する形式のメールアドレスを入力してください。</p>
            <input id="email" name="email" type="email" required aria-describedby="email-hint email-error" autocomplete="email" />
            <p id="email-error" class="field-error" data-error-for="email" data-field-error="email" hidden></p>
          </div>

          <div class="field" data-field="category">
            <label for="category">お問い合わせ種別 <span aria-hidden="true">*</span><span class="visually-hidden">(必須)</span></label>
            <p id="category-hint" class="hint">内容に近い種別を1つ選んでください。</p>
            <select id="category" name="category" required aria-describedby="category-hint category-error">
              <option value="">選択してください</option>
              <option value="usage">利用方法について</option>
              <option value="content">掲載内容について</option>
              <option value="other">その他</option>
            </select>
            <p id="category-error" class="field-error" data-error-for="category" data-field-error="category" hidden></p>
          </div>

          <div class="field" data-field="message">
            <label for="message">お問い合わせ内容 <span aria-hidden="true">*</span><span class="visually-hidden">(必須)</span></label>
            <p id="message-hint" class="hint">20〜1,000文字で入力してください。</p>
            <textarea id="message" name="message" required minlength="20" maxlength="1000" rows="7" aria-describedby="message-hint message-count message-error"></textarea>
            <p id="message-count" class="character-count" data-character-count>0 / 1,000文字</p>
            <p id="message-error" class="field-error" data-error-for="message" data-field-error="message" hidden></p>
          </div>

          <div class="field field--consent" data-field="consent">
            <input id="consent" name="consent" type="checkbox" required aria-describedby="consent-hint consent-error" />
            <label for="consent">内容を確認したうえで送信することに同意します <span aria-hidden="true">*</span><span class="visually-hidden">(必須)</span></label>
            <p id="consent-hint" class="hint">このフォームから外部へ情報は送信されません。</p>
            <p id="consent-error" class="field-error" data-error-for="consent" data-field-error="consent" hidden></p>
          </div>

          <button class="submit-button" type="submit" data-submit-button>送信する</button>
          <p class="form-status" data-form-status role="status" aria-live="polite"></p>
        </form>
      </section>
    </main>
  </body>
</html>

styles.css

利用前に変更する箇所

  • 色トークンと余白を実装先のデザインシステムへ接続する

:root {
  color-scheme: light;
  --bg: #f7f8fa;
  --surface: #ffffff;
  --surface-subtle: #f0f3f7;
  --text: #18212f;
  --muted: #5d6b7a;
  --border: #d7dee8;
  --accent: #245fd6;
  --accent-strong: #1849a9;
  --success: #087a55;
  --success-surface: #eaf8f2;
  --danger: #b42318;
  --danger-surface: #fff1f0;
  --focus: #0077cc;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans JP", sans-serif;
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --bg: #0f141a;
  --surface: #171d26;
  --surface-subtle: #202834;
  --text: #f2f5f8;
  --muted: #aab4c0;
  --border: #354152;
  --accent: #7ea7ff;
  --accent-strong: #a8c2ff;
  --success: #53d6a2;
  --success-surface: #13382d;
  --danger: #ff8a80;
  --danger-surface: #321b1d;
  --focus: #65c7ff;
}

* {
  box-sizing: border-box;
}
html {
  min-width: 0;
  background: var(--bg);
}
body {
  min-width: 0;
  margin: 0;
  padding: clamp(12px, 4vw, 28px);
  color: var(--text);
  background: var(--bg);
  font-size: 16px;
  line-height: 1.65;
}
main {
  width: min(100%, 720px);
  margin-inline: auto;
}
h1,
h2,
p,
label,
li,
button {
  overflow-wrap: anywhere;
}
h1,
h2 {
  line-height: 1.3;
}
h1 {
  margin: 0;
  font-size: clamp(1.5rem, 5vw, 2.15rem);
}
h2 {
  margin: 0;
  font-size: clamp(1.08rem, 4vw, 1.3rem);
}
p {
  margin: 0;
}
.contact-demo {
  min-width: 0;
  padding: clamp(16px, 4vw, 28px);
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface);
}
.demo-header {
  margin-bottom: 20px;
}
.eyebrow {
  color: var(--muted);
  font-size: 0.875rem;
  font-weight: 700;
  letter-spacing: 0.04em;
}
.lead {
  margin-top: 8px;
  color: var(--muted);
}
.error-summary,
.submission-notice {
  min-width: 0;
  margin-bottom: 20px;
  padding: 16px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface-subtle);
}
.error-summary {
  border-color: var(--danger);
  background: var(--danger-surface);
}
.error-summary h2,
.field-error {
  color: var(--danger);
}
.error-summary p {
  margin-top: 4px;
  font-weight: 700;
}
.error-summary ul {
  margin: 10px 0 0;
  padding-left: 1.25rem;
}
.error-summary a {
  color: inherit;
  font-weight: 700;
}
.submission-notice--error {
  border-color: var(--danger);
  background: var(--danger-surface);
}
.submission-notice--error h2 {
  color: var(--danger);
}
.submission-notice--success {
  border-color: var(--success);
  background: var(--success-surface);
}
.submission-notice--success h2 {
  color: var(--success);
}
.submission-notice p {
  margin-top: 5px;
}
.submission-notice button {
  margin-top: 14px;
}
[hidden] {
  display: none;
}
form {
  display: grid;
  gap: 20px;
}
.required-note {
  color: var(--muted);
}
.required-note span,
label > span[aria-hidden="true"] {
  color: var(--danger);
  font-weight: 700;
}
.field {
  min-width: 0;
}
.field > label {
  display: block;
  margin-bottom: 4px;
  font-weight: 700;
}
.hint,
.character-count {
  margin-bottom: 7px;
  color: var(--muted);
  font-size: 0.9rem;
}
.character-count {
  margin-top: 5px;
  margin-bottom: 0;
  text-align: right;
}
input:not([type="checkbox"]),
select,
textarea {
  width: 100%;
  min-height: 44px;
  padding: 9px 11px;
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  background: var(--surface);
  font: inherit;
}
textarea {
  min-height: 152px;
  resize: vertical;
}
input[aria-invalid="true"],
select[aria-invalid="true"],
textarea[aria-invalid="true"] {
  border: 2px solid var(--danger);
  padding: 8px 10px;
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
button:focus-visible,
.error-summary:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 3px;
}
.field-error {
  margin-top: 6px;
  font-weight: 700;
}
.field-error::before {
  content: "エラー: ";
}
.field--consent {
  display: grid;
  grid-template-columns: 24px minmax(0, 1fr);
  column-gap: 8px;
  align-items: start;
}
.field--consent input {
  width: 20px;
  height: 20px;
  margin: 3px 0 0;
  accent-color: var(--accent);
}
.field--consent label {
  margin: 0;
  cursor: pointer;
}
.field--consent .hint,
.field--consent .field-error {
  grid-column: 2;
}
button {
  min-height: 44px;
  padding: 9px 14px;
  border: 1px solid var(--accent);
  border-radius: 8px;
  color: #fff;
  background: var(--accent);
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
:root[data-theme="dark"] button {
  color: #0f141a;
}
button:hover:not(:disabled) {
  background: var(--accent-strong);
}
button:disabled {
  cursor: not-allowed;
  opacity: 0.7;
}
.submit-button {
  width: fit-content;
  min-width: 152px;
}
.form-status {
  min-height: 1.65em;
  color: var(--muted);
  font-weight: 700;
}
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}
@media (max-width: 480px) {
  .submit-button,
  .submission-notice button {
    width: 100%;
  }
}
@media (max-width: 360px) {
  body {
    padding: 10px;
  }
  .contact-demo,
  .error-summary,
  .submission-notice {
    padding: 14px;
  }
}
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    scroll-behavior: auto;
    transition-duration: 0s;
    animation-duration: 0s;
  }
}

validation.js

利用前に変更する箇所

  • 実送信を追加する場合はサンプル用の疑似送信結果と遅延処理を削除し、安全な送信APIとサーバー側検証へ置き換える
  • HTML属性とJavaScriptの検証規則を同じ要件に保つ

(() => {
  const params = new URLSearchParams(window.location.search);
  const instance = params.get("instance") ?? "missing-instance";
  const validStates = new Set([
    "idle",
    "focused",
    "touched-invalid",
    "submit-invalid",
    "submitting",
    "submit-error",
    "success",
  ]);
  const validThemes = new Set(["light", "dark"]);
  const root = document.querySelector("[data-form-root]");
  const form = document.querySelector("[data-contact-form]");
  const summary = document.querySelector("[data-error-summary]");
  const summaryCount = document.querySelector("[data-error-summary-count]");
  const summaryList = document.querySelector("[data-error-summary-list]");
  const submitError = document.querySelector("[data-submit-error]");
  const success = document.querySelector("[data-success]");
  const status = document.querySelector("[data-form-status]");
  const submitButton = document.querySelector("[data-submit-button]");
  const fields = ["name", "email", "category", "message", "consent"];
  const labels = {
    name: "お名前",
    email: "メールアドレス",
    category: "お問い合わせ種別",
    message: "お問い合わせ内容",
    consent: "内容確認への同意",
  };
  const sampleValues = {
    name: "試験利用者",
    email: "sample@example.test",
    category: "usage",
    message: "お問い合わせ内容を確認するためのサンプル入力です。",
    consent: true,
  };
  let currentState = validStates.has(params.get("state")) ? params.get("state") : "idle";
  let submitting = false;
  let transitionVersion = 0;
  let activeTimer;
  let shouldFailOnce = params.get("demoResult") === "error-once";

  function fieldElement(name) {
    return document.getElementById(name);
  }
  function errorElement(name) {
    return document.querySelector(`[data-error-for="${name}"]`);
  }
  function validate(name) {
    const input = fieldElement(name);
    const value =
      input instanceof HTMLInputElement ||
      input instanceof HTMLTextAreaElement ||
      input instanceof HTMLSelectElement
        ? input.value
        : "";
    if (name === "name")
      return value.length === 0
        ? "お名前を入力してください。"
        : value.length > 80
          ? "お名前は80文字以内で入力してください。"
          : "";
    if (name === "email")
      return value.length === 0
        ? "メールアドレスを入力してください。"
        : input instanceof HTMLInputElement && input.validity.typeMismatch
          ? "メールアドレスを確認して入力してください。"
          : "";
    if (name === "category") return value === "" ? "お問い合わせ種別を選択してください。" : "";
    if (name === "message")
      return value.length === 0
        ? "お問い合わせ内容を入力してください。"
        : value.length < 20
          ? "お問い合わせ内容は20文字以上で入力してください。"
          : value.length > 1000
            ? "お問い合わせ内容は1,000文字以内で入力してください。"
            : "";
    return input instanceof HTMLInputElement && !input.checked
      ? "内容確認への同意を選択してください。"
      : "";
  }
  function setFieldError(name, message) {
    const input = fieldElement(name);
    const error = errorElement(name);
    if (!(input instanceof HTMLElement) || !(error instanceof HTMLElement)) return;
    error.textContent = message;
    error.hidden = !message;
    input.setAttribute("aria-invalid", String(Boolean(message)));
  }
  function updateCount() {
    const message = fieldElement("message");
    const count = document.querySelector("[data-character-count]");
    if (message instanceof HTMLTextAreaElement && count instanceof HTMLElement)
      count.textContent = `${message.value.length} / 1,000文字`;
  }
  function updateSummary(errors, shouldFocus) {
    if (
      !(summary instanceof HTMLElement) ||
      !(summaryList instanceof HTMLElement) ||
      !(summaryCount instanceof HTMLElement)
    )
      return;
    summaryList.replaceChildren();
    for (const [name] of errors) {
      const item = document.createElement("li");
      const link = document.createElement("a");
      link.href = `#${name}`;
      link.textContent = `${labels[name]}を修正する`;
      link.addEventListener("click", (event) => {
        event.preventDefault();
        const input = fieldElement(name);
        if (input instanceof HTMLElement) input.focus();
      });
      item.append(link);
      summaryList.append(item);
    }
    summaryCount.textContent = `${errors.length}件の入力内容を確認してください。`;
    summary.hidden = errors.length === 0;
    if (shouldFocus && errors.length > 0) summary.focus();
  }
  function refreshSummaryIfVisible() {
    if (summary instanceof HTMLElement && !summary.hidden) {
      const errors = fields.map((name) => [name, validate(name)]).filter(([, message]) => message);
      updateSummary(errors, false);
    }
  }
  function setState(nextState, announce = true) {
    if (!validStates.has(nextState)) return;
    currentState = nextState;
    document.documentElement.dataset.state = nextState;
    if (root instanceof HTMLElement) root.dataset.state = nextState;
    if (status instanceof HTMLElement && announce)
      status.textContent =
        nextState === "submitting" ? "送信しています。完了するまでお待ちください。" : "";
  }
  function clearFieldErrors() {
    fields.forEach((name) => {
      setFieldError(name, "");
    });
  }
  function setSampleValues() {
    for (const name of fields) {
      const input = fieldElement(name);
      if (input instanceof HTMLInputElement && input.type === "checkbox")
        input.checked = sampleValues[name];
      else if (
        input instanceof HTMLInputElement ||
        input instanceof HTMLTextAreaElement ||
        input instanceof HTMLSelectElement
      )
        input.value = sampleValues[name];
    }
    updateCount();
  }
  function resetToIdle() {
    if (form instanceof HTMLFormElement) form.reset();
    clearFieldErrors();
    updateSummary([], false);
    updateCount();
    if (form instanceof HTMLElement) form.hidden = false;
    if (success instanceof HTMLElement) success.hidden = true;
    if (submitError instanceof HTMLElement) submitError.hidden = true;
  }
  function cancelSubmission() {
    transitionVersion += 1;
    submitting = false;
    if (activeTimer !== undefined) {
      window.clearTimeout(activeTimer);
      activeTimer = undefined;
    }
    if (submitButton instanceof HTMLButtonElement) {
      submitButton.disabled = false;
      submitButton.textContent = "送信する";
    }
  }
  function applyHostState(nextState) {
    if (!validStates.has(nextState)) return;
    cancelSubmission();
    if (nextState === "idle") {
      resetToIdle();
      setState(nextState);
      return;
    }
    if (nextState === "focused") {
      resetToIdle();
      setState(nextState);
      fieldElement("name")?.focus({ preventScroll: true });
      return;
    }
    if (nextState === "touched-invalid") {
      resetToIdle();
      const message = validate("name");
      setFieldError("name", message);
      setState(nextState);
      return;
    }
    if (nextState === "submit-invalid") {
      resetToIdle();
      const errors = fields.map((name) => [name, validate(name)]).filter(([, message]) => message);
      fields.forEach((name) => {
        setFieldError(name, errors.find(([field]) => field === name)?.[1] ?? "");
      });
      updateSummary(errors, true);
      setState(nextState, false);
      return;
    }
    setSampleValues();
    clearFieldErrors();
    updateSummary([], false);
    if (form instanceof HTMLElement) form.hidden = nextState === "success";
    if (success instanceof HTMLElement) success.hidden = nextState !== "success";
    if (submitError instanceof HTMLElement) submitError.hidden = nextState !== "submit-error";
    if (submitButton instanceof HTMLButtonElement && nextState === "submitting") {
      submitting = true;
      submitButton.disabled = true;
      submitButton.textContent = "送信中…";
    }
    setState(nextState);
  }
  function submitForm(event) {
    if (event) event.preventDefault();
    if (submitting) return;
    const errors = fields.map((name) => [name, validate(name)]).filter(([, message]) => message);
    fields.forEach((name) => {
      setFieldError(name, errors.find(([field]) => field === name)?.[1] ?? "");
    });
    if (errors.length) {
      submitError.hidden = true;
      updateSummary(errors, true);
      setState("submit-invalid", false);
      return;
    }
    updateSummary([], false);
    submitError.hidden = true;
    submitting = true;
    submitButton.disabled = true;
    submitButton.textContent = "送信中…";
    setState("submitting");
    const expectedVersion = transitionVersion;
    activeTimer = window.setTimeout(() => {
      if (expectedVersion !== transitionVersion) return;
      submitting = false;
      submitButton.disabled = false;
      submitButton.textContent = "送信する";
      if (shouldFailOnce) {
        shouldFailOnce = false;
        submitError.hidden = false;
        setState("submit-error");
        return;
      }
      form.hidden = true;
      success.hidden = false;
      success.focus();
      setState("success");
      activeTimer = undefined;
    }, 350);
  }
  function restart() {
    if (!(form instanceof HTMLFormElement)) return;
    cancelSubmission();
    resetToIdle();
    setState("idle");
    fieldElement("name")?.focus();
  }
  function setTheme(theme) {
    if (validThemes.has(theme)) document.documentElement.dataset.theme = theme;
  }
  if (form instanceof HTMLFormElement) {
    form.addEventListener("submit", submitForm);
    for (const name of fields) {
      const input = fieldElement(name);
      input?.addEventListener("focus", () => {
        if (!submitting) setState("focused", false);
      });
      input?.addEventListener("blur", () => {
        const message = validate(name);
        setFieldError(name, message);
        if (message) setState("touched-invalid", false);
      });
      input?.addEventListener("input", () => {
        if (name === "message") updateCount();
        if (input.getAttribute("aria-invalid") === "true") setFieldError(name, validate(name));
        refreshSummaryIfVisible();
      });
      input?.addEventListener("change", () => {
        if (input.getAttribute("aria-invalid") === "true") setFieldError(name, validate(name));
        refreshSummaryIfVisible();
      });
    }
  }
  document.querySelector('[data-action="restart"]')?.addEventListener("click", restart);
  window.addEventListener("message", (event) => {
    if (event.source !== window.parent || typeof event.data !== "object" || event.data === null)
      return;
    const data = event.data;
    if (data.source !== "wpl-host" || data.instance !== instance) return;
    if (data.type === "wpl-preview-state")
      applyHostState(typeof data.value === "string" ? data.value : "");
    if (data.type === "wpl-preview-theme")
      setTheme(typeof data.value === "string" ? data.value : "");
  });
  setTheme(validThemes.has(params.get("theme")) ? params.get("theme") : "light");
  updateCount();
  applyHostState(currentState);
  window.parent.postMessage({ source: "wpl-preview", type: "wpl-preview-ready", instance }, "*");
})();
プレビューで試せる動き
  • 未操作: 必須項目は示しますが、初期表示ではエラーを先回りして表示しません。
  • 入力中: 現在操作中の項目に補足説明と明確なフォーカス輪郭を表示します。
  • 項目エラー: 一度離れた不正な項目の直下に、原因と修正方法を表示します。
  • 送信時エラー: 送信時に全項目を検証し、上部の要約から不正な項目へ移動できます。
  • 送信中: 入力値を保持したまま送信ボタンを無効化し、同じ送信を二重に開始できなくします。
  • 送信失敗: 送信失敗を表示し、入力内容を保持して修正または再試行できます。
  • 送信完了: 完了内容と、明示操作で新しい入力を開始できることを表示します。

使い分けを確認する

このレシピが合う場面

適する場面

  • 複数の必須入力をまとめて受け付け、誤りを項目単位と全体で案内するフォーム
  • 送信中の二重操作を防ぎ、失敗後に入力内容を残して再試行できる問い合わせや申請
  • エラー修正、再試行、送信完了までを分かりやすく案内する

適さない場面

  • 一つの検索語だけを即時送信する場合
  • 入力値が機密情報で、画面上に保持すべきでない場合
  • 本人確認、決済、ファイル添付など専用の安全要件が必要な場合
  • 複数画面にまたがる複雑な申請フロー