{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "grid-pagination",
  "title": "网格分页",
  "description": "整条是一行格子的分页：信息、每页条数、首尾页、页码与跳页各占一格，可单独使用，也可贴在表格底部。",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "button",
    "pagination",
    "select"
  ],
  "files": [
    {
      "path": "src/registry/ui/grid-pagination/grid-pagination.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ChevronFirst,\n  ChevronLast,\n  ChevronLeft,\n  ChevronRight,\n} from \"lucide-react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Button, buttonVariants } from \"@/components/ui/button\"\nimport {\n  Pagination,\n  PaginationContent,\n  PaginationItem,\n  PaginationLink,\n} from \"@/components/ui/pagination\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\"\nimport { cn } from \"@/lib/utils\"\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\nimport {\n  getPaginationRange,\n  getPaginationSlots,\n} from \"@/lib/pagination-slots\"\n\nconst DEFAULT_LABELS = {\n  navigation: \"分页\",\n  first: \"首页\",\n  previous: \"上一页\",\n  next: \"下一页\",\n  last: \"尾页\",\n  perPage: \"每页\",\n  jump: \"跳至\",\n  pageUnit: \"页\",\n  jumpInput: \"跳转页码\",\n  jumpTrigger: \"跳转到指定页\",\n  page: (page: number) => `第 ${page} 页`,\n}\n\ntype GridPaginationProps = Omit<React.ComponentProps<\"nav\">, \"onChange\"> & {\n  /** 当前页，从 1 开始 */\n  page?: number\n  /** 初始页 @default 1 */\n  defaultPage?: number\n  /** 总页数，至少按 1 页展示 */\n  pageCount: number\n  /** 翻页回调 */\n  onPageChange?: (page: number) => void\n  /** 总条数；传入时信息格显示「范围 / 总数」 */\n  total?: number\n  /** 每页条数 @default 10 */\n  pageSize?: number\n  /** 可选每页条数；传入时显示每页条数格 */\n  pageSizes?: readonly number[]\n  /** 每页条数变化，调用方同步更新 pageCount 与 page */\n  onPageSizeChange?: (size: number) => void\n  /** 自定义信息格内容；不传时显示「范围 / 总数」（无 total 时显示「当前页 / 总页数」） */\n  info?: React.ReactNode\n  /** 显示跳页格 @default false */\n  jumper?: boolean\n  /**\n   * 窄容器（窄于 36rem）里把「当前页 / 总页数」变成可点击的跳页入口：点开后就地换成数字输入框，\n   * 回车跳转，Esc 或失焦取消。只在 `jumper` 开启且总页数大于 1 时生效，不影响宽容器的外观\n   * @default true\n   */\n  compactJumper?: boolean\n  /** 当前页两侧页数 @default 1 */\n  siblings?: number\n  /** 生成真实链接；未传入时使用按钮 */\n  getHref?: (page: number) => string\n  /**\n   * 外框：单独使用时整条带边框；放进表格等已有边线的容器时设为 false，由容器画线\n   * @default true\n   */\n  bordered?: boolean\n  /**\n   * 格子高度，对应按钮 `icon` / `icon-lg` 档（32 / 36px，触屏 40 / 44px）\n   * @default \"default\"\n   */\n  size?: \"sm\" | \"default\"\n  /** 覆盖默认文案 */\n  labels?: Partial<typeof DEFAULT_LABELS>\n}\n\n/**\n * 网格分页：整条是一行格子，信息、每页条数、首尾页、页码、跳页各占一格，格子之间用竖线分隔。\n * 底层是官方 Pagination；可单独使用，也可贴在表格底部当作最后一行。\n */\nfunction GridPagination({\n  page: pageProp,\n  defaultPage = 1,\n  pageCount,\n  onPageChange,\n  total,\n  pageSize = 10,\n  pageSizes,\n  onPageSizeChange,\n  info,\n  jumper = false,\n  compactJumper = true,\n  siblings = 1,\n  getHref,\n  bordered = true,\n  size = \"default\",\n  labels: labelsProp,\n  className,\n  ...props\n}: GridPaginationProps) {\n  const labels = { ...DEFAULT_LABELS, ...labelsProp }\n  const count = Math.max(1, Math.trunc(pageCount) || 1)\n  const clamp = (n: number) => Math.min(count, Math.max(1, Math.trunc(n) || 1))\n  const [rawPage, setPage] = useControllableState({\n    prop: pageProp,\n    defaultProp: defaultPage,\n    onChange: onPageChange,\n  })\n  const page = clamp(rawPage)\n  const [jump, setJump] = React.useState(\"\")\n  // 窄容器的紧凑跳页：editing 时「当前页 / 总页数」换成输入框\n  const [editing, setEditing] = React.useState(false)\n  const [compactValue, setCompactValue] = React.useState(\"\")\n  const compactTrigger = React.useRef<HTMLButtonElement>(null)\n  const canCompactJump = jumper && compactJumper && count > 1\n  /** 提交跳页：非法输入忽略并返回 false；链接模式额外整页跳转 */\n  function submitJump(value: string) {\n    if (!value.trim() || !Number.isFinite(Number(value))) return false\n    const next = clamp(Number(value))\n    setPage(next)\n    if (getHref) window.location.assign(getHref(next))\n    return true\n  }\n  /** 关闭输入框并把焦点还给入口，键盘用户不会丢位置 */\n  function closeCompact() {\n    setEditing(false)\n    setCompactValue(\"\")\n    requestAnimationFrame(() => compactTrigger.current?.focus())\n  }\n  const buttonSize = size === \"sm\" ? \"icon\" : \"icon-lg\"\n  // 固定槽位：翻页时格子数量不变，整条不抖动\n  const pages = getPaginationSlots(page, count, siblings)\n  const range = total !== undefined && getPaginationRange(page, pageSize, total)\n  const pad = (n: number) => String(n).padStart(2, \"0\")\n  // 翻页方向：页码格里的数字顺着这个方向滚动（往后翻向左滚，往前翻向右滚）\n  const [previous, setPrevious] = React.useState(page)\n  const [direction, setDirection] = React.useState(0)\n  if (previous !== page) {\n    setPrevious(page)\n    setDirection(page > previous ? 1 : -1)\n  }\n  // 当前页的主题色块：同一个实例里只有一块，翻页时滑到新位置\n  const indicatorId = React.useId()\n  const reduced = useReducedMotion()\n  const transition = reduced\n    ? { duration: 0 }\n    : { type: \"spring\" as const, bounce: 0.15, visualDuration: 0.3 }\n\n  // 每一格：方形、无圆角，焦点环画在格子内侧\n  const cell =\n    \"rounded-none border-0 font-mono text-xs tabular-nums focus-visible:ring-inset\"\n  const currentCell =\n    \"text-primary-foreground hover:bg-transparent hover:text-primary-foreground\"\n\n  /**\n   * 页码格的内容：主题色块与数字分两层。数字层裁切溢出，换数字时旧的滑出、新的滑入；\n   * 主题色块不在裁切层里，滑过相邻格子时不会被裁掉。\n   */\n  const slot = (value: React.ReactNode, key: string, current: boolean) => (\n    <>\n      {current && (\n        <motion.span\n          layoutId={indicatorId}\n          // 只在翻页时做动画；表格变高变矮等引起的整条位移直接跟随，不做上下滑动\n          layoutDependency={page}\n          aria-hidden=\"true\"\n          data-slot=\"grid-pagination-indicator\"\n          className=\"absolute inset-0 bg-primary\"\n          transition={transition}\n        />\n      )}\n      <span className=\"absolute inset-0 overflow-hidden\">\n        <AnimatePresence initial={false} custom={direction} mode=\"popLayout\">\n          <motion.span\n            key={key}\n            custom={direction}\n            variants={{\n              enter: (d: number) => ({ x: `${d * 70}%`, opacity: 0 }),\n              center: { x: \"0%\", opacity: 1 },\n              exit: (d: number) => ({ x: `${d * -70}%`, opacity: 0 }),\n            }}\n            initial=\"enter\"\n            animate=\"center\"\n            exit=\"exit\"\n            transition={transition}\n            className=\"absolute inset-0 flex items-center justify-center\"\n          >\n            {value}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </>\n  )\n\n  const item = (\n    n: number,\n    content: React.ReactNode,\n    label: string,\n    disabled = false,\n    current = false,\n  ) =>\n    getHref && !disabled ? (\n      <PaginationLink\n        href={getHref(n)}\n        size={buttonSize}\n        isActive={current}\n        aria-label={label}\n        className={cn(cell, \"relative\", current && currentCell)}\n        onClick={(event) => {\n          if (\n            !event.metaKey &&\n            !event.ctrlKey &&\n            !event.shiftKey &&\n            !event.altKey\n          )\n            setPage(n)\n        }}\n      >\n        {content}\n      </PaginationLink>\n    ) : (\n      <Button\n        type=\"button\"\n        size={buttonSize}\n        variant=\"ghost\"\n        aria-label={label}\n        aria-current={current ? \"page\" : undefined}\n        disabled={disabled}\n        className={cn(cell, \"relative\", current && currentCell)}\n        onClick={() => setPage(n)}\n      >\n        {content}\n      </Button>\n    )\n\n  // 只在宽容器里出现的格子\n  const wide = \"hidden @min-[36rem]/grid-pagination:flex\"\n\n  return (\n    <Pagination\n      {...props}\n      aria-label={labels.navigation}\n      data-slot=\"grid-pagination\"\n      data-bordered={bordered ? \"\" : undefined}\n      className={cn(\n        \"@container/grid-pagination mx-0 block w-full bg-background\",\n        bordered && \"border border-edge\",\n        className,\n      )}\n    >\n      <PaginationContent className=\"flex-wrap items-stretch gap-0 [&>li]:border-dashed [&>li]:border-edge [&>li+li]:border-s\">\n        <PaginationItem\n          data-slot=\"grid-pagination-info\"\n          className=\"flex flex-1 items-center px-3 font-mono text-xs whitespace-nowrap text-muted-foreground tabular-nums @max-[36rem]/grid-pagination:basis-full @max-[36rem]/grid-pagination:border-b @max-[36rem]/grid-pagination:py-2\"\n        >\n          {info ??\n            (range ? (\n              <span>\n                {pad(range.from)}–{pad(range.to)} / {total}\n              </span>\n            ) : (\n              <span>\n                {page} / {count}\n              </span>\n            ))}\n        </PaginationItem>\n        {pageSizes && (\n          <PaginationItem className={wide}>\n            {/* 每页条数：整格就是触发器（「每页 10 ⌄」），悬停、展开时整格变色；\n                弹出层与整格同宽、左缘对齐，选项数字与格内文字同一条左边线 */}\n            <Select\n              value={pageSize}\n              onValueChange={(value) => onPageSizeChange?.(Number(value))}\n            >\n              <SelectTrigger className=\"h-full! gap-2 rounded-none border-0 bg-transparent! px-3 py-0 text-xs shadow-none hover:bg-muted! focus-visible:ring-inset data-[popup-open]:bg-muted! [&_svg]:size-3.5!\">\n                <span className=\"text-muted-foreground\">{labels.perPage}</span>\n                <SelectValue className=\"font-mono text-foreground tabular-nums\" />\n              </SelectTrigger>\n              <SelectContent\n                alignItemWithTrigger={false}\n                align=\"start\"\n                className=\"w-(--anchor-width) min-w-0\"\n              >\n                {pageSizes\n                  .filter((n) => n > 0)\n                  .map((n) => (\n                    <SelectItem\n                      key={n}\n                      value={n}\n                      className=\"py-1 ps-2 pe-6 font-mono text-xs tabular-nums [&_svg]:size-3.5 [&>span:last-child]:right-2\"\n                    >\n                      {n}\n                    </SelectItem>\n                  ))}\n              </SelectContent>\n            </Select>\n          </PaginationItem>\n        )}\n        {/* 窄容器里信息格独占上一行，这一格成了第二行的行首，不再画左线 */}\n        <PaginationItem className=\"@max-[36rem]/grid-pagination:border-s-0\">\n          {item(\n            1,\n            <ChevronFirst aria-hidden=\"true\" />,\n            labels.first,\n            page === 1,\n          )}\n        </PaginationItem>\n        <PaginationItem>\n          {item(\n            clamp(page - 1),\n            <ChevronLeft aria-hidden=\"true\" />,\n            labels.previous,\n            page === 1,\n          )}\n        </PaginationItem>\n        {/* 按位置取 key：格子本身不重建，只有格里的数字滚动，主题色块滑动 */}\n        {pages.map((n, index) => (\n          <PaginationItem key={index} className={wide}>\n            {typeof n === \"number\" ? (\n              item(\n                n,\n                slot(n, String(n), n === page),\n                labels.page(n),\n                false,\n                n === page,\n              )\n            ) : (\n              <span\n                aria-hidden=\"true\"\n                className={cn(\n                  buttonVariants({ variant: \"ghost\", size: buttonSize }),\n                  cell,\n                  \"pointer-events-none relative text-muted-foreground\",\n                )}\n              >\n                {slot(\"…\", n, false)}\n              </span>\n            )}\n          </PaginationItem>\n        ))}\n        {/* 窄容器：页码收成一格「当前页 / 总页数」，占满中间 */}\n        <PaginationItem className=\"flex flex-1 items-center justify-center font-mono text-xs tabular-nums @min-[36rem]/grid-pagination:hidden\">\n          {canCompactJump ? (\n            <>\n              {/* 读屏播报翻页结果；入口按钮自己的名称不是 live 区域 */}\n              <span aria-live=\"polite\" className=\"sr-only\">\n                {page} / {count}\n              </span>\n              {editing ? (\n                <label className=\"flex h-full w-full items-center justify-center gap-1.5\">\n                  <input\n                    // 打开即聚焦；不用 autoFocus 属性，免得服务端渲染出的标记带上它\n                    ref={(node) => node?.focus()}\n                    aria-label={labels.jumpInput}\n                    inputMode=\"numeric\"\n                    enterKeyHint=\"go\"\n                    value={compactValue}\n                    placeholder={String(page)}\n                    onChange={(event) => setCompactValue(event.target.value)}\n                    onBlur={() => {\n                      // 失焦即取消；焦点已被别处接走，不再抢回来\n                      setEditing(false)\n                      setCompactValue(\"\")\n                    }}\n                    onKeyDown={(event) => {\n                      if (event.key === \"Escape\") {\n                        event.preventDefault()\n                        event.stopPropagation()\n                        closeCompact()\n                      } else if (event.key === \"Enter\") {\n                        event.preventDefault()\n                        if (submitJump(compactValue)) closeCompact()\n                      }\n                    }}\n                    // 触屏输入框保持 16px，避免 iOS 聚焦时页面放大\n                    className=\"h-6 w-12 rounded-sm border border-edge bg-background text-center font-mono text-base text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-xs\"\n                  />\n                  <span aria-hidden=\"true\">/ {count}</span>\n                </label>\n              ) : (\n                <button\n                  ref={compactTrigger}\n                  type=\"button\"\n                  data-slot=\"grid-pagination-jump-trigger\"\n                  aria-label={`${page} / ${count}，${labels.jumpTrigger}`}\n                  onClick={() => setEditing(true)}\n                  className=\"h-full w-full cursor-pointer outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset\"\n                >\n                  <span aria-hidden=\"true\">\n                    {page} / {count}\n                  </span>\n                </button>\n              )}\n            </>\n          ) : (\n            <span aria-live=\"polite\">\n              {page} / {count}\n            </span>\n          )}\n        </PaginationItem>\n        <PaginationItem>\n          {item(\n            clamp(page + 1),\n            <ChevronRight aria-hidden=\"true\" />,\n            labels.next,\n            page === count,\n          )}\n        </PaginationItem>\n        <PaginationItem>\n          {item(\n            count,\n            <ChevronLast aria-hidden=\"true\" />,\n            labels.last,\n            page === count,\n          )}\n        </PaginationItem>\n        {jumper && (\n          // 跳页最不常用，容器再宽一些才出现，先保证信息格放得下\n          <PaginationItem className=\"hidden @min-[44rem]/grid-pagination:flex\">\n            <label className=\"flex items-center gap-1.5 px-3 text-xs whitespace-nowrap text-muted-foreground\">\n              {labels.jump}\n              <input\n                aria-label={labels.jumpInput}\n                inputMode=\"numeric\"\n                value={jump}\n                onChange={(event) => setJump(event.target.value)}\n                onKeyDown={(event) => {\n                  if (event.key !== \"Enter\") return\n                  event.preventDefault()\n                  if (submitJump(jump)) setJump(\"\")\n                }}\n                className=\"h-6 w-10 rounded-sm border border-edge bg-background text-center font-mono text-base text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-xs\"\n              />\n              {labels.pageUnit}\n            </label>\n          </PaginationItem>\n        )}\n      </PaginationContent>\n    </Pagination>\n  )\n}\n\nexport { GridPagination }\nexport type { GridPaginationProps }\n",
      "type": "registry:ui",
      "target": "@ui/grid-pagination/grid-pagination.tsx"
    },
    {
      "path": "src/registry/lib/pagination-slots.ts",
      "content": "/** 页码槽位：数字是页码，`\"gap-start\"` / `\"gap-end\"` 是省略号 */\ntype PaginationSlot = number | \"gap-start\" | \"gap-end\"\n\n/**\n * 计算要渲染的页码槽位。\n * 固定槽位：总页数多于 `siblings × 2 + 5` 时，始终返回同样数量的格子（首页、末页、当前页及两侧、\n * 两个省略号或补位页码），翻页时分页条宽度不变，按钮不会在鼠标下移位。\n */\nfunction getPaginationSlots(\n  page: number,\n  pageCount: number,\n  siblings = 1,\n): PaginationSlot[] {\n  const count = Math.max(1, Math.trunc(pageCount) || 1)\n  const current = Math.min(count, Math.max(1, Math.trunc(page) || 1))\n  const radius = Math.max(0, Math.trunc(siblings) || 0)\n  const slots = radius * 2 + 5\n  const range = (from: number, to: number) =>\n    Array.from({ length: to - from + 1 }, (_, i) => from + i)\n\n  if (count <= slots) return range(1, count)\n  if (current <= radius + 3) return [...range(1, slots - 2), \"gap-end\", count]\n  if (current >= count - radius - 2)\n    return [1, \"gap-start\", ...range(count - slots + 3, count)]\n  return [\n    1,\n    \"gap-start\",\n    ...range(current - radius, current + radius),\n    \"gap-end\",\n    count,\n  ]\n}\n\n/** 当前页对应的条目范围（从 1 开始）；没有数据时返回 0–0 */\nfunction getPaginationRange(page: number, pageSize: number, total: number) {\n  const size = Math.max(1, pageSize)\n  const sum = Math.max(0, total)\n  return {\n    from: sum > 0 ? Math.min(sum, (page - 1) * size + 1) : 0,\n    to: Math.min(sum, page * size),\n  }\n}\n\nexport { getPaginationRange, getPaginationSlots }\nexport type { PaginationSlot }\n",
      "type": "registry:ui",
      "target": "lib/pagination-slots.ts"
    },
    {
      "path": "src/registry/hooks/use-controllable-state.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\ntype UseControllableStateParams<T> = {\n  /** 受控值；传入后组件不再维护内部状态 */\n  prop?: T\n  /** 非受控时的初始值 */\n  defaultProp: T\n  /** 值变化时回调（受控与非受控都会触发） */\n  onChange?: (value: T) => void\n}\n\n/**\n * 同时支持受控与非受控的状态 hook。\n * 约定见 component-conventions 技能第 4 节：open/defaultOpen/onOpenChange 等成对 props 都用它实现。\n */\nfunction useControllableState<T>({\n  prop,\n  defaultProp,\n  onChange,\n}: UseControllableStateParams<T>) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)\n  const isControlled = prop !== undefined\n  const value = isControlled ? prop : uncontrolled\n\n  // 用 ref 保存最新的回调和值，保证 setValue 引用稳定\n  const onChangeRef = React.useRef(onChange)\n  const valueRef = React.useRef(value)\n  React.useEffect(() => {\n    onChangeRef.current = onChange\n    valueRef.current = value\n  })\n\n  const setValue = React.useCallback(\n    (next: React.SetStateAction<T>) => {\n      const resolved =\n        typeof next === \"function\"\n          ? (next as (prev: T) => T)(valueRef.current)\n          : next\n      if (Object.is(resolved, valueRef.current)) return\n      valueRef.current = resolved\n      if (!isControlled) setUncontrolled(resolved)\n      onChangeRef.current?.(resolved)\n    },\n    [isControlled],\n  )\n\n  return [value, setValue] as const\n}\n\nexport { useControllableState }\n",
      "type": "registry:ui",
      "target": "hooks/use-controllable-state.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)"
    }
  },
  "type": "registry:ui"
}
