{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "table-14",
  "title": "Resizable columns",
  "description": "Drag a column edge to resize it and click a header to sort.",
  "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/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-14.tsx",
      "content": "'use client';\n\nimport {\n  type Column,\n  type ColumnDef,\n  flexRender,\n  getCoreRowModel,\n  getSortedRowModel,\n  type Header,\n  type Row,\n  type SortDirection,\n  type SortingState,\n  useReactTable,\n} from '@tanstack/react-table';\nimport {\n  AnimatePresence,\n  motion,\n  type Transition,\n  type Variants,\n} from 'motion/react';\nimport { useState } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport {\n  ChevronDownIcon,\n  ChevronsUpDownIcon,\n  ChevronUpIcon,\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  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 PERFORMANCE_RANKS: Record<Performance, number> = {\n  Poor: 0,\n  Average: 1,\n  Good: 2,\n  Excellent: 3,\n};\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 FIGURE_COLUMNS = new Set(['balance']);\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\nconst SWAP_TRANSITION: Transition = { duration: 0.15, ease: [0.2, 0, 0, 1] };\n\nconst GLYPH_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 dayOf(isoDate: string) {\n  const date = new Date(isoDate);\n  return DAY.format(date);\n}\n\nfunction byPerformance(first: Row<Employee>, second: Row<Employee>) {\n  const firstRank = PERFORMANCE_RANKS[first.original.performance];\n  const secondRank = PERFORMANCE_RANKS[second.original.performance];\n  return firstRank - secondRank;\n}\n\nconst COLUMNS: ColumnDef<Employee>[] = [\n  {\n    header: 'Name',\n    accessorKey: 'name',\n    sortDescFirst: false,\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    sortingFn: byPerformance,\n  },\n];\n\nfunction isFigureColumn(columnId: string) {\n  return FIGURE_COLUMNS.has(columnId);\n}\n\nfunction alignClassOf(columnId: string) {\n  if (isFigureColumn(columnId)) {\n    return 'text-right tabular-nums';\n  }\n  return undefined;\n}\n\nfunction sortButtonOrderOf(columnId: string) {\n  if (isFigureColumn(columnId)) {\n    return 'flex-row-reverse';\n  }\n  return undefined;\n}\n\nfunction resizingAttributeOf(isResizing: boolean) {\n  if (isResizing) {\n    return '';\n  }\n  return undefined;\n}\n\nfunction ariaSortOf(column: Column<Employee>) {\n  if (!column.getCanSort()) {\n    return undefined;\n  }\n  const direction = column.getIsSorted();\n  if (direction === 'asc') {\n    return 'ascending';\n  }\n  if (direction === 'desc') {\n    return 'descending';\n  }\n  return 'none';\n}\n\nfunction glyphVariantsOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return FADE_VARIANTS;\n  }\n  return GLYPH_VARIANTS;\n}\n\nfunction sortGlyphOf(direction: false | SortDirection) {\n  if (direction === 'asc') {\n    return {\n      key: 'asc',\n      icon: <ChevronUpIcon className='text-foreground size-3.5' />,\n    };\n  }\n  if (direction === 'desc') {\n    return {\n      key: 'desc',\n      icon: <ChevronDownIcon className='text-foreground size-3.5' />,\n    };\n  }\n  return {\n    key: 'none',\n    icon: <ChevronsUpDownIcon className='size-3.5 opacity-40' />,\n  };\n}\n\nfunction SortGlyph({ direction }: { direction: false | SortDirection }) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const variants = glyphVariantsOf(shouldReduceMotion);\n  const glyph = sortGlyphOf(direction);\n\n  return (\n    <span aria-hidden='true' className='relative inline-flex size-3.5'>\n      <AnimatePresence mode='popLayout' initial={false}>\n        <motion.span\n          key={glyph.key}\n          className='inline-flex'\n          variants={variants}\n          initial='enter'\n          animate='visible'\n          exit='exit'\n          transition={SWAP_TRANSITION}\n        >\n          {glyph.icon}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction HeadContent({ header }: { header: Header<Employee, unknown> }) {\n  if (header.isPlaceholder) {\n    return null;\n  }\n  const label = flexRender(header.column.columnDef.header, header.getContext());\n  return (\n    <Button\n      variant='ghost'\n      size='sm'\n      onClick={header.column.getToggleSortingHandler()}\n      className={cn(\n        'text-muted-foreground hover:text-foreground -mx-2.5 max-w-[calc(100%+1.25rem)] gap-1.5',\n        sortButtonOrderOf(header.column.id)\n      )}\n    >\n      <span className='truncate'>{label}</span>\n      <SortGlyph direction={header.column.getIsSorted()} />\n    </Button>\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 Table14() {\n  const [sorting, setSorting] = useState<SortingState>([\n    { id: 'name', desc: false },\n  ]);\n\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    state: { sorting },\n    onSortingChange: setSorting,\n    columnResizeMode: 'onChange',\n    getCoreRowModel: getCoreRowModel(),\n    getSortedRowModel: getSortedRowModel(),\n    enableSortingRemoval: false,\n  });\n  const tableWidth = table.getCenterTotalSize();\n\n  return (\n    <div className='w-full'>\n      <Table className='table-fixed' 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                <TableHead\n                  key={header.id}\n                  colSpan={header.colSpan}\n                  aria-sort={ariaSortOf(header.column)}\n                  style={{ width: header.getSize() }}\n                  className={cn(\n                    'relative select-none last:[&>div]:hidden',\n                    alignClassOf(header.column.id)\n                  )}\n                >\n                  <HeadContent header={header} />\n                  <ResizeHandle header={header} />\n                </TableHead>\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                <TableCell\n                  key={cell.id}\n                  className={cn('truncate', alignClassOf(cell.column.id))}\n                >\n                  {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                </TableCell>\n              ))}\n            </TableRow>\n          ))}\n        </TableBody>\n      </Table>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "components",
    "table"
  ],
  "type": "registry:component"
}