{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sortable-table",
  "type": "registry:ui",
  "title": "Sortable Table",
  "description": "Headers cycle through both directions and back, rows spring into their new order.",
  "categories": [
    "tables"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/sortable-table.tsx",
      "content": "\"use client\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type SortDirection = \"asc\" | \"desc\";\nexport type SortState = { column: string; direction: SortDirection } | null;\nexport type SortValue = string | number | bigint | boolean | Date | null | undefined;\n\nexport type SortableColumn<T> = {\n  /** Unique per table. Also the default field read from each row. */\n  key: string;\n  header: React.ReactNode;\n  /** Plain words for announcements when `header` isn't a string. */\n  label?: string;\n  /** What the cell shows. Defaults to `row[key]`. */\n  cell?: (row: T) => React.ReactNode;\n  /** What the column sorts by, when it differs from what it shows. Defaults to `row[key]`. */\n  sortValue?: (row: T) => SortValue;\n  /** Right-aligned, tabular figures, and sorts high-to-low first. */\n  numeric?: boolean;\n  /** Set false for columns that make no sense to sort (actions, avatars). */\n  sortable?: boolean;\n  /** The direction of the first press. Numbers default to \"desc\", everything else to \"asc\". */\n  firstDirection?: SortDirection;\n  /** CSS width of the column, e.g. 120 or \"30%\". */\n  width?: number | string;\n  /** Extra classes for this column's body cells. */\n  className?: string;\n};\n\n// Natural order: \"Invoice 9\" sorts before \"Invoice 10\", and case doesn't split the list.\nconst collator = new Intl.Collator(undefined, { numeric: true, sensitivity: \"base\" });\n\nfunction compareValues(a: SortValue, b: SortValue) {\n  const x = a instanceof Date ? a.getTime() : a;\n  const y = b instanceof Date ? b.getTime() : b;\n  if (typeof x === \"number\" && typeof y === \"number\") return x - y;\n  if (typeof x === \"bigint\" && typeof y === \"bigint\") return x < y ? -1 : x > y ? 1 : 0;\n  if (typeof x === \"boolean\" && typeof y === \"boolean\") return Number(x) - Number(y);\n  return collator.compare(String(x), String(y));\n}\n\nconst read = <T,>(row: T, col: SortableColumn<T>): SortValue =>\n  col.sortValue ? col.sortValue(row) : ((row as Record<string, unknown>)[col.key] as SortValue);\n\nconst isEmpty = (v: SortValue) => v == null || v === \"\" || (typeof v === \"number\" && Number.isNaN(v));\n\n/**\n * Sorts rows the way people expect: stable (ties keep their original order),\n * natural for text, and empty values always last whichever way you sort.\n */\nexport function useSortedRows<T>(rows: readonly T[], columns: readonly SortableColumn<T>[], sort: SortState) {\n  return useMemo(() => {\n    const col = sort && columns.find((c) => c.key === sort.column);\n    if (!sort || !col) return rows.slice();\n    const sign = sort.direction === \"asc\" ? 1 : -1;\n    return rows\n      .map((row, index) => ({ row, index, value: read(row, col) }))\n      .sort((a, b) => {\n        const ea = isEmpty(a.value);\n        const eb = isEmpty(b.value);\n        if (ea || eb) return ea === eb ? a.index - b.index : ea ? 1 : -1;\n        return compareValues(a.value, b.value) * sign || a.index - b.index;\n      })\n      .map((e) => e.row);\n  }, [rows, columns, sort]);\n}\n\n/** The next state in the header's cycle: first direction, the other one, then unsorted. */\nexport function nextSort<T>(sort: SortState, col: SortableColumn<T>): SortState {\n  const first = col.firstDirection ?? (col.numeric ? \"desc\" : \"asc\");\n  if (!sort || sort.column !== col.key) return { column: col.key, direction: first };\n  if (sort.direction === first) return { column: col.key, direction: first === \"asc\" ? \"desc\" : \"asc\" };\n  return null;\n}\n\nexport type SortableTableProps<T> = Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\"> & {\n  columns: SortableColumn<T>[];\n  rows: T[];\n  /** A stable id per row. Rows are tracked by it as they move. */\n  getRowId: (row: T) => string;\n  sort?: SortState;\n  defaultSort?: SortState;\n  onSortChange?: (sort: SortState) => void;\n  /** Names the table for assistive tech. Shown only to screen readers. */\n  caption: string;\n  size?: \"sm\" | \"md\";\n  /** No rows yet: skeleton rows. Rows already on screen: they stay, with a thin bar under the header. */\n  loading?: boolean;\n  /** Shown inside the table body when there are no rows. */\n  empty?: React.ReactNode;\n  /** Past this many rows the reorder is instant; animating hundreds of rows costs more than it tells. */\n  animateLimit?: number;\n  /** Below this width the table scrolls sideways instead of crushing its columns. */\n  minWidth?: number;\n};\n\nexport function SortableTable<T>({\n  columns,\n  rows,\n  getRowId,\n  sort: sortProp,\n  defaultSort = null,\n  onSortChange,\n  caption,\n  size = \"md\",\n  loading = false,\n  empty = \"Nothing here yet\",\n  animateLimit = 80,\n  minWidth,\n  className,\n  onScroll,\n  ...rest\n}: SortableTableProps<T>) {\n  const [sort, setSort] = useControllableState<SortState>({ value: sortProp, defaultValue: defaultSort, onChange: onSortChange });\n  const sorted = useSortedRows(rows, columns, sort);\n  const reduce = useReducedMotion();\n  const [scrolled, setScrolled] = useState(false);\n  const [edges, setEdges] = useState({ left: false, right: false });\n  const scroller = useRef<HTMLDivElement>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const captionId = useId();\n\n  const signature = sort ? `${sort.column}:${sort.direction}` : \"none\";\n\n  // Remember where each row sat before this sort, so rows traveling up can pass over the\n  // rows traveling down. Without it the row bound for the top slides underneath everything.\n  const ids = sorted.map(getRowId);\n  const [order, setOrder] = useState({ signature, ids, before: new Map<string, number>() });\n  if (order.signature !== signature) {\n    setOrder({ signature, ids, before: new Map(order.ids.map((id, i) => [id, i])) });\n  }\n  const animate = !reduce && sorted.length <= animateLimit;\n  const showSkeleton = loading && rows.length === 0;\n\n  const press = (col: SortableColumn<T>) => {\n    const next = nextSort(sort, col);\n    setSort(next);\n    const name = col.label ?? (typeof col.header === \"string\" ? col.header : col.key);\n    setAnnouncement(next ? `Sorted by ${name}, ${next.direction === \"asc\" ? \"ascending\" : \"descending\"}` : \"Sort cleared\");\n  };\n\n  // Which sides have more table hidden past them, for the edge fades on narrow screens.\n  const measure = (el: HTMLDivElement) => {\n    setScrolled(el.scrollTop > 0);\n    const left = el.scrollLeft > 1;\n    const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;\n    setEdges((e) => (e.left === left && e.right === right ? e : { left, right }));\n  };\n  useEffect(() => {\n    const el = scroller.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => measure(el));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  return (\n    <div\n      data-size={size}\n      data-scrolled={scrolled || undefined}\n      aria-busy={loading || undefined}\n      className={cn(\"relative flex flex-col overflow-hidden rounded-xl border border-line bg-frame\", className)}\n      {...rest}\n    >\n      <div\n        ref={scroller}\n        onScroll={(e) => {\n          onScroll?.(e);\n          measure(e.currentTarget);\n        }}\n        className=\"max-h-[inherit] min-h-0 flex-1 overflow-auto overscroll-contain\"\n      >\n        <table aria-labelledby={captionId} style={{ minWidth }} className=\"w-full border-separate border-spacing-0 text-[13px]\">\n          <caption id={captionId} className=\"sr-only\">\n            {caption}\n          </caption>\n          <colgroup>\n            {columns.map((c) => (\n              <col key={c.key} style={c.width != null ? { width: c.width } : undefined} />\n            ))}\n          </colgroup>\n          <thead>\n            <tr>\n              {columns.map((col) => {\n                const active = sort?.column === col.key;\n                const sortable = col.sortable !== false;\n                return (\n                  <th\n                    key={col.key}\n                    scope=\"col\"\n                    aria-sort={active ? (sort!.direction === \"asc\" ? \"ascending\" : \"descending\") : undefined}\n                    className={cn(\n                      \"sticky top-0 z-(--z-sticky) whitespace-nowrap border-b border-line bg-frame p-0 font-medium\",\n                      // Once rows slide under the header it earns a soft shade; at rest it's just a hairline.\n                      \"before:pointer-events-none before:absolute before:inset-x-0 before:top-full before:h-2 before:bg-linear-to-b before:from-overlay before:to-transparent before:opacity-0 before:transition-opacity before:duration-200 before:content-[''] in-data-scrolled:before:opacity-40\",\n                      col.numeric ? \"text-right\" : \"text-left\",\n                    )}\n                  >\n                    {sortable ? (\n                      <SortButton col={col} active={active} direction={active ? sort!.direction : undefined} size={size} onPress={() => press(col)} />\n                    ) : (\n                      <span className={cn(\"flex items-center px-3 text-[12px] text-fg-3\", size === \"sm\" ? \"h-8\" : \"h-9\", col.numeric && \"justify-end\")}>\n                        {col.header}\n                      </span>\n                    )}\n                  </th>\n                );\n              })}\n            </tr>\n          </thead>\n          <tbody>\n            {showSkeleton\n              ? Array.from({ length: 4 }, (_, r) => (\n                  <tr key={r} aria-hidden className=\"group/row\">\n                    {columns.map((col, c) => (\n                      <td key={col.key} className={cn(\"border-b border-line px-3 group-last/row:border-b-0\", size === \"sm\" ? \"h-8\" : \"h-10\")}>\n                        <span\n                          className={cn(\"block h-2.5 rounded-full bg-hover motion-safe:animate-pulse-soft\", col.numeric && \"ml-auto\")}\n                          style={{ width: `${[62, 48, 70, 40][(r + c) % 4]}%`, animationDelay: `${r * 120}ms` }}\n                        />\n                      </td>\n                    ))}\n                  </tr>\n                ))\n              : sorted.map((row, index) => (\n                  <motion.tr\n                    key={getRowId(row)}\n                    style={{ zIndex: Math.min(9, Math.max(0, (order.before.get(getRowId(row)) ?? index) - index)) }}\n                    layout={animate ? \"position\" : false}\n                    layoutDependency={signature}\n                    transition={spring.soft}\n                    // Opaque rows, so rows passing each other mid-sort slide over one another instead of blending.\n                    className=\"group/row relative bg-frame transition-colors duration-150 hover:bg-hover\"\n                  >\n                    {columns.map((col) => (\n                      <td\n                        key={col.key}\n                        className={cn(\n                          \"border-b border-line px-3 text-fg group-last/row:border-b-0\",\n                          size === \"sm\" ? \"h-8\" : \"h-10\",\n                          col.numeric ? \"whitespace-nowrap text-right tabular\" : \"text-left\",\n                          col.className,\n                        )}\n                      >\n                        {col.cell ? col.cell(row) : String((row as Record<string, unknown>)[col.key] ?? \"\")}\n                      </td>\n                    ))}\n                  </motion.tr>\n                ))}\n            {!loading && rows.length === 0 && (\n              <tr>\n                <td colSpan={columns.length} className=\"h-32 px-4 text-center text-[13px] text-fg-3\">\n                  {empty}\n                </td>\n              </tr>\n            )}\n          </tbody>\n        </table>\n      </div>\n\n      {/* More columns past the edge: a soft shadow says so without a scrollbar that phones hide. */}\n      <div aria-hidden className={cn(\"pointer-events-none absolute inset-y-0 left-0 z-(--z-sticky) w-4 bg-linear-to-r from-overlay to-transparent transition-opacity duration-200\", edges.left ? \"opacity-50\" : \"opacity-0\")} />\n      <div aria-hidden className={cn(\"pointer-events-none absolute inset-y-0 right-0 z-(--z-sticky) w-4 bg-linear-to-l from-overlay to-transparent transition-opacity duration-200\", edges.right ? \"opacity-50\" : \"opacity-0\")} />\n\n      {/* Refetching keeps the rows on screen and says so with a hairline under the header, not a spinner over the data. */}\n      {loading && rows.length > 0 && (\n        <div aria-hidden className={cn(\"pointer-events-none absolute inset-x-0 z-(--z-sticky) h-px overflow-hidden\", size === \"sm\" ? \"top-8\" : \"top-9\")}>\n          <div className=\"h-full w-full origin-left bg-fg-3 motion-safe:animate-indeterminate motion-reduce:opacity-40\" />\n        </div>\n      )}\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {loading ? \"Loading\" : announcement}\n      </span>\n    </div>\n  );\n}\n\nfunction SortButton<T>({\n  col,\n  active,\n  direction,\n  size,\n  onPress,\n}: {\n  col: SortableColumn<T>;\n  active: boolean;\n  direction?: SortDirection;\n  size: \"sm\" | \"md\";\n  onPress: () => void;\n}) {\n  const first = col.firstDirection ?? (col.numeric ? \"desc\" : \"asc\");\n  // At rest the arrow previews the first press; once active it shows the real direction.\n  const pointing = direction ?? first;\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onPress}\n      data-active={active || undefined}\n      className={cn(\n        \"group/sort relative flex w-full select-none items-center gap-1 px-3 text-[12px] outline-none\",\n        size === \"sm\" ? \"h-8\" : \"h-9\",\n        col.numeric && \"justify-end\",\n        \"text-fg-3 transition-colors duration-150 hover:text-fg-2 data-active:text-fg\",\n      )}\n    >\n      {/* The whole cell is the target; the ring and the press squash hug the words. */}\n      <span\n        className={cn(\n          \"-mx-1.5 flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 transition-transform duration-100 ease-out group-active/sort:scale-[0.97]\",\n          \"group-focus-visible/sort:outline-solid group-focus-visible/sort:outline-1 group-focus-visible/sort:outline-offset-1 group-focus-visible/sort:outline-fg-3\",\n          col.numeric && \"flex-row-reverse\",\n        )}\n      >\n        <span className=\"truncate\">{col.header}</span>\n        <span\n          aria-hidden\n          className={cn(\n            \"grid size-3.5 shrink-0 place-items-center transition-[opacity,color] duration-150\",\n            active\n              ? \"text-fg-2 opacity-100\"\n              : \"text-fg-4 opacity-0 group-hover/sort:opacity-100 group-focus-visible/sort:opacity-100 pointer-coarse:opacity-100\",\n          )}\n        >\n          <SortArrow pointing={pointing} />\n        </span>\n      </span>\n    </button>\n  );\n}\n\nfunction SortArrow({ pointing }: { pointing: SortDirection }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.svg\n      width=\"12\"\n      height=\"12\"\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.6}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      initial={false}\n      // Up is ascending. Flipping direction turns the same arrow over instead of swapping icons.\n      animate={{ rotate: pointing === \"asc\" ? 0 : 180 }}\n      transition={reduce ? { duration: 0 } : spring.snappy}\n    >\n      <path d=\"M8 13V3M4 7l4-4 4 4\" />\n    </motion.svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/sortable-table"
}
