Pagination
圆体的 32 高分页格:指针下是粉色淡底,当前页加一圈粉边;手机上每格 44 的触控区。
第 3 / 12 页
pagination-demo.tsx
"use client"
import { useState } from "react"
import { Pager } from "@/components/ui/pagination"
export default function PaginationDemo() {
const [page, setPage] = useState(3)
return (
<div className="flex flex-col items-center gap-3">
<Pager page={page} pageCount={12} onPageChange={setPage} />
<p className="anibt-num text-[12px] text-muted-foreground">第 {page} / 12 页</p>
</div>
)
}安装
npx shadcn@latest add @anibt/pagination第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。
手动安装
- 1. 先装主题组件只读
@anibt/theme的 token 和配方,没有主题就没有样子。npx shadcn@latest add @anibt/theme - 2. 安装依赖
npm install lucide-react radix-ui - 3. 把下面的文件放进项目components/ui/pagination.tsx
import * as React from "react" import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react" import { Slot } from "radix-ui" import { cn } from "@/lib/utils" /** * Pagination — the AniBT 「贴纸手账」 pager. * * Quiet 32px cells in the round face (`anibt-pager-item`); the pointer brings * the pink wash, and the current page is washed with a pink ring. On touch * widths each cell is a 44px target, from `sm` A's 32. * * The parts are shadcn's (`Pagination`, `PaginationContent`, `PaginationItem`, * `PaginationLink`, `PaginationPrevious`, `PaginationNext`, * `PaginationEllipsis`), with Chinese defaults for the words. Which pages * exist and where a link points stay with the page that owns the data — * or hand both to `Pager`, which draws the whole row from `page` and * `pageCount`. * * A link with an `href` is an `<a>`; without one it is a `<button>` (a pager * driven by state or a cursor still has to be reachable by Tab). `asChild` * hands the cell to a router link. */ function Pagination({ className, ...props }: React.ComponentProps<"nav">) { return ( <nav aria-label="分页" data-slot="pagination" className={cn("mx-auto flex w-full justify-center", className)} {...props} /> ) } function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) { return ( <ul data-slot="pagination-content" className={cn("flex flex-row items-center gap-1", className)} {...props} /> ) } function PaginationItem({ ...props }: React.ComponentProps<"li">) { return <li data-slot="pagination-item" {...props} /> } const CELL = "anibt-pager-item h-11 min-w-11 outline-hidden select-none sm:h-8 sm:min-w-8 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid disabled:pointer-events-none disabled:text-muted-foreground/60 aria-disabled:pointer-events-none aria-disabled:text-muted-foreground/60 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0" type PaginationLinkProps = { /** The current page: the pink ring and `aria-current="page"`. */ isActive?: boolean /** shadcn's button size names; `icon` is the square cell, the rest pad. */ size?: "default" | "sm" | "lg" | "icon" | "icon-sm" | "icon-lg" | "xs" | "icon-xs" /** Hand the cell to the single child (a router `Link`). */ asChild?: boolean disabled?: boolean } & Omit<React.ComponentProps<"a">, "ref"> & Omit<React.ComponentProps<"button">, keyof React.ComponentProps<"a"> | "ref"> function PaginationLink({ className, isActive, size = "icon", asChild = false, disabled, href, ...props }: PaginationLinkProps) { const classes = cn(CELL, size.startsWith("icon") ? "px-2" : "gap-1 px-2.5", isActive && "anibt-pager-item-on", className) const shared = { "data-slot": "pagination-link", "data-active": isActive ? "true" : undefined, "aria-current": isActive ? ("page" as const) : undefined, className: classes, } if (asChild) { return <Slot.Root {...shared} aria-disabled={disabled || undefined} {...props} /> } if (href !== undefined) { return ( <a {...shared} href={disabled ? undefined : href} aria-disabled={disabled || undefined} {...(props as React.ComponentProps<"a">)} /> ) } return ( <button type="button" {...shared} disabled={disabled} {...(props as React.ComponentProps<"button">)} /> ) } type PaginationStepProps = React.ComponentProps<typeof PaginationLink> & { /** The word beside the arrow (hidden below `sm`). */ text?: string /** Arrow only, at every width. */ iconOnly?: boolean } function PaginationPrevious({ className, text = "上一页", iconOnly = false, ...props }: PaginationStepProps) { return ( <PaginationLink aria-label={text} size={iconOnly ? "icon" : "default"} className={cn(!iconOnly && "sm:pl-2", className)} {...props} > <ChevronLeftIcon aria-hidden="true" /> {iconOnly ? null : <span className="hidden sm:block">{text}</span>} </PaginationLink> ) } function PaginationNext({ className, text = "下一页", iconOnly = false, ...props }: PaginationStepProps) { return ( <PaginationLink aria-label={text} size={iconOnly ? "icon" : "default"} className={cn(!iconOnly && "sm:pr-2", className)} {...props} > {iconOnly ? null : <span className="hidden sm:block">{text}</span>} <ChevronRightIcon aria-hidden="true" /> </PaginationLink> ) } function PaginationEllipsis({ className, label = "更多页", ...props }: React.ComponentProps<"span"> & { label?: string }) { return ( <span data-slot="pagination-ellipsis" className={cn("flex h-11 w-8 items-center justify-center text-muted-foreground sm:h-8 sm:w-6", className)} {...props} > <MoreHorizontalIcon className="size-4" aria-hidden="true" /> <span className="sr-only">{label}</span> </span> ) } /** * The pages to draw for `page` of `pageCount`: the first and last * `boundaries` pages, `siblings` on each side of the current one, and * `"ellipsis"` for every gap of two or more. A gap of one page draws the page. */ function getPageWindow(page: number, pageCount: number, siblings = 1, boundaries = 1): Array<number | "ellipsis"> { const count = Math.max(1, Math.floor(pageCount)) const current = Math.min(Math.max(1, Math.floor(page)), count) const keep = new Set<number>() for (let i = 1; i <= Math.min(boundaries, count); i++) keep.add(i) for (let i = Math.max(1, count - boundaries + 1); i <= count; i++) keep.add(i) for (let i = Math.max(1, current - siblings); i <= Math.min(count, current + siblings); i++) keep.add(i) const sorted = [...keep].sort((a, b) => a - b) const out: Array<number | "ellipsis"> = [] for (const value of sorted) { const last = out[out.length - 1] if (typeof last === "number" && value - last === 2) out.push(last + 1) else if (typeof last === "number" && value - last > 2) out.push("ellipsis") out.push(value) } return out } type PagerProps = Omit<React.ComponentProps<"nav">, "onChange"> & { /** The current page, from 1. */ page: number pageCount: number /** Links: the URL of a page. Leave it out to get buttons. */ getHref?: (page: number) => string /** Called with the page a reader picked (with or without `getHref`). */ onPageChange?: (page: number) => void /** Pages kept on each side of the current one. Default 1. */ siblings?: number /** Pages kept at each end. Default 1. */ boundaries?: number /** Show 上一页 / 下一页 beside the arrows from `sm`. Default arrows only. */ showText?: boolean previousText?: string nextText?: string /** The accessible name of a page cell. Default `第 N 页`. */ pageLabel?: (page: number) => string } /** * A whole pager row from `page` and `pageCount`: ‹ 1 2 [3] 4 … 12 ›. * One page draws nothing — a short list is not paginated. */ function Pager({ page, pageCount, getHref, onPageChange, siblings = 1, boundaries = 1, showText = false, previousText = "上一页", nextText = "下一页", pageLabel = (value) => `第 ${value} 页`, ...props }: PagerProps) { if (pageCount <= 1) return null const current = Math.min(Math.max(1, page), pageCount) const target = (value: number) => ({ href: getHref?.(value), onClick: onPageChange ? () => onPageChange(value) : undefined, }) return ( <Pagination {...props}> <PaginationContent> <PaginationItem> <PaginationPrevious text={previousText} iconOnly={!showText} disabled={current <= 1} {...target(current - 1)} /> </PaginationItem> {getPageWindow(current, pageCount, siblings, boundaries).map((value, index) => value === "ellipsis" ? ( <PaginationItem key={`gap-${index}`}> <PaginationEllipsis /> </PaginationItem> ) : ( <PaginationItem key={value}> <PaginationLink isActive={value === current} aria-label={pageLabel(value)} {...target(value)}> {value} </PaginationLink> </PaginationItem> ) )} <PaginationItem> <PaginationNext text={nextText} iconOnly={!showText} disabled={current >= pageCount} {...target(current + 1)} /> </PaginationItem> </PaginationContent> </Pagination> ) } export { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, Pager, getPageWindow, } export type { PaginationLinkProps, PagerProps }
用法
最省事的是 Pager:给它当前页和总页数,它画出整行 ‹ 1 2 [3] 4 … 12 ›。
用法
import { Pager } from "@/components/ui/pagination"
// 链接:每一页一个地址
<Pager page={page} pageCount={48} getHref={(p) => `/magnets?page=${p}`} />
// 按钮:由状态或游标驱动
<Pager page={page} pageCount={12} onPageChange={setPage} />只有一页时 Pager 什么都不画——短列表不分页。siblings 控制当前页两边各留几页,boundaries 控制两端各留几页;只隔一页的空档直接画出那一页,不画省略号。
自己拼
pagination-parts.tsx
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
export default function PaginationParts() {
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="?page=1" />
</PaginationItem>
<PaginationItem>
<PaginationLink href="?page=1">1</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="?page=2" isActive>
2
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="?page=3">3</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
<PaginationItem>
<PaginationLink href="?page=48">48</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext href="?page=3" />
</PaginationItem>
</PaginationContent>
</Pagination>
)
}各部分和 shadcn 同名:Pagination、PaginationContent、PaginationItem、PaginationLink、PaginationPrevious、PaginationNext、PaginationEllipsis。文字默认是中文(「上一页」「下一页」「更多页」),用 text / label 改。
PaginationLink 有 href 时是 <a>,没有时是 <button>——没有地址的分页也得能用 Tab 走到。路由链接用 asChild。到头的「上一页」用 disabled,链接会变成 aria-disabled。
属性
Pager
pagenumber默认 —必填:当前页,从 1 开始
pageCountnumber默认 —必填:总页数
getHref(page: number) => string默认 —每页的地址;不给时画按钮
onPageChange(page: number) => void默认 —读者点了哪一页
siblingsnumber默认 1当前页两边各留几页
boundariesnumber默认 1两端各留几页
showTextboolean默认 false从 sm 起在箭头旁写「上一页」「下一页」
pageLabel(page: number) => string默认 第 N 页每格的无障碍名称
PaginationLink
isActiveboolean默认 false当前页:粉边与 aria-current=page
hrefstring默认 —有则渲染 a,无则渲染 button
asChildboolean默认 false把样式交给唯一的子元素(路由链接)
disabledboolean默认 false到头了
sizeshadcn 的按钮尺寸名默认 "icon"icon 系列是方格,其余左右留白
PaginationPrevious · PaginationNext
textstring默认 "上一页" / "下一页"箭头旁的字(sm 以下隐藏),也是无障碍名称
iconOnlyboolean默认 false任何宽度都只画箭头
还导出 getPageWindow(page, pageCount, siblings, boundaries),返回要画的页码和 "ellipsis"。
