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>
|
|||
|
|
)
|
|||
|
|
}
|