{
  "schemaVersion": 1,
  "name": "data-table",
  "useFor": "Use for a list of records people manage (mailboxes, agents and lines, webhook subscriptions): pass columns (id, header, an accessor for sorting and filtering, a cell renderer), data and getRowId. Turn on selectable for bulk actions and put those actions in toolbar; set pageSize and pageSizes for long lists. For a short fixed list with no sorting or paging, use Table on its own. Keep row actions as ghost icon buttons in the last column.",
  "constraints": [
    "Zebra stripes",
    "A border around the whole table instead of the three panels",
    "Animating rows when sorting or paging",
    "More than one purple pill in the toolbar"
  ],
  "documentation": "",
  "source": "Figma 427:2715: Email (Mailboxes table and its pagination bar), iMessage (Agents and lines)",
  "exports": [
    "DataTable",
    "DataTableColumnHeader",
    "DataTablePagination",
    "DataTableViewOptions",
    "DataTableColumn",
    "DataTableProps",
    "SortState"
  ],
  "client": true,
  "docsUrl": "https://inkbox-ds.vercel.app/components/data-table",
  "markdownUrl": "https://inkbox-ds.vercel.app/r/data-table.md",
  "examples": [
    {
      "path": "examples/data-table.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowsClockwiseIcon, PencilSimpleIcon, TrashIcon } from \"@phosphor-icons/react/ssr\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { DataTable, type DataTableColumn } from \"@/components/ui/data-table\";\n\ntype Mailbox = { address: string; agent: string; webhooks: number; storage: number; created: Date; status: \"active\" | \"paused\" };\n\nconst AGENTS = [\"acme\", \"support-desk\", \"ops-runner\", \"acme-support\", \"test-agent-233\", \"billing-bot\"];\nconst MAILBOXES: Mailbox[] = Array.from({ length: 23 }, (_, i) => {\n  const agent = AGENTS[i % AGENTS.length];\n  return { address: `${agent}${i > 5 ? `-${i}` : \"\"}@inkboxmail.com`, agent, webhooks: (i * 7) % 4, storage: (i * 13) % 61, created: new Date(2026, 6, 27 + (i % 20)), status: i % 5 === 3 ? \"paused\" : \"active\" };\n});\n\nconst date = new Intl.DateTimeFormat(\"en-US\", { month: \"short\", day: \"numeric\", year: \"numeric\" });\n\nconst COLUMNS: DataTableColumn<Mailbox>[] = [\n  { id: \"address\", header: \"Email address\", accessor: (m) => m.address, sortable: true, hideable: false },\n  { id: \"agent\", header: \"Agent\", accessor: (m) => m.agent, sortable: true, cell: (m) => <span className=\"text-rose\">@{m.agent}</span> },\n  { id: \"webhooks\", header: \"Webhooks\", accessor: (m) => m.webhooks, sortable: true, cell: (m) => (m.webhooks ? m.webhooks : \"None\") },\n  { id: \"storage\", header: \"Storage\", accessor: (m) => m.storage, sortable: true, cell: (m) => `${m.storage}%` },\n  { id: \"status\", header: \"Status\", accessor: (m) => m.status, hidden: true, cell: (m) => <Badge variant={m.status === \"active\" ? \"green\" : \"gray\"} size=\"sm\">{m.status === \"active\" ? \"Active\" : \"Paused\"}</Badge> },\n  { id: \"created\", header: \"Created\", accessor: (m) => m.created, sortable: true, cell: (m) => date.format(m.created) },\n  {\n    id: \"actions\",\n    header: <span className=\"sr-only\">Actions</span>,\n    hideable: false,\n    align: \"end\",\n    className: \"w-14\",\n    cell: (m) => (\n      <Button variant=\"ghost\" size=\"icon\" aria-label={`Edit ${m.address}`}>\n        <PencilSimpleIcon weight=\"fill\" />\n      </Button>\n    ),\n  },\n];\n\nexport function Preview() {\n  return <DataTable columns={COLUMNS} data={MAILBOXES} getRowId={(m) => m.address} rowName=\"mailbox\" pageSize={5} filter={{ placeholder: \"Filter mailboxes\" }} defaultSort={{ id: \"created\", desc: false }} empty=\"No mailbox matches.\" />;\n}\n\nexport function Selection() {\n  const [rows, setRows] = React.useState(MAILBOXES.slice(0, 8));\n  const [selected, setSelected] = React.useState<string[]>([]);\n  const [refreshing, setRefreshing] = React.useState(false);\n  return (\n    <DataTable\n      columns={COLUMNS}\n      data={rows}\n      getRowId={(m) => m.address}\n      rowName=\"mailbox\"\n      selectable\n      selection={selected}\n      onSelectionChange={setSelected}\n      pageSize={5}\n      pageSizes={[5, 10, 20]}\n      filter={{ placeholder: \"Filter mailboxes\", columns: [\"address\", \"agent\"] }}\n      toolbar={\n        <>\n          <Button\n            variant=\"ghost\"\n            size=\"icon-lg\"\n            aria-label=\"Refresh\"\n            disabled={refreshing}\n            onClick={() => {\n              setRefreshing(true);\n              setTimeout(() => setRefreshing(false), 800);\n            }}\n          >\n            <ArrowsClockwiseIcon className={refreshing ? \"animate-spin-steady\" : undefined} />\n          </Button>\n          {selected.length > 0 && (\n            <Button\n              variant=\"destructive\"\n              onClick={() => {\n                setRows((r) => r.filter((m) => !selected.includes(m.address)));\n                setSelected([]);\n              }}\n            >\n              <TrashIcon weight=\"fill\" />\n              Delete {selected.length}\n            </Button>\n          )}\n        </>\n      }\n    />\n  );\n}\n\nexport function Empty() {\n  return <DataTable columns={COLUMNS} data={[]} getRowId={(m) => m.address} filter={false} hideViewOptions empty=\"No mailboxes yet. Mailboxes are created with their agent identity.\" />;\n}\n",
      "exports": [
        "Preview",
        "Selection",
        "Empty"
      ],
      "requires": [
        "data-table",
        "badge",
        "button"
      ],
      "packages": [
        "@phosphor-icons/react"
      ],
      "previews": [
        {
          "name": "Preview",
          "url": "https://inkbox-ds.vercel.app/preview/data-table/Preview"
        },
        {
          "name": "Selection",
          "url": "https://inkbox-ds.vercel.app/preview/data-table/Selection"
        },
        {
          "name": "Empty",
          "url": "https://inkbox-ds.vercel.app/preview/data-table/Empty"
        }
      ]
    }
  ],
  "type": "ui",
  "title": "Data Table",
  "description": "The console's table with a filter field, sortable heads, row checkboxes, a Columns menu and the lilac pagination bar.",
  "surface": "site",
  "kinds": [],
  "requires": [
    "theme",
    "button",
    "checkbox",
    "dropdown-menu",
    "input-group",
    "pagination",
    "select",
    "table",
    "utils"
  ],
  "packages": [
    "@phosphor-icons/react"
  ],
  "files": [
    {
      "path": "components/ui/data-table.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowDownIcon, ArrowUpIcon, CaretUpDownIcon, ColumnsIcon, MagnifyingGlassIcon } from \"@phosphor-icons/react/ssr\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from \"@/components/ui/dropdown-menu\";\nimport { InputGroup, InputGroupAddon, InputGroupInput } from \"@/components/ui/input-group\";\nimport { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from \"@/components/ui/pagination\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from \"@/components/ui/table\";\nimport { cn } from \"@/lib/utils\";\n\n// A table of records people sort, filter, select and page through (the console's Mailboxes table, Figma 427:2715,\n// Email). It keeps the table's three panels: the 48px lilac head with 14px muted labels (a sortable label is a ghost key\n// with an arrow for its direction), the white Shadow/S body with 64px rows (a selected row on the lilac) and, 8px below,\n// the 42px lilac pagination bar with the selection count at its left and the pages at its right. Above it, a toolbar:\n// a 40px filter field with a magnifying glass and the outline Columns key that opens a menu of checkboxes. Sorting,\n// filtering and paging are instant (no motion): they are repeated many times in a row.\n\ntype Value = string | number | Date | boolean | null | undefined;\n\ntype DataTableColumn<T> = {\n  id: string;\n  /** The column's label, in its head and in the Columns menu. */\n  header: React.ReactNode;\n  /** What the cell shows; defaults to the accessor's value. */\n  cell?: (row: T) => React.ReactNode;\n  /** The value used to sort and filter. */\n  accessor?: (row: T) => Value;\n  sortable?: boolean;\n  /** Whether the Columns menu can hide it (default true). */\n  hideable?: boolean;\n  /** Hidden until turned on in the Columns menu. */\n  hidden?: boolean;\n  align?: \"start\" | \"end\";\n  className?: string;\n};\n\ntype SortState = { id: string; desc: boolean } | null;\n\nconst text = (v: Value) => (v instanceof Date ? v.toISOString() : v == null ? \"\" : String(v));\nfunction compare(a: Value, b: Value) {\n  if (a == null && b == null) return 0;\n  if (a == null) return 1;\n  if (b == null) return -1;\n  if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();\n  if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n  return text(a).localeCompare(text(b), undefined, { numeric: true, sensitivity: \"base\" });\n}\n\n/** A sortable head label: a ghost key whose arrow shows the direction (both carets while unsorted). */\nfunction DataTableColumnHeader({ title, sorted, onSort, className }: { title: React.ReactNode; sorted: false | \"asc\" | \"desc\"; onSort?: () => void; className?: string }) {\n  if (!onSort) return <span className={className}>{title}</span>;\n  const Icon = sorted === \"asc\" ? ArrowUpIcon : sorted === \"desc\" ? ArrowDownIcon : CaretUpDownIcon;\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"data-table-column-header\"\n      onClick={onSort}\n      className={cn(\"-mx-2 inline-flex h-8 items-center gap-1.5 rounded-control px-2 font-sans text-body leading-tight font-normal text-fg-muted outline-none transition-colors duration-150 ease-out hover:bg-subtle-hover hover:text-fg focus-visible:outline-2 focus-visible:outline-focus-ring data-[sorted=true]:text-fg motion-reduce:transition-none\", className)}\n      data-sorted={!!sorted}\n    >\n      {title}\n      <Icon aria-hidden weight={sorted ? \"bold\" : \"regular\"} className=\"size-4\" />\n    </button>\n  );\n}\n\n/** The Columns key and its menu of checkboxes, one per column that can be hidden. */\nfunction DataTableViewOptions({ columns, visible, onVisibleChange }: { columns: { id: string; header: React.ReactNode }[]; visible: Record<string, boolean>; onVisibleChange: (id: string, on: boolean) => void }) {\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <Button variant=\"outline\" className=\"h-10 shrink-0\">\n          <ColumnsIcon weight=\"fill\" />\n          Columns\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\" className=\"w-52\">\n        <DropdownMenuLabel>Show columns</DropdownMenuLabel>\n        <DropdownMenuSeparator />\n        {columns.map((c) => (\n          <DropdownMenuCheckboxItem key={c.id} checked={visible[c.id] !== false} onCheckedChange={(on) => onVisibleChange(c.id, on === true)} onSelect={(e) => e.preventDefault()}>\n            {c.header}\n          </DropdownMenuCheckboxItem>\n        ))}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\n/** The page numbers around the current one: the ends, its neighbors and ellipses between. */\nfunction pageList(page: number, count: number): (number | \"gap\")[] {\n  if (count <= 7) return Array.from({ length: count }, (_, i) => i);\n  const set = new Set([0, count - 1, page - 1, page, page + 1].filter((p) => p >= 0 && p < count));\n  const sorted = [...set].sort((a, b) => a - b);\n  return sorted.flatMap((p, i) => (i && p - sorted[i - 1] > 1 ? ([\"gap\", p] as const) : [p]));\n}\n\n/** The lilac bar under the table: the selection count, rows per page and the pages. */\nfunction DataTablePagination({ page, pageCount, onPageChange, pageSize, pageSizes, onPageSizeChange, selected, total, className }: { page: number; pageCount: number; onPageChange: (page: number) => void; pageSize?: number; pageSizes?: number[]; onPageSizeChange?: (size: number) => void; selected?: number; total?: number; className?: string }) {\n  const go = (p: number) => (e: React.MouseEvent) => {\n    e.preventDefault();\n    if (p >= 0 && p < pageCount) onPageChange(p);\n  };\n  return (\n    <Pagination bar className={cn(\"flex-wrap justify-between gap-x-4 gap-y-1\", className)}>\n      <div className=\"flex min-h-7.5 flex-wrap items-center gap-x-4 gap-y-1 pl-2 text-body leading-tight text-fg-muted\">\n        {selected !== undefined && total !== undefined && (\n          <span aria-live=\"polite\">\n            {selected} of {total} selected\n          </span>\n        )}\n        {pageSizes && pageSize && onPageSizeChange && (\n          <span className=\"flex items-center gap-2\">\n            <span id=\"data-table-page-size\">Rows per page</span>\n            <Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>\n              <SelectTrigger size=\"sm\" aria-labelledby=\"data-table-page-size\" className=\"h-7.5 w-18 pr-2 pl-2.5\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                {pageSizes.map((s) => (\n                  <SelectItem key={s} value={String(s)}>\n                    {s}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n          </span>\n        )}\n      </div>\n      <PaginationContent className=\"ml-auto\">\n        <PaginationItem>\n          <PaginationPrevious href=\"#\" onClick={go(page - 1)} aria-disabled={page === 0 || undefined} tabIndex={page === 0 ? -1 : undefined} />\n        </PaginationItem>\n        {pageList(page, pageCount).map((p, i) =>\n          p === \"gap\" ? (\n            <PaginationItem key={`gap-${i}`}>\n              <PaginationEllipsis />\n            </PaginationItem>\n          ) : (\n            <PaginationItem key={p}>\n              <PaginationLink href=\"#\" isActive={p === page} onClick={go(p)} aria-label={`Page ${p + 1}`}>\n                {p + 1}\n              </PaginationLink>\n            </PaginationItem>\n          ),\n        )}\n        <PaginationItem>\n          <PaginationNext href=\"#\" onClick={go(page + 1)} aria-disabled={page >= pageCount - 1 || undefined} tabIndex={page >= pageCount - 1 ? -1 : undefined} />\n        </PaginationItem>\n      </PaginationContent>\n    </Pagination>\n  );\n}\n\ntype DataTableProps<T> = {\n  columns: DataTableColumn<T>[];\n  data: T[];\n  getRowId: (row: T) => string;\n  /** A filter field over these columns' values (all filterable columns when omitted). */\n  filter?: { placeholder?: string; columns?: string[] } | false;\n  /** A checkbox column for choosing rows. */\n  selectable?: boolean;\n  selection?: string[];\n  onSelectionChange?: (ids: string[]) => void;\n  /** Hide the Columns key. */\n  hideViewOptions?: boolean;\n  pageSize?: number;\n  /** Offer these page sizes in the bar. */\n  pageSizes?: number[];\n  defaultSort?: SortState;\n  /** Shown in the body when nothing matches. */\n  empty?: React.ReactNode;\n  /** Actions at the toolbar's right, before the Columns key. */\n  toolbar?: React.ReactNode;\n  /** What a row is called in labels (\"mailbox\"). */\n  rowName?: string;\n  className?: string;\n};\n\nfunction DataTable<T>({ columns, data, getRowId, filter = {}, selectable = false, selection: selectionProp, onSelectionChange, hideViewOptions = false, pageSize: pageSizeProp = 10, pageSizes, defaultSort = null, empty = \"No results.\", toolbar, rowName = \"row\", className }: DataTableProps<T>) {\n  const [query, setQuery] = React.useState(\"\");\n  const [sort, setSort] = React.useState<SortState>(defaultSort);\n  const [page, setPage] = React.useState(0);\n  const [pageSize, setPageSize] = React.useState(pageSizeProp);\n  const [visible, setVisible] = React.useState<Record<string, boolean>>(() => Object.fromEntries(columns.map((c) => [c.id, !c.hidden])));\n  const [selectionInner, setSelectionInner] = React.useState<string[]>([]);\n  const selection = selectionProp ?? selectionInner;\n  const setSelection = (ids: string[]) => {\n    if (!selectionProp) setSelectionInner(ids);\n    onSelectionChange?.(ids);\n  };\n\n  const shown = columns.filter((c) => visible[c.id] !== false);\n  const filterColumns = filter ? columns.filter((c) => c.accessor && (!filter.columns || filter.columns.includes(c.id))) : [];\n\n  const q = query.trim().toLowerCase();\n  let rows = q ? data.filter((row) => filterColumns.some((c) => text(c.accessor!(row)).toLowerCase().includes(q))) : data;\n  const sortColumn = sort && columns.find((c) => c.id === sort.id);\n  if (sort && sortColumn?.accessor) {\n    const get = sortColumn.accessor;\n    rows = [...rows].sort((a, b) => compare(get(a), get(b)) * (sort.desc ? -1 : 1));\n  }\n\n  const pageCount = Math.max(1, Math.ceil(rows.length / pageSize));\n  const current = Math.min(page, pageCount - 1);\n  const pageRows = rows.slice(current * pageSize, current * pageSize + pageSize);\n  const pageIds = pageRows.map(getRowId);\n  const allOnPage = pageIds.length > 0 && pageIds.every((id) => selection.includes(id));\n  const someOnPage = pageIds.some((id) => selection.includes(id));\n\n  const toggleSort = (id: string) => setSort((s) => (s?.id !== id ? { id, desc: false } : s.desc ? null : { id, desc: true }));\n\n  return (\n    <div data-slot=\"data-table\" className={cn(\"grid min-w-0 grid-cols-[minmax(0,1fr)] gap-3\", className)}>\n      {(filter || !hideViewOptions || toolbar) && (\n        <div data-slot=\"data-table-toolbar\" className=\"flex flex-wrap items-center gap-2\">\n          {filter && (\n            <InputGroup className=\"max-w-80 min-w-48 flex-1\">\n              <InputGroupAddon>\n                <MagnifyingGlassIcon />\n              </InputGroupAddon>\n              <InputGroupInput\n                type=\"search\"\n                value={query}\n                onChange={(e) => {\n                  setQuery(e.target.value);\n                  setPage(0);\n                }}\n                placeholder={filter.placeholder ?? \"Filter\"}\n                aria-label={filter.placeholder ?? \"Filter\"}\n              />\n            </InputGroup>\n          )}\n          <div className=\"ml-auto flex items-center gap-2\">\n            {toolbar}\n            {!hideViewOptions && (\n              <DataTableViewOptions\n                columns={columns.filter((c) => c.hideable !== false)}\n                visible={visible}\n                onVisibleChange={(id, on) => setVisible((v) => ({ ...v, [id]: on }))}\n              />\n            )}\n          </div>\n        </div>\n      )}\n      <div>\n        <Table>\n          <TableHeader>\n            <TableRow className=\"hover:[&>td]:bg-transparent\">\n              {selectable && (\n                <TableHead>\n                  <Checkbox\n                    aria-label={`Select every ${rowName} on this page`}\n                    checked={allOnPage ? true : someOnPage ? \"indeterminate\" : false}\n                    onCheckedChange={(on) => setSelection(on === true ? [...new Set([...selection, ...pageIds])] : selection.filter((id) => !pageIds.includes(id)))}\n                  />\n                </TableHead>\n              )}\n              {shown.map((c) => (\n                <TableHead key={c.id} aria-sort={sort?.id === c.id ? (sort.desc ? \"descending\" : \"ascending\") : c.sortable ? \"none\" : undefined} className={cn(c.align === \"end\" && \"text-right\", c.className)}>\n                  <DataTableColumnHeader title={c.header} sorted={sort?.id === c.id ? (sort.desc ? \"desc\" : \"asc\") : false} onSort={c.sortable && c.accessor ? () => toggleSort(c.id) : undefined} />\n                </TableHead>\n              ))}\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {pageRows.length ? (\n              pageRows.map((row) => {\n                const id = getRowId(row);\n                const selected = selection.includes(id);\n                return (\n                  <TableRow key={id} data-state={selected ? \"selected\" : undefined}>\n                    {selectable && (\n                      <TableCell>\n                        <Checkbox aria-label={`Select ${rowName} ${id}`} checked={selected} onCheckedChange={(on) => setSelection(on === true ? [...selection, id] : selection.filter((s) => s !== id))} />\n                      </TableCell>\n                    )}\n                    {shown.map((c) => (\n                      <TableCell key={c.id} className={cn(c.align === \"end\" && \"text-right\", c.className)}>\n                        {c.cell ? c.cell(row) : text(c.accessor?.(row))}\n                      </TableCell>\n                    ))}\n                  </TableRow>\n                );\n              })\n            ) : (\n              <TableRow className=\"hover:[&>td]:bg-surface\">\n                <TableCell colSpan={shown.length + (selectable ? 1 : 0)} className=\"h-24 text-center whitespace-normal text-fg-muted\">\n                  {empty}\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n        <DataTablePagination\n          className=\"mt-1\"\n          page={current}\n          pageCount={pageCount}\n          onPageChange={setPage}\n          pageSize={pageSize}\n          pageSizes={pageSizes}\n          onPageSizeChange={(s) => {\n            setPageSize(s);\n            setPage(0);\n          }}\n          selected={selectable ? selection.length : undefined}\n          total={selectable ? data.length : undefined}\n        />\n      </div>\n    </div>\n  );\n}\n\nexport { DataTable, DataTableColumnHeader, DataTablePagination, DataTableViewOptions };\nexport type { DataTableColumn, DataTableProps, SortState };\n"
    }
  ]
}
