ページネーションPaginationExperimental

複数ページに分かれた一覧や検索結果を移動するためのナビゲーションです。ページ番号、省略記号、前後移動に加えて、件数サマリーや表示件数の選択と組み合わせて使います。

プレビュー

状態とバリエーション

状態管理あり

現在ページをアプリ側の state で管理し、必要なページ番号だけを表示します。

現在のページ 7 / 50

先頭と末尾へ移動

ページ数が多い一覧では、前後移動に加えて先頭と末尾へ直接移動する操作を追加できます。

現在のページ 6 / 24

ラベルなし操作

スペースが限られる場所では、最初・前へ・次へ・最後のラベルを視覚的に隠し、ツールチップと aria-label で意味を補います。

テーブル向け

DataTable と同じ考え方で、件数サマリー、表示件数、先頭/末尾、スマホ用ページ選択を組み合わせます。

51 - 75 / 全238件

プロパティ

表は横にスクロールできます
プロパティ初期値説明
Paginationnav-ページ送り全体のランドマーク。
PaginationLink.isActiveboolean-現在ページを示します。
PaginationFirst / PaginationLasta-必要な場合に先頭ページ、末尾ページへ移動するリンク。長い一覧やテーブルではオプションとして組み合わせます。
PaginationPrevious / PaginationNexta-前後ページへ移動するリンク。label で表示文言を差し替えられます。
PaginationEllipsisspan-省略されたページ範囲を示します。

使い方

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 は PaginationPaginationContentPaginationItemPaginationLink などの集まりで、「いま何ページ目だから、どの番号と省略記号を並べるか」を持ちません。総件数も1ページの件数も画面ごとに違うためです。そのかわり、資料が挙げる「先頭と末尾を省略しない」を書けるように PaginationFirstPaginationLast は最初から用意してあります。
  • href を渡すかどうかで、abutton が入れ替わる。資料は「URL と現在ページを同期する」を必須に挙げています。href を渡した場合だけ本物のリンクを描き、渡さない場合は button を描きます。href の無い a にはキーボードのフォーカスが当たらないので、URL を持たない画面内のページ送りが操作できなくなるからです。
  • 現在地は属性と見た目の両方で出す。isActive を渡した項目は枠線のあるボタンの見た目になり、同時に aria-currentpage になります。省略記号(PaginationEllipsis)は逆に aria-hidden にして、読み上げには「さらにページがある」の一文だけを残しました。点が3つ読み上げられても意味にならないためです。

使用コンポーネント

いつ・なぜ使うか(UIXHERO)

「いつ・なぜ使うか」の判断は、姉妹サイト UIXHERO の記事で解説しています。