{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "table-15",
  "title": "Pinnable columns",
  "description": "Stick a column to the left or right edge while the rest scrolls.",
  "dependencies": [
    "@tanstack/react-table",
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/badge.json",
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/dropdown-menu.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/table.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/table/table-15.tsx",
      "content": "'use client';\n\nimport {\n  type Column,\n  type ColumnDef,\n  type ColumnPinningPosition,\n  flexRender,\n  getCoreRowModel,\n  type Header,\n  useReactTable,\n} from '@tanstack/react-table';\nimport {\n  AnimatePresence,\n  motion,\n  type Transition,\n  type Variants,\n} from 'motion/react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport {\n  ArrowLeftToLineIcon,\n  ArrowRightToLineIcon,\n  EllipsisIcon,\n  PinOffIcon,\n} from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Badge } from '@/registry/ndeko/ui/badge';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/registry/ndeko/ui/dropdown-menu';\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from '@/registry/ndeko/ui/table';\n\ntype Status = 'Active' | 'Inactive' | 'Pending';\n\ntype Performance = 'Excellent' | 'Good' | 'Average' | 'Poor';\n\ntype Employee = {\n  id: string;\n  name: string;\n  email: string;\n  location: string;\n  status: Status;\n  balance: number;\n  department: string;\n  role: string;\n  joinDate: string;\n  lastActive: string;\n  performance: Performance;\n};\n\nconst STATUS_VARIANTS = {\n  Active: 'success',\n  Inactive: 'secondary',\n  Pending: 'warning',\n} as const;\n\nconst EMPLOYEES: Employee[] = [\n  {\n    id: '1',\n    name: 'Jordan Hayes',\n    email: 'jordan.hayes@example.com',\n    location: 'Toronto, Canada',\n    status: 'Active',\n    balance: 1250,\n    department: 'Engineering',\n    role: 'Staff engineer',\n    joinDate: '2021-03-14',\n    lastActive: '2026-11-04',\n    performance: 'Excellent',\n  },\n  {\n    id: '2',\n    name: 'Anna Novak',\n    email: 'anna.novak@example.com',\n    location: 'Prague, Czechia',\n    status: 'Active',\n    balance: 600,\n    department: 'Design',\n    role: 'Product designer',\n    joinDate: '2022-07-01',\n    lastActive: '2026-11-05',\n    performance: 'Good',\n  },\n  {\n    id: '3',\n    name: 'Kenji Watanabe',\n    email: 'kenji.watanabe@example.com',\n    location: 'Osaka, Japan',\n    status: 'Inactive',\n    balance: 650,\n    department: 'Finance',\n    role: 'Analyst',\n    joinDate: '2019-10-21',\n    lastActive: '2026-08-30',\n    performance: 'Average',\n  },\n  {\n    id: '4',\n    name: 'Lina Haddad',\n    email: 'lina.haddad@example.com',\n    location: 'Lyon, France',\n    status: 'Active',\n    balance: 0,\n    department: 'Marketing',\n    role: 'Content lead',\n    joinDate: '2023-01-09',\n    lastActive: '2026-11-03',\n    performance: 'Good',\n  },\n  {\n    id: '5',\n    name: 'Ama Mensah',\n    email: 'ama.mensah@example.com',\n    location: 'Accra, Ghana',\n    status: 'Pending',\n    balance: -1000,\n    department: 'Sales',\n    role: 'Account executive',\n    joinDate: '2025-05-19',\n    lastActive: '2026-10-28',\n    performance: 'Excellent',\n  },\n];\n\nconst CURRENCY = new Intl.NumberFormat('en-US', {\n  style: 'currency',\n  currency: 'USD',\n});\n\nconst DAY = new Intl.DateTimeFormat('en-US', {\n  dateStyle: 'medium',\n  timeZone: 'UTC',\n});\n\nconst RESIZE_HANDLE = [\n  'absolute top-0 -right-2 z-10 flex h-full w-4 cursor-col-resize touch-none select-none justify-center',\n  'before:absolute before:inset-y-0 before:w-(--hairline) before:translate-x-px before:bg-border before:transition-colors before:duration-150',\n  'hover:before:bg-foreground/30 data-resizing:before:bg-primary',\n].join(' ');\n\nfunction dayOf(isoDate: string) {\n  const date = new Date(isoDate);\n  return DAY.format(date);\n}\n\nconst COLUMNS: ColumnDef<Employee>[] = [\n  {\n    header: 'Name',\n    accessorKey: 'name',\n    cell: ({ row }) => <span className='font-medium'>{row.original.name}</span>,\n  },\n  {\n    header: 'Email',\n    accessorKey: 'email',\n    cell: ({ row }) => (\n      <span className='text-muted-foreground'>{row.original.email}</span>\n    ),\n  },\n  {\n    header: 'Location',\n    accessorKey: 'location',\n  },\n  {\n    header: 'Status',\n    accessorKey: 'status',\n    cell: ({ row }) => (\n      <Badge variant={STATUS_VARIANTS[row.original.status]}>\n        {row.original.status}\n      </Badge>\n    ),\n  },\n  {\n    header: 'Balance',\n    accessorKey: 'balance',\n    cell: ({ row }) => CURRENCY.format(row.original.balance),\n  },\n  {\n    header: 'Department',\n    accessorKey: 'department',\n  },\n  {\n    header: 'Role',\n    accessorKey: 'role',\n  },\n  {\n    header: 'Join date',\n    accessorKey: 'joinDate',\n    cell: ({ row }) => dayOf(row.original.joinDate),\n  },\n  {\n    header: 'Last active',\n    accessorKey: 'lastActive',\n    cell: ({ row }) => dayOf(row.original.lastActive),\n  },\n  {\n    header: 'Performance',\n    accessorKey: 'performance',\n  },\n];\n\nconst FIGURE_COLUMNS = new Set(['balance']);\n\nconst SEPARATE_ROW_LINES = [\n  'table-fixed border-separate border-spacing-0',\n  '[&_tr]:border-0 [&_th]:hairline-b [&_tr:not(:last-child)_td]:hairline-b',\n  '[&_td]:border-border [&_th]:border-border',\n].join(' ');\n\nconst PINNED_CELL = [\n  'data-pinned:bg-card/90 data-pinned:backdrop-blur-xs',\n  '[&[data-pinned=left][data-last-col=left]]:hairline-r [&[data-pinned=right][data-last-col=right]]:hairline-l',\n].join(' ');\n\nconst SWAP_TRANSITION: Transition = { duration: 0.15, ease: [0.2, 0, 0, 1] };\n\nconst SWAP_VARIANTS: Variants = {\n  enter: { opacity: 0, scale: 0.6, filter: 'blur(2px)' },\n  visible: { opacity: 1, scale: 1, filter: 'blur(0px)' },\n  exit: { opacity: 0, scale: 0.6, filter: 'blur(2px)' },\n};\n\nconst FADE_VARIANTS: Variants = {\n  enter: { opacity: 0 },\n  visible: { opacity: 1 },\n  exit: { opacity: 0 },\n};\n\nfunction swapVariantsOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return FADE_VARIANTS;\n  }\n  return SWAP_VARIANTS;\n}\n\nfunction alignClassOf(columnId: string) {\n  if (FIGURE_COLUMNS.has(columnId)) {\n    return 'text-right tabular-nums';\n  }\n  return undefined;\n}\n\nfunction resizingAttributeOf(isResizing: boolean) {\n  if (isResizing) {\n    return '';\n  }\n  return undefined;\n}\n\nfunction pinnedAttributeOf(position: ColumnPinningPosition) {\n  if (position) {\n    return position;\n  }\n  return undefined;\n}\n\nfunction pinEdgeOf(column: Column<Employee>) {\n  const position = column.getIsPinned();\n  const isLastLeft = position === 'left' && column.getIsLastColumn('left');\n  if (isLastLeft) {\n    return 'left';\n  }\n  const isFirstRight = position === 'right' && column.getIsFirstColumn('right');\n  if (isFirstRight) {\n    return 'right';\n  }\n  return undefined;\n}\n\nfunction pinningStyleOf(column: Column<Employee>): React.CSSProperties {\n  const position = column.getIsPinned();\n  const width = column.getSize();\n  if (position === 'left') {\n    const left = column.getStart('left');\n    return { position: 'sticky', left, width, zIndex: 1 };\n  }\n  if (position === 'right') {\n    const right = column.getAfter('right');\n    return { position: 'sticky', right, width, zIndex: 1 };\n  }\n  return { position: 'relative', width, zIndex: 0 };\n}\n\nfunction labelOf(column: Column<Employee>) {\n  const header = column.columnDef.header;\n  if (typeof header === 'string') {\n    return header;\n  }\n  return column.id;\n}\n\nfunction pinControlKeyOf(position: ColumnPinningPosition) {\n  if (position) {\n    return 'pinned';\n  }\n  return 'unpinned';\n}\n\nfunction PinControl({ column }: { column: Column<Employee> }) {\n  const label = labelOf(column);\n  const position = column.getIsPinned();\n\n  if (position) {\n    return (\n      <Button\n        variant='ghost'\n        size='icon-sm'\n        onClick={() => {\n          column.pin(false);\n        }}\n        aria-label={`Unpin the ${label} column`}\n        className='text-muted-foreground hover:text-foreground'\n      >\n        <PinOffIcon />\n      </Button>\n    );\n  }\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger\n        render={\n          <Button\n            variant='ghost'\n            size='icon-sm'\n            aria-label={`Pin options for the ${label} column`}\n            className='text-muted-foreground hover:text-foreground'\n          />\n        }\n      >\n        <EllipsisIcon />\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align='end'>\n        <DropdownMenuItem\n          onClick={() => {\n            column.pin('left');\n          }}\n        >\n          <ArrowLeftToLineIcon />\n          Stick to left\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          onClick={() => {\n            column.pin('right');\n          }}\n        >\n          <ArrowRightToLineIcon />\n          Stick to right\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\nfunction SwappingPinControl({ column }: { column: Column<Employee> }) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const variants = swapVariantsOf(shouldReduceMotion);\n  const controlKey = pinControlKeyOf(column.getIsPinned());\n\n  return (\n    <span className='relative -me-1 inline-flex shrink-0'>\n      <AnimatePresence mode='popLayout' initial={false}>\n        <motion.span\n          key={controlKey}\n          className='inline-flex'\n          variants={variants}\n          initial='enter'\n          animate='visible'\n          exit='exit'\n          transition={SWAP_TRANSITION}\n        >\n          <PinControl column={column} />\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction ResizeHandle({ header }: { header: Header<Employee, unknown> }) {\n  if (!header.column.getCanResize()) {\n    return null;\n  }\n  const resizeHandler = header.getResizeHandler();\n\n  return (\n    <div\n      aria-hidden='true'\n      data-resizing={resizingAttributeOf(header.column.getIsResizing())}\n      onDoubleClick={() => {\n        header.column.resetSize();\n      }}\n      onMouseDown={resizeHandler}\n      onTouchStart={resizeHandler}\n      className={RESIZE_HANDLE}\n    />\n  );\n}\n\nexport default function Table15() {\n  // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Table v8 is required and cannot be memoized by the React Compiler.\n  const table = useReactTable({\n    data: EMPLOYEES,\n    columns: COLUMNS,\n    columnResizeMode: 'onChange',\n    getCoreRowModel: getCoreRowModel(),\n  });\n  const tableWidth = table.getTotalSize();\n\n  return (\n    <div className='w-full'>\n      <Table className={SEPARATE_ROW_LINES} style={{ width: tableWidth }}>\n        <TableHeader>\n          {table.getHeaderGroups().map((headerGroup) => (\n            <TableRow key={headerGroup.id} className='hover:bg-transparent'>\n              {headerGroup.headers.map((header) => {\n                const { column } = header;\n                return (\n                  <TableHead\n                    key={header.id}\n                    colSpan={header.colSpan}\n                    style={pinningStyleOf(column)}\n                    data-pinned={pinnedAttributeOf(column.getIsPinned())}\n                    data-last-col={pinEdgeOf(column)}\n                    className={cn(\n                      'relative truncate last:[&>div:last-child]:hidden',\n                      PINNED_CELL\n                    )}\n                  >\n                    <div className='flex items-center justify-between gap-2'>\n                      <span className='truncate'>\n                        {!header.isPlaceholder &&\n                          flexRender(\n                            column.columnDef.header,\n                            header.getContext()\n                          )}\n                      </span>\n                      {column.getCanPin() && (\n                        <SwappingPinControl column={column} />\n                      )}\n                    </div>\n                    <ResizeHandle header={header} />\n                  </TableHead>\n                );\n              })}\n            </TableRow>\n          ))}\n        </TableHeader>\n        <TableBody>\n          {table.getRowModel().rows.map((row) => (\n            <TableRow key={row.id}>\n              {row.getVisibleCells().map((cell) => {\n                const { column } = cell;\n                return (\n                  <TableCell\n                    key={cell.id}\n                    style={pinningStyleOf(column)}\n                    data-pinned={pinnedAttributeOf(column.getIsPinned())}\n                    data-last-col={pinEdgeOf(column)}\n                    className={cn(\n                      'truncate',\n                      PINNED_CELL,\n                      alignClassOf(column.id)\n                    )}\n                  >\n                    {flexRender(column.columnDef.cell, cell.getContext())}\n                  </TableCell>\n                );\n              })}\n            </TableRow>\n          ))}\n        </TableBody>\n      </Table>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "components",
    "table"
  ],
  "type": "registry:component"
}