{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "paged-grid",
  "title": "Paged grid",
  "description": "A paginated grid (the in-game Triumphs / Collections / vault pager): the page of cells flanked by full-height previous/next arrow bars, with page-indicator segments below and a fade+slide in the travel direction on change.",
  "registryDependencies": [
    "@engram/cn",
    "@engram/tokens"
  ],
  "files": [
    {
      "path": "src/components/paged-grid.tsx",
      "content": "\"use client\";\n\nimport { type CSSProperties, type ReactNode, useState } from \"react\";\nimport { cn } from \"../lib/cn.js\";\n\nexport interface PagedGridProps<T> {\n  items: T[];\n  renderItem: (item: T, index: number) => ReactNode;\n  /** Stable key per item. */\n  itemKey: (item: T, index: number) => string;\n  /** Columns on `sm`+ (mobile is always a single column). Default 2. */\n  columns?: number;\n  /** Rows per page — page size is `columns × rows`. Default 5. */\n  rows?: number;\n  /** Gap between cells (px). Default 8. */\n  gap?: number;\n  /** Fixed cell width (px). When set, columns are sized to the cell instead of\n   *  stretching to fill the row, and the packed grid is centered — keeping\n   *  fixed-size content (e.g. item tiles) tight at every breakpoint. Omit for\n   *  the default fluid columns. */\n  cellWidth?: number;\n  /** Reserve `rows × minRowHeight` so partial last pages keep the page height. */\n  minRowHeight?: number;\n  /** Accessible label for the paged region. */\n  label?: string;\n  className?: string;\n}\n\nfunction Arrow({\n  side,\n  disabled,\n  onClick,\n}: {\n  side: \"left\" | \"right\";\n  disabled: boolean;\n  onClick: () => void;\n}) {\n  return (\n    <button\n      type=\"button\"\n      disabled={disabled}\n      aria-label={side === \"left\" ? \"Previous page\" : \"Next page\"}\n      onClick={onClick}\n      className={cn(\n        \"flex w-6 shrink-0 items-center justify-center border transition-colors\",\n        disabled\n          ? \"border-engram-border/50 text-engram-faint/40\"\n          : \"cursor-pointer border-engram-border-strong text-engram-muted hover:border-engram-fg/60 hover:bg-white/[0.03] hover:text-engram-fg\",\n      )}\n    >\n      <svg viewBox=\"0 0 12 24\" aria-hidden=\"true\" className=\"size-3\">\n        <path\n          d={side === \"left\" ? \"M8 4 4 12l4 8\" : \"M4 4l4 8-4 8\"}\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2\"\n          strokeLinecap=\"square\"\n        />\n      </svg>\n    </button>\n  );\n}\n\n/**\n * A paginated grid (the in-game Triumphs / Collections / vault pager): the page\n * of cells flanked by full-height previous/next arrow bars, with page-indicator\n * segments below and a fade+slide in the travel direction on change. Pages are\n * `columns × rows`; on mobile the grid collapses to a single column. Generic over\n * the item type.\n */\nexport function PagedGrid<T>({\n  items,\n  renderItem,\n  itemKey,\n  columns = 2,\n  rows = 5,\n  gap = 8,\n  cellWidth,\n  minRowHeight,\n  label = \"Paged grid\",\n  className,\n}: PagedGridProps<T>) {\n  const tight = cellWidth != null;\n  const pageSize = Math.max(1, columns * rows);\n  const pageCount = Math.max(1, Math.ceil(items.length / pageSize));\n  const [page, setPage] = useState(0);\n  const [dir, setDir] = useState<\"left\" | \"right\">(\"right\");\n  const current = Math.min(page, pageCount - 1);\n  const start = current * pageSize;\n  const pageItems = items.slice(start, start + pageSize);\n\n  const go = (next: number) => {\n    if (next < 0 || next > pageCount - 1 || next === current) return;\n    setDir(next > current ? \"right\" : \"left\");\n    setPage(next);\n  };\n\n  const minHeight =\n    minRowHeight != null ? rows * minRowHeight + (rows - 1) * gap : undefined;\n\n  return (\n    <section\n      className={cn(\"flex flex-col gap-2\", className)}\n      aria-label={label}\n    >\n      <div className=\"flex items-stretch gap-2\">\n        <Arrow\n          side=\"left\"\n          disabled={current === 0}\n          onClick={() => go(current - 1)}\n        />\n        {/* Clip the horizontal page slide, but pad the block axis so the top/\n            bottom rows' masterwork frame + glow (drawn outside the tile) and the\n            hover outline aren't cut off. */}\n        <div className=\"min-w-0 flex-1 overflow-hidden py-2.5\">\n          <div\n            // Remount on page change so the slide+fade replays.\n            key={current}\n            className={cn(\n              \"grid content-start\",\n              tight\n                ? \"justify-center\"\n                : \"grid-cols-1 sm:[grid-template-columns:repeat(var(--engram-cols),minmax(0,1fr))]\",\n              dir === \"right\" ? \"engram-page-in-right\" : \"engram-page-in-left\",\n            )}\n            style={\n              {\n                \"--engram-cols\": columns,\n                gap,\n                minHeight,\n                ...(tight && {\n                  gridTemplateColumns: `repeat(${columns}, ${cellWidth}px)`,\n                }),\n              } as CSSProperties\n            }\n          >\n            {pageItems.map((it, i) => (\n              // A single-cell grid so the rendered item fills its cell height.\n              // The outer grid already stretches each cell to its row's tallest\n              // item; filling the cell makes every item in a row the same height\n              // (uniform row/column gaps) regardless of content length. Fixed-size\n              // content (e.g. tiles) keeps its own size — stretch is a no-op there.\n              <div key={itemKey(it, start + i)} className=\"grid\">\n                {renderItem(it, start + i)}\n              </div>\n            ))}\n          </div>\n        </div>\n        <Arrow\n          side=\"right\"\n          disabled={current >= pageCount - 1}\n          onClick={() => go(current + 1)}\n        />\n      </div>\n      {pageCount > 1 ? (\n        <div className=\"flex items-center justify-center gap-1.5\">\n          {Array.from({ length: pageCount }, (_, i) => (\n            <button\n              // fixed-count positional indicators — index is the identity\n              // biome-ignore lint/suspicious/noArrayIndexKey: positional page dots\n              key={i}\n              type=\"button\"\n              aria-label={`Page ${i + 1}`}\n              aria-current={i === current ? \"page\" : undefined}\n              onClick={() => go(i)}\n              className={cn(\n                \"h-1 w-6 transition-colors\",\n                i === current\n                  ? \"bg-engram-fg\"\n                  : \"bg-engram-border-strong hover:bg-engram-muted\",\n              )}\n            />\n          ))}\n        </div>\n      ) : null}\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/engram/paged-grid.tsx"
    }
  ],
  "meta": {
    "level": "component"
  },
  "docs": "Extend without forking: edit the copied source, use `asChild` (Radix Slot) to change the rendered element, pass the typed `annotations` prop for curated data (verdict/tags/per-plug), or use slot / render-prop props for arbitrary content. Requires the @engram/tokens theme (--engram-* CSS variables).",
  "categories": [
    "layout"
  ],
  "type": "registry:component"
}