{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-pagination",
  "title": "usePagination",
  "description": "Headless pagination state — clamped page, page count, item range, client-side slice() and the ellipsis page sequence, controlled or uncontrolled.",
  "files": [
    {
      "path": "src/registry/hooks/use-pagination.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nconst START_ELLIPSIS = \"start-ellipsis\" as const\nconst END_ELLIPSIS = \"end-ellipsis\" as const\n\ntype SequenceEntry = number | typeof START_ELLIPSIS | typeof END_ELLIPSIS\n\nexport type PaginationItemType = \"first\" | \"prev\" | \"page\" | \"ellipsis\" | \"next\" | \"last\"\n\nexport interface PaginationItem {\n  /** 控件类型。`\"page\"` 是可跳转的页码,`\"ellipsis\"` 是纯展示的省略号。 */\n  type: PaginationItemType\n  /**\n   * 这个控件点下去应该去的页码。页码项是它自己;`first`/`prev`/`next`/`last`\n   * 是已经夹取过的目标页(所以 `onClick={() => setPage(item.page)}` 永远安全)。\n   * 只有 `type === \"ellipsis\"` 时是 `undefined`。\n   */\n  page?: number\n  /** 是否是当前页(只有页码项可能为 true),用来上 `aria-current=\"page\"`。 */\n  selected: boolean\n  /** 省略号恒为 true;首页/上一页在第一页时、下一页/末页在最后一页时为 true。 */\n  disabled: boolean\n  /** 稳定的 React key,同一序列内唯一。 */\n  key: string\n}\n\nexport interface UsePaginationOptions {\n  /** 数据总条数(不是总页数)。小数向下取整,负数按 0 处理。 */\n  total: number\n  /** 每页条数,默认 10。`<= 0` / NaN 会被钳位到 1(否则 pageCount 会算出 Infinity)。 */\n  pageSize?: number\n  /** 传了它就是受控模式:页码由调用方持有,hook 不再自己存。 */\n  page?: number\n  /** 非受控模式的初始页,默认 1。只在挂载时读一次(与 `useState` 同语义)。 */\n  defaultPage?: number\n  /** 当前页两侧各显示几个页码,默认 1。 */\n  siblingCount?: number\n  /** 头尾各固定显示几个页码,默认 1。设 0 则不固定首尾。 */\n  boundaryCount?: number\n  /** 页码真的变了才触发(点当前页不触发);自动夹取也不触发,见下文。 */\n  onPageChange?: (page: number) => void\n}\n\nexport interface UsePaginationResult {\n  /** 已夹取到 `[1, pageCount]` 的当前页,永远可直接用来渲染。 */\n  page: number\n  /** 总页数,最小为 1(`total = 0` 时也是 1:一页空列表)。 */\n  pageCount: number\n  /** 钳位后的每页条数(调用方传了 0 或负数时会是 1)。 */\n  pageSize: number\n  /** 跳到指定页,内部夹取到 `[1, pageCount]`;和当前页相同则什么都不做。 */\n  setPage: (page: number) => void\n  next: () => void\n  prev: () => void\n  first: () => void\n  last: () => void\n  canNext: boolean\n  canPrev: boolean\n  /** 「显示第 start–end 条,共 total 条」用的 **1-based 闭区间**;`total = 0` 时两端都是 0。 */\n  range: { start: number; end: number }\n  /**\n   * 客户端分页:把完整数组切成当前页那一段(纯 `page`/`pageSize` 开窗,默认\n   * 你传进来的就是 `total` 描述的那份数据,不做长度校验)。服务端分页别用它,\n   * 用 `range` / `page` / `pageSize` 去拼请求。\n   */\n  slice: <T>(items: readonly T[]) => T[]\n  /** 完整控件序列:first · prev · 页码/省略号 · next · last。 */\n  items: PaginationItem[]\n}\n\n/** 非有限值(NaN / Infinity)一律退回 `min`,否则向零取整后夹到 `>= min`。 */\nfunction clampInt(value: number, min: number): number {\n  return Number.isFinite(value) ? Math.max(min, Math.trunc(value)) : min\n}\n\nfunction clampPage(value: number, pageCount: number): number {\n  return Math.min(clampInt(value, 1), pageCount)\n}\n\n/** 闭区间 [start, end];`end < start` 时返回空数组(边界情况全靠它兜底)。 */\nfunction range(start: number, end: number): number[] {\n  return end < start ? [] : Array.from({ length: end - start + 1 }, (_, i) => start + i)\n}\n\n/**\n * 生成 `1 … 4 5 6 … 20` 这样的页码序列。\n *\n * 关键约束:**省略号至少要藏住两页才划算**——如果一个省略号只盖住一页,那就\n * 直接把那一页画出来(同样宽度,还能点)。下面两处 `else` 分支就是干这个的。\n * 页数少到装得下时,序列自然退化成 `1 2 3 4 5`,不出现任何省略号。\n */\nfunction buildPageSequence(\n  page: number,\n  pageCount: number,\n  siblingCount: number,\n  boundaryCount: number,\n): SequenceEntry[] {\n  const startPages = range(1, Math.min(boundaryCount, pageCount))\n  const endPages = range(Math.max(pageCount - boundaryCount + 1, boundaryCount + 1), pageCount)\n\n  // 窗口贴近两端时向内滑动,让可见控件数保持恒定,页码不会左右跳。\n  const siblingsStart = Math.max(\n    Math.min(page - siblingCount, pageCount - boundaryCount - siblingCount * 2 - 1),\n    boundaryCount + 2,\n  )\n  const siblingsEnd = Math.min(\n    Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),\n    endPages.length > 0 ? endPages[0] - 2 : pageCount - 1,\n  )\n\n  return [\n    ...startPages,\n    ...(siblingsStart > boundaryCount + 2\n      ? [START_ELLIPSIS]\n      : // 只会藏一页 → 直接显示那一页\n        boundaryCount + 1 < pageCount - boundaryCount\n        ? [boundaryCount + 1]\n        : []),\n    ...range(siblingsStart, siblingsEnd),\n    ...(siblingsEnd < pageCount - boundaryCount - 1\n      ? [END_ELLIPSIS]\n      : pageCount - boundaryCount > boundaryCount\n        ? [pageCount - boundaryCount]\n        : []),\n    ...endPages,\n  ]\n}\n\n/**\n * 分页状态机 + 省略号页码序列。它只管「第几页、共几页、序列长什么样」,\n * 一个 DOM 节点都不渲染——UI 全交给调用方(可以直接喂给本仓的 `Pagination`\n * 组件,也可以自己用 `items` 摆按钮)。\n *\n * - **受控 / 非受控双支持**:传了 `page` 就是受控(hook 只做夹取和派生,不存\n *   状态);不传就用内部 state,`defaultPage` 只在挂载时读一次。挂载后不要在\n *   两种模式之间来回切。\n * - **越界永远自动夹取**:`page` 始终落在 `[1, pageCount]`。`total` 变小\n *   (删数据、换筛选条件)导致当前页不存在时,非受控模式用**渲染期\n *   adjust-state** 把内部页码写回到新的最后一页——不是在 effect 里 setState\n *   (那会多闪一帧,而且 `react-hooks/set-state-in-effect` 会拦)。\n * - **自动夹取不触发 `onPageChange`**:它是一次派生,不是一次导航。受控模式\n *   下 `total` 变小时,hook 返回的 `page` 会立刻落到合法范围,但调用方自己那\n *   份 state 不会被 hook 改写——渲染请以返回的 `page` 为准。\n * - `onPageChange` 走 latest-ref(每次渲染同步进 ref),所以调用方写内联箭头\n *   函数不会让任何 effect 或计时器重建。\n * - `pageSize <= 0` 会被钳位到 1,`pageCount` 因此永远是有限正整数,不会出现\n *   `Infinity` 页把渲染卡死。\n * - `total = 0` 时 `pageCount` 取 **1**(一页空列表)而不是 0:这样\n *   `page = 1` 恒合法,空态下页码条不需要额外分支,`canPrev`/`canNext` 双 false,\n *   `range` 返回 `{ start: 0, end: 0 }`(文案直接读成「0–0 条」)。\n * - `setPage` / `next` / `prev` / `first` / `last` 是普通事件处理器:它们的\n *   函数标识会随 `page`/`pageCount` 变化(闭包里必须是最新值),放进\n *   `onClick` 完全没问题,别把它们塞进 effect 依赖数组。\n */\nexport function usePagination(options: UsePaginationOptions): UsePaginationResult {\n  const {\n    total,\n    pageSize: pageSizeOption = 10,\n    page: controlledPage,\n    defaultPage = 1,\n    siblingCount: siblingCountOption = 1,\n    boundaryCount: boundaryCountOption = 1,\n  } = options\n\n  const pageSize = clampInt(pageSizeOption, 1)\n  const totalItems = clampInt(total, 0)\n  const siblingCount = clampInt(siblingCountOption, 0)\n  const boundaryCount = clampInt(boundaryCountOption, 0)\n  const pageCount = Math.max(1, Math.ceil(totalItems / pageSize))\n\n  const isControlled = controlledPage !== undefined\n\n  const [uncontrolledPage, setUncontrolledPage] = React.useState(() =>\n    clampPage(defaultPage, pageCount),\n  )\n\n  const page = clampPage(isControlled ? controlledPage : uncontrolledPage, pageCount)\n\n  // 渲染期 adjust-state:total 变小 / pageSize 变大导致当前页越界时,把内部\n  // 页码回退到新的最后一页。写回后 React 立刻用新值重渲,不会多提交一帧,也\n  // 不会在 total 涨回去时把用户弹回那个早已过期的旧页码。\n  if (!isControlled && page !== uncontrolledPage) {\n    setUncontrolledPage(page)\n  }\n\n  const onPageChangeRef = React.useRef(options.onPageChange)\n  React.useEffect(() => {\n    onPageChangeRef.current = options.onPageChange\n  })\n\n  const setPage = React.useCallback(\n    (value: number) => {\n      const target = clampPage(value, pageCount)\n      if (target === page) return\n      if (!isControlled) setUncontrolledPage(target)\n      onPageChangeRef.current?.(target)\n    },\n    [isControlled, page, pageCount],\n  )\n\n  const next = React.useCallback(() => setPage(page + 1), [page, setPage])\n  const prev = React.useCallback(() => setPage(page - 1), [page, setPage])\n  const first = React.useCallback(() => setPage(1), [setPage])\n  const last = React.useCallback(() => setPage(pageCount), [pageCount, setPage])\n\n  const slice = React.useCallback(\n    <T,>(items: readonly T[]): T[] => items.slice((page - 1) * pageSize, page * pageSize),\n    [page, pageSize],\n  )\n\n  const items = React.useMemo<PaginationItem[]>(() => {\n    const canPrev = page > 1\n    const canNext = page < pageCount\n\n    const pageItems = buildPageSequence(page, pageCount, siblingCount, boundaryCount).map(entry =>\n      typeof entry === \"number\"\n        ? {\n            type: \"page\" as const,\n            page: entry,\n            selected: entry === page,\n            disabled: false,\n            key: `page-${entry}`,\n          }\n        : {\n            type: \"ellipsis\" as const,\n            selected: false,\n            disabled: true,\n            key: entry === START_ELLIPSIS ? \"ellipsis-start\" : \"ellipsis-end\",\n          },\n    )\n\n    return [\n      { type: \"first\", page: 1, selected: false, disabled: !canPrev, key: \"first\" },\n      { type: \"prev\", page: Math.max(1, page - 1), selected: false, disabled: !canPrev, key: \"prev\" },\n      ...pageItems,\n      {\n        type: \"next\",\n        page: Math.min(pageCount, page + 1),\n        selected: false,\n        disabled: !canNext,\n        key: \"next\",\n      },\n      { type: \"last\", page: pageCount, selected: false, disabled: !canNext, key: \"last\" },\n    ]\n  }, [boundaryCount, page, pageCount, siblingCount])\n\n  return {\n    page,\n    pageCount,\n    pageSize,\n    setPage,\n    next,\n    prev,\n    first,\n    last,\n    canNext: page < pageCount,\n    canPrev: page > 1,\n    range:\n      totalItems === 0\n        ? { start: 0, end: 0 }\n        : { start: (page - 1) * pageSize + 1, end: Math.min(page * pageSize, totalItems) },\n    slice,\n    items,\n  }\n}\n\nexport default usePagination\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}