{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "table",
  "type": "registry:ui",
  "title": "Table",
  "description": "A table with real semantics, hairline rows, mono figures and a sticky header. It scrolls inside its own region on a phone, and its rows can arrive in one staggered cascade, sort, filter and insert. A virtual body draws ten thousand rows and loads more from a server as you scroll.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/checkbox.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/table.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/table 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Table: real table semantics, dressed to components.md (Table and list rows). Compose it from\n * Table, TableHeader, TableBody, TableRow, TableHead and TableCell. TableEmpty and TableSkeleton\n * are the empty and loading rows.\n *\n * - Look: rows are separated by a 1px line; cells pad space-3 and space-4 and use the small size. The primary\n *   cell is fg and the rest are fg-subtle. Numbers and dates are caption size, right-aligned, with tabular\n *   figures. Hover is an fg-4% fill on the instant tier and never moves anything. On a coarse pointer\n *   there is no hover, so a row held down fills instead (fg 6%, same tier, color only), and so does a\n *   sortable header; the OS tap flash is off so the two do not double. The header row is sticky,\n *   caption size at the label weight (R8), fg-subtle, sentence case; figures stay at 400. The box is a\n *   hairline at the surface radius (Flat: no frame, no header rule, no row lines; a row is a grey bubble under the pointer, its first and last cell rounded), filled surface-1 so the sticky header can cover the rows that scroll under it.\n * - Semantics: a <table> with a hidden <caption>, <th scope=\"col\"> headers and a <th scope=\"row\"> for each primary\n *   cell, so a screen reader announces the row's name with every value. A sortable header holds a real button and\n *   sets aria-sort on its th.\n * - Phones: the table never makes the page scroll sideways. It keeps the width its content needs and scrolls\n *   inside its own region. When there is something to scroll, the region becomes a focusable group named by the\n *   caption, so the arrow keys scroll it. When there is not, it adds no tab stop. It is a group and not a\n *   landmark, so a page with several tables, or a section with the same name, does not repeat a landmark.\n * - Sticky header: it sticks inside the region, so give the Table a max height (max-h-96) and the rows scroll under it.\n *   Without one the table is as tall as its rows and the header has nothing to stick against.\n * - Motion is opt-in. `entrance` plays the staggered cascade: expressive tier, the travel token, each row\n *   waiting its index times the stagger token, counted up to six. It is the page's single expressive\n *   moment. `animateChanges` adds the responsive tier for rows that are added, removed or re-ordered after\n *   the table has mounted: position only (Motion layout), so text never stretches; rows that leave fade, with no travel.\n *   Both are off by default, so a plain table is plain HTML with a hover fill. Under reduced motion every row\n *   simply appears, because the hooks return zero.\n * - paint containment on the <table> clips a row that is travelling, so the cascade never makes the region\n *   grow a scrollbar for a moment.\n * - Corners: the region is rounded-litt-surface, and clips the hover fill of the first and last row to it.\n * - Sorting is a small, accessible step, not a data layer: the header says which way it is sorted and reports\n *   the press; you sort your rows and keep them keyed, and `animateChanges` moves them.\n *\n * Big tables (components.md, \"Table and list rows\", Big tables):\n * - TableVirtualBody draws only the rows in view, plus a few either side, between two spacer rows that keep the\n *   scroll height true. Ten thousand rows cost what forty do. It fixes the column widths the first rows give, so the\n *   columns never shift as other rows scroll by; a cell wider than its column ends in an ellipsis, and a resizable\n *   column can be widened. It needs fixed-height rows (the 44px row: keep cells on\n *   one line) and a Table with a max height, which is the scroller. The header stays sticky, and the table keeps its\n *   semantics: aria-rowcount on the table and aria-rowindex on each row, so a screen reader still says \"row 4,812 of\n *   10,001\". The entrance and change motion are off in it: a long list arrives at once.\n * - Loading as you scroll: with `hasMore` and `onLoadMore` the body asks for the next page once the view comes within\n *   `loadAhead` rows of the last one loaded, once per count, so a fast scroll never asks twice. While `loading`, three\n *   skeleton rows wait after the last row (static, as every skeleton) and the table says \"Loading more rows\" in its\n *   status. A failed page is one row in words with Retry, which asks again. `total`, when the server knows it, makes\n *   aria-rowcount true; without it, while more can come, it is -1, which says the size is not known yet.\n * - Sticky columns: `stickyColumns` keeps the first one or two columns (a box and the row's name) in view while the table\n *   scrolls sideways. They take an opaque fill that follows the row's hover and selection, and a 1px --litt-line hairline\n *   appears at their edge once anything is scrolled under them. No shadow: depth comes from the hairline (R3).\n * - Resizable columns: `resizable` on a TableHead puts a handle at its end edge. The first drag freezes the widths the\n *   columns have, so nothing jumps, and the table becomes fixed-layout: each column then keeps the width it is given,\n *   and what no longer fits ends in an ellipsis. The handle follows the pointer one to one, ← and → move it 16px (Shift\n *   64), Home gives it its minimum, and a double click fits the column to its widest cell. `columnsId` keeps the widths\n *   in the browser. Its hairline shows under the pointer and on focus, as the Resizable's does.\n * - Selection: TableSelectHead and TableSelectCell are the select-all and per-row boxes (the Checkbox, its label\n *   hidden and named for the row), and `selected` on a TableRow fills it fg 6%. useRowSelection holds the selected ids\n *   in a Set, so a check is constant time at any size: Shift selects the range from the last box pressed, the head\n *   box selects or clears the rows shown, and it counts what is selected for an ActionBar.\n */\nimport {\n  Children,\n  createContext,\n  isValidElement,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type ComponentProps,\n  type CSSProperties,\n  type KeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\nimport { flushSync } from \"react-dom\";\nimport { AnimatePresence, motion, type HTMLMotionProps } from \"motion/react\";\nimport { useExit, useStagger, useTransition, useTravel } from \"../../lib/litt/motion\";\nimport { Button } from \"./button\";\nimport { Checkbox } from \"./checkbox\";\nimport { AlertIcon, RetryIcon } from \"./icons\";\n\n/** The entrance cascade stops counting at this many rows, so a long table never drags. */\nconst CASCADE_CAP = 6;\n\ntype TableState = {\n  entrance: boolean;\n  animateChanges: boolean;\n  ready: boolean;\n  region: RefObject<HTMLDivElement | null>;\n  table: RefObject<HTMLTableElement | null>;\n  /** The columns' widths once one has been resized, else null (the table lays itself out). */\n  widths: number[] | null;\n  /** Gives column `index` a width, freezing the others at the widths they have. */\n  resizeColumn: (index: number, width: number) => void;\n  /** Tells onColumnsResize and the browser's storage after a drag or a key. */\n  settleColumns: () => void;\n  /** Fixes the widths the columns have now, if nothing has yet: a virtual body does it, so columns never shift as rows scroll by. */\n  freezeColumns: () => void;\n  /** A virtual body says it is loading more rows, so the table's status says so. */\n  setLoadingMore: (loading: boolean) => void;\n  /** Checks whether the region has something to scroll, so it is focusable from the commit that makes it scroll. */\n  measureScroll: () => void;\n};\nconst TableContext = createContext<TableState>({\n  entrance: false,\n  animateChanges: false,\n  ready: true,\n  region: { current: null },\n  table: { current: null },\n  widths: null,\n  resizeColumn: () => {},\n  settleColumns: () => {},\n  freezeColumns: () => {},\n  setLoadingMore: () => {},\n  measureScroll: () => {},\n});\n/* Inside a TableVirtualBody: the row's place in the whole table, for aria-rowindex. Null elsewhere. */\nconst VirtualIndexContext = createContext<number | null>(null);\nconst SectionContext = createContext<\"head\" | \"body\">(\"body\");\nconst RowIndexContext = createContext(0);\n\n/* -------------------------------------------------------------------------- */\n/* Table                                                                      */\n/* -------------------------------------------------------------------------- */\n\nexport type TableProps = {\n  /** The table's name. It labels the scroll region and is the table's hidden caption, so say what the rows are: \"Deploy history\". */\n  label: string;\n  /** Plays the staggered entrance (expressive tier) when the table mounts. It is the page's one expressive moment, so turn it on for one table. Remount with a new key to replay. */\n  entrance?: boolean;\n  /** Animates rows added, removed or re-ordered after the table has mounted (responsive tier, position only). Give every row a stable key. */\n  animateChanges?: boolean;\n  /** Marks the table as loading: sets aria-busy and announces \"Loading\" with the label. Render a TableSkeleton for the rows. */\n  busy?: boolean;\n  /** Keeps the first one or two columns in view while the table scrolls sideways: the row's box and its name. */\n  stickyColumns?: 0 | 1 | 2;\n  /** Keeps resized column widths in the browser under this name, so the layout someone chose comes back. */\n  columnsId?: string;\n  /** Called with every column's width in px after one is resized. */\n  onColumnsResize?: (widths: number[]) => void;\n  /** Extra classes for the scroll region: a max width, or a max height such as max-h-96 so the header sticks while the rows scroll. */\n  className?: string;\n  /** A TableHeader and a TableBody. */\n  children: ReactNode;\n};\n\n/* Sticky columns. Tailwind only builds classes it can read, so each count is written out. The cells stick to the start\n   edge with an opaque fill that follows the row's hover and selection (fg mixed into surface-1), the header's corner\n   cells sit above both, and the edge cell draws a hairline once something is scrolled under it. */\nconst STICKY_1 = [\n  \"[&_tr>*:first-child]:sticky [&_tr>*:first-child]:start-0\",\n  \"[&_thead_tr>*:first-child]:z-20 [&_tbody_tr>*:first-child]:z-1 [&_tbody_tr>*:first-child]:bg-litt-surface-1\",\n  \"[&_tbody_tr>*:first-child]:transition-[background-color] [&_tbody_tr>*:first-child]:duration-(--litt-dur-instant) [&_tbody_tr>*:first-child]:ease-litt\",\n  \"[&_tbody_tr:hover>*:first-child]:bg-[color-mix(in_oklab,var(--litt-fg)_4%,var(--litt-surface-1))]\",\n  \"[&_tbody_tr[data-selected]>*:first-child]:bg-[color-mix(in_oklab,var(--litt-fg)_6%,var(--litt-surface-1))]\",\n  \"in-[[data-material=flat]]:[&_tbody_tr:hover>*:first-child]:bg-litt-surface-3 in-[[data-material=flat]]:[&_tbody_tr[data-selected]>*:first-child]:bg-litt-surface-3\",\n  \"[&_tr>*:first-child]:after:pointer-events-none [&_tr>*:first-child]:after:absolute [&_tr>*:first-child]:after:inset-y-0 [&_tr>*:first-child]:after:end-0\",\n  \"[&_tr>*:first-child]:after:w-px [&_tr>*:first-child]:after:bg-litt-line [&_tr>*:first-child]:after:opacity-0 data-scrolled:[&_tr>*:first-child]:after:opacity-100\",\n].join(\" \");\nconst STICKY_2 = [\n  \"[&_tr>*:nth-child(-n+2)]:sticky [&_tr>*:first-child]:start-0 [&_tr>*:nth-child(2)]:start-(--sticky-2)\",\n  \"[&_thead_tr>*:nth-child(-n+2)]:z-20 [&_tbody_tr>*:nth-child(-n+2)]:z-1 [&_tbody_tr>*:nth-child(-n+2)]:bg-litt-surface-1\",\n  \"[&_tbody_tr>*:nth-child(-n+2)]:transition-[background-color] [&_tbody_tr>*:nth-child(-n+2)]:duration-(--litt-dur-instant) [&_tbody_tr>*:nth-child(-n+2)]:ease-litt\",\n  \"[&_tbody_tr:hover>*:nth-child(-n+2)]:bg-[color-mix(in_oklab,var(--litt-fg)_4%,var(--litt-surface-1))]\",\n  \"[&_tbody_tr[data-selected]>*:nth-child(-n+2)]:bg-[color-mix(in_oklab,var(--litt-fg)_6%,var(--litt-surface-1))]\",\n  \"in-[[data-material=flat]]:[&_tbody_tr:hover>*:nth-child(-n+2)]:bg-litt-surface-3 in-[[data-material=flat]]:[&_tbody_tr[data-selected]>*:nth-child(-n+2)]:bg-litt-surface-3\",\n  \"[&_tr>*:nth-child(2)]:after:pointer-events-none [&_tr>*:nth-child(2)]:after:absolute [&_tr>*:nth-child(2)]:after:inset-y-0 [&_tr>*:nth-child(2)]:after:end-0\",\n  \"[&_tr>*:nth-child(2)]:after:w-px [&_tr>*:nth-child(2)]:after:bg-litt-line [&_tr>*:nth-child(2)]:after:opacity-0 data-scrolled:[&_tr>*:nth-child(2)]:after:opacity-100\",\n].join(\" \");\n\n/* Once resized, every cell clips to its column and ends in an ellipsis. */\nconst FIXED = \"table-fixed [&_td]:overflow-hidden [&_td]:text-ellipsis [&_th]:overflow-hidden [&_th]:text-ellipsis\";\n\nconst COLUMNS_STORE = (id: string) => `litt-table-columns:${id}`;\n\n/** A table in its own scroll region. Compose it from TableHeader and TableBody. */\nexport function Table({\n  label,\n  entrance = false,\n  animateChanges = false,\n  busy = false,\n  stickyColumns = 0,\n  columnsId,\n  onColumnsResize,\n  className = \"\",\n  children,\n}: TableProps) {\n  const captionId = useId();\n  const region = useRef<HTMLDivElement>(null);\n  const table = useRef<HTMLTableElement>(null);\n  const [ready, setReady] = useState(false);\n  const [scrolls, setScrolls] = useState(false);\n  const [widths, setWidths] = useState<number[] | null>(null);\n  const latestWidths = useRef<number[] | null>(null);\n  latestWidths.current = widths;\n  /* Who fixed the widths: a virtual body (the table still fills its region) or a person (each column keeps its width). */\n  const [frozenBy, setFrozenBy] = useState<\"auto\" | \"person\">(\"auto\");\n  const [scrolledX, setScrolledX] = useState(false);\n  const [stickyStart, setStickyStart] = useState(0);\n  const [loadingMore, setLoadingMore] = useState(false);\n\n  /* The widths the header cells have now, to freeze before the first resize. */\n  const measureColumns = useCallback(() => {\n    const cells = table.current?.tHead?.rows[0]?.cells;\n    /* Rounded up, so a column fixed at the width it had never clips its own widest cell by a fraction of a pixel. */\n    return cells ? Array.from(cells, (c) => Math.ceil(c.getBoundingClientRect().width)) : [];\n  }, []);\n\n  const resizeColumn = useCallback(\n    (index: number, width: number) => {\n      const base = latestWidths.current ?? measureColumns();\n      if (index < 0 || index >= base.length) return;\n      const next = [...base];\n      next[index] = Math.round(width);\n      latestWidths.current = next;\n      setWidths(next);\n      setFrozenBy(\"person\");\n    },\n    [measureColumns],\n  );\n\n  const freezeColumns = useCallback(() => {\n    if (latestWidths.current) return;\n    const now = measureColumns();\n    /* A table drawn while hidden (a closed tab) measures zeros: leave it to lay itself out. */\n    if (!now.length || now.some((w) => w === 0)) return;\n    latestWidths.current = now;\n    setWidths(now);\n  }, [measureColumns]);\n\n  const settleColumns = useCallback(() => {\n    const now = latestWidths.current;\n    if (!now) return;\n    onColumnsResize?.(now);\n    if (!columnsId) return;\n    try {\n      localStorage.setItem(COLUMNS_STORE(columnsId), JSON.stringify(now));\n    } catch {\n      /* storage is full or off: the widths still hold for this visit */\n    }\n  }, [columnsId, onColumnsResize]);\n\n  /* Saved widths come back after the first render, so the server and the browser agree on that one. A saved list that\n     no longer has one width per column is ignored. */\n  useLayoutEffect(() => {\n    if (!columnsId) return;\n    try {\n      const saved = JSON.parse(localStorage.getItem(COLUMNS_STORE(columnsId)) ?? \"null\") as unknown;\n      const count = table.current?.tHead?.rows[0]?.cells.length ?? -1;\n      if (Array.isArray(saved) && saved.length === count && saved.every((n) => typeof n === \"number\" && n > 0)) {\n        latestWidths.current = saved as number[];\n        setWidths(saved as number[]);\n        setFrozenBy(\"person\");\n      }\n    } catch {\n      /* no storage, or something else stored there */\n    }\n  }, [columnsId]);\n\n  /* Sticky columns: the second one starts where the first ends, and the edge hairline shows once anything is scrolled under them. */\n  useLayoutEffect(() => {\n    if (stickyColumns === 0) return;\n    const el = region.current;\n    const first = table.current?.tHead?.rows[0]?.cells[0];\n    if (!el) return;\n    const onScroll = () => setScrolledX(Math.abs(el.scrollLeft) > 0);\n    onScroll();\n    el.addEventListener(\"scroll\", onScroll, { passive: true });\n    const observer = first ? new ResizeObserver(() => setStickyStart(first.getBoundingClientRect().width)) : null;\n    if (first && observer) observer.observe(first);\n    return () => {\n      el.removeEventListener(\"scroll\", onScroll);\n      observer?.disconnect();\n    };\n  }, [stickyColumns]);\n\n  // Rows that mount with the table are its first wave. Rows that mount later are insertions.\n  useEffect(() => setReady(true), []);\n\n  /* The region is focusable only while there is something to scroll. It is measured after every commit, in the same\n     frame, so rows that land never make it scroll a frame before it can take focus (a virtual body measures after its\n     own commits too). The observer catches what React does not draw: a resize, a font that loads. */\n  const measureScroll = useCallback(() => {\n    const el = region.current;\n    if (el) setScrolls(el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1);\n  }, []);\n  useLayoutEffect(measureScroll);\n  useEffect(() => {\n    const observer = new ResizeObserver(() => flushSync(measureScroll));\n    if (region.current) observer.observe(region.current);\n    if (table.current) observer.observe(table.current);\n    return () => observer.disconnect();\n  }, [measureScroll]);\n\n  return (\n    <TableContext.Provider\n      value={{ entrance, animateChanges, ready, region, table, widths, resizeColumn, settleColumns, freezeColumns, setLoadingMore, measureScroll }}\n    >\n      <motion.div\n        ref={region}\n        layoutScroll\n        {...(scrolls ? { role: \"group\", \"aria-labelledby\": captionId, tabIndex: 0 } : {})}\n        aria-busy={busy || undefined}\n        className={\n          \"relative w-full overflow-auto rounded-litt-surface border border-litt-line bg-litt-surface-1 in-[[data-material=flat]]:border-transparent \" +\n          \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus \" +\n          className\n        }\n      >\n        <table\n          ref={table}\n          data-scrolled={scrolledX || undefined}\n          className={[\n            \"border-separate border-spacing-0 contain-paint text-left text-litt-small\",\n            widths ? FIXED + (frozenBy === \"auto\" ? \" min-w-full\" : \"\") : \"w-full min-w-max\",\n            stickyColumns === 1 ? STICKY_1 : stickyColumns === 2 ? STICKY_2 : \"\",\n          ].join(\" \")}\n          style={{\n            ...(widths ? { width: widths.reduce((a, b) => a + b, 0) } : {}),\n            ...(stickyColumns === 2 ? ({ \"--sticky-2\": `${stickyStart}px` } as CSSProperties) : {}),\n          }}\n        >\n          <caption id={captionId} className=\"sr-only\">\n            {label}\n          </caption>\n          {widths && (\n            <colgroup>\n              {widths.map((w, i) => (\n                <col key={i} style={{ width: w }} />\n              ))}\n            </colgroup>\n          )}\n          {children}\n        </table>\n        <div role=\"status\" className=\"sr-only\">\n          {busy ? `Loading ${label}` : loadingMore ? \"Loading more rows\" : \"\"}\n        </div>\n      </motion.div>\n    </TableContext.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* TableHeader, TableBody                                                     */\n/* -------------------------------------------------------------------------- */\n\nexport type TableHeaderProps = {\n  /** One TableRow of TableHead cells. */\n  children: ReactNode;\n};\n\n/** The header section. Its cells stick to the top of the table's region. */\nexport function TableHeader({ children }: TableHeaderProps) {\n  return (\n    <SectionContext.Provider value=\"head\">\n      <thead>{children}</thead>\n    </SectionContext.Provider>\n  );\n}\n\nexport type TableBodyProps = {\n  /** The rows, as direct children (an array from map is fine), each with a stable key. A TableEmpty or TableSkeleton can stand in for them. */\n  children: ReactNode;\n};\n\n/** The body section. It numbers its rows for the entrance cascade and hosts the leaving rows. */\nexport function TableBody({ children }: TableBodyProps) {\n  const { entrance, animateChanges } = useContext(TableContext);\n  // Each row learns its place through context, so a row can be your own component and still be counted.\n  const rows = Children.toArray(children).map((child, i) =>\n    isValidElement(child) ? (\n      <RowIndexContext.Provider key={child.key} value={i}>\n        {child}\n      </RowIndexContext.Provider>\n    ) : (\n      child\n    ),\n  );\n  return (\n    <SectionContext.Provider value=\"body\">\n      {/* The last row keeps no line under it: the region's own hairline closes the table. */}\n      <tbody className=\"[&>tr:last-child>*]:border-b-0\">\n        {/* Its own AnimatePresence gives the rows a presence of their own. It hosts the leaving rows, and it keeps an\n            ancestor's initial={false} (the Tabs panel has one) from swallowing the entrance cascade. */}\n        {entrance || animateChanges ? <AnimatePresence>{rows}</AnimatePresence> : rows}\n      </tbody>\n    </SectionContext.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* TableVirtualBody                                                           */\n/* -------------------------------------------------------------------------- */\n\n/** The row height a virtual body starts from: the 44px row (h-litt-control-md). It measures the real one once drawn. */\nconst ROW_HEIGHT = 44;\n\nexport type TableVirtualBodyProps = {\n  /** How many rows there are in all. */\n  count: number;\n  /** Draws row `index`: a TableRow with a stable key. Called only for the rows in view. */\n  children: (index: number) => ReactNode;\n  /** How many rows to draw beyond each edge of the view, so a fast scroll never shows a gap. */\n  overscan?: number;\n  /** How many columns the table has, so the spacer rows span it. */\n  columns: number;\n  /** There are rows beyond `count` still to load: the body calls onLoadMore as the view nears the last row loaded. */\n  hasMore?: boolean;\n  /** Called once per count when the view comes within `loadAhead` rows of the end and hasMore is set. Fetch the next page and append it. To start over (a new filter on the server), empty the rows: the body asks for the first page itself. */\n  onLoadMore?: () => void;\n  /** A page is on its way: skeleton rows wait after the last row, and the table says \"Loading more rows\". */\n  loading?: boolean;\n  /** The last page failed, in plain words: \"The next deploys did not load.\". It shows as the last row, with Retry calling onLoadMore. */\n  loadError?: string;\n  /** How many rows before the last one loaded to ask for more, so the next page is usually there before anyone reaches the end. */\n  loadAhead?: number;\n  /** How many rows there are on the server, when it says. It makes aria-rowcount true while rows are still loading. */\n  total?: number;\n  /** One entry per column for the loading rows, as TableSkeleton's columns. By default a text bar in each. */\n  skeleton?: SkeletonColumn[];\n};\n\n/**\n * A body that draws only the rows in view. Give the Table a max height (it is the scroller) and keep each row on one\n * line, so every row is the same height. The rows are read from `children(index)`. With hasMore and onLoadMore it\n * loads the next page from a server as the view nears the end.\n */\nexport function TableVirtualBody({\n  count,\n  children,\n  overscan = 8,\n  columns,\n  hasMore = false,\n  onLoadMore,\n  loading = false,\n  loadError,\n  loadAhead = 20,\n  total,\n  skeleton,\n}: TableVirtualBodyProps) {\n  const { region, table, freezeColumns, setLoadingMore, measureScroll } = useContext(TableContext);\n  const body = useRef<HTMLTableSectionElement>(null);\n\n  const [height, setHeight] = useState(ROW_HEIGHT);\n  /* start and end are the rows drawn; last is the last row in view, which decides when to load more. */\n  const [range, setRange] = useState({ start: 0, end: Math.min(count, 30), last: Math.min(count, 30) });\n\n  /* Which rows the view shows, from the scroll position, less the sticky header's height above the first row. */\n  const measure = useCallback(() => {\n    const el = region.current;\n    const tbody = body.current;\n    if (!el || !tbody) return;\n    const top = tbody.offsetTop;\n    const first = Math.floor(Math.max(0, el.scrollTop - top) / height);\n    const shown = Math.ceil(el.clientHeight / height);\n    const start = Math.max(0, first - overscan);\n    const end = Math.min(count, first + shown + overscan);\n    const last = first + shown;\n    setRange((r) => (r.start === start && r.end === end && r.last === last ? r : { start, end, last }));\n  }, [region, height, overscan, count]);\n\n  useLayoutEffect(() => {\n    measure();\n    const el = region.current;\n    if (!el) return;\n    let frame = 0;\n    const onScroll = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(measure);\n    };\n    el.addEventListener(\"scroll\", onScroll, { passive: true });\n    const observer = new ResizeObserver(() => measure());\n    observer.observe(el);\n    /* Spacer heights change as it scrolls, so the browser must not try to keep a row anchored in place. */\n    const anchor = el.style.overflowAnchor;\n    el.style.overflowAnchor = \"none\";\n    return () => {\n      el.removeEventListener(\"scroll\", onScroll);\n      observer.disconnect();\n      cancelAnimationFrame(frame);\n      el.style.overflowAnchor = anchor;\n    };\n  }, [region, measure]);\n\n  /* The real row height, read from the first row drawn, in case the host's type makes it taller than 44px. */\n  useLayoutEffect(() => {\n    const row = body.current?.querySelector<HTMLElement>(\"tr[aria-rowindex]\");\n    const real = row?.getBoundingClientRect().height;\n    if (real && Math.abs(real - height) > 0.5) setHeight(real);\n  });\n  /* Its own commits change the spacers' height, so the region's: the table rechecks whether it scrolls. */\n  useLayoutEffect(measureScroll);\n\n  /* The whole table's size, for screen readers: the header row plus every row. -1 says it is not known yet. */\n  useEffect(() => {\n    const el = table.current;\n    if (!el) return;\n    el.setAttribute(\"aria-rowcount\", String(total !== undefined ? total + 1 : hasMore ? -1 : count + 1));\n    return () => el.removeAttribute(\"aria-rowcount\");\n  }, [table, count, total, hasMore]);\n\n  /* The next page, asked for once per count: a scroll that keeps moving near the end, or a second run of this effect in\n     development, never asks twice for the same page. Retry asks again on purpose. */\n  const asked = useRef(-1);\n  useEffect(() => {\n    if (!hasMore || loading || loadError || !onLoadMore) return;\n    if (count - range.last > loadAhead || asked.current === count) return;\n    asked.current = count;\n    onLoadMore();\n  }, [hasMore, loading, loadError, onLoadMore, count, range.last, loadAhead]);\n\n  useEffect(() => {\n    setLoadingMore(loading);\n    return () => setLoadingMore(false);\n  }, [loading, setLoadingMore]);\n\n  const start = Math.min(range.start, count);\n  const end = Math.min(range.end, count);\n\n  /* The browser sizes a table's columns from the rows it has, and a virtual body has different rows at every scroll\n     position, so the columns would shift as it scrolls. Fix them at the widths the first rows drawn give: not before,\n     or a first page from a server would fix them to the header's words alone. A passive effect, because React attaches\n     the <table>'s ref after this body's layout effects run. */\n  const drawn = end > start;\n  useEffect(() => {\n    if (drawn) freezeColumns();\n  }, [drawn, freezeColumns]);\n  const rows: ReactNode[] = [];\n  for (let i = start; i < end; i++) {\n    rows.push(\n      <VirtualIndexContext.Provider key={i} value={i + 2}>\n        {children(i)}\n      </VirtualIndexContext.Provider>,\n    );\n  }\n  const spacer = (h: number, key: string) =>\n    h > 0 ? (\n      <tr key={key} aria-hidden=\"true\">\n        <td colSpan={columns} className=\"border-0 p-0\" style={{ height: h }} />\n      </tr>\n    ) : null;\n\n  const retry = () => {\n    asked.current = count;\n    onLoadMore?.();\n  };\n\n  return (\n    <SectionContext.Provider value=\"body\">\n      {/* The last row keeps no line under it. At the end of the list that is the last real row; before it, the spacer. */}\n      <tbody ref={body} className=\"[&>tr:last-child>*]:border-b-0\">\n        {spacer(start * height, \"before\")}\n        {rows}\n        {spacer((count - end) * height, \"after\")}\n        {loadError ? (\n          <tr>\n            <td colSpan={columns} className=\"h-litt-control-md border-b border-litt-line in-[[data-material=flat]]:border-transparent px-4 py-1 align-middle\">\n              {/* Sticky, so the words and Retry stay in view in a table scrolled sideways. */}\n              <div role=\"alert\" className=\"sticky start-4 flex w-max items-center gap-3 text-litt-small text-litt-fg-subtle\">\n                <span className=\"text-litt-danger\">\n                  <AlertIcon />\n                </span>\n                {loadError}\n                <Button size=\"sm\" variant=\"ghost\" icon={<RetryIcon />} onClick={retry}>\n                  Retry\n                </Button>\n              </div>\n            </td>\n          </tr>\n        ) : (\n          loading && hasMore && <TableSkeleton columns={skeleton ?? Array.from({ length: columns }, () => \"text\")} rows={3} />\n        )}\n      </tbody>\n    </SectionContext.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Selection                                                                  */\n/* -------------------------------------------------------------------------- */\n\nexport type RowSelection = {\n  /** The selected ids. */\n  selected: ReadonlySet<string>;\n  /** How many are selected, shown or not. */\n  count: number;\n  /** Whether one id is selected. Constant time. */\n  isSelected: (id: string) => boolean;\n  /** Turns one row on or off. With `range` (Shift held), every row shown between it and the last row pressed follows. */\n  toggle: (id: string, range?: boolean) => void;\n  /** The head box's state over the rows shown: all, some or none of them selected. */\n  head: \"all\" | \"some\" | \"none\";\n  /** The head box: selects every row shown, or clears them when all are selected. */\n  toggleAll: () => void;\n  /** Clears the whole selection, for an ActionBar's Clear. */\n  clear: () => void;\n};\n\n/**\n * Selected rows for a Table of any size, by id. Pass the ids of the rows shown, in their order (after your filter and\n * sort): Shift ranges follow that order and the head box acts on those rows. Rows hidden by a filter stay selected.\n */\nexport function useRowSelection(shown: readonly string[], initial: Iterable<string> = []): RowSelection {\n  const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set(initial));\n  const anchor = useRef<string | null>(null);\n  const order = useMemo(() => new Map(shown.map((id, i) => [id, i])), [shown]);\n\n  const toggle = useCallback(\n    (id: string, range = false) => {\n      setSelected((now) => {\n        const next = new Set(now);\n        const on = !now.has(id);\n        const from = anchor.current === null ? undefined : order.get(anchor.current);\n        const to = order.get(id);\n        if (range && from !== undefined && to !== undefined) {\n          for (let i = Math.min(from, to); i <= Math.max(from, to); i++) {\n            if (on) next.add(shown[i]!);\n            else next.delete(shown[i]!);\n          }\n        } else if (on) next.add(id);\n        else next.delete(id);\n        return next;\n      });\n      anchor.current = id;\n    },\n    [order, shown],\n  );\n\n  let inView = 0;\n  for (const id of shown) if (selected.has(id)) inView++;\n  const head = inView === 0 ? \"none\" : inView === shown.length ? \"all\" : \"some\";\n\n  const toggleAll = useCallback(() => {\n    setSelected((now) => {\n      const next = new Set(now);\n      const all = shown.length > 0 && shown.every((id) => now.has(id));\n      for (const id of shown) {\n        if (all) next.delete(id);\n        else next.add(id);\n      }\n      return next;\n    });\n  }, [shown]);\n\n  const clear = useCallback(() => {\n    setSelected(new Set());\n    anchor.current = null;\n  }, []);\n\n  return { selected, count: selected.size, isSelected: (id) => selected.has(id), toggle, head, toggleAll, clear };\n}\n\n/** Whether Shift was held for this change: a click's, or the key's on Space. */\nconst shifted = (e: ChangeEvent<HTMLInputElement>) => (e.nativeEvent as MouseEvent).shiftKey === true;\n\nconst selectCell = \"w-12 in-[[data-material=flat]]:group-hover/row:bg-litt-surface-3 in-[[data-material=flat]]:group-data-selected/row:bg-litt-surface-3 in-[[data-material=flat]]:first:rounded-l-litt-control in-[[data-material=flat]]:last:rounded-r-litt-control border-b border-litt-line in-[[data-material=flat]]:border-transparent py-0 pr-0 pl-4 align-middle [&>div]:py-0\";\n\nexport type TableSelectHeadProps = {\n  /** All, some or none of the rows shown are selected: useRowSelection's `head`. */\n  state: \"all\" | \"some\" | \"none\";\n  /** Called when the box is pressed: useRowSelection's `toggleAll`. */\n  onToggle: () => void;\n  /** What the box selects, for screen readers: \"Select every deploy shown\". */\n  label?: string;\n};\n\n/** The select-all box, as the first header cell. Mixed while some of the rows shown are selected. */\nexport function TableSelectHead({ state, onToggle, label = \"Select every row shown\" }: TableSelectHeadProps) {\n  return (\n    <th scope=\"col\" className={head + \" \" + selectCell}>\n      <Checkbox\n        label={<span className=\"sr-only\">{label}</span>}\n        checked={state === \"all\"}\n        indeterminate={state === \"some\"}\n        onChange={onToggle}\n      />\n    </th>\n  );\n}\n\nexport type TableSelectCellProps = {\n  /** Whether the row is selected: useRowSelection's `isSelected(id)`. */\n  checked: boolean;\n  /** Called with whether Shift was held: pass it to useRowSelection's `toggle(id, range)`. */\n  onToggle: (range: boolean) => void;\n  /** Names the box for the row: \"Select deploy 8f2ka1c\". */\n  label: string;\n};\n\n/** The row's box, as the first cell. Shift-press selects the range from the last box pressed. */\nexport function TableSelectCell({ checked, onToggle, label }: TableSelectCellProps) {\n  return (\n    <td className={selectCell + \" h-litt-control-md\"}>\n      <Checkbox label={<span className=\"sr-only\">{label}</span>} checked={checked} onChange={(e) => onToggle(shifted(e))} />\n    </td>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* TableRow                                                                   */\n/* -------------------------------------------------------------------------- */\n\nconst rowHover =\n  \"group/row transition-[background-color] duration-(--litt-dur-instant) ease-litt [-webkit-tap-highlight-color:transparent] \" +\n  \"hover:bg-[color-mix(in_oklab,var(--litt-fg)_4%,transparent)] \" +\n  \"pointer-coarse:active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] \" +\n  /* A selected row keeps the press fill, hovered or not. The box says it in words; the fill only helps the eye. */\n  \"data-selected:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] \" +\n  /* Flat: the bubble is painted on the cells so its ends can round; the row itself stays clear. */\n  \"in-[[data-material=flat]]:hover:bg-transparent in-[[data-material=flat]]:pointer-coarse:active:bg-transparent in-[[data-material=flat]]:data-selected:bg-transparent\";\n\nexport type TableRowProps = Omit<ComponentProps<\"tr\">, \"ref\"> & {\n  /** Fills the row fg 6%, for a row whose TableSelectCell is checked. */\n  selected?: boolean;\n  /** The row's cells. */\n  children?: ReactNode;\n  /** Extra classes for the row. */\n  className?: string;\n};\n\n/** A row. In the body it gets the hover fill, and the entrance or change motion when the Table asks for it. */\nexport function TableRow({ selected = false, className = \"\", children, ...rest }: TableRowProps) {\n  const section = useContext(SectionContext);\n  const { entrance, animateChanges } = useContext(TableContext);\n  const virtualIndex = useContext(VirtualIndexContext);\n  const marks = { \"data-selected\": selected || undefined, \"aria-rowindex\": virtualIndex ?? undefined };\n  if (section === \"head\" || virtualIndex !== null || !(entrance || animateChanges)) {\n    return (\n      <tr className={(section === \"head\" ? \"\" : rowHover + \" \") + className} {...marks} {...rest}>\n        {children}\n      </tr>\n    );\n  }\n  return (\n    <MotionRow className={className} {...marks} {...rest}>\n      {children}\n    </MotionRow>\n  );\n}\n\nfunction MotionRow({ className = \"\", children, ...rest }: TableRowProps) {\n  const { entrance, animateChanges, ready } = useContext(TableContext);\n  const index = useContext(RowIndexContext);\n  const enter = useTransition(\"expressive\");\n  const settle = useTransition(\"responsive\");\n  const leave = useExit(\"responsive\");\n  const travel = useTravel();\n  const stagger = useStagger();\n  // Fixed when the row mounts: did it arrive with the table, or after it?\n  const [withTable] = useState(!ready);\n  const cascade = withTable && entrance;\n  const inserted = !withTable && animateChanges;\n  // Rows after the sixth reuse the sixth's delay.\n  const wait = Math.min(index, CASCADE_CAP - 1) * stagger;\n  return (\n    <motion.tr\n      layout={animateChanges ? \"position\" : false}\n      initial={cascade ? { opacity: 0, y: travel } : inserted ? { opacity: 0, y: travel / 2 } : false}\n      animate={{ opacity: 1, y: 0, transition: cascade ? { ...enter, delay: wait } : settle }}\n      exit={animateChanges ? { opacity: 0, transition: leave } : undefined}\n      className={rowHover + \" \" + className}\n      {...(rest as HTMLMotionProps<\"tr\">)}\n    >\n      {children}\n    </motion.tr>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* TableHead                                                                  */\n/* -------------------------------------------------------------------------- */\n\nconst head =\n  \"group/head sticky top-0 z-10 h-litt-control-md border-b border-litt-line in-[[data-material=flat]]:border-transparent bg-litt-surface-1 text-litt-caption font-litt-label whitespace-nowrap litt-legend\";\n\n/** How far one press of ← or → moves a column's edge, in px; Shift moves four times as far. */\nconst COLUMN_STEP = 16;\n\n/**\n * The end edge of a resizable column: a 12px handle inside the header with a 1px hairline at the edge, shown under the\n * pointer, on focus and while dragged. A separator, valued in px, so a screen reader can say how wide the column is.\n */\nfunction ColumnHandle({ name, minWidth }: { name: string; minWidth: number }) {\n  const { widths, resizeColumn, settleColumns, table } = useContext(TableContext);\n  const ref = useRef<HTMLDivElement>(null);\n  const [index, setIndex] = useState(-1);\n  const [dragging, setDragging] = useState(false);\n  const [natural, setNatural] = useState(0);\n  useLayoutEffect(() => {\n    const th = ref.current?.closest(\"th\");\n    if (!th) return;\n    setIndex(th.cellIndex);\n    setNatural(Math.round(th.getBoundingClientRect().width));\n  }, [widths]);\n  const width = (index >= 0 && widths?.[index]) || natural;\n  const rtl = () => (ref.current ? getComputedStyle(ref.current).direction === \"rtl\" : false);\n  const set = (w: number) => resizeColumn(index, Math.max(minWidth, w));\n\n  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {\n    const th = ref.current?.closest(\"th\");\n    if (e.button !== 0 || !th) return;\n    e.preventDefault();\n    e.stopPropagation();\n    const el = e.currentTarget;\n    el.focus({ preventScroll: true });\n    const start = th.getBoundingClientRect().width;\n    const from = e.clientX;\n    const sign = rtl() ? -1 : 1;\n    el.setPointerCapture(e.pointerId);\n    setDragging(true);\n    const cursor = document.body.style.cursor;\n    document.body.style.cursor = \"col-resize\";\n    const move = (ev: PointerEvent) => set(start + (ev.clientX - from) * sign);\n    const up = () => {\n      el.removeEventListener(\"pointermove\", move);\n      el.removeEventListener(\"pointerup\", up);\n      el.removeEventListener(\"pointercancel\", up);\n      document.body.style.cursor = cursor;\n      setDragging(false);\n      settleColumns();\n    };\n    el.addEventListener(\"pointermove\", move);\n    el.addEventListener(\"pointerup\", up);\n    el.addEventListener(\"pointercancel\", up);\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    const by = e.shiftKey ? COLUMN_STEP * 4 : COLUMN_STEP;\n    const grow = rtl() ? \"ArrowLeft\" : \"ArrowRight\";\n    const shrink = rtl() ? \"ArrowRight\" : \"ArrowLeft\";\n    if (e.key === grow) set(width + by);\n    else if (e.key === shrink) set(width - by);\n    else if (e.key === \"Home\") set(minWidth);\n    else return;\n    e.preventDefault();\n    settleColumns();\n  };\n\n  /* A double click fits the column to its widest cell drawn: what each cell would need with nothing clipped. */\n  const fit = () => {\n    const rows = table.current?.rows;\n    if (!rows || index < 0) return;\n    let widest = minWidth;\n    for (const row of Array.from(rows)) {\n      const cell = row.cells[index];\n      if (!cell || cell.colSpan > 1) continue;\n      const clone = cell.cloneNode(true) as HTMLElement;\n      clone.style.cssText = \"position:absolute;visibility:hidden;width:auto;max-width:none;overflow:visible\";\n      row.appendChild(clone);\n      widest = Math.max(widest, Math.ceil(clone.getBoundingClientRect().width));\n      clone.remove();\n    }\n    set(widest);\n    settleColumns();\n  };\n\n  return (\n    <div\n      ref={ref}\n      role=\"separator\"\n      tabIndex={0}\n      aria-orientation=\"vertical\"\n      aria-label={`Resize the ${name.toLowerCase()} column`}\n      aria-valuenow={Math.round(width)}\n      aria-valuemin={minWidth}\n      data-dragging={dragging || undefined}\n      onPointerDown={onPointerDown}\n      onKeyDown={onKeyDown}\n      onDoubleClick={fit}\n      onClick={(e) => e.stopPropagation()}\n      className={\n        \"group/edge absolute inset-y-0 end-0 z-1 w-3 cursor-col-resize touch-none pointer-coarse:w-5 \" +\n        \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus\"\n      }\n    >\n      <span\n        aria-hidden=\"true\"\n        className={\n          \"absolute inset-y-2 end-0 w-px bg-litt-line-strong opacity-0 transition-[opacity,background-color] duration-(--litt-dur-instant) ease-litt \" +\n          \"group-hover/head:opacity-100 group-focus-visible/edge:opacity-100 group-data-dragging/edge:bg-litt-fg-subtle group-data-dragging/edge:opacity-100\"\n        }\n      />\n    </div>\n  );\n}\n\nexport type SortDirection = \"ascending\" | \"descending\" | \"none\";\n\n/** 16px, 1.5px stroke, like the icon set. Unsorted shows both chevrons so the header reads as sortable without color. */\nfunction SortIcon({ direction }: { direction: SortDirection }) {\n  return (\n    <svg\n      width=\"16\"\n      height=\"16\"\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.5\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n      focusable=\"false\"\n      className=\"shrink-0\"\n    >\n      {direction === \"none\" ? (\n        <path d=\"m5 6.25 3-3 3 3M5 9.75l3 3 3-3\" />\n      ) : direction === \"ascending\" ? (\n        <path d=\"M8 13V3M4.5 6.5 8 3l3.5 3.5\" />\n      ) : (\n        <path d=\"M8 3v10M4.5 9.5 8 13l3.5-3.5\" />\n      )}\n    </svg>\n  );\n}\n\nexport type TableHeadProps = Omit<ComponentProps<\"th\">, \"ref\" | \"scope\" | \"aria-sort\" | \"filter\"> & {\n  /** Right-aligns the header over a column of numbers or dates. */\n  numeric?: boolean;\n  /** A TableFilter (table-filter) for this column. It sits at the end of the header, or before the label over numbers. */\n  filter?: ReactNode;\n  /** Puts a handle at the header's end edge to drag the column wider or narrower. Name the column in children, so the handle says what it resizes. */\n  resizable?: boolean;\n  /** The narrowest a resizable column may be, in px. */\n  minWidth?: number;\n  /** Which way this column is sorted now. Only read when onSort is set. */\n  sort?: SortDirection;\n  /** Makes the header a sortable button and sets aria-sort. Called when it is pressed; you sort the rows and pass the new `sort`. */\n  onSort?: () => void;\n  /** The column's name, in sentence case. */\n  children?: ReactNode;\n  /** Extra classes for the header cell. */\n  className?: string;\n};\n\n/** A column header. Sticky, caption size at the label weight, fg-subtle. Add onSort to make it a sortable button, and filter for a TableFilter. */\nexport function TableHead({\n  numeric = false,\n  sort = \"none\",\n  onSort,\n  filter,\n  resizable = false,\n  minWidth = 64,\n  className = \"\",\n  children,\n  ...rest\n}: TableHeadProps) {\n  const align = numeric ? \"text-right\" : \"text-left\";\n  const handle = resizable ? <ColumnHandle name={typeof children === \"string\" ? children : \"this\"} minWidth={minWidth} /> : null;\n  /* With a filter the cell is a row of two: the label (or the sort button) and the filter's button. Over numbers the\n     filter goes first, so the label stays flush right above the figures. */\n  const withFilter = (label: ReactNode) => (\n    <div className={\"flex h-litt-control-md items-center \" + (numeric ? \"flex-row-reverse pl-2\" : \"pr-2\")}>\n      <div className=\"min-w-0 flex-1\">{label}</div>\n      <div className=\"shrink-0\">{filter}</div>\n    </div>\n  );\n  if (!onSort) {\n    const label = filter ? <span className={\"block px-4 \" + align}>{children}</span> : children;\n    return (\n      <th scope=\"col\" className={[head, align, filter ? \"p-0 text-litt-fg-subtle\" : \"px-4 py-3 text-litt-fg-subtle\", className].join(\" \")} {...rest}>\n        {filter ? withFilter(label) : label}\n        {handle}\n      </th>\n    );\n  }\n  const button = (\n    <button\n      type=\"button\"\n      onClick={onSort}\n      className={[\n        \"flex h-litt-control-md w-full cursor-pointer items-center gap-1 px-4 text-litt-caption litt-legend\",\n        // Numbers sit flush right, so the icon goes to the left of the label to keep the label above the figures.\n        numeric ? \"flex-row-reverse\" : \"\",\n        \"transition-[background-color,color] duration-(--litt-dur-instant) ease-litt [-webkit-tap-highlight-color:transparent]\",\n        \"pointer-coarse:active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)]\",\n        \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus\",\n        sort === \"none\" ? \"text-litt-fg-subtle hover:text-litt-fg pointer-coarse:active:text-litt-fg\" : \"text-litt-fg\",\n      ].join(\" \")}\n    >\n      <span>{children}</span>\n      <SortIcon direction={sort} />\n    </button>\n  );\n  return (\n    <th scope=\"col\" aria-sort={sort} className={[head, align, \"p-0\", className].join(\" \")} {...rest}>\n      {filter ? withFilter(button) : button}\n      {handle}\n    </th>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* TableCell                                                                  */\n/* -------------------------------------------------------------------------- */\n\nexport type TableCellProps = Omit<ComponentProps<\"td\">, \"ref\" | \"scope\"> & {\n  /** Mono caption size, right-aligned with tabular figures. For numbers and dates. */\n  numeric?: boolean;\n  /** The row's name: fg instead of fg-subtle, and a row header (<th scope=\"row\">) so a screen reader announces it with each value. One per row. */\n  primary?: boolean;\n  /** The cell's content. Keep it on one line; a cell never wraps unless you add whitespace-normal. */\n  children?: ReactNode;\n  /** Extra classes for the cell. */\n  className?: string;\n};\n\n/** A body cell. fg-subtle by default; `primary` makes it the row's name, and `numeric` sets tabular figures. */\nexport function TableCell({ numeric = false, primary = false, className = \"\", children, ...rest }: TableCellProps) {\n  const cls = [\n    // font-normal because a <th> is bold by default, and a host app may not reset it.\n    \"h-litt-control-md border-b border-litt-line in-[[data-material=flat]]:border-transparent px-4 py-3 align-middle font-normal whitespace-nowrap in-[[data-material=flat]]:group-hover/row:bg-litt-surface-3 in-[[data-material=flat]]:group-data-selected/row:bg-litt-surface-3 in-[[data-material=flat]]:first:rounded-l-litt-control in-[[data-material=flat]]:last:rounded-r-litt-control\",\n    numeric ? \"text-right text-litt-caption font-normal tabular-nums\" : \"text-left text-litt-small\",\n    primary ? \"text-litt-fg\" : \"text-litt-fg-subtle\",\n    className,\n  ].join(\" \");\n  return primary ? (\n    <th scope=\"row\" className={cls} {...(rest as ComponentProps<\"th\">)}>\n      {children}\n    </th>\n  ) : (\n    <td className={cls} {...rest}>\n      {children}\n    </td>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* TableEmpty, TableSkeleton                                                  */\n/* -------------------------------------------------------------------------- */\n\nexport type TableEmptyProps = {\n  /** How many columns the table has, so the message spans the row. */\n  columns: number;\n  /** One sentence saying what will appear here. */\n  children: ReactNode;\n  /** The one action that makes rows appear, such as a Button. */\n  action?: ReactNode;\n};\n\n/** The empty body row: one sentence and one action, left-aligned, with no illustration. It is announced as a status. */\nexport function TableEmpty({ columns, children, action }: TableEmptyProps) {\n  return (\n    <tr>\n      <td colSpan={columns} className=\"px-4 py-8 text-left align-top\">\n        <p role=\"status\" className=\"max-w-[20rem] text-litt-small text-litt-fg-muted\">\n          {children}\n        </p>\n        {action && <div className=\"mt-4\">{action}</div>}\n      </td>\n    </tr>\n  );\n}\n\nexport type SkeletonColumn = \"primary\" | \"text\" | \"number\" | \"box\";\nconst skeletonBar: Record<SkeletonColumn, string> = {\n  primary: \"h-4 w-40\",\n  text: \"h-4 w-24\",\n  number: \"ml-auto h-4 w-12\",\n  box: \"size-5\",\n};\n\nexport type TableSkeletonProps = {\n  /** One entry per column, so each bar has its column's width and alignment: primary, text or number, or box for a TableSelectCell's column. */\n  columns: SkeletonColumn[];\n  /** How many placeholder rows to draw. */\n  rows?: number;\n};\n\n/** Placeholder rows at the real row height: surface-2 bars, static, and hidden from screen readers. Pair it with `busy` on the Table. */\nexport function TableSkeleton({ columns, rows = 4 }: TableSkeletonProps) {\n  return (\n    <>\n      {Array.from({ length: rows }, (_, row) => (\n        <tr key={row} aria-hidden=\"true\">\n          {columns.map((kind, col) => (\n            <td key={col} className=\"h-litt-control-md border-b border-litt-line in-[[data-material=flat]]:border-transparent px-4 py-3 align-middle\">\n              <span className={\"block rounded-litt-control bg-litt-surface-2 \" + skeletonBar[kind]} />\n            </td>\n          ))}\n        </tr>\n      ))}\n    </>\n  );\n}\n",
      "target": "components/litt/table.tsx"
    }
  ],
  "docs": "Import Table, TableHeader, TableBody, TableRow, TableHead and TableCell from components/litt/table. Give the Table a label, and a max height such as max-h-96 if you want the header to stick. The entrance is off until you pass entrance.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
