263 lines
7.9 KiB
TypeScript
263 lines
7.9 KiB
TypeScript
'use client'
|
|
|
|
import * as React from 'react'
|
|
import clsx from 'clsx'
|
|
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid'
|
|
|
|
type PageItem = number | 'ellipsis'
|
|
|
|
export type PaginationProps = {
|
|
page: number // 1-based
|
|
pageSize: number
|
|
totalItems: number
|
|
onPageChange: (page: number) => void
|
|
|
|
/** wie viele Seiten links/rechts neben aktueller Seite */
|
|
siblingCount?: number
|
|
/** wie viele Seiten am Anfang/Ende immer gezeigt werden */
|
|
boundaryCount?: number
|
|
|
|
/** Summary "Showing x to y of z" anzeigen */
|
|
showSummary?: boolean
|
|
/** Wrapper Klassen */
|
|
className?: string
|
|
|
|
/** Labels (optional) */
|
|
ariaLabel?: string
|
|
prevLabel?: string
|
|
nextLabel?: string
|
|
}
|
|
|
|
function clamp(n: number, min: number, max: number) {
|
|
return Math.max(min, Math.min(max, n))
|
|
}
|
|
|
|
function range(start: number, end: number): number[] {
|
|
const out: number[] = []
|
|
for (let i = start; i <= end; i++) out.push(i)
|
|
return out
|
|
}
|
|
|
|
function getPageItems(
|
|
totalPages: number,
|
|
current: number,
|
|
boundaryCount: number,
|
|
siblingCount: number
|
|
): PageItem[] {
|
|
if (totalPages <= 1) return [1]
|
|
|
|
const first = 1
|
|
const last = totalPages
|
|
|
|
const startPages = range(first, Math.min(boundaryCount, last))
|
|
const endPages = range(Math.max(last - boundaryCount + 1, boundaryCount + 1), last)
|
|
|
|
const siblingsStart = Math.max(
|
|
Math.min(
|
|
current - siblingCount,
|
|
last - boundaryCount - siblingCount * 2 - 1
|
|
),
|
|
boundaryCount + 1
|
|
)
|
|
|
|
const siblingsEnd = Math.min(
|
|
Math.max(
|
|
current + siblingCount,
|
|
boundaryCount + siblingCount * 2 + 2
|
|
),
|
|
last - boundaryCount
|
|
)
|
|
|
|
const items: PageItem[] = []
|
|
|
|
// start
|
|
items.push(...startPages)
|
|
|
|
// left gap
|
|
if (siblingsStart > boundaryCount + 1) {
|
|
items.push('ellipsis')
|
|
} else if (boundaryCount + 1 < last - boundaryCount) {
|
|
items.push(boundaryCount + 1)
|
|
}
|
|
|
|
// siblings
|
|
items.push(...range(siblingsStart, siblingsEnd))
|
|
|
|
// right gap
|
|
if (siblingsEnd < last - boundaryCount) {
|
|
items.push('ellipsis')
|
|
} else if (last - boundaryCount > boundaryCount) {
|
|
items.push(last - boundaryCount)
|
|
}
|
|
|
|
// end
|
|
items.push(...endPages)
|
|
|
|
// dedupe + keep order
|
|
const seen = new Set<string>()
|
|
return items.filter((x) => {
|
|
const k = String(x)
|
|
if (seen.has(k)) return false
|
|
seen.add(k)
|
|
return true
|
|
})
|
|
}
|
|
|
|
function PageButton({
|
|
active,
|
|
disabled,
|
|
rounded,
|
|
onClick,
|
|
children,
|
|
title,
|
|
}: {
|
|
active?: boolean
|
|
disabled?: boolean
|
|
rounded?: 'l' | 'r' | 'none'
|
|
onClick?: () => void
|
|
children: React.ReactNode
|
|
title?: string
|
|
}) {
|
|
const roundedCls =
|
|
rounded === 'l' ? 'rounded-l-md' : rounded === 'r' ? 'rounded-r-md' : ''
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={disabled ? undefined : onClick}
|
|
title={title}
|
|
className={clsx(
|
|
'relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20 focus:outline-offset-0',
|
|
roundedCls,
|
|
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
|
active
|
|
? 'z-10 bg-indigo-600 text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:focus-visible:outline-indigo-500'
|
|
: 'text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 dark:text-gray-200 dark:inset-ring-gray-700 dark:hover:bg-white/5'
|
|
)}
|
|
aria-current={active ? 'page' : undefined}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
export default function Pagination({
|
|
page,
|
|
pageSize,
|
|
totalItems,
|
|
onPageChange,
|
|
siblingCount = 1,
|
|
boundaryCount = 1,
|
|
showSummary = true,
|
|
className,
|
|
ariaLabel = 'Pagination',
|
|
prevLabel = 'Previous',
|
|
nextLabel = 'Next',
|
|
}: PaginationProps) {
|
|
const totalPages = Math.max(1, Math.ceil((totalItems || 0) / Math.max(1, pageSize || 1)))
|
|
const current = clamp(page || 1, 1, totalPages)
|
|
|
|
if (totalPages <= 1) return null
|
|
|
|
const from = totalItems === 0 ? 0 : (current - 1) * pageSize + 1
|
|
const to = Math.min(current * pageSize, totalItems)
|
|
|
|
const items = getPageItems(totalPages, current, boundaryCount, siblingCount)
|
|
|
|
const go = (p: number) => onPageChange(clamp(p, 1, totalPages))
|
|
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
'flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 dark:border-white/10 dark:bg-transparent',
|
|
className
|
|
)}
|
|
>
|
|
{/* Mobile: nur Previous/Next */}
|
|
<div className="flex flex-1 justify-between sm:hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => go(current - 1)}
|
|
disabled={current <= 1}
|
|
className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10"
|
|
>
|
|
{prevLabel}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => go(current + 1)}
|
|
disabled={current >= totalPages}
|
|
className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10"
|
|
>
|
|
{nextLabel}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Desktop: Summary + Zahlen */}
|
|
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
|
<div>
|
|
{showSummary ? (
|
|
<p className="text-sm text-gray-700 dark:text-gray-300">
|
|
Showing <span className="font-medium">{from}</span> to{' '}
|
|
<span className="font-medium">{to}</span> of{' '}
|
|
<span className="font-medium">{totalItems}</span> results
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div>
|
|
<nav
|
|
aria-label={ariaLabel}
|
|
className="isolate inline-flex -space-x-px rounded-md shadow-xs dark:shadow-none"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => go(current - 1)}
|
|
disabled={current <= 1}
|
|
className="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed dark:inset-ring-gray-700 dark:hover:bg-white/5"
|
|
>
|
|
<span className="sr-only">{prevLabel}</span>
|
|
<ChevronLeftIcon aria-hidden="true" className="size-5" />
|
|
</button>
|
|
|
|
{items.map((it, idx) => {
|
|
if (it === 'ellipsis') {
|
|
return (
|
|
<span
|
|
key={`e-${idx}`}
|
|
className="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 inset-ring inset-ring-gray-300 dark:text-gray-400 dark:inset-ring-gray-700"
|
|
>
|
|
…
|
|
</span>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<PageButton
|
|
key={it}
|
|
active={it === current}
|
|
onClick={() => go(it)}
|
|
rounded="none"
|
|
>
|
|
{it}
|
|
</PageButton>
|
|
)
|
|
})}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => go(current + 1)}
|
|
disabled={current >= totalPages}
|
|
className="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed dark:inset-ring-gray-700 dark:hover:bg-white/5"
|
|
>
|
|
<span className="sr-only">{nextLabel}</span>
|
|
<ChevronRightIcon aria-hidden="true" className="size-5" />
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|