データ表示

レスポンシブ申請履歴表

表としての意味構造を保ち、長い日本語や大きい金額を含むデータを狭い画面では表コンテナ内だけ横スクロールして比較できるレシピです。

動きを試す

プレビュー

プレビュー設定

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

必要なファイルを選ぶ

コード

index.html

利用前に変更する箇所

  • 申請履歴の見出し、固定データ、詳細リンク先を実装先のデータへ置き換える
  • 状態・テーマ切替が不要でも、overflow時のフォーカス制御はstates.jsから残すか同等機能へ置き換える

<!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="table-demo" aria-labelledby="table-demo-title" data-table-root>
        <header class="demo-header">
          <p class="eyebrow">申請履歴の表示例</p>
          <h1 id="table-demo-title">申請履歴</h1>
          <p class="lead">
            件名、状態、申請日、金額を列ごとに比較し、必要な行の詳細へ移動できます。
          </p>
        </header>

        <p id="table-scroll-guide" class="scroll-guide" data-scroll-guide>
          狭い表示では、表を左右にスクロールするとすべての列を確認できます。
        </p>

        <section class="table-scroll" aria-label="申請履歴表" data-table-scroll>
          <table data-application-table>
            <caption>
              申請履歴。件名、状態、申請日、金額、操作を比較できます。
            </caption>
            <thead>
              <tr>
                <th scope="col">件名</th>
                <th scope="col">状態</th>
                <th scope="col">申請日</th>
                <th scope="col" class="amount-heading">金額</th>
                <th scope="col">操作</th>
              </tr>
            </thead>

            <tbody data-state-body="populated">
              <tr>
                <th scope="row">備品購入の申請</th>
                <td><span class="status status--received"><span aria-hidden="true">●</span>受付</span></td>
                <td class="date">2026年8月3日</td>
                <td class="amount">12,800円</td>
                <td class="action"><a href="#detail-a101">詳細を開く<span class="visually-hidden">(申請A-101)</span></a></td>
              </tr>
              <tr>
                <th scope="row">研修参加に伴う交通費の事前申請</th>
                <td><span class="status status--checking"><span aria-hidden="true">◆</span>確認中</span></td>
                <td class="date">2026年8月8日</td>
                <td class="amount">48,600円</td>
                <td class="action"><a href="#detail-a102">詳細を開く<span class="visually-hidden">(申請A-102)</span></a></td>
              </tr>
              <tr>
                <th scope="row">PROJECT-ALPHA-2026 / 共有作業スペース利用料</th>
                <td><span class="status status--complete"><span aria-hidden="true">✓</span>完了</span></td>
                <td class="date">2026年8月12日</td>
                <td class="amount">0円</td>
                <td class="action"><a href="#detail-a103">詳細を開く<span class="visually-hidden">(申請A-103)</span></a></td>
              </tr>
              <tr>
                <th scope="row">備考追加が必要な申請</th>
                <td><span class="status status--returned"><span aria-hidden="true">!</span>差戻し</span></td>
                <td class="date">2026年8月17日</td>
                <td class="amount">1,250,000円</td>
                <td class="action"><a href="#detail-a104">詳細を開く<span class="visually-hidden">(申請A-104)</span></a></td>
              </tr>
            </tbody>

            <tbody data-state-body="single-row" hidden>
              <tr>
                <th scope="row">備品購入の申請</th>
                <td><span class="status status--received"><span aria-hidden="true">●</span>受付</span></td>
                <td class="date">2026年8月3日</td>
                <td class="amount">12,800円</td>
                <td class="action"><a href="#detail-a101">詳細を開く<span class="visually-hidden">(申請A-101)</span></a></td>
              </tr>
            </tbody>

            <tbody data-state-body="dense-data" hidden>
              <tr>
                <th scope="row">備品購入の申請</th>
                <td><span class="status status--received"><span aria-hidden="true">●</span>受付</span></td>
                <td class="date">2026年8月3日</td>
                <td class="amount">12,800円</td>
                <td class="action"><a href="#detail-a101">詳細を開く<span class="visually-hidden">(申請A-101)</span></a></td>
              </tr>
              <tr>
                <th scope="row">複数部署で利用する共用備品と周辺用品をまとめて購入するための長い日本語件名を含む申請</th>
                <td><span class="status status--checking"><span aria-hidden="true">◆</span>確認中</span></td>
                <td class="date">2026年8月18日</td>
                <td class="amount">98,765,432円</td>
                <td class="action"><a href="#detail-a102">詳細を開く<span class="visually-hidden">(申請A-102)</span></a></td>
              </tr>
              <tr>
                <th scope="row">PROJECT-ALPHA-2026 / 共有作業スペース利用料</th>
                <td><span class="status status--complete"><span aria-hidden="true">✓</span>完了</span></td>
                <td class="date">2026年8月20日</td>
                <td class="amount">0円</td>
                <td class="action"><a href="#detail-a103">詳細を開く<span class="visually-hidden">(申請A-103)</span></a></td>
              </tr>
              <tr>
                <th scope="row">REQUEST-2026-08-ULTRALONGTOKEN-WITHOUT-NATURAL-BREAK-0000000000000000000001</th>
                <td><span class="status status--returned"><span aria-hidden="true">!</span>差戻し(追加資料の確認待ち)</span></td>
                <td class="date">2026年8月22日</td>
                <td class="amount">7,654,321円</td>
                <td class="action"><a href="#detail-a104">詳細を開く<span class="visually-hidden">(申請A-104)</span></a></td>
              </tr>
            </tbody>
          </table>
        </section>

        <section class="empty-state" aria-labelledby="empty-title" data-empty-state hidden>
          <h2 id="empty-title">申請履歴は0件です</h2>
          <p>現在の条件に一致する申請はありません。条件を変更すると別の履歴を確認できます。</p>
          <a href="#table-demo-title">条件を見直す</a>
        </section>

        <section class="detail-list" aria-label="申請詳細の確認先" data-detail-list>
          <section id="detail-a101" class="row-detail" tabindex="-1">
            <h2>申請A-101の詳細</h2>
            <p>備品購入の申請です。表示例のため、実際の個人・企業情報は含みません。</p>
          </section>
          <section id="detail-a102" class="row-detail" tabindex="-1">
            <h2>申請A-102の詳細</h2>
            <p>確認中の申請です。長い日本語を含む条件でも読み取れることを確認します。</p>
          </section>
          <section id="detail-a103" class="row-detail" tabindex="-1">
            <h2>申請A-103の詳細</h2>
            <p>完了済みの申請です。英数字混在の件名を含む表示例です。</p>
          </section>
          <section id="detail-a104" class="row-detail" tabindex="-1">
            <h2>申請A-104の詳細</h2>
            <p>差戻し中の申請です。修正が必要な状態を色だけに依存せず表示します。</p>
          </section>
        </section>
      </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;
  --focus: #0077cc;
  --success: #087a55;
  --warning: #8a6100;
  --danger: #b42318;
  font-family:
    -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans JP", "Hiragino Kaku Gothic ProN",
    Meiryo, sans-serif;
}

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

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  min-width: 0;
  background: var(--bg);
  scroll-behavior: smooth;
}

body {
  min-width: 0;
  margin: 0;
  color: var(--text);
  background: var(--bg);
  font-size: 1rem;
  line-height: 1.65;
}

main {
  width: 100%;
  min-width: 0;
  padding: 24px;
}

h1,
h2,
p {
  overflow-wrap: anywhere;
}

h1,
h2 {
  line-height: 1.3;
}

h1 {
  margin: 0;
  font-size: clamp(1.7rem, 6vw, 2.5rem);
}

h2 {
  margin: 0;
  font-size: 1.05rem;
}

p {
  margin-block: 0 1em;
}

a {
  color: var(--accent);
  font-weight: 700;
  text-underline-offset: 0.2em;
}

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

.table-demo {
  width: 100%;
  max-width: 1120px;
  min-width: 0;
  margin-inline: auto;
}

.demo-header {
  display: grid;
  gap: 8px;
  margin-bottom: 20px;
}

.eyebrow {
  margin: 0;
  color: var(--muted);
  font-size: 0.875rem;
  font-weight: 800;
  letter-spacing: 0.05em;
  text-transform: uppercase;
}

.lead {
  max-width: 48rem;
  margin: 0;
  color: var(--muted);
}

.scroll-guide {
  margin-bottom: 10px;
  padding: 10px 12px;
  border-left: 4px solid var(--accent);
  background: var(--surface-subtle);
  color: var(--text);
  font-weight: 700;
}

.table-scroll {
  display: block;
  width: 100%;
  min-width: 0;
  max-width: 100%;
  contain: inline-size paint;
  overflow-x: auto;
  overflow-y: auto;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
  overscroll-behavior-inline: contain;
  scrollbar-gutter: stable;
}

.table-scroll:focus-visible {
  outline-offset: 4px;
}

table {
  width: 100%;
  min-width: 760px;
  border-collapse: collapse;
  background: var(--surface);
}

caption {
  padding: 12px 14px;
  color: var(--muted);
  font-size: 0.875rem;
  font-weight: 700;
  text-align: left;
}

th,
td {
  padding: 12px 14px;
  border-top: 1px solid var(--border);
  text-align: left;
  vertical-align: top;
}

thead th {
  border-top: 0;
  background: var(--surface-subtle);
  color: var(--text);
  font-size: 0.875rem;
  white-space: nowrap;
}

tbody th[scope="row"] {
  min-width: 18rem;
  max-width: 32rem;
  font-weight: 700;
  overflow-wrap: anywhere;
  word-break: normal;
}

tbody tr:hover {
  background: var(--surface-subtle);
}

.date,
.amount,
.action {
  white-space: nowrap;
}

.amount,
.amount-heading {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

.status {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-weight: 800;
  white-space: nowrap;
}

.status--received {
  color: var(--accent);
}

.status--checking {
  color: var(--warning);
}

.status--complete {
  color: var(--success);
}

.status--returned {
  color: var(--danger);
}

.empty-state,
.row-detail {
  margin-top: 16px;
  padding: 16px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
}

.empty-state p,
.row-detail p {
  margin: 8px 0 0;
  color: var(--muted);
}

.detail-list {
  display: grid;
  gap: 12px;
  margin-top: 24px;
}

.row-detail:target {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 24%, transparent);
}

.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: 800px) {
  .table-scroll {
    box-shadow: inset -10px 0 12px -14px var(--text);
  }
}

@media (max-width: 360px) {
  main {
    padding: 12px;
  }

  th,
  td {
    padding: 10px 12px;
  }

  table {
    min-width: 700px;
  }

  tbody th[scope="row"] {
    min-width: 16rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
}

states.js

利用前に変更する箇所

  • 状態・テーマ切替が不要ならその部分だけ削除できるが、overflow時のフォーカス制御は残すか同等機能へ置き換える

(() => {
  const params = new URLSearchParams(window.location.search);
  const instance = params.get("instance") ?? "missing-instance";
  const validStates = new Set(["populated", "single-row", "empty", "dense-data"]);
  const validThemes = new Set(["light", "dark"]);
  const root = document.querySelector("[data-table-root]");
  const table = document.querySelector("[data-application-table]");
  const scrollRegion = document.querySelector("[data-table-scroll]");
  const scrollGuide = document.querySelector("[data-scroll-guide]");
  const emptyState = document.querySelector("[data-empty-state]");
  const detailList = document.querySelector("[data-detail-list]");
  const detailSections = Array.from(document.querySelectorAll(".row-detail"));
  const stateBodies = Array.from(document.querySelectorAll("[data-state-body]"));
  let currentState = validStates.has(params.get("state")) ? params.get("state") : "populated";

  function updateScrollAccessibility() {
    if (!(scrollRegion instanceof HTMLElement) || !(scrollGuide instanceof HTMLElement)) {
      return;
    }

    const tableVisible = table instanceof HTMLElement && !table.hidden;
    const overflows = tableVisible && scrollRegion.scrollWidth > scrollRegion.clientWidth + 1;

    if (overflows) {
      scrollRegion.tabIndex = 0;
      scrollRegion.setAttribute("aria-describedby", "table-scroll-guide");
      scrollGuide.textContent =
        "表は左右にスクロールできます。コンテナへフォーカスするとキーボードでも移動できます。";
      return;
    }

    scrollRegion.removeAttribute("tabindex");
    scrollRegion.removeAttribute("aria-describedby");
    scrollGuide.textContent = tableVisible
      ? "現在の表示幅では、すべての列を一度に確認できます。"
      : "申請履歴がある場合、狭い表示では表を左右にスクロールして確認できます。";
  }

  function scheduleScrollAccessibilityUpdate() {
    window.requestAnimationFrame(updateScrollAccessibility);
  }

  function applyState(nextState) {
    if (!nextState || !validStates.has(nextState)) {
      return;
    }

    currentState = nextState;
    document.documentElement.dataset.state = currentState;
    const empty = currentState === "empty";
    const singleRow = currentState === "single-row";

    if (root instanceof HTMLElement) {
      root.dataset.state = currentState;
    }
    if (table instanceof HTMLTableElement) {
      table.hidden = empty;
    }
    if (emptyState instanceof HTMLElement) {
      emptyState.hidden = !empty;
    }
    if (detailList instanceof HTMLElement) {
      detailList.hidden = empty;
    }

    for (const body of stateBodies) {
      if (body instanceof HTMLTableSectionElement) {
        body.hidden = body.dataset.stateBody !== currentState;
      }
    }

    for (const detail of detailSections) {
      if (detail instanceof HTMLElement) {
        detail.hidden = empty || (singleRow && detail.id !== "detail-a101");
      }
    }

    updateScrollAccessibility();
    scheduleScrollAccessibilityUpdate();
  }

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

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

  window.addEventListener("resize", scheduleScrollAccessibilityUpdate);
  if (typeof ResizeObserver === "function" && scrollRegion instanceof HTMLElement) {
    const observer = new ResizeObserver(scheduleScrollAccessibilityUpdate);
    observer.observe(scrollRegion);
    if (table instanceof HTMLElement) {
      observer.observe(table);
    }
  }

  setTheme(validThemes.has(params.get("theme")) ? params.get("theme") : "light");
  applyState(currentState);

  window.parent.postMessage(
    {
      source: "wpl-preview",
      type: "wpl-preview-ready",
      instance,
    },
    "*",
  );
})();
プレビューで試せる動き
  • 複数件: 4件の申請履歴を列構造のまま比較します。
  • 1件: 1件だけでもcaptionと列見出しを含む表構造を維持します。
  • 0件: 空の見出し行を残さず、0件であることと次の行動を表示します。
  • 高密度データ: 長い日本語、改行しにくい英数字、大きい金額、長い状態名を含む境界条件です。

使い分けを確認する

このレシピが合う場面

適する場面

  • 行と列の対応関係を保ったまま複数項目を比較する一覧
  • 列を削ると判断情報が失われる管理・履歴・集計画面
  • 長い日本語や大きい数値を含む構造化データの閲覧

適さない場面

  • 各行を独立した紹介カードとして読む方が自然な場合
  • 列が1〜2個しかなく通常の一覧で十分な場合
  • 小画面では一部情報だけで目的を達成できる場合
  • 行内へ複雑な入力フォームや多段操作を配置する場合