187 lines
5.5 KiB
TypeScript
187 lines
5.5 KiB
TypeScript
// frontend\src\components\ui\Pagination.tsx
|
|
|
|
'use client'
|
|
|
|
import { type ReactNode } from 'react'
|
|
import clsx from 'clsx'
|
|
import {
|
|
ChevronLeftIcon,
|
|
ChevronRightIcon,
|
|
ChevronDoubleLeftIcon,
|
|
ChevronDoubleRightIcon,
|
|
ChevronDownIcon,
|
|
} from '@heroicons/react/20/solid'
|
|
|
|
export type PaginationProps = {
|
|
page: number // 1-based
|
|
pageSize: number
|
|
totalItems: number
|
|
onPageChange: (page: number) => void
|
|
|
|
showSummary?: boolean
|
|
className?: string
|
|
|
|
ariaLabel?: string
|
|
prevLabel?: string
|
|
nextLabel?: string
|
|
firstLabel?: string
|
|
lastLabel?: string
|
|
}
|
|
|
|
function clamp(n: number, min: number, max: number) {
|
|
return Math.max(min, Math.min(max, n))
|
|
}
|
|
|
|
function PageButton({
|
|
disabled,
|
|
rounded,
|
|
onClick,
|
|
children,
|
|
title,
|
|
}: {
|
|
disabled?: boolean
|
|
rounded?: 'l' | 'r' | 'none'
|
|
onClick?: () => void
|
|
children: 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 h-10 min-w-10 items-center justify-center px-3 text-sm font-semibold focus:z-20 focus:outline-offset-0',
|
|
roundedCls,
|
|
disabled
|
|
? 'opacity-100 cursor-not-allowed bg-white text-gray-400 inset-ring inset-ring-gray-200 dark:bg-slate-900 dark:text-gray-500 dark:inset-ring-slate-800'
|
|
: 'cursor-pointer bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700'
|
|
)}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
export default function Pagination({
|
|
page,
|
|
pageSize,
|
|
totalItems,
|
|
onPageChange,
|
|
showSummary = false,
|
|
className,
|
|
ariaLabel = 'Pagination',
|
|
prevLabel = 'Vorherige Seite',
|
|
nextLabel = 'Nächste Seite',
|
|
firstLabel = 'Erste Seite',
|
|
lastLabel = 'Letzte Seite',
|
|
}: 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 go = (p: number) => onPageChange(clamp(p, 1, totalPages))
|
|
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
'flex items-center justify-center bg-transparent dark:border-white/10',
|
|
className
|
|
)}
|
|
>
|
|
<div className="flex flex-col items-center gap-2">
|
|
{showSummary ? (
|
|
<p className="text-sm text-gray-700 dark:text-gray-300 px-2">
|
|
Showing <span className="font-medium">{from}</span> to{' '}
|
|
<span className="font-medium">{to}</span> of{' '}
|
|
<span className="font-medium">{totalItems}</span> results
|
|
</p>
|
|
) : null}
|
|
|
|
<nav
|
|
aria-label={ariaLabel}
|
|
className="isolate inline-flex -space-x-px rounded-md shadow-xs dark:shadow-none"
|
|
>
|
|
<PageButton
|
|
rounded="l"
|
|
onClick={() => go(1)}
|
|
disabled={current <= 1}
|
|
title={firstLabel}
|
|
>
|
|
<span className="sr-only">{firstLabel}</span>
|
|
<ChevronDoubleLeftIcon aria-hidden="true" className="size-5" />
|
|
</PageButton>
|
|
|
|
<PageButton
|
|
rounded="none"
|
|
onClick={() => go(current - 1)}
|
|
disabled={current <= 1}
|
|
title={prevLabel}
|
|
>
|
|
<span className="sr-only">{prevLabel}</span>
|
|
<ChevronLeftIcon aria-hidden="true" className="size-5" />
|
|
</PageButton>
|
|
|
|
<div className="relative">
|
|
<select
|
|
value={current}
|
|
onChange={(e) => go(Number(e.target.value))}
|
|
aria-label="Seite auswählen"
|
|
className={clsx(
|
|
'relative h-10 appearance-none bg-white pl-4 pr-10 text-sm font-semibold',
|
|
'text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0',
|
|
'dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700',
|
|
'min-w-[112px] cursor-pointer'
|
|
)}
|
|
>
|
|
{Array.from({ length: totalPages }, (_, i) => {
|
|
const p = i + 1
|
|
return (
|
|
<option key={p} value={p} className="text-black dark:text-white">
|
|
{p} von {totalPages}
|
|
</option>
|
|
)
|
|
})}
|
|
</select>
|
|
|
|
<ChevronDownIcon
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-gray-500 dark:text-gray-400"
|
|
/>
|
|
</div>
|
|
|
|
<PageButton
|
|
rounded="none"
|
|
onClick={() => go(current + 1)}
|
|
disabled={current >= totalPages}
|
|
title={nextLabel}
|
|
>
|
|
<span className="sr-only">{nextLabel}</span>
|
|
<ChevronRightIcon aria-hidden="true" className="size-5" />
|
|
</PageButton>
|
|
|
|
<PageButton
|
|
rounded="r"
|
|
onClick={() => go(totalPages)}
|
|
disabled={current >= totalPages}
|
|
title={lastLabel}
|
|
>
|
|
<span className="sr-only">{lastLabel}</span>
|
|
<ChevronDoubleRightIcon aria-hidden="true" className="size-5" />
|
|
</PageButton>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
)
|
|
} |