139 lines
4.4 KiB
TypeScript
139 lines
4.4 KiB
TypeScript
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||
import type { ChangeEvent } from "react"
|
||
|
||
import { Button } from "@/shared/ui/button"
|
||
import { cn } from "@/shared/ui/utils"
|
||
|
||
interface PaginationProps {
|
||
page: number
|
||
pageSize: number
|
||
total: number
|
||
pageSizeOptions?: readonly number[]
|
||
onPageChange: (page: number) => void
|
||
onPageSizeChange: (pageSize: number) => void
|
||
}
|
||
|
||
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50] as const
|
||
|
||
function clamp(value: number, min: number, max: number) {
|
||
return Math.min(max, Math.max(min, value))
|
||
}
|
||
|
||
function rangeLabel(page: number, pageSize: number, total: number) {
|
||
if (total <= 0) return "共 0 条"
|
||
const start = (page - 1) * pageSize + 1
|
||
const end = Math.min(page * pageSize, total)
|
||
return `第 ${start}–${end} 条,共 ${total} 条`
|
||
}
|
||
|
||
function buildPageItems(page: number, pageCount: number) {
|
||
if (pageCount <= 1) return [1]
|
||
const items: Array<number | "ellipsis"> = [1]
|
||
const start = Math.max(2, page - 1)
|
||
const end = Math.min(pageCount - 1, page + 1)
|
||
if (start > 2) items.push("ellipsis")
|
||
for (let p = start; p <= end; p += 1) items.push(p)
|
||
if (end < pageCount - 1) items.push("ellipsis")
|
||
if (pageCount > 1) items.push(pageCount)
|
||
return items
|
||
}
|
||
|
||
export function Pagination({
|
||
page,
|
||
pageSize,
|
||
total,
|
||
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
|
||
onPageChange,
|
||
onPageSizeChange,
|
||
}: PaginationProps) {
|
||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||
const currentPage = clamp(page, 1, pageCount)
|
||
const hasPrev = currentPage > 1
|
||
const hasNext = currentPage < pageCount
|
||
const items = buildPageItems(currentPage, pageCount)
|
||
|
||
function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) {
|
||
onPageSizeChange(Number(event.target.value))
|
||
}
|
||
|
||
return (
|
||
<footer
|
||
aria-label="表格分页"
|
||
className="flex min-h-12 flex-wrap items-center gap-3 border-t border-border/60 px-3 py-2 text-xs text-muted-foreground"
|
||
>
|
||
<span className="tabular-nums" aria-live="polite">
|
||
{rangeLabel(currentPage, pageSize, total)}
|
||
</span>
|
||
<label className="ml-auto flex items-center gap-1.5">
|
||
每页
|
||
<select
|
||
className="h-11 rounded-md border border-input bg-card px-2 text-xs text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:h-7"
|
||
aria-label="每页数量"
|
||
value={pageSize}
|
||
onChange={handlePageSizeChange}
|
||
>
|
||
{pageSizeOptions.map((option) => (
|
||
<option key={option} value={option}>
|
||
{option}
|
||
</option>
|
||
))}
|
||
</select>
|
||
条
|
||
</label>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
aria-label="上一页"
|
||
disabled={!hasPrev}
|
||
onClick={() => onPageChange(currentPage - 1)}
|
||
className="size-11 md:size-7"
|
||
size="icon-sm"
|
||
variant="ghost"
|
||
>
|
||
<ChevronLeft aria-hidden="true" />
|
||
</Button>
|
||
{items.map((item, index) => {
|
||
if (item === "ellipsis") {
|
||
return (
|
||
<span
|
||
key={`ellipsis-${index}`}
|
||
className="px-1.5 text-muted-foreground/70"
|
||
aria-hidden="true"
|
||
>
|
||
…
|
||
</span>
|
||
)
|
||
}
|
||
const active = item === currentPage
|
||
return (
|
||
<button
|
||
key={item}
|
||
type="button"
|
||
aria-current={active ? "page" : undefined}
|
||
aria-label={`第 ${item} 页`}
|
||
onClick={() => onPageChange(item)}
|
||
className={cn(
|
||
"size-11 rounded-md border border-transparent text-xs tabular-nums transition-colors outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:size-7",
|
||
active
|
||
? "bg-primary text-primary-foreground"
|
||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||
)}
|
||
>
|
||
{item}
|
||
</button>
|
||
)
|
||
})}
|
||
<Button
|
||
aria-label="下一页"
|
||
disabled={!hasNext}
|
||
onClick={() => onPageChange(currentPage + 1)}
|
||
className="size-11 md:size-7"
|
||
size="icon-sm"
|
||
variant="ghost"
|
||
>
|
||
<ChevronRight aria-hidden="true" />
|
||
</Button>
|
||
</div>
|
||
</footer>
|
||
)
|
||
}
|