{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "table-13",
  "title": "Filterable data table",
  "description": "Search, select and range filters above a sortable table.",
  "dependencies": [
    "@tanstack/react-table",
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/checkbox.json",
    "https://ui.ndekocode.dev/r/chip.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/input.json",
    "https://ui.ndekocode.dev/r/label.json",
    "https://ui.ndekocode.dev/r/select.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-13.tsx",
      "content": "'use client';\n\nimport {\n  type Column,\n  type ColumnDef,\n  type ColumnFiltersState,\n  type FilterFn,\n  flexRender,\n  getCoreRowModel,\n  getFilteredRowModel,\n  getSortedRowModel,\n  type Header,\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 { useId, useState } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport {\n  ChevronDownIcon,\n  ChevronsUpDownIcon,\n  ChevronUpIcon,\n  ExternalLinkIcon,\n  SearchIcon,\n} from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport { Checkbox } from '@/registry/ndeko/ui/checkbox';\nimport { Chip } from '@/registry/ndeko/ui/chip';\nimport { Input } from '@/registry/ndeko/ui/input';\nimport { Label } from '@/registry/ndeko/ui/label';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/registry/ndeko/ui/select';\nimport {\n  Table,\n  TableBody,\n  TableCaption,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from '@/registry/ndeko/ui/table';\n\ntype Intent = 'Informational' | 'Navigational' | 'Commercial' | 'Transactional';\n\ntype Keyword = {\n  id: string;\n  keyword: string;\n  intents: Intent[];\n  volume: number;\n  cpc: number;\n  traffic: number;\n  link: string;\n};\n\ntype NumberRange = [number | undefined, number | undefined];\n\nconst INTENTS: Intent[] = [\n  'Informational',\n  'Navigational',\n  'Commercial',\n  'Transactional',\n];\n\nconst INTENT_COLORS: Record<Intent, string> = {\n  Informational: 'var(--info)',\n  Navigational: 'var(--primary)',\n  Commercial: 'var(--warning)',\n  Transactional: 'var(--success)',\n};\n\nconst ALL_INTENTS = 'all';\n\nconst INTENT_ITEMS = [\n  { value: ALL_INTENTS, label: 'All intents' },\n  ...INTENTS.map((intent) => ({ value: intent, label: intent })),\n];\n\nconst KEYWORDS: Keyword[] = [\n  {\n    id: '1',\n    keyword: 'react table component',\n    intents: ['Informational', 'Navigational'],\n    volume: 2507,\n    cpc: 2.5,\n    traffic: 88,\n    link: 'example.com/table',\n  },\n  {\n    id: '2',\n    keyword: 'buy react templates',\n    intents: ['Commercial', 'Transactional'],\n    volume: 1850,\n    cpc: 4.75,\n    traffic: 65,\n    link: 'example.com/templates',\n  },\n  {\n    id: '3',\n    keyword: 'react ui library',\n    intents: ['Informational', 'Commercial'],\n    volume: 3200,\n    cpc: 3.25,\n    traffic: 112,\n    link: 'example.com/library',\n  },\n  {\n    id: '4',\n    keyword: 'tailwind components download',\n    intents: ['Transactional'],\n    volume: 890,\n    cpc: 1.95,\n    traffic: 45,\n    link: 'example.com/download',\n  },\n  {\n    id: '5',\n    keyword: 'react dashboard template free',\n    intents: ['Commercial', 'Transactional'],\n    volume: 4100,\n    cpc: 5.5,\n    traffic: 156,\n    link: 'example.com/dashboard',\n  },\n  {\n    id: '6',\n    keyword: 'how to sort a table in react',\n    intents: ['Informational'],\n    volume: 1200,\n    cpc: 1.25,\n    traffic: 42,\n    link: 'example.com/guides/sorting',\n  },\n  {\n    id: '7',\n    keyword: 'react ui kit premium',\n    intents: ['Commercial', 'Transactional'],\n    volume: 760,\n    cpc: 6.8,\n    traffic: 28,\n    link: 'example.com/pricing',\n  },\n  {\n    id: '8',\n    keyword: 'react component documentation',\n    intents: ['Informational', 'Navigational'],\n    volume: 950,\n    cpc: 1.8,\n    traffic: 35,\n    link: 'example.com/docs',\n  },\n];\n\nconst COMPACT = new Intl.NumberFormat('en-US', {\n  notation: 'compact',\n  maximumFractionDigits: 1,\n});\n\nconst CURRENCY = new Intl.NumberFormat('en-US', {\n  style: 'currency',\n  currency: 'USD',\n});\n\nconst FIGURE_COLUMNS = new Set(['volume', 'cpc', 'traffic']);\n\nconst NUMBER_INPUT =\n  'flex-1 [-moz-appearance:textfield] focus-visible:z-10 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none';\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\nconst includesIntent: FilterFn<Keyword> = (row, columnId, filterValue) => {\n  const intents = row.getValue<Intent[]>(columnId);\n  return intents.includes(filterValue);\n};\n\nconst COLUMNS: ColumnDef<Keyword>[] = [\n  {\n    id: 'select',\n    header: ({ table }) => (\n      <Checkbox\n        checked={table.getIsAllPageRowsSelected()}\n        indeterminate={table.getIsSomePageRowsSelected()}\n        onCheckedChange={(checked) => {\n          table.toggleAllPageRowsSelected(checked);\n        }}\n        aria-label='Select all rows'\n      />\n    ),\n    cell: ({ row }) => (\n      <Checkbox\n        checked={row.getIsSelected()}\n        onCheckedChange={(checked) => {\n          row.toggleSelected(checked);\n        }}\n        aria-label={`Select ${row.original.keyword}`}\n      />\n    ),\n    enableSorting: false,\n  },\n  {\n    header: 'Keyword',\n    accessorKey: 'keyword',\n    filterFn: 'includesString',\n    cell: ({ row }) => (\n      <span className='font-medium'>{row.original.keyword}</span>\n    ),\n  },\n  {\n    header: 'Intents',\n    accessorKey: 'intents',\n    filterFn: includesIntent,\n    enableSorting: false,\n    cell: ({ row }) => (\n      <div className='flex gap-1'>\n        {row.original.intents.map((intent) => (\n          <Chip\n            key={intent}\n            color={INTENT_COLORS[intent]}\n            title={intent}\n            className='w-5 justify-center px-0'\n          >\n            <span aria-hidden='true'>{intent.charAt(0)}</span>\n            <span className='sr-only'>{intent}</span>\n          </Chip>\n        ))}\n      </div>\n    ),\n  },\n  {\n    header: 'Volume',\n    accessorKey: 'volume',\n    filterFn: 'inNumberRange',\n    cell: ({ row }) => COMPACT.format(row.original.volume),\n  },\n  {\n    header: 'CPC',\n    accessorKey: 'cpc',\n    filterFn: 'inNumberRange',\n    cell: ({ row }) => CURRENCY.format(row.original.cpc),\n  },\n  {\n    header: 'Traffic',\n    accessorKey: 'traffic',\n    filterFn: 'inNumberRange',\n    cell: ({ row }) => COMPACT.format(row.original.traffic),\n  },\n  {\n    header: 'Link',\n    accessorKey: 'link',\n    enableSorting: false,\n    cell: ({ row }) => (\n      <a\n        href={`https://${row.original.link}`}\n        target='_blank'\n        rel='noreferrer'\n        className='text-primary-ink inline-flex items-center gap-1 underline-offset-4 hover:underline'\n      >\n        {row.original.link}\n        <ExternalLinkIcon aria-hidden='true' className='size-3' />\n      </a>\n    ),\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 rowStateOf(isSelected: boolean) {\n  if (isSelected) {\n    return 'selected';\n  }\n  return undefined;\n}\n\nfunction ariaSortOf(column: Column<Keyword>) {\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 sortButtonOrderOf(columnId: string) {\n  if (isFigureColumn(columnId)) {\n    return 'flex-row-reverse';\n  }\n  return undefined;\n}\n\nfunction rangeOf(value: unknown): NumberRange {\n  if (Array.isArray(value)) {\n    return [value[0], value[1]];\n  }\n  return [undefined, undefined];\n}\n\nfunction boundOf(text: string) {\n  if (text === '') {\n    return undefined;\n  }\n  return Number(text);\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<Keyword, unknown> }) {\n  if (header.isPlaceholder) {\n    return null;\n  }\n  const label = flexRender(header.column.columnDef.header, header.getContext());\n  if (!header.column.getCanSort()) {\n    return label;\n  }\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 gap-1.5',\n        sortButtonOrderOf(header.column.id)\n      )}\n    >\n      {label}\n      <SortGlyph direction={header.column.getIsSorted()} />\n    </Button>\n  );\n}\n\nfunction TextFilter({\n  column,\n  label,\n}: {\n  column: Column<Keyword> | undefined;\n  label: string;\n}) {\n  const id = useId();\n  if (!column) {\n    return null;\n  }\n  const value = String(column.getFilterValue() ?? '');\n\n  return (\n    <div className='flex w-44 flex-col gap-2'>\n      <Label htmlFor={id}>{label}</Label>\n      <div className='relative'>\n        <Input\n          id={id}\n          type='search'\n          className='peer ps-8'\n          value={value}\n          onChange={(event) => {\n            column.setFilterValue(event.currentTarget.value);\n          }}\n          placeholder={`Search ${label.toLowerCase()}`}\n        />\n        <span className='text-muted-foreground pointer-events-none absolute inset-y-0 start-0 flex items-center ps-2.5 peer-disabled:opacity-50'>\n          <SearchIcon aria-hidden='true' className='size-4' />\n        </span>\n      </div>\n    </div>\n  );\n}\n\nfunction IntentFilter({ column }: { column: Column<Keyword> | undefined }) {\n  const id = useId();\n  if (!column) {\n    return null;\n  }\n  const value = String(column.getFilterValue() ?? ALL_INTENTS);\n\n  function changeIntent(intent: string | null) {\n    if (!column) {\n      return;\n    }\n    const isAll = intent === ALL_INTENTS;\n    if (isAll) {\n      column.setFilterValue(undefined);\n      return;\n    }\n    column.setFilterValue(intent ?? undefined);\n  }\n\n  return (\n    <div className='flex w-40 flex-col gap-2'>\n      <Label htmlFor={id}>Intents</Label>\n      <Select items={INTENT_ITEMS} value={value} onValueChange={changeIntent}>\n        <SelectTrigger id={id}>\n          <SelectValue />\n        </SelectTrigger>\n        <SelectContent>\n          {INTENT_ITEMS.map((item) => (\n            <SelectItem key={item.value} value={item.value}>\n              {item.label}\n            </SelectItem>\n          ))}\n        </SelectContent>\n      </Select>\n    </div>\n  );\n}\n\nfunction RangeFilter({\n  column,\n  label,\n}: {\n  column: Column<Keyword> | undefined;\n  label: string;\n}) {\n  const labelId = useId();\n  if (!column) {\n    return null;\n  }\n  const [min, max] = rangeOf(column.getFilterValue());\n\n  function changeMin(text: string) {\n    column?.setFilterValue((previous: unknown) => {\n      const [, previousMax] = rangeOf(previous);\n      return [boundOf(text), previousMax];\n    });\n  }\n\n  function changeMax(text: string) {\n    column?.setFilterValue((previous: unknown) => {\n      const [previousMin] = rangeOf(previous);\n      return [previousMin, boundOf(text)];\n    });\n  }\n\n  return (\n    <div className='flex w-36 flex-col gap-2'>\n      <span id={labelId} className='text-[13px] leading-none font-medium'>\n        {label}\n      </span>\n      <div role='group' aria-labelledby={labelId} className='flex'>\n        <Input\n          type='number'\n          inputMode='decimal'\n          className={cn(NUMBER_INPUT, 'rounded-e-none')}\n          value={min ?? ''}\n          onChange={(event) => {\n            changeMin(event.currentTarget.value);\n          }}\n          placeholder='Min'\n          aria-label={`${label} minimum`}\n        />\n        <Input\n          type='number'\n          inputMode='decimal'\n          className={cn(NUMBER_INPUT, '-ms-px rounded-s-none')}\n          value={max ?? ''}\n          onChange={(event) => {\n            changeMax(event.currentTarget.value);\n          }}\n          placeholder='Max'\n          aria-label={`${label} maximum`}\n        />\n      </div>\n    </div>\n  );\n}\n\nexport default function Table13() {\n  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);\n  const [sorting, setSorting] = useState<SortingState>([\n    { id: 'traffic', 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: KEYWORDS,\n    columns: COLUMNS,\n    state: { sorting, columnFilters },\n    onColumnFiltersChange: setColumnFilters,\n    onSortingChange: setSorting,\n    getCoreRowModel: getCoreRowModel(),\n    getFilteredRowModel: getFilteredRowModel(),\n    getSortedRowModel: getSortedRowModel(),\n    enableSortingRemoval: false,\n  });\n  const rows = table.getRowModel().rows;\n  const hasRows = rows.length > 0;\n\n  function clearFilters() {\n    table.resetColumnFilters();\n  }\n\n  return (\n    <div className='flex w-full flex-col gap-6'>\n      <div className='flex flex-wrap gap-3'>\n        <TextFilter column={table.getColumn('keyword')} label='Keyword' />\n        <IntentFilter column={table.getColumn('intents')} />\n        <RangeFilter column={table.getColumn('volume')} label='Volume' />\n        <RangeFilter column={table.getColumn('cpc')} label='CPC' />\n        <RangeFilter column={table.getColumn('traffic')} label='Traffic' />\n      </div>\n      <Table>\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                  aria-sort={ariaSortOf(header.column)}\n                  className={alignClassOf(header.column.id)}\n                >\n                  <HeadContent header={header} />\n                </TableHead>\n              ))}\n            </TableRow>\n          ))}\n        </TableHeader>\n        <TableBody>\n          {rows.map((row) => (\n            <TableRow key={row.id} data-state={rowStateOf(row.getIsSelected())}>\n              {row.getVisibleCells().map((cell) => (\n                <TableCell\n                  key={cell.id}\n                  className={alignClassOf(cell.column.id)}\n                >\n                  {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                </TableCell>\n              ))}\n            </TableRow>\n          ))}\n          {!hasRows && (\n            <TableRow className='hover:bg-transparent'>\n              <TableCell colSpan={COLUMNS.length} className='h-24 text-center'>\n                <div className='flex flex-col items-center gap-1'>\n                  <span className='text-muted-foreground'>\n                    No keywords match these filters.\n                  </span>\n                  <Button variant='link' size='sm' onClick={clearFilters}>\n                    Clear filters\n                  </Button>\n                </div>\n              </TableCell>\n            </TableRow>\n          )}\n        </TableBody>\n        <TableCaption>Illustrative search figures.</TableCaption>\n      </Table>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "components",
    "table"
  ],
  "type": "registry:component"
}