ナビゲーション
ページ送り付き検索結果
件数が多い結果で、現在位置、前後移動、先頭・末尾、欠番を意味構造と操作で確認できるページ送りレシピです。
動きを試す
プレビュー
必要なファイルを選ぶ
コード
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単位で保持する一覧
- 先頭・現在・前後・末尾へ短い操作で移動したい一覧
適さない場面
- 一ページだけで全件を無理なく読める場合
- 入力手順や申請段階を表す場合
- 意味的な章分けを前後へ移動する場合
