{
  "name": "data-table",
  "type": "registry:ui",
  "title": "Data Table",
  "description": "A sortable project table with typed columns, active rows, row actions and empty states.",
  "registryDependencies": [
    "@siteplane/base",
    "@siteplane/button",
    "@siteplane/empty",
    "@siteplane/table"
  ],
  "dependencies": [
    "lucide-react@1.18.0"
  ],
  "files": [
    {
      "path": "src/components/ui/data-table.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport {\n  ArrowDownIcon,\n  ArrowUpDownIcon,\n  ArrowUpIcon,\n} from \"lucide-react\";\nimport {\n  useMemo,\n  useState,\n  type KeyboardEvent,\n  type MouseEvent,\n  type ReactNode,\n} from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Empty,\n  EmptyDescription,\n  EmptyHeader,\n  EmptyTitle,\n} from \"@/components/ui/empty\";\nimport {\n  Table,\n  TableBody,\n  TableCaption,\n  TableCell,\n  TableFooter,\n  TableHead,\n  TableHeader,\n  TableRow,\n  type TableVariant,\n} from \"@/components/ui/table\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DataTableSortDirection = \"asc\" | \"desc\";\n\nexport type DataTableSortClickArea = \"cell\" | \"content\";\n\nexport type DataTableSort = {\n  direction: DataTableSortDirection;\n  id: string;\n};\n\nexport type DataTableRowClickHandler<TData> = (row: TData, rowId: string) => void;\n\nexport type DataTableRowAriaLabelGetter<TData> = (\n  row: TData,\n  rowId: string,\n) => string;\n\nexport type DataTableSortValue =\n  | boolean\n  | Date\n  | null\n  | number\n  | string\n  | undefined;\n\nexport type DataTableColumn<TData> = {\n  accessor?: (row: TData) => DataTableSortValue;\n  align?: \"center\" | \"left\" | \"right\";\n  cell?: (row: TData) => ReactNode;\n  className?: string;\n  header: ReactNode;\n  headerClassName?: string;\n  id: string;\n  sortable?: boolean;\n};\n\nexport type DataTableProps<TData> = {\n  activeRowId?: string;\n  caption?: ReactNode;\n  className?: string;\n  columns: readonly DataTableColumn<TData>[];\n  data: readonly TData[];\n  emptyState?: ReactNode;\n  footer?: ReactNode;\n  getRowAriaLabel?: DataTableRowAriaLabelGetter<TData>;\n  getRowId?: (row: TData, index: number) => string;\n  initialSort?: DataTableSort;\n  onRowClick?: DataTableRowClickHandler<TData>;\n  onSortChange?: (sort: DataTableSort) => void;\n  sort?: DataTableSort;\n  sortClickArea?: DataTableSortClickArea;\n  variant?: TableVariant;\n};\n\nconst dataTableRowClickIgnoreSelector =\n  \"a,button,input,select,textarea,[role='button'],[role='link'],[data-row-click-ignore='true']\";\n\nexport function compareDataTableValues(\n  a: DataTableSortValue,\n  b: DataTableSortValue,\n): number {\n  if (a == null && b == null) {\n    return 0;\n  }\n\n  if (a == null) {\n    return 1;\n  }\n\n  if (b == null) {\n    return -1;\n  }\n\n  const normalizedA = a instanceof Date ? a.getTime() : a;\n  const normalizedB = b instanceof Date ? b.getTime() : b;\n\n  if (typeof normalizedA === \"number\" && typeof normalizedB === \"number\") {\n    return normalizedA - normalizedB;\n  }\n\n  if (typeof normalizedA === \"boolean\" && typeof normalizedB === \"boolean\") {\n    return Number(normalizedA) - Number(normalizedB);\n  }\n\n  return String(normalizedA).localeCompare(String(normalizedB), undefined, {\n    numeric: true,\n    sensitivity: \"base\",\n  });\n}\n\nexport function isDataTableColumnSortable<TData>(\n  column: DataTableColumn<TData>,\n): boolean {\n  if (column.sortable === false) {\n    return false;\n  }\n\n  return typeof column.accessor === \"function\";\n}\n\nexport function sortDataTableRows<TData>(\n  rows: readonly TData[],\n  columns: readonly DataTableColumn<TData>[],\n  sort: DataTableSort | null | undefined,\n): TData[] {\n  if (!sort) {\n    return [...rows];\n  }\n\n  const column = columns.find((candidate) => candidate.id === sort.id);\n\n  if (!column?.accessor || !isDataTableColumnSortable(column)) {\n    return [...rows];\n  }\n\n  const directionFactor = sort.direction === \"asc\" ? 1 : -1;\n\n  return [...rows].sort((a, b) => {\n    const aValue = column.accessor?.(a);\n    const bValue = column.accessor?.(b);\n\n    return compareDataTableValues(aValue, bValue) * directionFactor;\n  });\n}\n\nexport function getDataTableRowId<TData>(\n  row: TData,\n  index: number,\n  getRowId?: (row: TData, index: number) => string,\n): string {\n  return getRowId?.(row, index) ?? String(index);\n}\n\nexport function DataTable<TData>({\n  activeRowId,\n  caption,\n  className,\n  columns,\n  data,\n  emptyState = <DataTableEmptyState />,\n  footer,\n  getRowAriaLabel,\n  getRowId,\n  initialSort,\n  onRowClick,\n  onSortChange,\n  sort,\n  sortClickArea = \"content\",\n  variant = \"default\",\n}: DataTableProps<TData>): React.ReactElement {\n  const [internalSort, setInternalSort] = useState<DataTableSort | undefined>(\n    initialSort,\n  );\n  const activeSort = sort ?? internalSort;\n  const sortedRows = useMemo(\n    () => sortDataTableRows(data, columns, activeSort),\n    [activeSort, columns, data],\n  );\n  const isRowInteractive = typeof onRowClick === \"function\";\n\n  function setNextSort(column: DataTableColumn<TData>) {\n    if (!isDataTableColumnSortable(column)) {\n      return;\n    }\n\n    const nextSort: DataTableSort = {\n      direction:\n        activeSort?.id === column.id && activeSort.direction === \"asc\"\n          ? \"desc\"\n          : \"asc\",\n      id: column.id,\n    };\n\n    if (sort === undefined) {\n      setInternalSort(nextSort);\n    }\n\n    onSortChange?.(nextSort);\n  }\n\n  return (\n    <Table className={className} render={<div className=\"min-w-0\" />} variant={variant}>\n      {caption ? <TableCaption>{caption}</TableCaption> : null}\n      <TableHeader>\n        <TableRow>\n          {columns.map((column) => {\n            const isSortable = isDataTableColumnSortable(column);\n            const sortDirection =\n              activeSort?.id === column.id ? activeSort.direction : undefined;\n\n            return (\n              <TableHead\n                aria-sort={getAriaSort(isSortable, sortDirection)}\n                className={cn(\n                  getCellAlignmentClass(column.align),\n                  isSortable && sortClickArea === \"cell\" && \"p-0\",\n                  column.headerClassName,\n                )}\n                data-click-area={sortClickArea}\n                key={column.id}\n              >\n                {isSortable ? (\n                  <Button\n                    className={cn(\n                      \"w-full justify-start border-transparent bg-transparent text-left font-medium text-muted-foreground text-sm sm:text-sm shadow-none ![transform:none] hover:bg-transparent hover:text-foreground\",\n                      sortClickArea === \"cell\"\n                        ? \"h-8 w-full rounded-none px-2.5 sm:h-8\"\n                        : \"h-auto p-0\",\n                      column.align === \"right\" && \"justify-end text-right\",\n                      column.align === \"center\" && \"justify-center text-center\",\n                    )}\n                    data-slot=\"data-table-sort-trigger\"\n                    onClick={() => setNextSort(column)}\n                    size=\"xs\"\n                    type=\"button\"\n                    variant=\"ghost\"\n                  >\n                    <span>{column.header}</span>\n                    <DataTableSortIcon direction={sortDirection} />\n                  </Button>\n                ) : (\n                  <span>{column.header}</span>\n                )}\n              </TableHead>\n            );\n          })}\n        </TableRow>\n      </TableHeader>\n      <TableBody>\n        {sortedRows.length > 0 ? (\n          sortedRows.map((row, rowIndex) => {\n            const rowId = getDataTableRowId(row, rowIndex, getRowId);\n\n            return (\n              <TableRow\n                aria-label={getRowAriaLabel?.(row, rowId)}\n                aria-current={activeRowId === rowId ? \"true\" : undefined}\n                data-active={activeRowId === rowId ? \"true\" : undefined}\n                data-interactive={isRowInteractive ? \"true\" : undefined}\n                key={rowId}\n                onClick={\n                  isRowInteractive\n                    ? (event) => handleDataTableRowClick(event, row, rowId, onRowClick)\n                    : undefined\n                }\n                onKeyDown={\n                  isRowInteractive\n                    ? (event) =>\n                        handleDataTableRowKeyDown(event, row, rowId, onRowClick)\n                    : undefined\n                }\n                tabIndex={isRowInteractive ? 0 : undefined}\n              >\n                {columns.map((column) => (\n                  <TableCell\n                    className={cn(getCellAlignmentClass(column.align), column.className)}\n                    key={column.id}\n                  >\n                    {renderDataTableCell(row, column)}\n                  </TableCell>\n                ))}\n              </TableRow>\n            );\n          })\n        ) : (\n          <TableRow>\n            <TableCell className=\"p-0\" colSpan={columns.length}>\n              {emptyState}\n            </TableCell>\n          </TableRow>\n        )}\n      </TableBody>\n      {footer ? <TableFooter>{footer}</TableFooter> : null}\n    </Table>\n  );\n}\n\nfunction DataTableEmptyState(): React.ReactElement {\n  return (\n    <Empty className=\"min-h-48 py-10 md:py-12\">\n      <EmptyHeader>\n        <EmptyTitle>No results</EmptyTitle>\n        <EmptyDescription>\n          Try adjusting filters or search terms.\n        </EmptyDescription>\n      </EmptyHeader>\n    </Empty>\n  );\n}\n\nfunction DataTableSortIcon({\n  direction,\n}: {\n  direction?: DataTableSortDirection | undefined;\n}): React.ReactElement {\n  const Icon =\n    direction === \"asc\"\n      ? ArrowUpIcon\n      : direction === \"desc\"\n        ? ArrowDownIcon\n        : ArrowUpDownIcon;\n\n  return (\n    <Icon\n      aria-hidden=\"true\"\n      className={cn(\n        \"size-3.5 shrink-0\",\n        direction ? \"opacity-90\" : \"opacity-50\",\n      )}\n    />\n  );\n}\n\nfunction getAriaSort(\n  isSortable: boolean,\n  direction?: DataTableSortDirection,\n): \"ascending\" | \"descending\" | \"none\" | undefined {\n  if (!isSortable) {\n    return undefined;\n  }\n\n  if (direction === \"asc\") {\n    return \"ascending\";\n  }\n\n  if (direction === \"desc\") {\n    return \"descending\";\n  }\n\n  return \"none\";\n}\n\nfunction getCellAlignmentClass(align: DataTableColumn<unknown>[\"align\"]) {\n  if (align === \"right\") {\n    return \"text-right\";\n  }\n\n  if (align === \"center\") {\n    return \"text-center\";\n  }\n\n  return undefined;\n}\n\nfunction renderDataTableCell<TData>(\n  row: TData,\n  column: DataTableColumn<TData>,\n) {\n  if (column.cell) {\n    return column.cell(row);\n  }\n\n  const value = column.accessor?.(row);\n\n  if (value instanceof Date) {\n    return value.toLocaleDateString();\n  }\n\n  if (value == null) {\n    return null;\n  }\n\n  return String(value);\n}\n\nfunction handleDataTableRowClick<TData>(\n  event: MouseEvent<HTMLTableRowElement>,\n  row: TData,\n  rowId: string,\n  onRowClick: DataTableRowClickHandler<TData> | undefined,\n) {\n  if (isDataTableRowClickIgnored(event.target)) {\n    return;\n  }\n\n  onRowClick?.(row, rowId);\n}\n\nfunction handleDataTableRowKeyDown<TData>(\n  event: KeyboardEvent<HTMLTableRowElement>,\n  row: TData,\n  rowId: string,\n  onRowClick: DataTableRowClickHandler<TData> | undefined,\n) {\n  if (event.target !== event.currentTarget) {\n    return;\n  }\n\n  if (event.key !== \"Enter\" && event.key !== \" \") {\n    return;\n  }\n\n  event.preventDefault();\n  onRowClick?.(row, rowId);\n}\n\nfunction isDataTableRowClickIgnored(target: EventTarget | null): boolean {\n  if (!(target instanceof Element)) {\n    return false;\n  }\n\n  return Boolean(target.closest(dataTableRowClickIgnoreSelector));\n}\n"
    }
  ],
  "categories": [
    "data-display"
  ],
  "docs": "# Data Table\n\n> Siteplane UI release `0.1.1`.\n\n## Public Purpose\n\nDataTable adds typed columns, sorting, active-row behavior, and empty states to\nthe Siteplane UI Table primitive. Use Table directly for static markup.\n\n## Import\n\n`import { DataTable } from \"@/components/ui/data-table\";`\n\n`import type { DataTableColumn } from \"@/components/ui/data-table\";`\n\n## Public API\n\n- Accessor columns are sortable by default. Set `sortable: false` when sorting\n  has no product meaning.\n- Use `sort` with `onSortChange` for controlled sorting or `initialSort`\n  for uncontrolled initial state.\n- `column.align` accepts `\"left\"`, `\"center\"`, or `\"right\"`.\n- Without a custom `cell`, Date values use `toLocaleDateString()`,\n  nullish values render empty, and other values use `String(value)`.\n- When `emptyState` is omitted, DataTable renders its built-in “No results”\n  state.\n- `onRowClick`, `getRowId`, `activeRowId`, and `getRowAriaLabel` create\n  an accessible active-row flow.\n- Row clicks ignore links, buttons, inputs, selects, textareas,\n  `[role=button]`, `[role=link]`, and elements marked\n  `data-row-click-ignore=\"true\"`.\n- `sortClickArea=\"cell\"` makes the complete sortable header cell interactive;\n  the default is `\"content\"`.\n\n## Public Motion\n\n- DataTable adds no mount, exit, or sorting animation.\n- Sort-header Buttons disable scale so header text remains stationary.\n- Row hover and active styling come from Table.\n\n## Public Accessibility\n\n- Sortable headers expose `aria-sort`; their icons are decorative.\n- Clickable rows require a stable accessible label and support Enter and Space.\n- Interactive cell controls remain independent of the row action and must keep\n  visible focus.\n- Active row state is contextual selection, not a substitute for checkbox-based\n  bulk selection.\n- Empty states need a short explanation and, when possible, a useful next\n  action.\n\n## Public Agent Guidance\n\n- Keep Table as the native rendering layer and DataTable as the small typed\n  behavior layer; do not add a TanStack dependency.\n- Define behavior in `DataTableColumn` rather than duplicating sorting in\n  feature components.\n- Use row clicks only for opening or activating row context.\n\n## Public Links\n\n- [Documentation and preview](https://ui.siteplane.io/docs/components/data-display/data-table)\n\n- [Registry source](https://ui.siteplane.io/r/data-table.json)\n",
  "meta": {
    "siteplane:agentContractVersion": 1,
    "siteplane:docs": "https://ui.siteplane.io/docs/components/data-display/data-table",
    "siteplane:releaseVersion": "0.1.1",
    "siteplane:sourceOwned": true
  },
  "devDependencies": []
}
