{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-table",
  "title": "Data Table",
  "author": "Zubeyr <https://zubeyr.dev>",
  "description": "An index table with view tabs, search, filter chips, a sort menu, sortable headers, row selection with bulk actions, loading rows and an empty state.",
  "registryDependencies": [
    "https://tomei.zubeyr.dev/ui/r/popover.json"
  ],
  "files": [
    {
      "path": "registry/dashboard/components/data-table/data-table.tsx",
      "content": "'use client'\n\nimport { useEffect, useMemo, useRef, useState } from 'react'\n\nimport { cn } from '@/lib/utils'\n\nimport { Popover } from '@/registry/dashboard/components/popover/popover'\n\nexport type Column<T> = {\n  key: string\n  header: React.ReactNode\n  cell: (row: T) => React.ReactNode\n  sortValue?: (row: T) => string | number\n  sortLabel?: string\n  align?: 'left' | 'right'\n  className?: string\n}\n\nexport type View<T> = { label: string; filter: (row: T) => boolean }\n\nexport type Filter<T> = {\n  key: string\n  label: string\n  options: string[]\n  value: (row: T) => string\n}\n\ntype Sort = { key: string; direction: 'asc' | 'desc' }\n\ntype DataTableProps<T> = {\n  rows: T[]\n  columns: Column<T>[]\n  getRowId: (row: T) => string\n  views?: View<T>[]\n  filters?: Filter<T>[]\n  searchText?: (row: T) => string\n  searchPlaceholder?: string\n  defaultSort?: Sort\n  selectable?: boolean\n  bulkActions?: (selected: string[]) => React.ReactNode\n  pageSize?: number\n  pageSizeOptions?: number[]\n  onRefresh?: () => void | Promise<void>\n  loading?: boolean\n  skeletonRows?: number\n  empty?: React.ReactNode\n  className?: string\n}\n\nconst iconButton =\n  'bg-card text-foreground hover:bg-muted inline-flex size-8 items-center justify-center rounded-lg shadow-(--shadow-button) [&_svg]:size-4'\n\nfunction Checkbox({\n  checked,\n  indeterminate = false,\n  onChange,\n  label,\n}: {\n  checked: boolean\n  indeterminate?: boolean\n  onChange: (checked: boolean) => void\n  label: string\n}) {\n  const ref = useRef<HTMLInputElement>(null)\n  useEffect(() => {\n    if (ref.current) ref.current.indeterminate = indeterminate\n  }, [indeterminate])\n  return (\n    <input\n      ref={ref}\n      type=\"checkbox\"\n      aria-label={label}\n      checked={checked}\n      onChange={(e) => onChange(e.target.checked)}\n      className=\"block size-4 cursor-pointer accent-primary dark:scheme-dark\"\n    />\n  )\n}\n\nexport function DataTable<T>({\n  rows,\n  columns,\n  getRowId,\n  views = [],\n  filters = [],\n  searchText,\n  searchPlaceholder = 'Search',\n  defaultSort,\n  selectable = false,\n  bulkActions,\n  pageSize,\n  pageSizeOptions = [10, 25, 50],\n  onRefresh,\n  loading = false,\n  skeletonRows = 4,\n  empty = 'No results',\n  className,\n}: DataTableProps<T>) {\n  const [view, setView] = useState(-1)\n  const [searching, setSearching] = useState(false)\n  const [query, setQuery] = useState('')\n  const [applied, setApplied] = useState<Record<string, string[]>>({})\n  const [sort, setSort] = useState<Sort | undefined>(defaultSort)\n  const [selected, setSelected] = useState<Set<string>>(new Set())\n  const [page, setPage] = useState(0)\n  const [limit, setLimit] = useState(pageSize ?? 0)\n  const [refreshing, setRefreshing] = useState(false)\n\n  const sortable = columns.filter((c) => c.sortValue)\n\n  const visible = useMemo(() => {\n    const q = query.trim().toLowerCase()\n    const text = searchText ?? ((row: T) => Object.values(row as object).join(' '))\n    const result = rows.filter(\n      (row) =>\n        (view < 0 || views[view].filter(row)) &&\n        (!q || text(row).toLowerCase().includes(q)) &&\n        filters.every((f) => !applied[f.key]?.length || applied[f.key].includes(f.value(row))),\n    )\n    const column = sort && columns.find((c) => c.key === sort.key)\n    if (!column?.sortValue || !sort) return result\n    const value = column.sortValue\n    return [...result].sort((a, b) => {\n      const [x, y] = [value(a), value(b)]\n      const order = x < y ? -1 : x > y ? 1 : 0\n      return sort.direction === 'asc' ? order : -order\n    })\n  }, [rows, columns, views, view, filters, applied, query, searchText, sort])\n\n  const pageCount = limit ? Math.max(1, Math.ceil(visible.length / limit)) : 1\n  const current = Math.min(page, pageCount - 1)\n  const pageRows = limit ? visible.slice(current * limit, (current + 1) * limit) : visible\n  const span = columns.length + (selectable ? 1 : 0)\n\n  const visibleIds = pageRows.map(getRowId)\n  const selectedVisible = visibleIds.filter((id) => selected.has(id))\n  const allSelected = visibleIds.length > 0 && selectedVisible.length === visibleIds.length\n\n  const toggleAll = (checked: boolean) => setSelected(checked ? new Set(visibleIds) : new Set())\n\n  const toggleRow = (id: string, checked: boolean) => {\n    const next = new Set(selected)\n    if (checked) next.add(id)\n    else next.delete(id)\n    setSelected(next)\n  }\n\n  const toggleFilter = (key: string, option: string) => {\n    const active = applied[key] ?? []\n    setPage(0)\n    setApplied({\n      ...applied,\n      [key]: active.includes(option) ? active.filter((o) => o !== option) : [...active, option],\n    })\n  }\n\n  const refresh = async () => {\n    if (!onRefresh) return\n    setRefreshing(true)\n    try {\n      await onRefresh()\n    } finally {\n      setRefreshing(false)\n    }\n  }\n\n  const sortBy = (key: string) =>\n    setSort(\n      sort?.key === key\n        ? { key, direction: sort.direction === 'asc' ? 'desc' : 'asc' }\n        : { key, direction: 'asc' },\n    )\n\n  const stopSearching = () => {\n    setSearching(false)\n    setQuery('')\n    setApplied({})\n    setPage(0)\n  }\n\n  return (\n    <div\n      className={cn('rounded-xl bg-card text-card-foreground shadow-(--shadow-card)', className)}\n    >\n      <div className=\"flex min-h-12 items-center gap-2 border-b px-2 py-1.5\">\n        {searching ? (\n          <div className=\"flex flex-1 items-center gap-2\">\n            <label className=\"flex h-8 flex-1 items-center gap-2 rounded-lg border border-input bg-card px-2.5 focus-within:border-ring\">\n              <SearchIcon />\n              <input\n                autoFocus\n                value={query}\n                onChange={(e) => {\n                  setQuery(e.target.value)\n                  setPage(0)\n                }}\n                placeholder={searchPlaceholder}\n                aria-label={searchPlaceholder}\n                className=\"h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n              />\n            </label>\n            <button\n              type=\"button\"\n              onClick={stopSearching}\n              className=\"h-8 rounded-lg px-3 text-sm font-medium hover:bg-muted\"\n            >\n              Cancel\n            </button>\n          </div>\n        ) : (\n          <div role=\"tablist\" className=\"flex flex-1 gap-0.5 overflow-x-auto\">\n            {[{ label: 'All' }, ...views].map((v, i) => (\n              <button\n                key={v.label}\n                type=\"button\"\n                role=\"tab\"\n                aria-selected={view === i - 1}\n                onClick={() => {\n                  setView(i - 1)\n                  setPage(0)\n                  setSelected(new Set())\n                }}\n                className={cn(\n                  'h-8 shrink-0 rounded-lg px-3 text-sm font-medium whitespace-nowrap transition-colors',\n                  view === i - 1\n                    ? 'bg-muted text-foreground'\n                    : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',\n                )}\n              >\n                {v.label}\n              </button>\n            ))}\n          </div>\n        )}\n        {!searching && (\n          <button\n            type=\"button\"\n            aria-label=\"Search and filter\"\n            onClick={() => setSearching(true)}\n            className={cn(iconButton, 'w-12 gap-0.5')}\n          >\n            <SearchIcon />\n            <FilterIcon />\n          </button>\n        )}\n        {onRefresh && (\n          <button\n            type=\"button\"\n            aria-label=\"Refresh\"\n            disabled={refreshing}\n            onClick={refresh}\n            className={cn(iconButton, 'disabled:opacity-60')}\n          >\n            <RefreshIcon spinning={refreshing} />\n          </button>\n        )}\n        {sortable.length > 0 && (\n          <Popover\n            align=\"end\"\n            trigger={({ toggle, open }) => (\n              <button\n                type=\"button\"\n                aria-label=\"Sort\"\n                aria-expanded={open}\n                onClick={toggle}\n                className={iconButton}\n              >\n                <SortIcon />\n              </button>\n            )}\n          >\n            <p className=\"px-2 pt-1 pb-2 text-xs font-medium text-muted-foreground\">Sort by</p>\n            {sortable.map((c) => (\n              <label\n                key={c.key}\n                className=\"flex h-8 cursor-pointer items-center gap-2 rounded-lg px-2 text-sm hover:bg-muted\"\n              >\n                <input\n                  type=\"radio\"\n                  name=\"sort\"\n                  checked={sort?.key === c.key}\n                  onChange={() => setSort({ key: c.key, direction: sort?.direction ?? 'asc' })}\n                  className=\"accent-primary dark:scheme-dark\"\n                />\n                {c.sortLabel ?? c.header}\n              </label>\n            ))}\n            <div className=\"mt-1.5 grid grid-cols-2 gap-1 border-t pt-1.5\">\n              {(['asc', 'desc'] as const).map((direction) => (\n                <button\n                  key={direction}\n                  type=\"button\"\n                  disabled={!sort}\n                  onClick={() => sort && setSort({ ...sort, direction })}\n                  className={cn(\n                    'h-8 rounded-lg text-sm hover:bg-muted disabled:opacity-50',\n                    sort?.direction === direction && 'bg-muted font-medium',\n                  )}\n                >\n                  {direction === 'asc' ? '↑ Ascending' : '↓ Descending'}\n                </button>\n              ))}\n            </div>\n          </Popover>\n        )}\n      </div>\n\n      {searching && filters.length > 0 && (\n        <div className=\"flex flex-wrap items-center gap-1.5 border-b px-3 py-2\">\n          {filters.map((f) => {\n            const active = applied[f.key] ?? []\n            return (\n              <Popover\n                key={f.key}\n                trigger={({ toggle, open }) => (\n                  <span\n                    className={cn(\n                      'inline-flex h-7 items-center rounded-full border text-xs font-medium',\n                      active.length ? 'border-transparent bg-muted' : 'border-dashed',\n                    )}\n                  >\n                    <button\n                      type=\"button\"\n                      aria-expanded={open}\n                      onClick={toggle}\n                      className=\"flex h-full items-center gap-1 pr-1.5 pl-3\"\n                    >\n                      {f.label}\n                      {active.length > 0 && (\n                        <span className=\"text-muted-foreground\">: {active.join(', ')}</span>\n                      )}\n                      <span aria-hidden className=\"text-muted-foreground\">\n                        ▾\n                      </span>\n                    </button>\n                    {active.length > 0 && (\n                      <button\n                        type=\"button\"\n                        aria-label={`Clear ${f.label}`}\n                        onClick={() => setApplied({ ...applied, [f.key]: [] })}\n                        className=\"h-full pr-2.5 text-muted-foreground hover:text-foreground\"\n                      >\n                        ✕\n                      </button>\n                    )}\n                  </span>\n                )}\n              >\n                {f.options.map((option) => (\n                  <label\n                    key={option}\n                    className=\"flex h-8 cursor-pointer items-center gap-2 rounded-lg px-2 text-sm hover:bg-muted\"\n                  >\n                    <input\n                      type=\"checkbox\"\n                      checked={active.includes(option)}\n                      onChange={() => toggleFilter(f.key, option)}\n                      className=\"size-4 accent-primary dark:scheme-dark\"\n                    />\n                    {option}\n                  </label>\n                ))}\n              </Popover>\n            )\n          })}\n          {Object.values(applied).some((v) => v.length) && (\n            <button\n              type=\"button\"\n              onClick={() => setApplied({})}\n              className=\"px-2 text-xs font-medium text-muted-foreground hover:text-foreground\"\n            >\n              Clear all\n            </button>\n          )}\n        </div>\n      )}\n\n      <div className=\"overflow-x-auto rounded-b-xl\">\n        <table className=\"w-full border-collapse text-sm\">\n          <thead className=\"bg-muted/50 text-muted-foreground\">\n            <tr className=\"h-10 border-b\">\n              {selectable && (\n                <th className=\"w-10 pl-3 text-left align-middle\">\n                  <Checkbox\n                    label=\"Select all rows\"\n                    checked={allSelected}\n                    indeterminate={selectedVisible.length > 0 && !allSelected}\n                    onChange={toggleAll}\n                  />\n                </th>\n              )}\n              {selectable && selectedVisible.length > 0 ? (\n                <th colSpan={columns.length} className=\"px-3 text-left font-medium\">\n                  <div className=\"flex items-center gap-3\">\n                    <span className=\"text-foreground\">{selectedVisible.length} selected</span>\n                    {bulkActions?.(selectedVisible)}\n                  </div>\n                </th>\n              ) : (\n                columns.map((c) => (\n                  <th\n                    key={c.key}\n                    aria-sort={\n                      sort?.key === c.key\n                        ? sort.direction === 'asc'\n                          ? 'ascending'\n                          : 'descending'\n                        : undefined\n                    }\n                    className={cn(\n                      'px-3 font-medium whitespace-nowrap',\n                      c.align === 'right' ? 'text-right' : 'text-left',\n                      c.className,\n                    )}\n                  >\n                    {c.sortValue ? (\n                      <button\n                        type=\"button\"\n                        onClick={() => sortBy(c.key)}\n                        className={cn(\n                          'inline-flex items-center gap-1 hover:text-foreground',\n                          sort?.key === c.key && 'text-foreground',\n                        )}\n                      >\n                        {c.header}\n                        <span aria-hidden className=\"text-[10px]\">\n                          {sort?.key === c.key ? (sort.direction === 'asc' ? '▲' : '▼') : '⇅'}\n                        </span>\n                      </button>\n                    ) : (\n                      c.header\n                    )}\n                  </th>\n                ))\n              )}\n            </tr>\n          </thead>\n          <tbody>\n            {loading\n              ? Array.from({ length: limit || skeletonRows }, (_, i) => (\n                  <tr key={i} className=\"h-11 border-b last:border-0\">\n                    {selectable && (\n                      <td className=\"w-10 pl-3 align-middle\">\n                        <span className=\"block size-4 rounded bg-muted\" />\n                      </td>\n                    )}\n                    {columns.map((c) => (\n                      <td key={c.key} className=\"px-3\">\n                        <span\n                          className={cn(\n                            'block h-3 animate-pulse rounded-full bg-muted',\n                            c.align === 'right' ? 'ml-auto w-14' : 'w-24',\n                          )}\n                        />\n                      </td>\n                    ))}\n                  </tr>\n                ))\n              : pageRows.map((row) => {\n                  const id = getRowId(row)\n                  const isSelected = selected.has(id)\n                  return (\n                    <tr\n                      key={id}\n                      data-selected={isSelected}\n                      className=\"h-11 border-b transition-colors last:border-0 hover:bg-muted/50 data-[selected=true]:bg-muted\"\n                    >\n                      {selectable && (\n                        <td className=\"w-10 pl-3 align-middle\">\n                          <Checkbox\n                            label={`Select row ${id}`}\n                            checked={isSelected}\n                            onChange={(checked) => toggleRow(id, checked)}\n                          />\n                        </td>\n                      )}\n                      {columns.map((c) => (\n                        <td\n                          key={c.key}\n                          className={cn(\n                            'px-3 whitespace-nowrap',\n                            c.align === 'right' && 'text-right tabular-nums',\n                            c.className,\n                          )}\n                        >\n                          {c.cell(row)}\n                        </td>\n                      ))}\n                    </tr>\n                  )\n                })}\n            {!loading && visible.length === 0 && (\n              <tr>\n                <td colSpan={span} className=\"h-32 text-center text-sm text-muted-foreground\">\n                  {empty}\n                </td>\n              </tr>\n            )}\n          </tbody>\n        </table>\n      </div>\n\n      {limit > 0 && (\n        <div className=\"flex flex-wrap items-center justify-between gap-3 border-t px-3 py-2 text-sm text-muted-foreground\">\n          <div className=\"flex items-center gap-4\">\n            <span className=\"flex items-center gap-2\">\n              Rows\n              <Popover\n                side=\"top\"\n                className=\"min-w-24\"\n                trigger={({ open, toggle }) => (\n                  <button\n                    type=\"button\"\n                    aria-haspopup=\"listbox\"\n                    aria-expanded={open}\n                    aria-label={`Rows per page: ${limit}`}\n                    onClick={toggle}\n                    className=\"inline-flex h-8 items-center gap-1.5 rounded-lg bg-card px-2.5 font-medium text-foreground tabular-nums shadow-(--shadow-button) hover:bg-muted\"\n                  >\n                    {limit}\n                    <span aria-hidden className=\"text-[10px] text-muted-foreground\">\n                      ▾\n                    </span>\n                  </button>\n                )}\n              >\n                {(close) => (\n                  <ul role=\"listbox\" aria-label=\"Rows per page\">\n                    {[...new Set([...pageSizeOptions, pageSize ?? limit])]\n                      .sort((x, y) => x - y)\n                      .map((n) => (\n                        <li key={n}>\n                          <button\n                            type=\"button\"\n                            role=\"option\"\n                            aria-selected={n === limit}\n                            onClick={() => {\n                              setLimit(n)\n                              setPage(0)\n                              close()\n                            }}\n                            className=\"flex h-8 w-full items-center justify-between gap-3 rounded-lg px-2 text-sm text-foreground tabular-nums hover:bg-muted\"\n                          >\n                            {n}\n                            {n === limit && <span aria-hidden>✓</span>}\n                          </button>\n                        </li>\n                      ))}\n                  </ul>\n                )}\n              </Popover>\n            </span>\n            {!loading && visible.length > 0 && (\n              <span className=\"tabular-nums\">\n                {current * limit + 1}–{Math.min((current + 1) * limit, visible.length)} of{' '}\n                {visible.length}\n              </span>\n            )}\n          </div>\n          <div className=\"flex gap-1\">\n            {(\n              [\n                ['first', 'First page', 0, current === 0],\n                ['left', 'Previous page', current - 1, current === 0],\n                ['right', 'Next page', current + 1, current >= pageCount - 1],\n                ['last', 'Last page', pageCount - 1, current >= pageCount - 1],\n              ] as const\n            ).map(([direction, label, target, disabled]) => (\n              <button\n                key={direction}\n                type=\"button\"\n                aria-label={label}\n                disabled={disabled || loading}\n                onClick={() => setPage(target)}\n                className={cn(iconButton, 'disabled:opacity-40')}\n              >\n                <ChevronIcon direction={direction} />\n              </button>\n            ))}\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n\nfunction RefreshIcon({ spinning }: { spinning: boolean }) {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      aria-hidden\n      className={cn('size-4', spinning && 'animate-spin')}\n    >\n      <path\n        d=\"M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v3h-3\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n\nconst chevrons = {\n  first: 'M12 3 7 8l5 5M7.5 3 2.5 8l5 5',\n  left: 'M10 3 5 8l5 5',\n  right: 'm6 3 5 5-5 5',\n  last: 'm4 3 5 5-5 5m4.5-10 5 5-5 5',\n}\n\nfunction ChevronIcon({ direction }: { direction: keyof typeof chevrons }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"size-4\">\n      <path\n        d={chevrons[direction]}\n        stroke=\"currentColor\"\n        strokeWidth=\"1.6\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n\nfunction SearchIcon() {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      aria-hidden\n      className=\"size-4 shrink-0 text-muted-foreground\"\n    >\n      <circle cx=\"7\" cy=\"7\" r=\"4.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n      <path d=\"m10.5 10.5 3 3\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n    </svg>\n  )\n}\n\nfunction FilterIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"size-4\">\n      <path\n        d=\"M2.5 4.5h11M4.5 8h7M6.5 11.5h3\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  )\n}\n\nfunction SortIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"size-4\">\n      <path\n        d=\"M5 13V3M5 3 2.5 5.5M5 3l2.5 2.5M11 3v10m0 0 2.5-2.5M11 13l-2.5-2.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/dashboard/data-table.tsx"
    }
  ],
  "cssVars": {
    "light": {
      "shadow-button": "0 1px 2px rgb(0 0 0 / 0.05), 0 0 0 1px rgb(0 0 0 / 0.1), inset 0 1px 0 rgb(255 255 255 / 0.8)",
      "shadow-card": "0 1px 2px rgb(0 0 0 / 0.04), 0 0 0 1px rgb(0 0 0 / 0.07)",
      "shadow-overlay": "0 16px 40px rgb(0 0 0 / 0.12), 0 0 0 1px rgb(0 0 0 / 0.08)"
    },
    "dark": {
      "shadow-button": "0 0 0 1px rgb(0 0 0 / 0.4), inset 0 1px 0 rgb(255 255 255 / 0.1), inset 0 0 0 1px rgb(255 255 255 / 0.06)",
      "shadow-card": "0 4px 4px rgb(42 42 42 / 0.32), 0 0 0 1px #0e0e0e, inset 0 1px 0 rgb(255 255 255 / 0.08), inset 0 0 0 1px rgb(255 255 255 / 0.08)",
      "shadow-overlay": "0 16px 40px rgb(0 0 0 / 0.5), 0 0 0 1px #0e0e0e"
    }
  },
  "meta": {
    "scenario": "dashboard",
    "kind": "components"
  },
  "categories": [
    "dashboard",
    "tables",
    "filters"
  ],
  "type": "registry:component"
}