{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "table",
  "title": "Table",
  "description": "Table parts with sortable headers, selection cells, densities, an empty state and skeleton rows.",
  "dependencies": [
    "class-variance-authority@^0.7.1",
    "lucide-react@^1.27.0"
  ],
  "registryDependencies": [
    "@pestoui/skeleton",
    "@pestoui/style",
    "@pestoui/utils"
  ],
  "files": [
    {
      "path": "pestoui/ui/table.tsx",
      "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { ArrowUp, ChevronsUpDown } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\n\n/* ---------------------------------------------------------------------------\n   Boundary — read this before filing a bug\n\n   Nothing in this file holds state. `direction`, `onSort`, `checked`,\n   `onCheckedChange`, page and page size are all owned by the caller. Clicking a\n   sort header fires a callback and re-renders an arrow; it does not sort\n   anything. Checking the select-all box fires a callback; it does not select\n   any rows. These are presentational parts over a plain `<table>`, deliberately\n   shaped so a headless table layer can be dropped on top later without any of\n   this needing to be restyled.\n\n   If you want the convenience, a `Set<string>` plus toggle/toggleAll/clear is\n   about thirty lines of plain React and belongs in `src/hooks/`, not here.\n--------------------------------------------------------------------------- */\n\n/* Density lives in three custom properties rather than in per-part variants, so\n   a cell can never disagree with the row it sits in. They are declared once —\n   either on the table root (via the `size` variant below) or on an ancestor\n   such as `TableCard` — and every part reads them by inheritance.\n\n   Exported because `table-card.tsx` sets them on the card root: that makes the\n   card the single owner of density for everything inside it, and a `<Table>`\n   with no `size` of its own simply inherits. Pass `size` on the table only when\n   you want to override a card that disagrees. */\nconst tableDensity = {\n  default:\n    \"[--table-cell-px:--spacing(4)] [--table-cell-py:--spacing(3)] [--table-head-h:--spacing(11)]\",\n  sm: \"[--table-cell-px:--spacing(3)] [--table-cell-py:--spacing(2)] [--table-head-h:--spacing(9)]\",\n} as const\n\n/* Padding and row height only — no font-size. The density axis moves layout,\n   not type: a table that shrinks its own text stops matching the page it sits\n   on, and the two-tier size token split exists precisely so nobody hard-codes a\n   second scale here. */\nconst tableVariants = cva(\"w-full caption-bottom text-body\", {\n  variants: {\n    size: tableDensity,\n    /* `zebra` re-declares hover, the expanded hold and selected at its own\n       specificity on purpose. A `:nth-child(even)` fill outranks the plain\n       `tr:hover` rule on the row, so without these three the striped rows would\n       go dead under the pointer. Re-stating them inside the same compound puts\n       every state one step above the stripe and leaves the hover/selected\n       tie-break exactly where it already sits on an unstriped table. */\n    separators: {\n      row: \"\",\n      zebra:\n        \"[&_tbody_tr]:border-0 [&_tbody_tr:nth-child(even)]:bg-secondary/40 [&_tbody_tr:nth-child(even)]:hover:bg-accent/60 [&_tbody_tr:nth-child(even)]:has-aria-expanded:bg-accent/60 [&_tbody_tr:nth-child(even)]:data-[state=selected]:bg-selected/8\",\n      grid: \"[&_th:not(:last-child)]:border-r [&_td:not(:last-child)]:border-r\",\n      none: \"[&_tbody_tr]:border-0\",\n    },\n  },\n  /* No default for `size` — an unset size leaves the custom properties to\n     inherit, which is what lets a `TableCard` set density for the whole shell.\n     The parts carry the `default` values as `var()` fallbacks, so a bare\n     `<Table>` renders exactly as it always has. */\n  defaultVariants: { separators: \"row\" },\n})\n\ninterface TableProps\n  extends React.ComponentProps<\"table\">,\n    VariantProps<typeof tableVariants> {\n  /** Escape hatch to the scroll container that wraps every table. Required for\n   *  a sticky header, which needs a bounded scroller to stick against — e.g.\n   *  `{ className: \"max-h-96 overflow-y-auto\" }`. */\n  containerProps?: React.ComponentProps<\"div\">\n}\n\nfunction Table({\n  className,\n  size,\n  separators,\n  containerProps,\n  ...props\n}: TableProps) {\n  const { className: containerClassName, ...container } = containerProps ?? {}\n\n  return (\n    <div\n      data-slot=\"table-container\"\n      className={cn(\"relative w-full overflow-x-auto\", containerClassName)}\n      {...container}\n    >\n      <table\n        data-slot=\"table\"\n        data-size={size ?? undefined}\n        data-separators={separators ?? \"row\"}\n        className={cn(tableVariants({ size, separators }), className)}\n        {...props}\n      />\n    </div>\n  )\n}\n\n/* The hairline is drawn by a pseudo-element rather than `border-b` when the\n   header is sticky, and that is not a style preference. Preflight sets\n   `border-collapse: collapse`, and a collapsed border belongs to the table\n   rather than to the cell — so it stays behind with the table body and the\n   sticky header visibly sheds its underline mid-scroll. Switching the table to\n   `border-collapse: separate` would fix it and move every other border in the\n   system, so the pseudo-element is the cheap answer. */\nconst tableHeaderSticky =\n  \"sticky top-0 z-[var(--z-sticky,1)] bg-card [&_tr]:border-b-0! [&_th]:relative [&_th]:bg-inherit [&_th]:after:absolute [&_th]:after:inset-x-0 [&_th]:after:bottom-0 [&_th]:after:h-px [&_th]:after:bg-border\"\n\ninterface TableHeaderProps extends React.ComponentProps<\"thead\"> {\n  /** Pin the header while the body scrolls. Needs a bounded scroll container —\n   *  pass one through `Table`'s `containerProps`. Sticking to the page viewport\n   *  will not work inside a `TableCard`: the card clips its own overflow. */\n  sticky?: boolean\n}\n\nfunction TableHeader({ className, sticky = false, ...props }: TableHeaderProps) {\n  return (\n    <thead\n      data-slot=\"table-header\"\n      data-sticky={sticky ? \"true\" : undefined}\n      className={cn(\"[&_tr]:border-b\", sticky && tableHeaderSticky, className)}\n      {...props}\n    />\n  )\n}\n\nfunction TableBody({ className, ...props }: React.ComponentProps<\"tbody\">) {\n  return (\n    <tbody\n      data-slot=\"table-body\"\n      className={cn(\"[&_tr:last-child]:border-0\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction TableFooter({ className, ...props }: React.ComponentProps<\"tfoot\">) {\n  return (\n    <tfoot\n      data-slot=\"table-footer\"\n      className={cn(\n        \"border-t bg-secondary/60 font-medium [&>tr]:last:border-b-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\n/* Motion: colour only, at `duration-instant`. A row is the highest-frequency\n   element in an admin UI — dragging the pointer down forty of them at the\n   default transition length leaves a comet tail of half-faded rows behind the\n   cursor, which reads as lag rather than polish. At 75ms the highlight lands\n   before the eye can register it as travelling.\n\n   All three backgrounds (hover, the expanded hold, selected) share the one\n   timing deliberately: a row that is selected *and* hovered would otherwise\n   cross-fade against itself on two different clocks.\n\n   No lift, no scale, no border animation. Rows are scanned, not admired.\n\n   The selected wash is mixed from --selected rather than --primary. At 8% the\n   CTA lime comes out lighter than the zebra stripe it has to out-rank, which\n   would leave a selected row reading as less marked than an ordinary even one.\n   Same substitution in the `zebra` compound above, for the same reason. */\nfunction TableRow({ className, ...props }: React.ComponentProps<\"tr\">) {\n  return (\n    <tr\n      data-slot=\"table-row\"\n      className={cn(\n        \"border-b transition-colors duration-instant ease-out-expo hover:bg-accent/60 has-aria-expanded:bg-accent/60 data-[state=selected]:bg-selected/8\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TableHead({ className, ...props }: React.ComponentProps<\"th\">) {\n  return (\n    <th\n      data-slot=\"table-head\"\n      className={cn(\n        \"h-[var(--table-head-h,calc(var(--spacing)*11))] px-[var(--table-cell-px,calc(var(--spacing)*4))] text-left align-middle text-caption font-medium whitespace-nowrap text-muted-foreground [&:has([role=checkbox])]:pr-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TableCell({ className, ...props }: React.ComponentProps<\"td\">) {\n  return (\n    <td\n      data-slot=\"table-cell\"\n      className={cn(\n        \"px-[var(--table-cell-px,calc(var(--spacing)*4))] py-[var(--table-cell-py,calc(var(--spacing)*3))] align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TableCaption({\n  className,\n  ...props\n}: React.ComponentProps<\"caption\">) {\n  return (\n    <caption\n      data-slot=\"table-caption\"\n      className={cn(\"mt-4 text-sm text-muted-foreground\", className)}\n      {...props}\n    />\n  )\n}\n\n/* ---------------------------------------------------------------------------\n   Sortable column header\n--------------------------------------------------------------------------- */\n\ntype SortDirection = \"asc\" | \"desc\"\n\n/* Two cvas rather than one because the axis lands on two elements: the cell\n   decides where the text sits, the button decides which side the arrow takes. */\nconst tableSortHeadVariants = cva(\"group/table-sort\", {\n  variants: {\n    align: {\n      start: \"\",\n      end: \"text-right\",\n    },\n  },\n  defaultVariants: { align: \"start\" },\n})\n\n/* The negative inline margin buys the button a hit area and a focus ring\n   without moving the column: it eats back exactly the padding it adds, so a\n   sortable header lines up with a plain one to the pixel. */\nconst tableSortButtonVariants = cva(\n  \"-mx-2 inline-flex h-8 max-w-full items-center gap-1.5 rounded-md px-2 align-middle font-medium whitespace-nowrap transition-colors duration-instant ease-out-expo outline-none select-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50\",\n  {\n    variants: {\n      align: {\n        start: \"\",\n        end: \"flex-row-reverse\",\n      },\n    },\n    defaultVariants: { align: \"start\" },\n  }\n)\n\n/* `align` is omitted from the native `<th>` attributes rather than merged with\n   them: the deprecated HTML attribute takes `left`/`right`, the variant takes\n   `start`/`end`, and an intersection of the two is not a type anything can\n   satisfy. */\ninterface TableSortHeaderProps\n  extends Omit<React.ComponentProps<\"th\">, \"onClick\" | \"align\">,\n    VariantProps<typeof tableSortButtonVariants> {\n  /** `null` or omitted means this column is not the sorted one. Controlled —\n   *  the component renders the arrow, it never decides the direction. */\n  direction?: SortDirection | null\n  /** Fired with the direction the caller should move to. */\n  onSort?: (next: SortDirection) => void\n}\n\nfunction TableSortHeader({\n  className,\n  children,\n  direction = null,\n  onSort,\n  align = \"start\",\n  ...props\n}: TableSortHeaderProps) {\n  const sorted = direction === \"asc\" || direction === \"desc\"\n  const next: SortDirection = direction === \"asc\" ? \"desc\" : \"asc\"\n\n  return (\n    <TableHead\n      aria-sort={\n        direction === \"asc\"\n          ? \"ascending\"\n          : direction === \"desc\"\n            ? \"descending\"\n            : \"none\"\n      }\n      className={cn(tableSortHeadVariants({ align }), className)}\n      {...props}\n    >\n      <button\n        type=\"button\"\n        data-slot=\"table-sort-button\"\n        onClick={onSort ? () => onSort(next) : undefined}\n        className={cn(\n          tableSortButtonVariants({ align }),\n          sorted && \"text-foreground\"\n        )}\n      >\n        <span className=\"min-w-0 truncate\">{children}</span>\n        {sorted ? (\n          /* The entrance and the flip run on different clocks, so they sit on\n             different elements — one `ease-*` per element, since the animation\n             and the transition would otherwise fight over the same variable.\n             Arriving is a content swap (219ms); turning over asc↔desc is travel\n             between two positions (364ms), and at that length the arrow reads\n             as rotating rather than as replacing itself.\n\n             `duration-*` rather than `anim-*` on the wrapper: the wrapper runs\n             a keyframe animation and nothing else, and the enter utilities fall\n             back to `--tw-duration` when no animation-only duration is set. */\n          <span className=\"inline-flex animate-in fade-in zoom-in-90 duration-spring-swap ease-spring-swap\">\n            <ArrowUp\n              aria-hidden=\"true\"\n              className={cn(\n                \"size-3.5 shrink-0 transition-transform duration-spring-glide ease-spring-glide\",\n                direction === \"desc\" && \"rotate-180\"\n              )}\n            />\n          </span>\n        ) : (\n          /* Held at zero opacity rather than unmounted: the column keeps its\n             width whether or not the pointer is over it. Same clock as the row\n             hover — this is the same frequency class of interaction. */\n          <ChevronsUpDown\n            aria-hidden=\"true\"\n            className=\"size-3.5 shrink-0 opacity-0 transition-opacity duration-instant ease-out-expo group-hover/table-sort:opacity-60 group-focus-within/table-sort:opacity-60\"\n          />\n        )}\n      </button>\n    </TableHead>\n  )\n}\n\n/* ---------------------------------------------------------------------------\n   Selection column — sizing only, never selection state\n--------------------------------------------------------------------------- */\n\n/* Fixes the column so the checkbox does not shift left and right as the widest\n   cell in the next column changes between pages. The caller supplies the\n   `<Checkbox>`; select-all goes indeterminate by passing\n   `checked=\"indeterminate\"`, and it needs an `aria-label` because it has no\n   visible text of its own. */\nfunction TableSelectHead({ className, ...props }: React.ComponentProps<\"th\">) {\n  return <TableHead className={cn(\"w-12 pr-0\", className)} {...props} />\n}\n\nfunction TableSelectCell({ className, ...props }: React.ComponentProps<\"td\">) {\n  return <TableCell className={cn(\"w-12 pr-0\", className)} {...props} />\n}\n\n/* ---------------------------------------------------------------------------\n   States\n--------------------------------------------------------------------------- */\n\ninterface TableEmptyProps extends React.ComponentProps<\"td\"> {\n  /** Must match the real column count. Nothing here can validate it — a wrong\n   *  number silently breaks the grid. */\n  colSpan: number\n}\n\n/* Renders both the row and the cell, because the row is the part callers keep\n   getting wrong: an empty state that inherits the row hover fill looks like a\n   clickable record. Drop an `<EmptyState size=\"sm\">` inside. */\nfunction TableEmpty({ className, colSpan, ...props }: TableEmptyProps) {\n  return (\n    <tr data-slot=\"table-empty-row\" className=\"border-0 hover:bg-transparent\">\n      <td\n        data-slot=\"table-empty\"\n        colSpan={colSpan}\n        className={cn(\"p-0 text-center\", className)}\n        {...props}\n      />\n    </tr>\n  )\n}\n\ninterface TableSkeletonColumn {\n  /** Tailwind width utility, e.g. `\"w-32\"`. Keep it close to the real content —\n   *  a placeholder that is the wrong width makes the table jump when data\n   *  lands, which is worse than no placeholder at all. */\n  width?: string\n  className?: string\n}\n\ninterface TableSkeletonRowsProps {\n  rows?: number\n  /** One entry per column. Length must match the header row. */\n  columns: TableSkeletonColumn[]\n  className?: string\n}\n\n/* Set `aria-busy` on the `<Table>` while these are up. The rows themselves are\n   hidden from assistive tech — a screen reader reading out thirty-five empty\n   placeholder cells is worse than silence. */\nfunction TableSkeletonRows({\n  rows = 5,\n  columns,\n  className,\n}: TableSkeletonRowsProps) {\n  return (\n    <>\n      {Array.from({ length: rows }, (_, row) => (\n        <tr\n          key={row}\n          aria-hidden=\"true\"\n          data-slot=\"table-skeleton-row\"\n          className={cn(\"border-b\", className)}\n        >\n          {columns.map((column, index) => (\n            <TableCell key={index}>\n              {/* Offsetting each row against the stagger token turns a block of\n                  rows pulsing in unison — which reads as one big flashing\n                  rectangle — into a sweep travelling down the table. */}\n              <Skeleton\n                className={cn(\"h-4\", column.width ?? \"w-24\", column.className)}\n                style={{ animationDelay: `calc(var(--stagger-tight) * ${row})` }}\n              />\n            </TableCell>\n          ))}\n        </tr>\n      ))}\n    </>\n  )\n}\n\n/* ---------------------------------------------------------------------------\n   Cell layout\n--------------------------------------------------------------------------- */\n\ninterface TableCellStackProps extends React.ComponentProps<\"div\"> {\n  primary: React.ReactNode\n  secondary?: React.ReactNode\n  /** Slot on the inline-start side — an avatar, a logo, an icon tile. */\n  media?: React.ReactNode\n}\n\n/* Name over email, id over agent, product over sku. Far and away the most\n   repeated cell shape, and the one thing here worth owning: every other pattern\n   in a table cell (status badge, progress bar, avatar group, rating, action\n   icons) is an existing component dropped straight into a `TableCell`, and\n   wrapping those would only duplicate their props. */\nfunction TableCellStack({\n  className,\n  primary,\n  secondary,\n  media,\n  children,\n  ...props\n}: TableCellStackProps) {\n  return (\n    <div\n      data-slot=\"table-cell-stack\"\n      className={cn(\"flex items-center gap-2.5\", className)}\n      {...props}\n    >\n      {media ? (\n        <span className=\"flex shrink-0 items-center\">{media}</span>\n      ) : null}\n      <span className=\"flex min-w-0 flex-col leading-tight\">\n        <span className=\"truncate font-medium\">{primary}</span>\n        {secondary ? (\n          <span className=\"truncate text-sm text-muted-foreground\">\n            {secondary}\n          </span>\n        ) : null}\n      </span>\n      {children}\n    </div>\n  )\n}\n\n/* Holder for the trailing icon buttons and the row menu trigger. Deliberately\n   always visible: actions that fade in on row hover are undiscoverable, and on\n   a touch screen they are unreachable. A caller who really wants them hidden\n   can do it with one class — the default should not be the trap. */\nfunction TableActions({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"table-actions\"\n      className={cn(\"ml-auto flex items-center justify-end gap-1\", className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Table,\n  TableHeader,\n  TableBody,\n  TableFooter,\n  TableHead,\n  TableRow,\n  TableCell,\n  TableCaption,\n  TableSortHeader,\n  TableSelectHead,\n  TableSelectCell,\n  TableEmpty,\n  TableSkeletonRows,\n  TableCellStack,\n  TableActions,\n  tableVariants,\n  tableDensity,\n}\n\nexport type {\n  SortDirection,\n  TableProps,\n  TableHeaderProps,\n  TableSortHeaderProps,\n  TableEmptyProps,\n  TableSkeletonColumn,\n  TableSkeletonRowsProps,\n  TableCellStackProps,\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}