ダイアログ

確認ダイアログ

元に戻しにくい操作の対象と影響を説明し、取消、処理中、失敗、完了までを安全に扱う確認レシピです。

動きを試す

プレビュー

プレビュー設定

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

必要なファイルを選ぶ

コード

index.html

利用前に変更する箇所

  • 対象名、影響、実行文言を実装先の操作へ置き換える

<!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="./dialog.js" defer></script>
  </head>
  <body>
    <main>
      <section class="demo-card" aria-labelledby="page-title">
        <p class="eyebrow">確認操作の表示例</p>
        <h1 id="page-title">保存前の下書き</h1>
        <p class="lead">取り消しに手間がかかる操作だけを、対象と影響が分かる確認画面につなぎます。</p>
        <div class="draft-summary">
          <strong>週次レポートの下書き</strong>
          <span>最終編集: 10分前</span>
        </div>
        <button class="danger-trigger" type="button" data-open-dialog>下書きを破棄する</button>
        <p class="result" role="status" aria-live="polite" data-result></p>
      </section>

      <dialog aria-labelledby="dialog-title" aria-describedby="dialog-description" data-dialog>
        <form method="dialog" class="dialog-card">
          <p class="eyebrow">実行前の確認</p>
          <h2 id="dialog-title">この下書きを破棄しますか?</h2>
          <p id="dialog-description">
            「週次レポートの下書き」に保存していない変更があります。破棄すると、この画面からは戻せません。
          </p>
          <div class="error-message" role="alert" data-error hidden>
            <strong>破棄を完了できませんでした。</strong>
            <span>通信は行っていません。もう一度試すか、取り消して内容を確認してください。</span>
          </div>
          <p class="processing-message" role="status" data-processing hidden>破棄処理を確認しています…</p>
          <div class="dialog-actions">
            <button type="submit" value="cancel" data-cancel>取り消す</button>
            <button class="danger-action" type="button" data-confirm>破棄する</button>
          </div>
        </form>
      </dialog>
    </main>
  </body>
</html>

styles.css

利用前に変更する箇所

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

:root {
  color-scheme: light;
  --bg: #f4f7f8;
  --surface: #ffffff;
  --surface-subtle: #e9f0f2;
  --text: #152129;
  --muted: #53656f;
  --border: #c6d3d8;
  --danger: #a52a3a;
  --danger-strong: #7f1625;
  --danger-surface: #fff0f1;
  --focus: #007c92;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans JP", sans-serif;
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --bg: #0b1117;
  --surface: #111a21;
  --surface-subtle: #19252e;
  --text: #edf5f7;
  --muted: #a8b8c0;
  --border: #33434e;
  --danger: #ff8794;
  --danger-strong: #ffb5bd;
  --danger-surface: #351820;
  --focus: #5adaf2;
}

* {
  box-sizing: border-box;
}

body {
  min-width: 0;
  margin: 0;
  padding: clamp(12px, 4vw, 28px);
  color: var(--text);
  background: var(--bg);
  font-size: 16px;
  line-height: 1.6;
}

main {
  width: min(100%, 680px);
  margin-inline: auto;
}

.demo-card,
.dialog-card {
  min-width: 0;
  padding: clamp(16px, 4vw, 28px);
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface);
}

.eyebrow {
  margin: 0 0 4px;
  color: var(--muted);
  font-size: 0.875rem;
  font-weight: 800;
}
h1,
h2 {
  margin: 0;
  line-height: 1.35;
  overflow-wrap: anywhere;
}
h1 {
  font-size: clamp(1.4rem, 5vw, 2rem);
}
h2 {
  font-size: clamp(1.25rem, 5vw, 1.6rem);
}
.lead,
#dialog-description {
  margin: 8px 0 20px;
  color: var(--muted);
}

.draft-summary {
  display: grid;
  gap: 2px;
  margin-bottom: 18px;
  padding: 14px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface-subtle);
}

.draft-summary span {
  color: var(--muted);
  font-size: 0.875rem;
}
button {
  min-height: 44px;
  padding: 9px 14px;
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  background: var(--surface);
  font: inherit;
  font-weight: 800;
  cursor: pointer;
}
button:hover:not(:disabled) {
  border-color: currentColor;
}
button:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 3px;
}
button:disabled {
  cursor: wait;
  opacity: 0.62;
}
.danger-trigger {
  color: var(--danger-strong);
}
.danger-action {
  border-color: var(--danger);
  color: #fff;
  background: var(--danger-strong);
}
:root[data-theme="dark"] .danger-action {
  color: #1b0910;
  background: var(--danger);
}
.result {
  min-height: 1.6em;
  margin: 14px 0 0;
  color: var(--muted);
}

dialog {
  width: min(calc(100% - 24px), 560px);
  max-height: calc(100dvh - 24px);
  padding: 0;
  border: 0;
  color: var(--text);
  background: transparent;
}

dialog::backdrop {
  background: rgb(5 12 17 / 72%);
}
.dialog-card {
  overflow: auto;
}
.dialog-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 10px;
  margin-top: 22px;
}
.error-message {
  display: grid;
  gap: 3px;
  margin-top: 16px;
  padding: 12px;
  border-left: 4px solid var(--danger);
  background: var(--danger-surface);
  overflow-wrap: anywhere;
}
.processing-message {
  margin: 16px 0 0;
  color: var(--muted);
  font-weight: 700;
}
[hidden] {
  display: none;
}

@media (max-width: 420px) {
  body {
    padding: 10px;
  }
  .demo-card,
  .dialog-card {
    padding: 16px;
  }
  .dialog-actions {
    display: grid;
  }
  .dialog-actions button {
    width: 100%;
  }
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    scroll-behavior: auto;
    transition-duration: 0.01ms;
  }
}

dialog.js

利用前に変更する箇所

  • 固定timerを実処理へ置き換え、失敗時もdialog内で回復できるよう接続する

(() => {
  const params = new URLSearchParams(window.location.search);
  const instance = params.get("instance") ?? "missing-instance";
  const validStates = new Set(["closed", "open", "processing", "action-error", "completed"]);
  const validThemes = new Set(["light", "dark"]);
  const dialog = document.querySelector("[data-dialog]");
  const openButton = document.querySelector("[data-open-dialog]");
  const cancelButton = document.querySelector("[data-cancel]");
  const confirmButton = document.querySelector("[data-confirm]");
  const errorMessage = document.querySelector("[data-error]");
  const processingMessage = document.querySelector("[data-processing]");
  const result = document.querySelector("[data-result]");
  let currentState = validStates.has(params.get("state")) ? params.get("state") : "closed";
  let activeTimer;

  function cancelTimer() {
    if (activeTimer !== undefined) {
      window.clearTimeout(activeTimer);
      activeTimer = undefined;
    }
  }

  function setTheme(theme) {
    if (theme && validThemes.has(theme)) document.documentElement.dataset.theme = theme;
  }

  function ensureOpen() {
    if (dialog instanceof HTMLDialogElement && !dialog.open) {
      dialog.showModal();
    }
  }

  function setState(nextState, shouldFocus = false) {
    if (!nextState || !validStates.has(nextState)) return;
    cancelTimer();
    currentState = nextState;
    document.documentElement.dataset.state = currentState;

    const isProcessing = currentState === "processing";
    if (errorMessage instanceof HTMLElement) errorMessage.hidden = currentState !== "action-error";
    if (processingMessage instanceof HTMLElement) processingMessage.hidden = !isProcessing;
    if (confirmButton instanceof HTMLButtonElement) {
      confirmButton.disabled = isProcessing;
      confirmButton.textContent =
        currentState === "action-error" ? "もう一度試す" : isProcessing ? "処理中…" : "破棄する";
    }

    if (currentState === "closed" || currentState === "completed") {
      if (dialog instanceof HTMLDialogElement && dialog.open) dialog.close(currentState);
      if (result instanceof HTMLElement) {
        result.textContent =
          currentState === "completed"
            ? "下書きを破棄しました。このデモでは実際のデータは変更していません。"
            : "";
      }
      return;
    }

    ensureOpen();
    if (result instanceof HTMLElement) result.textContent = "";
    if (shouldFocus && cancelButton instanceof HTMLButtonElement) cancelButton.focus();
  }

  function completeAction() {
    setState("processing");
    activeTimer = window.setTimeout(() => setState("completed"), 350);
  }

  openButton?.addEventListener("click", () => setState("open", true));
  confirmButton?.addEventListener("click", completeAction);

  dialog?.addEventListener("cancel", () => {
    cancelTimer();
    currentState = "closed";
    document.documentElement.dataset.state = currentState;
  });

  dialog?.addEventListener("close", () => {
    cancelTimer();
    if (currentState !== "completed") currentState = "closed";
    document.documentElement.dataset.state = currentState;
    if (openButton instanceof HTMLButtonElement) openButton.focus();
  });

  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")
      setState(typeof data.value === "string" ? data.value : null, true);
    if (data.type === "wpl-preview-theme")
      setTheme(typeof data.value === "string" ? data.value : null);
  });

  setTheme(validThemes.has(params.get("theme")) ? params.get("theme") : "light");
  setState(currentState, currentState !== "closed" && currentState !== "completed");
  window.parent.postMessage({ source: "wpl-preview", type: "wpl-preview-ready", instance }, "*");
})();
プレビューで試せる動き
  • 閉じた状態: triggerと対象だけを表示します。
  • 確認中: 取消と実行を選べるmodal状態です。
  • 処理中: 多重実行を防ぎながら処理状況を示します。
  • 実行失敗: 失敗理由と再試行・取消をdialog内に示します。
  • 完了: dialogを閉じ、実行結果を元画面に示します。

使い分けを確認する

このレシピが合う場面

適する場面

  • 保存していない入力の破棄
  • 取り消しに手間がかかる変更の最終確認
  • 対象名と影響を明示して誤操作を減らす操作

適さない場面

  • 通常の保存や頻繁な軽微操作
  • 長い入力フォームや複数段階の手続き
  • 単なるお知らせ