ナビゲーション

ページ送り付き検索結果

件数が多い結果で、現在位置、前後移動、先頭・末尾、欠番を意味構造と操作で確認できるページ送りレシピです。

動きを試す

プレビュー

プレビュー設定

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

必要なファイルを選ぶ

コード

index.html

利用前に変更する箇所

  • hrefと結果要約を実装先の検索・一覧URLへ接続する

<!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="result-panel" aria-labelledby="result-title" data-pagination-root>
        <header>
          <p class="eyebrow">検索結果の表示例</p>
          <h1 id="result-title">実装メモ</h1>
          <p class="lead" tabindex="-1" data-result-summary>全96件のうち1〜4件を表示しています。</p>
        </header>

        <ul class="result-list" data-result-list aria-label="現在ページの検索結果">
          <li><strong>入力エラーの伝え方</strong><span>フォーム</span></li>
          <li><strong>狭い画面で表を比較する方法</strong><span>データ表示</span></li>
          <li><strong>0件と失敗を区別する状態設計</strong><span>状態表示</span></li>
          <li><strong>操作後にフォーカスを戻す方法</strong><span>キーボード操作</span></li>
        </ul>

        <nav class="pagination" aria-label="検索結果のページ">
          <a class="page-direction" href="?page=1" rel="prev" data-page-previous hidden>前のページ</a>
          <span class="page-direction is-disabled" aria-disabled="true" data-page-previous-disabled
            >前のページ</span
          >
          <ol class="page-list" data-page-list>
            <li><span aria-current="page">1</span></li>
            <li><a href="?page=2" aria-label="2ページ目">2</a></li>
            <li><a href="?page=3" aria-label="3ページ目">3</a></li>
            <li><a href="?page=4" aria-label="4ページ目">4</a></li>
            <li><a href="?page=5" aria-label="5ページ目">5</a></li>
            <li><span aria-hidden="true">…</span></li>
            <li><a href="?page=24" aria-label="24ページ目">24</a></li>
          </ol>
          <a class="page-direction" href="?page=2" rel="next" data-page-next>次のページ</a>
          <span class="page-direction is-disabled" aria-disabled="true" data-page-next-disabled hidden
            >次のページ</span
          >
        </nav>

        <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.6;
}

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

.result-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);
}

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

.result-list {
  display: grid;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.result-list li {
  display: grid;
  min-width: 0;
  gap: 2px;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface-subtle);
}

.result-list strong,
.result-list span {
  overflow-wrap: anywhere;
}

.result-list span {
  color: var(--muted);
  font-size: 0.875rem;
}

.pagination {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-top: 24px;
}

.page-list {
  display: flex;
  flex-wrap: nowrap;
  gap: 6px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.page-list a,
.page-list span,
.page-direction {
  display: inline-flex;
  min-width: 44px;
  min-height: 44px;
  align-items: center;
  justify-content: center;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  background: var(--surface);
  font-weight: 800;
  text-decoration: none;
}

.page-list span[aria-current="page"] {
  border-color: var(--accent);
  box-shadow: inset 0 -3px 0 var(--accent);
  color: var(--accent);
}

.page-list span[aria-hidden="true"] {
  min-width: 32px;
  border-color: transparent;
}

.page-list a:hover,
.page-direction:hover {
  border-color: var(--accent);
  color: var(--accent-strong);
}

.page-list a:focus-visible,
.page-direction:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 3px;
}

.is-disabled {
  color: var(--muted);
  background: var(--surface-subtle);
}

[hidden] {
  display: none;
}

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

@media (max-width: 560px) {
  .pagination {
    display: grid;
    grid-template-columns: 1fr 1fr;
  }

  .page-list {
    grid-column: 1 / -1;
    grid-row: 1;
    max-width: 100%;
    overflow-x: auto;
    padding: 3px;
  }

  .page-direction {
    width: 100%;
  }
}

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

  .result-panel {
    padding: 14px;
  }
}

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

states.js

利用前に変更する箇所

  • 固定件数とページ移動を実装先のデータ取得・URL状態へ置き換える

(() => {
  const params = new URLSearchParams(window.location.search);
  const instance = params.get("instance") ?? "missing-instance";
  const validThemes = new Set(["light", "dark"]);
  const statePages = {
    "first-page": { current: 1, total: 24 },
    "middle-page": { current: 10, total: 24 },
    "last-page": { current: 24, total: 24 },
    "compact-set": { current: 3, total: 4 },
  };
  const root = document.querySelector("[data-pagination-root]");
  const pageList = document.querySelector("[data-page-list]");
  const resultList = document.querySelector("[data-result-list]");
  const resultSummary = document.querySelector("[data-result-summary]");
  const previous = document.querySelector("[data-page-previous]");
  const previousDisabled = document.querySelector("[data-page-previous-disabled]");
  const next = document.querySelector("[data-page-next]");
  const nextDisabled = document.querySelector("[data-page-next-disabled]");
  const status = document.querySelector("[data-status]");
  let currentPage = 1;
  let totalPages = 24;
  const resultTopics = ["入力状態の確認", "狭幅表示の確認", "操作順序の確認", "回復手順の確認"];

  function getPageItems(current, total) {
    if (total <= 7) {
      return Array.from({ length: total }, (_, index) => index + 1);
    }
    if (current <= 4) {
      return [1, 2, 3, 4, 5, "ellipsis", total];
    }
    if (current >= total - 3) {
      return [1, "ellipsis", total - 4, total - 3, total - 2, total - 1, total];
    }
    return [1, "ellipsis", current - 1, current, current + 1, "ellipsis", total];
  }

  function setDirection(element, disabledElement, target, disabled) {
    if (element instanceof HTMLAnchorElement) {
      element.hidden = disabled;
      element.href = `?page=${target}`;
      element.dataset.pageTarget = String(target);
    }
    if (disabledElement instanceof HTMLElement) {
      disabledElement.hidden = !disabled;
    }
  }

  function renderPageLinks() {
    if (!(pageList instanceof HTMLOListElement)) {
      return;
    }
    pageList.replaceChildren();
    for (const item of getPageItems(currentPage, totalPages)) {
      const listItem = document.createElement("li");
      if (item === "ellipsis") {
        const ellipsis = document.createElement("span");
        ellipsis.textContent = "…";
        ellipsis.setAttribute("aria-hidden", "true");
        listItem.append(ellipsis);
      } else if (item === currentPage) {
        const current = document.createElement("span");
        current.textContent = String(item);
        current.setAttribute("aria-current", "page");
        current.setAttribute("aria-label", `${item}ページ目、現在のページ`);
        listItem.append(current);
      } else {
        const link = document.createElement("a");
        link.href = `?page=${item}`;
        link.dataset.pageTarget = String(item);
        link.setAttribute("aria-label", `${item}ページ目`);
        link.textContent = String(item);
        listItem.append(link);
      }
      pageList.append(listItem);
    }
  }

  function renderResults() {
    const first = (currentPage - 1) * 4 + 1;
    const last = Math.min(currentPage * 4, totalPages * 4);
    if (resultSummary instanceof HTMLElement) {
      resultSummary.textContent = `全${totalPages * 4}件のうち${first}〜${last}件を表示しています。`;
    }
    if (resultList instanceof HTMLUListElement) {
      resultList.dataset.page = String(currentPage);
      resultList.replaceChildren();
      for (let itemNumber = first; itemNumber <= last; itemNumber += 1) {
        const item = document.createElement("li");
        const title = document.createElement("strong");
        const metadata = document.createElement("span");
        title.textContent = `${resultTopics[(itemNumber - 1) % resultTopics.length]}(項目${itemNumber})`;
        metadata.textContent = `実装メモ ${itemNumber} / ${totalPages * 4}`;
        item.append(title, metadata);
        resultList.append(item);
      }
    }
  }

  function render(announce = true, focusResults = false) {
    renderPageLinks();
    renderResults();
    setDirection(previous, previousDisabled, currentPage - 1, currentPage === 1);
    setDirection(next, nextDisabled, currentPage + 1, currentPage === totalPages);
    if (root instanceof HTMLElement) {
      root.dataset.currentPage = String(currentPage);
    }
    if (announce && status instanceof HTMLElement) {
      status.textContent = `${currentPage}ページ目を表示しました。`;
    }
    if (focusResults && resultSummary instanceof HTMLElement) {
      resultSummary.focus({ preventScroll: true });
    }
  }

  function setState(state, announce = true) {
    const setting = statePages[state];
    if (!setting) {
      return;
    }
    currentPage = setting.current;
    totalPages = setting.total;
    document.documentElement.dataset.state = state;
    render(announce);
  }

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

  document.addEventListener("click", (event) => {
    const target =
      event.target instanceof Element ? event.target.closest("[data-page-target]") : null;
    if (!(target instanceof HTMLAnchorElement)) {
      return;
    }
    event.preventDefault();
    const page = Number.parseInt(target.dataset.pageTarget ?? "", 10);
    if (Number.isInteger(page) && page >= 1 && page <= totalPages) {
      currentPage = page;
      render(true, true);
    }
  });

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

  const initialTheme = params.get("theme");
  const initialState = params.get("state");
  setTheme(validThemes.has(initialTheme) ? initialTheme : "light");
  setState(
    initialState && Object.hasOwn(statePages, initialState) ? initialState : "first-page",
    false,
  );
  window.parent.postMessage({ source: "wpl-preview", type: "wpl-preview-ready", instance }, "*");
})();
プレビューで試せる動き
  • 先頭ページ: 前へ戻る操作が無効な先頭状態です。
  • 中間ページ: 前後と主要ページへ移動できる状態です。
  • 最終ページ: 次へ進む操作が無効な末尾状態です。
  • 少数ページ: 欠番表示を必要としない4ページの状態です。

使い分けを確認する

このレシピが合う場面

適する場面

  • 検索結果や履歴が一画面へ収まらない一覧
  • 現在位置と前後の移動先をURL単位で保持する一覧
  • 先頭・現在・前後・末尾へ短い操作で移動したい一覧

適さない場面

  • 一ページだけで全件を無理なく読める場合
  • 入力手順や申請段階を表す場合
  • 意味的な章分けを前後へ移動する場合