ページネーションPaginationExperimental
複数ページに分かれた一覧や検索結果を移動するためのナビゲーションです。ページ番号、省略記号、前後移動に加えて、件数サマリーや表示件数の選択と組み合わせて使います。
プレビュー
状態とバリエーション
状態管理あり
現在ページをアプリ側の state で管理し、必要なページ番号だけを表示します。
現在のページ 7 / 50
先頭と末尾へ移動
ページ数が多い一覧では、前後移動に加えて先頭と末尾へ直接移動する操作を追加できます。
現在のページ 6 / 24
ラベルなし操作
スペースが限られる場所では、最初・前へ・次へ・最後のラベルを視覚的に隠し、ツールチップと aria-label で意味を補います。
現在のページ 3 / 12
テーブル向け
DataTable と同じ考え方で、件数サマリー、表示件数、先頭/末尾、スマホ用ページ選択を組み合わせます。
プロパティ
表は横にスクロールできます
| プロパティ | 型 | 初期値 | 説明 |
|---|---|---|---|
| Pagination | nav | - | ページ送り全体のランドマーク。 |
| PaginationLink.isActive | boolean | - | 現在ページを示します。 |
| PaginationFirst / PaginationLast | a | - | 必要な場合に先頭ページ、末尾ページへ移動するリンク。長い一覧やテーブルではオプションとして組み合わせます。 |
| PaginationPrevious / PaginationNext | a | - | 前後ページへ移動するリンク。label で表示文言を差し替えられます。 |
| PaginationEllipsis | span | - | 省略されたページ範囲を示します。 |
使い方
import * as React from "react"
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationFirst,
PaginationItem,
PaginationLast,
PaginationLink,
PaginationNext,
PaginationPrevious,
Select,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@gunjo/ui"
function getPageItems(currentPage: number, pageCount: number): Array<number | "ellipsis-start" | "ellipsis-end"> {
if (pageCount <= 7) return Array.from({ length: pageCount }, (_, index) => index + 1)
if (currentPage <= 4) return [1, 2, 3, 4, 5, "ellipsis-end", pageCount]
if (currentPage >= pageCount - 3) return [
1,
"ellipsis-start",
pageCount - 4,
pageCount - 3,
pageCount - 2,
pageCount - 1,
pageCount,
]
return [
1,
"ellipsis-start",
currentPage - 1,
currentPage,
currentPage + 1,
"ellipsis-end",
pageCount,
]
}
function PaginationControlTooltip(props: {
label: string
disabledLabel: string
disabled: boolean
children: React.ReactNode
}) {
const { label, disabledLabel, disabled, children } = props
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">{children}</span>
</TooltipTrigger>
<TooltipContent>{disabled ? disabledLabel : label}</TooltipContent>
</Tooltip>
)
}
function CompactPaginationControls(props: {
page: number
totalPages: number
onPageChange: (page: number) => void
showBoundary?: boolean
ariaLabel: string
}) {
const { page, totalPages, onPageChange, showBoundary, ariaLabel } = props
const canPrevious = page > 1
const canNext = page < totalPages
return (
<Pagination className="sm:hidden" aria-label={ariaLabel}>
<div className="grid w-full max-w-xs grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2">
<div className="flex items-center gap-1">
{showBoundary ? (
<PaginationControlTooltip
label="最初のページへ"
disabledLabel="すでに最初のページです"
disabled={!canPrevious}
>
<PaginationFirst href="#" label={<span className="sr-only">最初のページへ</span>} aria-label="最初のページへ" aria-disabled={!canPrevious} className="h-9 w-9 gap-0 px-0" onClick={(event) => { event.preventDefault(); onPageChange(1) }} />
</PaginationControlTooltip>
) : null}
<PaginationControlTooltip
label="前へ"
disabledLabel="前のページはありません"
disabled={!canPrevious}
>
<PaginationPrevious href="#" label={<span className="sr-only">前へ</span>} aria-label="前のページへ" aria-disabled={!canPrevious} className="h-9 w-9 gap-0 px-0" onClick={(event) => { event.preventDefault(); onPageChange(Math.max(1, page - 1)) }} />
</PaginationControlTooltip>
</div>
<label className="flex min-w-0 items-center justify-center gap-1 text-sm text-muted-foreground">
<span className="sr-only">ページを選択</span>
<Select aria-label="ページを選択" value={String(page)} onChange={(event) => onPageChange(Number(event.target.value))} className="h-9 w-16 rounded-md py-1 text-center text-sm">
{Array.from({ length: totalPages }, (_, index) => index + 1).map((pageNumber) => (
<option key={pageNumber} value={pageNumber}>{pageNumber}</option>
))}
</Select>
<span aria-hidden="true">/</span>
<span aria-hidden="true">{totalPages}</span>
</label>
<div className="flex items-center justify-end gap-1">
<PaginationControlTooltip
label="次へ"
disabledLabel="次のページはありません"
disabled={!canNext}
>
<PaginationNext href="#" label={<span className="sr-only">次へ</span>} aria-label="次のページへ" aria-disabled={!canNext} className="h-9 w-9 gap-0 px-0" onClick={(event) => { event.preventDefault(); onPageChange(Math.min(totalPages, page + 1)) }} />
</PaginationControlTooltip>
{showBoundary ? (
<PaginationControlTooltip
label="最後のページへ"
disabledLabel="すでに最後のページです"
disabled={!canNext}
>
<PaginationLast href="#" label={<span className="sr-only">最後のページへ</span>} aria-label="最後のページへ" aria-disabled={!canNext} className="h-9 w-9 gap-0 px-0" onClick={(event) => { event.preventDefault(); onPageChange(totalPages) }} />
</PaginationControlTooltip>
) : null}
</div>
</div>
</Pagination>
)
}
export function PaginationExample() {
const [page, setPage] = React.useState(2)
const totalPages = 10
const pages = React.useMemo(() => getPageItems(page, totalPages), [page, totalPages])
return (
<>
<CompactPaginationControls
page={page}
totalPages={totalPages}
onPageChange={setPage}
ariaLabel="ページ送り"
/>
<Pagination className="hidden sm:flex" aria-label="ページ送り">
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" label="前へ" aria-label="前のページへ" onClick={(event) => { event.preventDefault(); setPage((current) => Math.max(1, current - 1)) }} />
</PaginationItem>
{pages.map((entry) => entry === "ellipsis-start" || entry === "ellipsis-end" ? (
<PaginationItem key={entry}><PaginationEllipsis /></PaginationItem>
) : (
<PaginationItem key={entry}>
<PaginationLink href="#" isActive={entry === page} aria-label={entry + "ページへ移動"} onClick={(event) => { event.preventDefault(); setPage(entry) }}>
{entry}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext href="#" label="次へ" aria-label="次のページへ" onClick={(event) => { event.preventDefault(); setPage((current) => Math.min(totalPages, current + 1)) }} />
</PaginationItem>
</PaginationContent>
</Pagination>
</>
)
}設計の判断
- ページ番号の計算は部品に持たせない。GUNJO の Pagination は
PaginationとPaginationContentとPaginationItemとPaginationLinkなどの集まりで、「いま何ページ目だから、どの番号と省略記号を並べるか」を持ちません。総件数も1ページの件数も画面ごとに違うためです。そのかわり、資料が挙げる「先頭と末尾を省略しない」を書けるようにPaginationFirstとPaginationLastは最初から用意してあります。 hrefを渡すかどうかで、aとbuttonが入れ替わる。資料は「URL と現在ページを同期する」を必須に挙げています。hrefを渡した場合だけ本物のリンクを描き、渡さない場合はbuttonを描きます。hrefの無いaにはキーボードのフォーカスが当たらないので、URL を持たない画面内のページ送りが操作できなくなるからです。- 現在地は属性と見た目の両方で出す。
isActiveを渡した項目は枠線のあるボタンの見た目になり、同時にaria-currentがpageになります。省略記号(PaginationEllipsis)は逆にaria-hiddenにして、読み上げには「さらにページがある」の一文だけを残しました。点が3つ読み上げられても意味にならないためです。
使用コンポーネント
関連コンポーネント
いつ・なぜ使うか(UIXHERO)
「いつ・なぜ使うか」の判断は、姉妹サイト UIXHERO の記事で解説しています。