{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-table",
  "type": "registry:ui",
  "title": "Data Table",
  "description": "Columns, sorting, row selection, pagination, and the three states a table spends most of its life in (loading / empty / error). It composes primitives — Checkbox, Pagination, Skeleton, Button — so it is a pattern, not a primitive.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "data",
    "pattern"
  ],
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/button.json",
    "https://ds.interlace.tools/r/checkbox.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/pagination.json",
    "https://ds.interlace.tools/r/skeleton.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/patterns/data-table.tsx",
      "target": "components/ui/patterns/data-table.tsx",
      "type": "registry:ui",
      "content": "'use client';\n\nimport * as React from 'react';\n\n// @interlace/data-table v1.0.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/data-table\n// What changed since: https://ds.interlace.tools/c/data-table#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — DataTable\n *\n * Columns, sorting, row selection, pagination, and the three states a table\n * spends most of its life in (loading / empty / error). It composes\n * primitives — Checkbox, Pagination, Skeleton, Button — so it is a pattern,\n * not a primitive.\n *\n * ## It is a real `<table>`\n *\n * Not a grid of divs. `<caption>` names it, `<th scope=\"col\">` names each\n * column, and one column per row is a `<th scope=\"row\">` that names the row\n * (`rowHeader` on the column, defaulting to the first). That triple is what\n * makes a screen reader announce \"Revenue, March, 41,200\" instead of\n * \"41,200\" — a bare number in a bare cell is unreadable by anyone not\n * looking at the screen, and no amount of `aria-label` on the wrapper fixes\n * it. It is the same contract `charts/series-table.tsx` exists to honour for\n * the chart layer.\n *\n * ## State is the caller's\n *\n * `sort` / `onSortChange` and `selected` / `onSelectionChange` are props, not\n * `useState`. The table renders `rows` in the order it received them and\n * highlights the keys it was handed.\n *\n * That is not purity for its own sake — it is the only shape that satisfies\n * URL_PHILOSOPHY / DEEP_LINKING_PHILOSOPHY. Table state belongs in the query\n * string (`?sort=createdAt&dir=desc&page=3`) so a filtered, sorted, paged\n * view survives a refresh and can be pasted to a colleague. A table holding\n * its own sort state cannot put it there, and the \"controlled OR uncontrolled\"\n * compromise is worse: it makes the URL an optional feature, which means it\n * is the feature nobody wires up. It also means the SORT ITSELF is the\n * caller's — server-side for anything real; `sortRows` from\n * `./data-table-model.js` for the small in-memory case.\n *\n * Selection is keyed by row ID (`rowKey`), never by index, so it survives\n * both sorting and pagination.\n *\n * ## MIN_VIEWPORT — 320\n *\n * The table scrolls horizontally inside its own container; the page never\n * does. The pager and the selection bar sit OUTSIDE that container, so they\n * stay put while the columns scroll under them.\n *\n * | Rule | Concept                    | Where in this file                                            |\n * | ---- | -------------------------- | ------------------------------------------------------------- |\n * | R4   | Extends native el          | `Omit<React.ComponentProps<'div'>, 'onSelect'>`                |\n * | R6   | data-slot on every part    | `data-table` / `-scroll` / `-head` / `-row` / `-pagination`    |\n * | R7   | className merged + ...rest | `cn(...)` + `{...props}`                                       |\n * | R8   | No `isXxx`                 | `loading`, `dense`, `selected`, `sort`                         |\n * | R10  | Composition seam           | `columns[].cell`, `empty`, `error`, `toolbar` take nodes       |\n * | R11  | One variable per part      | the row owns selection; the head owns sort                     |\n * | R12  | Reuse over wrap            | Checkbox / Pagination / Skeleton / Button are the primitives   |\n * | R13  | Ecosystem first            | zero new dependencies — no TanStack Table                      |\n * | R14  | Declares min viewport      | `data-min-viewport={String(MIN_VIEWPORT)}`                     |\n * | R18  | Tailwind only              | zero inline `style`                                            |\n * | R19  | Tokens only                | `border-border`, `bg-muted`, `text-muted-foreground`           |\n * | R20  | AA contrast                | selected row = `bg-accent`/`text-accent-foreground` (9.31:1 light, 9.85:1 dark) |\n * | R23  | No layout shift            | `loading` paints `<Skeleton variant=\"data-table\" />`           |\n * | R25  | Client component           | sort / selection handlers                                      |\n * | R26  | A11y                       | table semantics + `aria-sort` + named checkboxes + live region |\n *\n * ## What this is not\n *\n * No filtering UI, no column resize/reorder, no virtualization, no grouping.\n * Each is named in DATA_TABLE_PHILOSOPHY; each is a separate surface with its\n * own URL contract, and a half-built one is worse than an absent one because\n * it reads as capability. See the notes at the bottom of this file.\n */\n\nimport { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\nimport { Button } from '@/components/ui/button';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport {\n  Pagination,\n  PaginationContent,\n  PaginationEllipsis,\n  PaginationItem,\n  PaginationLink,\n  PaginationNext,\n  PaginationPrevious,\n} from '@/components/ui/pagination';\nimport { Skeleton } from '@/components/ui/skeleton';\nimport {\n  ariaSortOf,\n  clampPage,\n  columnName,\n  nextSort,\n  pageSelectionState,\n  pageWindow,\n  selectionMessage,\n  sortActionLabel,\n  toggleKey,\n  togglePageSelection,\n  type DataTableSort,\n} from '@/components/ui/patterns/data-table-model';\n\nexport const MIN_VIEWPORT = 320 as const;\n\n/**\n * Stable empty array for the `selected` default.\n *\n * A `= []` default literal is a new array on every render, which changes the\n * identity of the memo input below it and turns the memo into a no-op.\n */\nconst EMPTY_SELECTION: readonly string[] = [];\n\nexport interface DataTableColumn<Row> {\n  /**\n   * Stable identifier AND the sort key. It is what `sort.columnId` carries\n   * into `?sort=`, so it should be the server's field name — not the display\n   * label, which is free to change.\n   */\n  id: string;\n  /** Header content. Usually a string; may be any node. */\n  header: React.ReactNode;\n  /** Cell renderer for this column. */\n  cell: (row: Row) => React.ReactNode;\n  /**\n   * Plain-text column name for the strings assistive tech reads (the sort\n   * button's action label). Only needed when `header` is not a string.\n   */\n  name?: string;\n  /**\n   * Marks the column sortable: the header becomes a real `<button>` and the\n   * `<th>` carries `aria-sort`. Requires `onSortChange` to do anything.\n   */\n  sortable?: boolean;\n  /** `end` right-aligns the column — use it for numbers. */\n  align?: 'start' | 'end';\n  /**\n   * Renders this column's cell as `<th scope=\"row\">` — the cell that NAMES\n   * the row. Exactly one column should set it; if none does, the first\n   * column is used, because a table whose rows have no header announces\n   * every value as an orphan.\n   */\n  rowHeader?: boolean;\n  /** Merged onto both the `<th>` and the `<td>` of this column. */\n  className?: string;\n}\n\nexport interface DataTablePaginationState {\n  /** 1-based. */\n  page: number;\n  pageCount: number;\n  /**\n   * The href for a page. Give a real one — a pager of `href=\"#\"` is a pager\n   * you cannot middle-click, bookmark, or hand to a crawler\n   * (PAGINATION_PHILOSOPHY).\n   */\n  href?: (page: number) => string;\n  /**\n   * Called on activation. When present the click is intercepted\n   * (`preventDefault`) so a router can own the navigation; when absent the\n   * link navigates on its own.\n   */\n  onPageChange?: (page: number) => void;\n}\n\nexport interface DataTableProps<Row>\n  extends Omit<React.ComponentProps<'div'>, 'onSelect'> {\n  columns: readonly DataTableColumn<Row>[];\n  rows: readonly Row[];\n  /**\n   * Stable identity per row. Selection is keyed by this value, so it must\n   * survive sorting, paging and refetching — a database id, not an index.\n   */\n  rowKey: (row: Row) => string;\n  /**\n   * The `<caption>`. Required: an unnamed table of numbers is a wall of\n   * numbers, and it is the first thing a screen reader reads.\n   */\n  caption: string;\n  /**\n   * Visually hides the caption (it stays in the accessibility tree). Use\n   * when a heading directly above already names the table.\n   */\n  captionHidden?: boolean;\n  /**\n   * Human name of a row, used as the accessible name of its selection\n   * checkbox — \"Select Ada Lovelace\", never \"Select\". Defaults to `rowKey`,\n   * which is better than nothing and worse than a name.\n   */\n  rowLabel?: (row: Row) => string;\n  /** Current sort, or `null` for the server's natural order. */\n  sort?: DataTableSort | null;\n  /**\n   * Receives the next sort on header activation (asc → desc → null).\n   * Omitting it leaves every header inert, whatever the columns declare.\n   */\n  onSortChange?: (next: DataTableSort | null) => void;\n  /**\n   * Selected row keys — including keys that are not on the current page.\n   * Those survive untouched through \"select page\".\n   */\n  selected?: readonly string[];\n  /** Omitting it removes the selection column entirely. */\n  onSelectionChange?: (next: string[]) => void;\n  /** Tighter row height for long tables. */\n  dense?: boolean;\n  /** Paints `<Skeleton variant=\"data-table\" />` instead of the table. */\n  loading?: boolean;\n  /**\n   * Error message. Replaces the BODY only — the header and the pager stay\n   * put, so the retry lands the reader back where they were.\n   */\n  error?: React.ReactNode;\n  /** Renders a Retry button beside `error`. */\n  onRetry?: () => void;\n  /**\n   * Shown in place of the body when `rows` is empty. Pass a different node\n   * for \"no matches, clear filters\" than for \"nothing here yet\" — they are\n   * different messages and only one of them is the reader's fault.\n   */\n  empty?: React.ReactNode;\n  /** Wires the Pagination primitive under the table. */\n  pagination?: DataTablePaginationState;\n  /** Extra controls in the selection bar (bulk actions). */\n  toolbar?: React.ReactNode;\n}\n\nexport function DataTable<Row>({\n  columns,\n  rows,\n  rowKey,\n  caption,\n  captionHidden = false,\n  rowLabel,\n  sort = null,\n  onSortChange,\n  selected = EMPTY_SELECTION,\n  onSelectionChange,\n  dense = false,\n  loading = false,\n  error,\n  onRetry,\n  empty,\n  pagination,\n  toolbar,\n  className,\n  ...props\n}: DataTableProps<Row>) {\n  const selectable = Boolean(onSelectionChange);\n  const pageKeys = React.useMemo(() => rows.map(rowKey), [rows, rowKey]);\n  const chosen = React.useMemo(() => new Set(selected), [selected]);\n\n  // `rowHeader` on any column wins; otherwise the first column names the row.\n  const headerColumnId =\n    columns.find((column) => column.rowHeader)?.id ?? columns[0]?.id;\n\n  const columnCount = columns.length + (selectable ? 1 : 0);\n  const headState = pageSelectionState(pageKeys, selected);\n  const cellPadding = dense ? 'px-3 py-1' : 'px-3 py-2';\n\n  if (loading) {\n    return (\n      <div\n        data-slot=\"data-table\"\n        data-min-viewport={String(MIN_VIEWPORT)}\n        className={cn('flex w-full flex-col gap-3', className)}\n        {...props}\n      >\n        <Skeleton variant=\"data-table\" label={`Loading ${caption}`} />\n      </div>\n    );\n  }\n\n  return (\n    <div\n      data-slot=\"data-table\"\n      data-min-viewport={String(MIN_VIEWPORT)}\n      className={cn('flex w-full flex-col gap-3', className)}\n      {...props}\n    >\n      {/* Rendered unconditionally, even while empty: a live region inserted\n          in the same tick as its first message is usually dropped. */}\n      <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {selectionMessage(selected.length)}\n      </p>\n\n      {selectable && selected.length > 0 ? (\n        <div\n          data-slot=\"data-table-selection\"\n          className=\"flex flex-wrap items-center gap-3 rounded-md border border-border bg-muted px-3 py-2 text-sm\"\n        >\n          <span className=\"text-muted-foreground\">\n            {selectionMessage(selected.length)}\n          </span>\n          {toolbar}\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"ms-auto\"\n            onClick={() => onSelectionChange!([])}\n          >\n            Clear selection\n          </Button>\n        </div>\n      ) : null}\n\n      {/*\n        The scroll box is the table's own, and it is focusable + labelled:\n        a region that scrolls but cannot be reached by keyboard is\n        unreadable to anyone not using a mouse (axe: scrollable-region-\n        focusable), and a sortless, selectionless table has no focusable\n        descendant to inherit that from.\n      */}\n      <section\n        data-slot=\"data-table-scroll\"\n        aria-label={caption}\n        tabIndex={0}\n        // `relative` is not styling — it is what makes this box the\n        // containing block for the absolutely-positioned `sr-only` spans\n        // inside the sort buttons. Without it those spans resolve against\n        // the viewport, escape `overflow-x-auto` entirely, and their static\n        // position in column 13 gives the PAGE a 233px horizontal scroll on\n        // a 375px phone. The table scrolled correctly the whole time; the\n        // thing dragging the page sideways was invisible text.\n        className=\"relative w-full overflow-x-auto rounded-md border border-border focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring\"\n      >\n        <table className=\"w-full border-collapse text-sm\">\n          <caption\n            className={cn(\n              // `text-start`, not `text-left`: in RTL the caption belongs on\n              // the reading edge with the row headers it introduces.\n              'px-3 py-2 text-start text-xs text-muted-foreground',\n              captionHidden && 'sr-only',\n            )}\n          >\n            {caption}\n          </caption>\n          <thead>\n            <tr data-slot=\"data-table-head-row\">\n              {selectable ? (\n                <th\n                  scope=\"col\"\n                  data-slot=\"data-table-head\"\n                  className={cn(\n                    'w-px border-b border-border text-start',\n                    cellPadding,\n                  )}\n                >\n                  {/* The flex wrapper is load-bearing, not decoration. Base\n                      UI renders Checkbox.Root as a `<span>`, and a bare span\n                      in a table cell computes `display: inline` — where\n                      `size-4` is ignored outright and the 16px control paints\n                      as a 2px sliver. Caught in a real browser; jsdom reports\n                      every box as 0×0 and would have called it fine. */}\n                  <span className=\"flex items-center\">\n                    <Checkbox\n                      checked={headState === 'all'}\n                      indeterminate={headState === 'some'}\n                      aria-label={`Select all ${rows.length} rows on this page`}\n                      data-slot=\"data-table-select-page\"\n                      onCheckedChange={() =>\n                        onSelectionChange!(\n                          togglePageSelection(selected, pageKeys),\n                        )\n                      }\n                    />\n                  </span>\n                </th>\n              ) : null}\n              {columns.map((column) => {\n                const sortable = Boolean(column.sortable && onSortChange);\n                const name = columnName(column);\n                return (\n                  <th\n                    key={column.id}\n                    scope=\"col\"\n                    data-slot=\"data-table-head\"\n                    data-column={column.id}\n                    aria-sort={\n                      sortable ? ariaSortOf(sort, column.id) : undefined\n                    }\n                    className={cn(\n                      'whitespace-nowrap border-b border-border text-xs font-medium uppercase tracking-wide text-muted-foreground',\n                      column.align === 'end' ? 'text-end' : 'text-start',\n                      // A sortable head's padding lives on the button, so the\n                      // whole cell is the hit target rather than a word in the\n                      // middle of it.\n                      sortable ? 'p-0' : cellPadding,\n                      column.className,\n                    )}\n                  >\n                    {sortable ? (\n                      <button\n                        type=\"button\"\n                        data-slot=\"data-table-sort\"\n                        className={cn(\n                          'flex w-full items-center gap-1 rounded-sm text-xs font-medium uppercase tracking-wide hover:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring',\n                          cellPadding,\n                          column.align === 'end' && 'justify-end',\n                        )}\n                        onClick={() => onSortChange!(nextSort(sort, column.id))}\n                      >\n                        {column.header}\n                        <SortGlyph\n                          direction={\n                            sort?.columnId === column.id ? sort.direction : null\n                          }\n                        />\n                        {/* `aria-sort` says where the column IS; nothing in\n                            the platform says where a press takes it. */}\n                        <span className=\"sr-only\">\n                          {sortActionLabel(sort, column.id, name)}\n                        </span>\n                      </button>\n                    ) : (\n                      column.header\n                    )}\n                  </th>\n                );\n              })}\n            </tr>\n          </thead>\n          <tbody>\n            {error !== null && error !== undefined ? (\n              <tr data-slot=\"data-table-message\">\n                <td colSpan={columnCount} className=\"px-3 py-8 text-center\">\n                  <div\n                    role=\"alert\"\n                    className=\"flex flex-col items-center gap-2 text-sm text-destructive\"\n                  >\n                    {error}\n                    {onRetry ? (\n                      <Button\n                        type=\"button\"\n                        variant=\"outline\"\n                        size=\"sm\"\n                        onClick={onRetry}\n                      >\n                        Retry\n                      </Button>\n                    ) : null}\n                  </div>\n                </td>\n              </tr>\n            ) : rows.length === 0 ? (\n              <tr data-slot=\"data-table-message\">\n                <td\n                  colSpan={columnCount}\n                  className=\"px-3 py-8 text-center text-sm text-muted-foreground\"\n                >\n                  {empty ?? 'No results.'}\n                </td>\n              </tr>\n            ) : (\n              rows.map((row) => {\n                const key = rowKey(row);\n                const isSelected = chosen.has(key);\n                return (\n                  <tr\n                    key={key}\n                    data-slot=\"data-table-row\"\n                    data-selected={isSelected || undefined}\n                    aria-selected={selectable ? isSelected : undefined}\n                    className={cn(\n                      'border-b border-border last:border-b-0',\n                      isSelected\n                        ? 'bg-accent text-accent-foreground'\n                        : 'hover:bg-muted',\n                    )}\n                  >\n                    {selectable ? (\n                      <td className={cn('w-px', cellPadding)}>\n                        {/* See the head cell: the flex wrapper is what gives\n                            the Base UI span a block formatting context. */}\n                        <span className=\"flex items-center\">\n                          <Checkbox\n                            checked={isSelected}\n                            // Names the ROW, not the control. \"Select\" ×20 is\n                            // a list of identical controls with no way to\n                            // tell which row you are about to act on.\n                            aria-label={`Select ${rowLabel ? rowLabel(row) : key}`}\n                            data-slot=\"data-table-select-row\"\n                            onCheckedChange={() =>\n                              onSelectionChange!(toggleKey(selected, key))\n                            }\n                          />\n                        </span>\n                      </td>\n                    ) : null}\n                    {columns.map((column) =>\n                      column.id === headerColumnId ? (\n                        <th\n                          key={column.id}\n                          scope=\"row\"\n                          data-slot=\"data-table-row-header\"\n                          className={cn(\n                            'whitespace-nowrap text-start font-normal',\n                            cellPadding,\n                            column.className,\n                          )}\n                        >\n                          {column.cell(row)}\n                        </th>\n                      ) : (\n                        <td\n                          key={column.id}\n                          className={cn(\n                            cellPadding,\n                            column.align === 'end'\n                              ? 'text-end tabular-nums'\n                              : 'text-start',\n                            column.className,\n                          )}\n                        >\n                          {column.cell(row)}\n                        </td>\n                      ),\n                    )}\n                  </tr>\n                );\n              })\n            )}\n          </tbody>\n        </table>\n      </section>\n\n      {pagination ? <DataTablePager {...pagination} /> : null}\n    </div>\n  );\n}\n\nfunction SortGlyph({ direction }: { direction: 'asc' | 'desc' | null }) {\n  // Direction is never colour-only: the glyph changes shape, and `aria-sort`\n  // carries it for anyone not looking.\n  const Icon =\n    direction === 'asc'\n      ? ArrowUpIcon\n      : direction === 'desc'\n        ? ArrowDownIcon\n        : ChevronsUpDownIcon;\n  return (\n    <Icon\n      aria-hidden\n      data-slot=\"data-table-sort-glyph\"\n      data-direction={direction ?? 'none'}\n      className={cn('size-3.5 shrink-0', direction === null && 'opacity-50')}\n    />\n  );\n}\n\n/**\n * The pager, wired to the Pagination primitive.\n *\n * Page-based, never infinite scroll (PAGINATION_PHILOSOPHY /\n * DATA_TABLE_PHILOSOPHY §4): page 3 has to be a place you can return to.\n * Every control is a real `<a href>`; `onPageChange` only intercepts the\n * click so a client router can take it.\n */\nfunction DataTablePager({\n  page,\n  pageCount,\n  href,\n  onPageChange,\n}: DataTablePaginationState) {\n  const current = clampPage(page, pageCount);\n  const hrefFor = (target: number) =>\n    href?.(clampPage(target, pageCount)) ?? '#';\n\n  const go = (target: number) => (event: React.MouseEvent<HTMLAnchorElement>) => {\n    const clamped = clampPage(target, pageCount);\n    if (clamped === current && target !== current) {\n      // Prev on page 1 / Next on the last page: the control is\n      // `aria-disabled`, so it must also not navigate.\n      event.preventDefault();\n      return;\n    }\n    if (!onPageChange) return;\n    event.preventDefault();\n    onPageChange(clamped);\n  };\n\n  return (\n    <Pagination>\n      {/* gap-2, not the primitive's gap-1: `size-9` targets plus an 8px\n          boundary gap keep axe's target-spacing math clear (WCAG 2.2 2.5.8). */}\n      <PaginationContent className=\"gap-2\">\n        <PaginationItem>\n          <PaginationPrevious\n            href={hrefFor(current - 1)}\n            onClick={go(current - 1)}\n            aria-disabled={current === 1 || undefined}\n          />\n        </PaginationItem>\n        {pageWindow(current, pageCount).map((value, index) =>\n          value === null ? (\n            <PaginationItem key={`gap-${index}`}>\n              <PaginationEllipsis />\n            </PaginationItem>\n          ) : (\n            <PaginationItem key={value}>\n              <PaginationLink\n                href={hrefFor(value)}\n                active={value === current}\n                onClick={go(value)}\n              >\n                {value}\n              </PaginationLink>\n            </PaginationItem>\n          ),\n        )}\n        <PaginationItem>\n          <PaginationNext\n            href={hrefFor(current + 1)}\n            onClick={go(current + 1)}\n            aria-disabled={current === pageCount || undefined}\n          />\n        </PaginationItem>\n      </PaginationContent>\n    </Pagination>\n  );\n}\n\nexport {\n  ariaSortOf,\n  clampPage,\n  columnName,\n  compareValues,\n  nextSort,\n  pageSelectionState,\n  pageWindow,\n  selectionMessage,\n  sortActionLabel,\n  sortRows,\n  toggleKey,\n  togglePageSelection,\n} from '@/components/ui/patterns/data-table-model';\nexport type {\n  DataTableSort,\n  PageSelectionState,\n  SortDirection,\n  SortValue,\n} from '@/components/ui/patterns/data-table-model';\n"
    },
    {
      "path": "registry/interlace-ui/patterns/data-table-model.ts",
      "target": "components/ui/patterns/data-table-model.ts",
      "type": "registry:ui",
      "content": "export type SortDirection = 'asc' | 'desc';\n\n/**\n * The whole sort state of a table: one column, one direction.\n *\n * Multi-column sort is deliberately not modelled — see the note on\n * `nextSort`.\n */\nexport interface DataTableSort {\n  /**\n   * The column's `id`. This is the SORT KEY, not the display name — it is\n   * what goes into `?sort=` and what a server-side query orders by, so it has\n   * to survive a column being relabelled.\n   */\n  columnId: string;\n  direction: SortDirection;\n}\n\n/**\n * Values a column can sort by. `sortRows` compares these; anything richer\n * (a struct, a locale-sensitive collation) is the caller's job to reduce to\n * one of these first.\n */\nexport type SortValue = string | number | boolean | Date | null | undefined;\n\n/**\n * The sort a header click should produce, given what is sorted now.\n *\n * The cycle is **asc → desc → unsorted**, and clicking a different column\n * always starts that column at ascending rather than inheriting the previous\n * column's direction (inheriting reads as \"the table re-sorted itself\n * backwards\", which is exactly the surprise a table should not produce).\n *\n * The third state is the reason this is a function and not `!direction`.\n * Dropping back to unsorted is what returns the reader to the server's\n * natural order — usually \"newest first\", usually the order they wanted —\n * and a two-state toggle makes that order unreachable without a page reload.\n *\n * Multi-column sort (shift-click, `?sort=a,b&dir=asc,desc`) is described by\n * DATA_TABLE_PHILOSOPHY §2 as opt-in. It is not implemented: a second sort\n * key changes the URL contract, the header UI and the comparator all at once,\n * and shipping half of it would put a shift-click affordance on a table that\n * silently ignores it.\n */\nexport function nextSort(\n  current: DataTableSort | null | undefined,\n  columnId: string,\n): DataTableSort | null {\n  if (!current || current.columnId !== columnId) {\n    return { columnId, direction: 'asc' };\n  }\n  if (current.direction === 'asc') return { columnId, direction: 'desc' };\n  return null;\n}\n\n/**\n * The `aria-sort` value for a column header.\n *\n * Every sortable column gets one — `'none'` on the inactive ones — because\n * the attribute is also how a screen reader knows the column is sortable at\n * all. Non-sortable columns pass `undefined` and render no attribute.\n */\nexport function ariaSortOf(\n  current: DataTableSort | null | undefined,\n  columnId: string,\n): 'ascending' | 'descending' | 'none' {\n  if (!current || current.columnId !== columnId) return 'none';\n  return current.direction === 'asc' ? 'ascending' : 'descending';\n}\n\n/**\n * What activating this header will do next, as a sentence.\n *\n * Rendered `sr-only` inside the sort button. `aria-sort` announces the state\n * the column is IN; nothing in the platform announces the state a press will\n * move it TO, and a control whose effect is unannounced is a control you have\n * to click to discover.\n */\nexport function sortActionLabel(\n  current: DataTableSort | null | undefined,\n  columnId: string,\n  columnName: string,\n): string {\n  const next = nextSort(current, columnId);\n  if (next === null) return `Remove sorting from ${columnName}`;\n  return `Sort by ${columnName}, ${next.direction === 'asc' ? 'ascending' : 'descending'}`;\n}\n\n/**\n * Sort rows locally.\n *\n * The table never calls this — it renders `rows` in the order it was given,\n * because at any real scale the sort belongs to the query that fetched them\n * (DATA_TABLE_PHILOSOPHY §10: client-only sort above ~1,000 rows is\n * forbidden). It is exported for the small-table case, where the caller holds\n * the whole array in memory and would otherwise write this by hand — badly:\n * `Array#sort` is comparison-based, so the naive `a - b` on mixed\n * null/string/number data throws or produces an order that changes between\n * runs.\n *\n * Stable: equal values keep their input order, via an index tiebreak.\n * `null` / `undefined` sort last in BOTH directions — a missing value is not\n * \"small\", and flipping the direction should not march the blanks to the top.\n */\nexport function sortRows<Row>(\n  rows: readonly Row[],\n  sort: DataTableSort | null | undefined,\n  getValue: (row: Row, columnId: string) => SortValue,\n): Row[] {\n  if (!sort) return [...rows];\n  const sign = sort.direction === 'asc' ? 1 : -1;\n  return rows\n    .map((row, index) => ({ row, index }))\n    .sort((a, b) => {\n      const left = getValue(a.row, sort.columnId);\n      const right = getValue(b.row, sort.columnId);\n      const leftBlank = left === null || left === undefined;\n      const rightBlank = right === null || right === undefined;\n      // Resolved BEFORE `sign` is applied: a missing value is not \"small\",\n      // and reversing the sort should not march every blank to the top.\n      if (leftBlank !== rightBlank) return leftBlank ? 1 : -1;\n      const delta = compareValues(left, right);\n      return delta !== 0 ? delta * sign : a.index - b.index;\n    })\n    .map((entry) => entry.row);\n}\n\n/**\n * Ascending comparison across the `SortValue` union.\n *\n * Blanks last (returned unsigned so `sortRows` does not flip them), numbers\n * and dates numerically, everything else through `localeCompare` with\n * `numeric` so `item-2` precedes `item-10`.\n */\nexport function compareValues(a: SortValue, b: SortValue): number {\n  const aBlank = a === null || a === undefined;\n  const bBlank = b === null || b === undefined;\n  if (aBlank && bBlank) return 0;\n  // Not multiplied by `sign` in sortRows — see the note there.\n  if (aBlank) return 1;\n  if (bBlank) return -1;\n\n  const left = a instanceof Date ? a.getTime() : a;\n  const right = b instanceof Date ? b.getTime() : b;\n\n  if (typeof left === 'number' && typeof right === 'number') {\n    return left === right ? 0 : left < right ? -1 : 1;\n  }\n  if (typeof left === 'boolean' || typeof right === 'boolean') {\n    return Number(left) - Number(right);\n  }\n  return String(left).localeCompare(String(right), undefined, {\n    numeric: true,\n    sensitivity: 'base',\n  });\n}\n\n/** Header-checkbox state for the rows currently on screen. */\nexport type PageSelectionState = 'none' | 'some' | 'all';\n\n/**\n * How much of the current page is selected.\n *\n * `'some'` drives the checkbox's indeterminate state. An empty page is\n * `'none'`, never `'all'` — `every()` over an empty array is `true`, which is\n * how a header checkbox on an empty table ends up rendering as checked.\n */\nexport function pageSelectionState(\n  pageKeys: readonly string[],\n  selected: readonly string[],\n): PageSelectionState {\n  if (pageKeys.length === 0) return 'none';\n  const chosen = new Set(selected);\n  let hits = 0;\n  for (const key of pageKeys) if (chosen.has(key)) hits += 1;\n  if (hits === 0) return 'none';\n  return hits === pageKeys.length ? 'all' : 'some';\n}\n\n/**\n * Add or remove one row key, preserving order of the rest.\n *\n * Keyed by row ID, never by index — DATA_TABLE_PHILOSOPHY §5's selection\n * test is exactly the index-keyed bug: sort or paginate and index 3 is a\n * different row, so the checkmarks stay put while the selection underneath\n * them silently changes rows.\n */\nexport function toggleKey(\n  selected: readonly string[],\n  key: string,\n): string[] {\n  return selected.includes(key)\n    ? selected.filter((entry) => entry !== key)\n    : [...selected, key];\n}\n\n/**\n * Select or clear every row on the current page, leaving selections made on\n * OTHER pages untouched.\n *\n * That last clause is the whole point. \"Select page\" that replaces the\n * selection array is how selections vanish when the reader pages back — the\n * failure DATA_TABLE_PHILOSOPHY §5 names, and the one nobody notices until a\n * bulk action runs on a third of the rows they thought they had picked.\n *\n * Partial (`'some'`) promotes to all, matching the checkbox's own\n * indeterminate → checked semantics.\n */\nexport function togglePageSelection(\n  selected: readonly string[],\n  pageKeys: readonly string[],\n): string[] {\n  const state = pageSelectionState(pageKeys, selected);\n  if (state === 'all') {\n    const onPage = new Set(pageKeys);\n    return selected.filter((key) => !onPage.has(key));\n  }\n  const chosen = new Set(selected);\n  return [...selected, ...pageKeys.filter((key) => !chosen.has(key))];\n}\n\n/**\n * The live-region sentence for the current selection.\n *\n * Empty string when nothing is selected, so the region can be rendered\n * unconditionally: an `aria-live` element that only appears once there is\n * something to say is inserted and announced in the same tick, which most\n * screen readers drop.\n *\n * Deliberately no \"of N\": the selection spans pages and the page only knows\n * its own row count, so \"1 of 3 rows selected\" over a 3-row page reads as\n * \"one of these three\" when the selected row may be two pages away. A bare\n * count is the only number the table can honestly report.\n */\nexport function selectionMessage(count: number): string {\n  if (count <= 0) return '';\n  return `${count} ${count === 1 ? 'row' : 'rows'} selected`;\n}\n\n/**\n * The page numbers a pager should render, with `null` for each elided run.\n *\n * First and last are always present (they are the two destinations a reader\n * actually aims for), plus `siblings` pages either side of the current one.\n * A gap of exactly one page is filled rather than eliding it — `1 … 3` and\n * `1 2 3` cost the same width, and the ellipsis is a lie about how much was\n * hidden.\n */\nexport function pageWindow(\n  page: number,\n  pageCount: number,\n  siblings = 1,\n): (number | null)[] {\n  if (pageCount <= 0) return [];\n  const current = clampPage(page, pageCount);\n  const shown = new Set<number>([1, pageCount]);\n  for (let offset = -siblings; offset <= siblings; offset += 1) {\n    const candidate = current + offset;\n    if (candidate >= 1 && candidate <= pageCount) shown.add(candidate);\n  }\n\n  const out: (number | null)[] = [];\n  let previous = 0;\n  for (const value of [...shown].sort((a, b) => a - b)) {\n    // A gap of exactly one page is filled, not elided: `1 … 3` and `1 2 3`\n    // cost the same width, and the ellipsis claims more was hidden than was.\n    if (value - previous === 2 && previous !== 0) out.push(previous + 1);\n    else if (value - previous > 1) out.push(null);\n    out.push(value);\n    previous = value;\n  }\n  return out;\n}\n\n/** Keep a page number inside `1..pageCount`. */\nexport function clampPage(page: number, pageCount: number): number {\n  if (!Number.isFinite(page)) return 1;\n  return Math.min(Math.max(Math.trunc(page), 1), Math.max(pageCount, 1));\n}\n\n/**\n * The plain-text name of a column, for the strings assistive tech reads.\n *\n * A column header is a `ReactNode` — it can be an icon, a badge, a wrapped\n * two-line label — and none of that can be concatenated into \"Sort by …\".\n * `name` is the escape hatch; a string header is used as-is; the `id` is the\n * last resort, and it is at least a word the caller chose.\n */\nexport function columnName(column: {\n  id: string;\n  header?: unknown;\n  name?: string;\n}): string {\n  if (column.name) return column.name;\n  return typeof column.header === 'string' ? column.header : column.id;\n}\n\n/**\n * @interlace/ui — DataTable, the parts with no JSX in them.\n *\n * Every decision a data table makes that is arithmetic rather than markup\n * lives here: what the next sort is, which rows a header checkbox owns, what\n * `aria-sort` should say, which page numbers the pager renders.\n *\n * ## Why a separate module\n *\n * These are the only parts of a table that can be *wrong* in a way a\n * screenshot will not show. A sort cycle that skips a state, a \"select page\"\n * checkbox that quietly drops the selections made on page 1, an ellipsis\n * window that renders `1 … 2` — each is a pure function of its inputs and\n * each shipped, historically, buried in a JSX callback where no test could\n * reach it. Anything below is exercised directly by\n * `__tests__/data-table-model.test.ts`; `data-table.tsx` holds no branch that\n * is not a render decision.\n *\n * Nothing here imports React. The module is safe on a server, in a worker, or\n * in a consumer's own URL-state layer — which matters, because the table\n * itself owns no state (see `data-table.tsx`) and the caller has to compute\n * exactly these values when it wires sort and selection to the query string.\n */\n\n/** Sort direction. There is no third value — \"unsorted\" is `sort === null`. */\n"
    }
  ],
  "meta": {
    "tier": "pattern",
    "client": true,
    "minViewport": 320,
    "loading": true,
    "version": "1.0.0",
    "since": "1.1.0"
  },
  "docs": "## @interlace/data-table\n\nInstalled to `components/ui/patterns/data-table.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/patterns/data-table';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/data-table\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
