コンテンツ

開閉できる補足セクション

必須ではない補足を見出し単位で開閉し、複数項目を開いたまま比較できるnative disclosureレシピです。

動きを試す

プレビュー

プレビュー設定

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

必要なファイルを選ぶ

コード

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="./states.js" defer></script>
  </head>
  <body>
    <main>
      <section class="disclosure-panel" aria-labelledby="disclosure-title" data-disclosure-root>
        <header>
          <p class="eyebrow">補足情報の表示例</p>
          <h1 id="disclosure-title">実装前に確認すること</h1>
          <p class="lead">必要な項目を複数開いたまま比較できます。重要な利用条件はこの外側へ表示します。</p>
        </header>

        <div class="disclosure-list">
          <details data-section="data">
            <summary>表示するデータの境界</summary>
            <div class="section-content">
              <p>短い値だけでなく、長い日本語、0件、英数字が連続する値を用意します。</p>
              <ul>
                <li>0件を失敗と混同しない</li>
                <li>長い値を固定高で切らない</li>
              </ul>
            </div>
          </details>

          <details data-section="interaction">
            <summary>操作できない状態の扱い</summary>
            <div class="section-content">
              <p>処理中や無効状態では、操作できない理由と次に可能な行動を近くへ表示します。</p>
            </div>
          </details>

          <details data-section="long-content">
            <summary>長い識別子と補足文の確認</summary>
            <div class="section-content">
              <p>
                画面幅を超えやすい値
                SAMPLE-CONTENT-SECTION-2026-08-24-ALPHA-0000000000000001
                と、複数行になる日本語説明を同じ領域で確認します。
              </p>
            </div>
          </details>
        </div>

        <p class="status" role="status" aria-live="polite" data-status></p>
      </section>
    </main>
  </body>
</html>

styles.css

利用前に変更する箇所

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

:root {
  color-scheme: light;
  --bg: #f4f7f8;
  --surface: #ffffff;
  --surface-subtle: #e9f0f2;
  --text: #152129;
  --muted: #53656f;
  --border: #c6d3d8;
  --accent: #006e82;
  --accent-strong: #004f60;
  --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;
  --accent: #35c9e8;
  --accent-strong: #70def2;
  --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.65;
}

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

.disclosure-panel {
  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 {
  margin: 0;
  font-size: clamp(1.4rem, 5vw, 2rem);
}

.lead {
  margin: 8px 0 20px;
  color: var(--muted);
}

.disclosure-list {
  display: grid;
  gap: 10px;
}

details {
  min-width: 0;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface-subtle);
}

details[open] {
  border-color: var(--accent);
}

summary {
  min-height: 48px;
  padding: 12px 44px 12px 14px;
  color: var(--text);
  font-weight: 800;
  cursor: pointer;
  overflow-wrap: anywhere;
}

summary::marker {
  color: var(--accent);
}

summary:hover {
  color: var(--accent-strong);
}

summary:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 3px;
}

.section-content {
  padding: 0 14px 14px;
  border-top: 1px solid var(--border);
}

.section-content p {
  margin: 12px 0 0;
  overflow-wrap: anywhere;
}

.section-content ul {
  margin: 10px 0 0;
  padding-left: 1.25rem;
}

.status {
  min-height: 1.6em;
  margin: 14px 0 0;
  color: var(--muted);
  font-size: 0.875rem;
}

@media (max-width: 360px) {
  body {
    padding: 10px;
  }

  .disclosure-panel {
    padding: 14px;
  }

  summary {
    padding-inline: 12px;
  }
}

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

states.js

利用前に変更する箇所

  • preview用の固定状態切替が不要ならhost連携部分を削除できる

(() => {
  const params = new URLSearchParams(window.location.search);
  const instance = params.get("instance") ?? "missing-instance";
  const validStates = new Set([
    "all-collapsed",
    "first-expanded",
    "multiple-expanded",
    "long-content",
  ]);
  const validThemes = new Set(["light", "dark"]);
  const sections = Array.from(document.querySelectorAll("details[data-section]"));
  const status = document.querySelector("[data-status]");

  function applyState(state, announce = true) {
    if (!validStates.has(state)) {
      return;
    }
    const openSections = {
      "all-collapsed": [],
      "first-expanded": ["data"],
      "multiple-expanded": ["data", "interaction"],
      "long-content": ["long-content"],
    }[state];
    for (const section of sections) {
      if (section instanceof HTMLDetailsElement) {
        section.open = openSections.includes(section.dataset.section);
      }
    }
    document.documentElement.dataset.state = state;
    if (announce && status instanceof HTMLElement) {
      status.textContent = `${openSections.length}件の補足を開いています。`;
    }
  }

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

  for (const section of sections) {
    section.addEventListener("toggle", () => {
      if (status instanceof HTMLElement) {
        const count = sections.filter(
          (candidate) => candidate instanceof HTMLDetailsElement && candidate.open,
        ).length;
        status.textContent = `${count}件の補足を開いています。`;
      }
    });
  }

  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") {
      applyState(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");
  applyState(validStates.has(params.get("state")) ? params.get("state") : "all-collapsed", false);
  window.parent.postMessage({ source: "wpl-preview", type: "wpl-preview-ready", instance }, "*");
})();
プレビューで試せる動き
  • すべて閉じる: 補足をすべて閉じた初期状態です。
  • 先頭を開く: 先頭の補足だけを表示します。
  • 複数を開く: 複数の補足を同時に比較します。
  • 長文を開く: 長い日本語と改行しにくい識別子を確認します。

使い分けを確認する

このレシピが合う場面

適する場面

  • 同じ主題に属する複数の補足情報
  • 利用者が複数項目を同時に開いて比較する説明
  • 初期表示の文量を抑えても判断を誤らせない補助領域

適さない場面

  • 全員が必ず読む警告や利用条件
  • 入力手順を隠して分割する場合
  • 開閉領域を入れ子にする場合