{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-table",
  "type": "registry:component",
  "title": "Data table",
  "description": "Sorts, filters and selects structured records. Native controls expose sort and selection state.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/table.json",
    "https://vlak.dev/r/input.json",
    "https://vlak.dev/r/checkbox.json",
    "https://vlak.dev/r/vlak-lib.json",
    "https://vlak.dev/r/icons.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/data-table.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak, mq } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\nimport { Icon } from \"./icons\";\nimport { Checkbox } from \"./checkbox\";\nimport { Input } from \"./input\";\n\nexport interface DataTableColumn<Row> {\n  key: string;\n  header: React.ReactNode;\n  /** Cell renderer; defaults to the row's value at `key`. */\n  render?: (row: Row) => React.ReactNode;\n  sortable?: boolean;\n  /** Sort accessor; defaults to the row's value at `key`. */\n  sortValue?: (row: Row) => string | number;\n}\n\nexport interface DataTableProps<Row> extends React.HTMLAttributes<HTMLDivElement> {\n  columns: Array<DataTableColumn<Row>>;\n  rows: Row[];\n  rowKey?: (row: Row, index: number) => React.Key;\n  emptyLabel?: React.ReactNode;\n  caption?: string;\n  sort?: DataTableSort | null;\n  defaultSort?: DataTableSort | null;\n  onSortChange?: (sort: DataTableSort | null) => void;\n  selectable?: boolean;\n  selectedKeys?: React.Key[];\n  defaultSelectedKeys?: React.Key[];\n  onSelectionChange?: (keys: React.Key[]) => void;\n  filterable?: boolean;\n  filter?: string;\n  defaultFilter?: string;\n  onFilterChange?: (filter: string) => void;\n  filterRow?: (row: Row, filter: string) => boolean;\n}\n\nexport interface DataTableSort { key: string; dir: \"asc\" | \"desc\" }\n\nconst styles = stylex.create({\n  scroll: { width: \"100%\", overflowX: \"auto\" },\n  table: {\n    width: {\n      default: \"100%\",\n      [mq.phone]: \"100%\",\n    },\n    borderCollapse: \"collapse\",\n    marginTop: \"1rem\",\n    marginBottom: \"1.5rem\",\n    marginInlineStart: {\n      default: 0,\n      [mq.phone]: 0,\n    },\n    marginInlineEnd: {\n      default: 0,\n      [mq.phone]: 0,\n    },\n    fontSize: {\n      default: \"0.875rem\",\n      [mq.phone]: \"1rem\",\n    },\n  },\n  th: {\n    textAlign: {\n      default: \"start\",\n      \":last-child\": \"end\",\n    },\n    fontWeight: 600,\n    color: vlak.ink,\n    letterSpacing: \"-0.01em\",\n    paddingTop: {\n      default: \"0.625rem\",\n      [mq.phone]: \"0.875rem\",\n    },\n    paddingInlineEnd: {\n      default: \"1rem\",\n      [mq.phone]: \"0.75rem\",\n      \":last-child\": {\n        default: 20,\n        [mq.phone]: vlak.pad,\n      },\n    },\n    paddingBottom: {\n      default: \"0.625rem\",\n      [mq.phone]: \"0.875rem\",\n    },\n    paddingInlineStart: {\n      default: \"0.75rem\",\n      [mq.phone]: \"0.75rem\",\n      \":first-child\": {\n        default: 20,\n        [mq.phone]: vlak.pad,\n      },\n    },\n    borderBottomWidth: 2,\n    borderBottomStyle: \"solid\",\n    borderBottomColor: vlak.divider,\n    fontSize: {\n      default: \"0.8125rem\",\n      [mq.phone]: \"0.9375rem\",\n    },\n  },\n  td: {\n    paddingTop: {\n      default: \"0.625rem\",\n      [mq.phone]: \"0.875rem\",\n    },\n    paddingInlineEnd: {\n      default: \"1rem\",\n      [mq.phone]: \"0.75rem\",\n      \":last-child\": {\n        default: 20,\n        [mq.phone]: vlak.pad,\n      },\n    },\n    paddingBottom: {\n      default: \"0.625rem\",\n      [mq.phone]: \"0.875rem\",\n    },\n    paddingInlineStart: {\n      default: \"0.75rem\",\n      [mq.phone]: \"0.75rem\",\n      \":first-child\": {\n        default: 20,\n        [mq.phone]: vlak.pad,\n      },\n    },\n    color: vlak.gray,\n    fontWeight: 500,\n    letterSpacing: \"-0.01em\",\n    borderBottomWidth: vlak.hairline,\n    borderBottomStyle: \"solid\",\n    borderBottomColor: vlak.divider,\n    verticalAlign: \"top\",\n    fontSize: {\n      default: null,\n      [mq.phone]: \"1rem\",\n    },\n    textAlign: {\n      default: null,\n      \":last-child\": \"end\",\n    },\n  },\n  tdAlt: {\n    backgroundColor: vlak.tableAlt,\n  },\n  selected: { backgroundColor: vlak.controlFill, color: vlak.ink },\n  sort: {\n    backgroundColor: \"transparent\",\n    borderWidth: 0,\n    borderStyle: \"none\",\n    padding: 0,\n    fontFamily: \"inherit\",\n    fontSize: \"inherit\",\n    fontWeight: 600,\n    letterSpacing: \"inherit\",\n    color: \"inherit\",\n    cursor: \"pointer\",\n    display: \"inline-flex\",\n    alignItems: \"center\",\n    gap: \"0.3125rem\",\n    minHeight: vlak.hit,\n    minWidth: vlak.hit,\n    outlineWidth: {\n      default: null,\n      \":focus-visible\": 2,\n    },\n    outlineStyle: {\n      default: null,\n      \":focus-visible\": \"solid\",\n    },\n    outlineColor: {\n      default: null,\n      \":focus-visible\": vlak.ink,\n    },\n    outlineOffset: {\n      default: null,\n      \":focus-visible\": 2,\n    },\n  },\n  /* Unsorted columns show a neutral sort mark in gray; the active one is an ink arrow. */\n  sortIcon: {\n    color: vlak.gray,\n    opacity: 1,\n  },\n  sortIconOn: {\n    color: vlak.ink,\n  },\n  empty: {\n    paddingTop: \"1.5rem\",\n    paddingBottom: \"1.5rem\",\n    paddingInline: \"1.25rem\",\n    textAlign: \"center\",\n    fontSize: \"0.8125rem\",\n    letterSpacing: \"-0.01em\",\n    color: vlak.gray,\n  },\n});\n\n/** Sortable rows over the plain rs-table. */\nexport const DataTable = React.forwardRef(function DataTable<Row extends Record<string, unknown>>({\n  columns,\n  rows,\n  rowKey = (_, i) => i,\n  emptyLabel = \"Nothing here yet.\",\n  caption,\n  sort: controlledSort,\n  defaultSort = null,\n  onSortChange,\n  selectable = false,\n  selectedKeys,\n  defaultSelectedKeys = [],\n  onSelectionChange,\n  filterable = false,\n  filter,\n  defaultFilter = \"\",\n  onFilterChange,\n  filterRow,\n  className,\n  style,\n  ...props\n}: DataTableProps<Row>, ref: React.ForwardedRef<HTMLDivElement>) {\n  const [innerSort, setInnerSort] = React.useState<DataTableSort | null>(defaultSort);\n  const sort = controlledSort === undefined ? innerSort : controlledSort;\n  const [innerKeys, setInnerKeys] = React.useState<React.Key[]>(defaultSelectedKeys);\n  const selected = new Set(selectedKeys ?? innerKeys);\n  const [innerFilter, setInnerFilter] = React.useState(defaultFilter);\n  const query = filter ?? innerFilter;\n  const getRowKey = (row: Row, index: number): React.Key => {\n    const key = rowKey(row, index);\n    return typeof key === \"symbol\" ? String(key) : key;\n  };\n  const records: Array<{ row: Row; key: React.Key }> = rows.map((row, index) => ({ row, key: getRowKey(row, index) }));\n  const filtered: Array<{ row: Row; key: React.Key }> = records.filter(({ row }) => !query || (filterRow ? filterRow(row, query) :\n    columns.some((column) => String(row[column.key] ?? \"\").toLocaleLowerCase().includes(query.toLocaleLowerCase()))));\n  const column = sort ? columns.find((c) => c.key === sort.key && c.sortable) : undefined;\n  const sorted: Array<{ row: Row; key: React.Key }> = column && sort ? [...filtered].sort((a, b) => {\n    const get = column.sortValue ?? ((row: Row) => row[column.key] as string | number);\n    const av = get(a.row);\n    const bv = get(b.row);\n    const cmp = typeof av === \"number\" && typeof bv === \"number\" ? av - bv : String(av).localeCompare(String(bv));\n    return sort.dir === \"asc\" ? cmp : -cmp;\n  }) : filtered;\n  const toggle = (key: string) => {\n    const next: DataTableSort | null = sort?.key === key ? (sort.dir === \"asc\" ? { key, dir: \"desc\" } : null) : { key, dir: \"asc\" };\n    if (controlledSort === undefined) setInnerSort(next);\n    onSortChange?.(next);\n  };\n  const selectKeys = (next: Set<React.Key>) => {\n    const keys = [...next];\n    if (selectedKeys === undefined) setInnerKeys(keys);\n    onSelectionChange?.(keys);\n  };\n  const allSelected = filtered.length > 0 && filtered.every(({ key }) => selected.has(key));\n  const someSelected = filtered.some(({ key }) => selected.has(key));\n  const scroll = rs([\"rs-datatable-scroll\"], styles.scroll);\n\n  const table = rs([\"rs-table\", \"rs-datatable-table\"], styles.table);\n  const th = rs([\"rs-datatable-th\"], styles.th);\n  const empty = rs([\"rs-datatable-empty\"], styles.empty);\n\n  return (\n    <div ref={ref} {...props} className={className} style={style}>\n      {filterable && <Input type=\"search\" aria-label=\"Filter rows\" placeholder=\"Filter rows\" value={query} onChange={(event) => { if (filter === undefined) setInnerFilter(event.target.value); onFilterChange?.(event.target.value); }} />}\n      <div className={scroll.className} style={scroll.style}>\n      <table className={table.className} style={table.style}>\n        {caption && <caption>{caption}</caption>}\n        <thead>\n          <tr>\n            {selectable && <th scope=\"col\" className={th.className} style={th.style}><Checkbox aria-label=\"Select all visible rows\" checked={allSelected} indeterminate={!allSelected && someSelected} disabled={!filtered.length} onCheckedChange={(checked) => { const next = new Set(selected); for (const { key } of filtered) { if (checked) next.add(key); else next.delete(key); } selectKeys(next); }} /></th>}\n            {columns.map((column) => {\n              const active = sort?.key === column.key;\n              const sortBtn = rs([\"rs-datatable-sort\"], styles.sort);\n              const sortIcon = rs([\"rs-datatable-sort-icon\", active && \"rs-datatable-sort-icon-on\"], styles.sortIcon, active && styles.sortIconOn);\n              return (\n                <th\n                  scope=\"col\"\n                  key={column.key}\n                  className={th.className}\n                  style={th.style}\n                  aria-sort={active ? (sort.dir === \"asc\" ? \"ascending\" : \"descending\") : undefined}\n                >\n                  {column.sortable ? (\n                    <button\n                      type=\"button\"\n                      className={sortBtn.className}\n                      style={sortBtn.style}\n                      data-active={active}\n                      onClick={() => toggle(column.key)}\n                    >\n                      {column.header}\n                      <Icon\n                        name={active ? (sort.dir === \"desc\" ? \"arrow-down\" : \"arrow-up\") : \"sort\"}\n                        size={12}\n                        className={sortIcon.className}\n                        style={sortIcon.style}\n                      />\n                    </button>\n                  ) : (\n                    column.header\n                  )}\n                </th>\n              );\n            })}\n          </tr>\n        </thead>\n        <tbody>\n          {sorted.map(({ row, key }, index) => (\n            <tr key={key} data-selected={selected.has(key) || undefined}>\n              {selectable && <td className={th.className} style={th.style}><Checkbox aria-label={`Select row ${index + 1}`} checked={selected.has(key)} onCheckedChange={(checked) => { const next = new Set(selected); if (checked) next.add(key); else next.delete(key); selectKeys(next); }} /></td>}\n              {columns.map((column) => {\n                const cell = rs([\"rs-datatable-td\", index % 2 === 1 && \"rs-datatable-td-alt\", selected.has(key) && \"rs-datatable-td-selected\"], styles.td, index % 2 === 1 && styles.tdAlt, selected.has(key) && styles.selected);\n                return (\n                  <td key={column.key} className={cell.className} style={cell.style}>\n                    {column.render ? column.render(row) : (row[column.key] as React.ReactNode)}\n                  </td>\n                );\n              })}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n      </div>\n      {sorted.length === 0 && (\n        <div role=\"status\" className={empty.className} style={empty.style}>\n          {emptyLabel}\n        </div>\n      )}\n    </div>\n  );\n  /* forwardRef cannot carry the row type parameter, so the export restates it. */\n}) as <Row extends Record<string, unknown>>(\n  props: DataTableProps<Row> & React.RefAttributes<HTMLDivElement>,\n) => React.ReactElement;\n",
      "type": "registry:component",
      "target": "components/vlak/data-table.tsx"
    },
    {
      "path": "vlak/styles/data-table.css",
      "content": "/* ── data-table: generated from packages/react/src/components/data-table.tsx ── */\n.rs-datatable-scroll{width:100%;overflow-x:auto}\n.rs-table.rs-datatable-table{width:100%;border-collapse:collapse;margin-top:1rem;margin-bottom:1.5rem;margin-inline-start:0;margin-inline-end:0;font-size:0.875rem}\n@media (max-width: 640px){.rs-table.rs-datatable-table{width:100%;margin-inline-start:0;margin-inline-end:0;font-size:1rem}}\n.rs-datatable-th{text-align:start;font-weight:600;color:var(--text);letter-spacing:-0.01em;padding-top:0.625rem;padding-inline-end:1rem;padding-bottom:0.625rem;padding-inline-start:0.75rem;border-bottom-width:2px;border-bottom-style:solid;border-bottom-color:var(--divider);font-size:0.8125rem}\n.rs-datatable-th:last-child{text-align:end;padding-inline-end:20px}\n.rs-datatable-th:first-child{padding-inline-start:20px}\n@media (max-width: 640px){.rs-datatable-th{padding-top:0.875rem;padding-inline-end:0.75rem;padding-bottom:0.875rem;padding-inline-start:0.75rem;font-size:0.9375rem}.rs-datatable-th:last-child{padding-inline-end:var(--pad)}.rs-datatable-th:first-child{padding-inline-start:var(--pad)}}\n.rs-datatable-td{padding-top:0.625rem;padding-inline-end:1rem;padding-bottom:0.625rem;padding-inline-start:0.75rem;color:var(--text-secondary);font-weight:500;letter-spacing:-0.01em;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:var(--divider);vertical-align:top}\n.rs-datatable-td:last-child{padding-inline-end:20px;text-align:end}\n.rs-datatable-td:first-child{padding-inline-start:20px}\n@media (max-width: 640px){.rs-datatable-td{padding-top:0.875rem;padding-inline-end:0.75rem;padding-bottom:0.875rem;padding-inline-start:0.75rem;font-size:1rem}.rs-datatable-td:last-child{padding-inline-end:var(--pad)}.rs-datatable-td:first-child{padding-inline-start:var(--pad)}}\n.rs-datatable-td-alt{background-color:var(--table-alt)}\n.rs-datatable-td-selected{background-color:var(--control-fill);color:var(--text)}\n.rs-datatable-sort{background-color:transparent;border-width:0;border-style:none;padding:0;font-family:inherit;font-size:inherit;font-weight:600;letter-spacing:inherit;color:inherit;cursor:pointer;display:inline-flex;align-items:center;gap:0.3125rem;min-height:var(--hit);min-width:var(--hit)}\n.rs-datatable-sort:focus-visible{outline-width:2px;outline-style:solid;outline-color:var(--text);outline-offset:2px}\n.rs-datatable-sort-icon{color:var(--text-secondary);opacity:1}\n.rs-datatable-sort-icon-on{color:var(--text)}\n.rs-datatable-empty{padding-top:1.5rem;padding-bottom:1.5rem;padding-inline:1.25rem;text-align:center;font-size:0.8125rem;letter-spacing:-0.01em;color:var(--text-secondary)}\n",
      "type": "registry:file",
      "target": "styles/vlak/data-table.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "content",
      "classes": [
        "rs-datatable-sort",
        "rs-datatable-empty",
        "rs-datatable-sort-icon",
        "rs-datatable-sort-icon-on",
        "rs-datatable-table",
        "rs-datatable-td",
        "rs-datatable-td-alt",
        "rs-datatable-th",
        "rs-datatable-scroll",
        "rs-datatable-td-selected"
      ],
      "snippet": "<table class=\"rs-table\"><thead><tr><th><button class=\"rs-datatable-sort\">Phase</button></th><th>Weeks</th></tr></thead><tbody><tr><td>Identity</td><td>4</td></tr><tr><td>Strategy</td><td>2</td></tr></tbody></table>",
      "cssOnly": false,
      "registryDependencies": [
        "table",
        "input",
        "checkbox",
        "vlak-lib",
        "icons"
      ],
      "aliases": [
        "Data table",
        "Sortable table",
        "Grid"
      ],
      "example": "import { DataTable } from \"@noorddev/vlak-react\";\n\n<DataTable\n  columns={[\n    { key: \"phase\", header: \"Phase\", sortable: true },\n    { key: \"weeks\", header: \"Weeks\", sortable: true },\n    { key: \"owner\", header: \"Owner\", render: (row) => row.owner.name },\n  ]}\n  rows={rows}\n  rowKey={(row) => row.id}\n  emptyLabel=\"No phases yet.\"\n/>",
      "usage": {
        "use": [
          "Rows from data with controlled or default sorting, selection keys and text filtering.",
          "render for rich cells, sortValue for sort keys, filterRow for custom filtering, and a stable rowKey to keep selection attached to records."
        ],
        "avoid": [
          "Hand-written rows; use Table.",
          "Inline editing or pagination; compose those around it. Large datasets need windowing or server-side data management."
        ]
      },
      "keyboard": [
        {
          "keys": "Tab",
          "does": "Moves through the optional filter, sort buttons and selection checkboxes"
        },
        {
          "keys": "Enter, Space",
          "does": "Sorts ascending, then descending, then clears"
        }
      ],
      "a11y": [
        "Sortable headers hold a native <button>; the <th> carries aria-sort while sorted.",
        "Filter and sort have value/callback pairs: filter/onFilterChange and sort/onSortChange, with defaultFilter/defaultSort for local state.",
        "selectable adds native checkboxes; selectedKeys/onSelectionChange controls selection, with defaultSelectedKeys for local state. Select all affects visible filtered rows and exposes mixed state.",
        "A horizontal scroll container keeps the table inside its grid; caption names the data. Empty filtered results are a live status. Interactive targets are at least 44px."
      ]
    }
  }
}
