{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sortable-data-table",
  "type": "registry:ui",
  "title": "Sortable data table",
  "description": "Compare structured records with sortable columns.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/sortable-data-table/sortable-data-table.tsx",
      "content": "\"use client\";\n\nimport { useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { KeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from \"react\";\nimport { ArrowUp } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  type Transition,\n  type Variants,\n} from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport type SortDirection = \"asc\" | \"desc\";\nexport type SortState = { key: string; direction: SortDirection };\n\nexport type DataColumn<T> = {\n  key: string;\n  label: string;\n  sortable?: boolean;\n  render?: (value: unknown, row: T) => ReactNode;\n  /** Right-aligns the column with tabular numerals. Detected when every value is a number. */\n  numeric?: boolean;\n  /** Fixed width such as 120 or \"20%\". Other columns are measured once and held, so sorting never reflows them. */\n  width?: number | string;\n};\n\nexport type SortableDataTableProps<T extends Record<string, unknown>> = {\n  rows: T[];\n  columns: DataColumn<T>[];\n  rowKey: keyof T | ((row: T) => string);\n  caption?: string;\n  emptyMessage?: string;\n  /** Sort applied on the first render. */\n  defaultSort?: SortState;\n  onSortChange?: (sort: SortState) => void;\n  /** Adds a checkbox column, row click selection, and a count line with a clear action. */\n  selectable?: boolean;\n  selectedKeys?: string[];\n  defaultSelectedKeys?: string[];\n  onSelectionChange?: (keys: string[]) => void;\n  /** Noun for the count line, as in \"6 projects\". */\n  itemName?: { one: string; other: string };\n};\n\nconst collator = new Intl.Collator(\"en\", { numeric: true, sensitivity: \"base\" });\n/** Readable text for any cell value: primitives as written, dates in local form, other values as JSON. */\nfunction text(value: unknown): string {\n  if (typeof value === \"string\") return value;\n  if (typeof value === \"number\" || typeof value === \"boolean\" || typeof value === \"bigint\") return String(value);\n  if (value instanceof Date) return value.toString();\n  return JSON.stringify(value) ?? \"\";\n}\nconst isEmpty = (value: unknown) => value == null || value === \"\";\nconst comparable = (value: unknown) => (value instanceof Date ? value.getTime() : value);\nconst blur = (px: number) => `blur(${px}px)`;\nconst enter: Transition = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };\nconst leave: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };\nconst instant: Transition = { duration: motionTokens.duration.instant };\n/** Changing text rises in from the side it is moving toward and lifts away on the other. */\nconst rise: Variants = {\n  enter: (direction: number) => ({ opacity: 0, y: `${0.3 * direction}em`, filter: blur(motionTokens.blur.soft) }),\n  shown: { opacity: 1, y: \"0em\", filter: blur(0), transition: enter },\n  gone: (direction: number) => ({\n    opacity: 0,\n    y: `${-0.3 * direction}em`,\n    filter: blur(motionTokens.blur.subtle),\n    transition: leave,\n  }),\n};\n/** Reduced motion keeps the same properties as rise (no travel, no blur) so the server and a reduced-motion client render identical styles. */\nconst fade: Variants = {\n  enter: { opacity: 0, y: \"0em\", filter: blur(0) },\n  shown: { opacity: 1, y: \"0em\", filter: blur(0), transition: instant },\n  gone: { opacity: 0, y: \"0em\", filter: blur(0), transition: instant },\n};\nconst checkPath = \"M4.25 9.25 L7.25 12.25 L13.75 5.75\";\nconst dashPath = \"M4.75 9 L9 9 L13.25 9\";\n\n/* Below 620px the table folds: the header becomes a scrolling strip of sort controls, so sorting stays one tap away on a phone,\n * and each row folds into two lines, the first column with its number, then the remaining details. */\n/** Cells are opaque so rows passing each other during a sort cover one another cleanly instead of blending text. */\nconst cell = cn(\n  \"bg-(--cell-fill) transition-[background-color,color] duration-standard ease-standard [--cell-base:var(--surface)] [--cell-fill:var(--cell-base)] motion-reduce:transition-none!\",\n  \"max-[620px]:block max-[620px]:h-auto max-[620px]:border-0 max-[620px]:p-0 max-[620px]:bg-transparent\",\n);\nconst rowFold =\n  \"max-[620px]:relative max-[620px]:flex max-[620px]:w-full max-[620px]:min-h-16 max-[620px]:box-border max-[620px]:flex-wrap max-[620px]:items-baseline max-[620px]:content-center max-[620px]:gap-x-2 max-[620px]:gap-y-0.5 max-[620px]:border-b max-[620px]:border-line-soft max-[620px]:bg-(--row-fill) max-[620px]:px-4 max-[620px]:py-2.5 max-[620px]:[--row-fill:var(--surface)] max-[620px]:transition-[background-color] max-[620px]:duration-standard max-[620px]:ease-standard max-[620px]:last:border-b-0 max-[620px]:after:order-1 max-[620px]:after:basis-full max-[620px]:after:content-['']\";\nconst numericCell = \"text-right tabular-nums\";\n/** Check and dash share three points, so the header box morphs between all and some. */\n/** Text that changes in place. The slot eases to the new width, so the words beside it never jump. */\nfunction Swap({\n  value,\n  direction,\n  reduced,\n  className,\n}: {\n  value: string;\n  direction: number;\n  reduced: boolean;\n  className?: string;\n}) {\n  const inner = useRef<HTMLSpanElement>(null);\n  const width = useMotionValue<number | string>(\"auto\");\n  useLayoutEffect(() => {\n    const node = inner.current;\n    if (!node) return;\n    let settled = false;\n    const measure = () => {\n      const next = node.getBoundingClientRect().width;\n      if (settled && !reduced) animate(width, next, motionTokens.spring.morph);\n      else width.jump(next);\n      settled = true;\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [reduced, width]);\n  return (\n    <motion.span className={cn(\"inline-block\", className)} style={{ width }}>\n      <span ref={inner} className=\"relative inline-flex w-max\">\n        <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n          <motion.span\n            key={value}\n            className=\"inline-block whitespace-nowrap\"\n            custom={direction}\n            variants={reduced ? fade : rise}\n            initial=\"enter\"\n            animate=\"shown\"\n            exit=\"gone\"\n          >\n            {value}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </motion.span>\n  );\n}\n\n/** The arrow fades in where a column becomes sorted and flips on a spring when the direction changes. */\nfunction SortGlyph({ active, descending, reduced }: { active: boolean; descending: boolean; reduced: boolean }) {\n  const rotate = useMotionValue(descending ? 180 : 0);\n  const seen = useRef({ active, hiddenAt: -Infinity });\n  useLayoutEffect(() => {\n    const state = seen.current;\n    if (active) {\n      // Turn only while the arrow is visible; a freshly shown arrow starts pointing the right way.\n      const visible = state.active || performance.now() - state.hiddenAt < 160;\n      if (visible && !reduced) animate(rotate, descending ? 180 : 0, motionTokens.spring.snappy);\n      else rotate.jump(descending ? 180 : 0);\n    } else if (state.active) state.hiddenAt = performance.now();\n    state.active = active;\n  }, [active, descending, reduced, rotate]);\n  const transition: Transition = reduced\n    ? instant\n    : active\n      ? { opacity: enter, filter: enter, scale: motionTokens.spring.snappy }\n      : { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] };\n  return (\n    <span className=\"relative grid size-4 flex-none place-items-center\" aria-hidden=\"true\">\n      <ArrowUp\n        className={cn(\n          \"absolute inset-0 grid place-items-center text-ink-3 opacity-0 transition-opacity duration-fast ease-standard motion-reduce:transition-none!\",\n          !active && \"group-hover/sort:opacity-60 group-focus-visible/sort:opacity-60\",\n        )}\n        size={16}\n        strokeWidth={1.75}\n      />\n      <motion.span\n        className=\"absolute inset-0 grid place-items-center text-ink\"\n        style={{ rotate }}\n        initial={false}\n        animate={\n          active\n            ? { opacity: 1, scale: 1, filter: blur(0) }\n            : { opacity: 0, scale: 0.6, filter: blur(motionTokens.blur.subtle) }\n        }\n        transition={transition}\n      >\n        <ArrowUp size={16} strokeWidth={1.75} />\n      </motion.span>\n    </span>\n  );\n}\n\nfunction SelectBox({\n  checked,\n  mixed = false,\n  label,\n  nav,\n  reduced,\n  onToggle,\n  inputRef,\n}: {\n  checked: boolean;\n  mixed?: boolean;\n  label: string;\n  nav: \"head\" | \"row\";\n  reduced: boolean;\n  onToggle: (extend: boolean) => void;\n  inputRef?: RefObject<HTMLInputElement | null>;\n}) {\n  const local = useRef<HTMLInputElement>(null);\n  const ref = inputRef ?? local;\n  const on = checked || mixed;\n  useLayoutEffect(() => {\n    if (ref.current) ref.current.indeterminate = mixed;\n  }, [mixed, ref]);\n  const opacity: Transition = {\n    duration: on ? motionTokens.duration.instant : motionTokens.duration.fast,\n    ease: [...motionTokens.ease.standard],\n  };\n  return (\n    <label\n      className={cn(\n        \"group/select relative flex h-11 w-full cursor-pointer items-center\",\n        nav === \"head\" ? \"max-[620px]:h-8\" : \"max-[620px]:justify-center\",\n      )}\n    >\n      <input\n        ref={ref}\n        type=\"checkbox\"\n        className=\"peer absolute inset-0 m-0 size-full cursor-pointer opacity-0\"\n        checked={checked}\n        aria-label={label}\n        data-nav={nav}\n        onChange={(event) => onToggle((event.nativeEvent as MouseEvent).shiftKey === true)}\n      />\n      {/* The native checkbox is hidden, so its keyboard focus outline is drawn on the visible box. */}\n      <span\n        className={cn(\n          \"pointer-events-none relative block size-[18px] flex-none rounded-[5px] border bg-surface text-control-glyph [transition:border-color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-spring)_var(--ease-spring)] peer-focus-visible:[outline:var(--focus-outline-width,2px)_solid_var(--focus-outline,color-mix(in_oklab,var(--accent-ink)_72%,transparent))] peer-focus-visible:outline-offset-2 peer-active:[transform:scale(.95)] peer-active:[transition:border-color_var(--duration-fast)_var(--ease-standard),transform_110ms_var(--ease-standard)] motion-reduce:transition-none! motion-reduce:peer-active:[transform:none]\",\n          on ? \"border-control-on\" : \"border-line-strong group-hover/select:border-ink-3\",\n        )}\n        data-on={on || undefined}\n        aria-hidden=\"true\"\n      >\n        <motion.span\n          className=\"absolute -inset-px rounded-[inherit] bg-control-on\"\n          initial={false}\n          animate={{ opacity: on ? 1 : 0, scale: on ? 1 : 0.6 }}\n          transition={reduced ? { duration: 0 } : { scale: motionTokens.spring.snappy, opacity }}\n        />\n        <svg\n          className=\"absolute -inset-px size-[18px] overflow-visible\"\n          viewBox=\"0 0 18 18\"\n          fill=\"none\"\n          focusable=\"false\"\n        >\n          <motion.path\n            initial={false}\n            animate={{ d: mixed ? dashPath : checkPath, pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}\n            transition={\n              reduced\n                ? { duration: 0 }\n                : { d: motionTokens.spring.morph, pathLength: motionTokens.spring.snappy, opacity }\n            }\n            stroke=\"currentColor\"\n            strokeWidth={1.75}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        </svg>\n      </span>\n    </label>\n  );\n}\n\nexport function SortableDataTable<T extends Record<string, unknown>>({\n  rows,\n  columns,\n  rowKey,\n  caption = \"Data table\",\n  emptyMessage = \"No rows to show\",\n  defaultSort,\n  onSortChange,\n  selectable = false,\n  selectedKeys,\n  defaultSelectedKeys,\n  onSelectionChange,\n  itemName = { one: \"row\", other: \"rows\" },\n}: SortableDataTableProps<T>) {\n  const reduced = useReducedMotion() ?? false;\n  const tableRef = useRef<HTMLTableElement>(null);\n  const selectAllRef = useRef<HTMLInputElement>(null);\n  const anchor = useRef<string | null>(null);\n  const [sort, setSort] = useState<SortState | null>(defaultSort ?? null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const [internalSelection, setInternalSelection] = useState<string[]>(defaultSelectedKeys ?? []);\n  const selection = useMemo(() => new Set(selectedKeys ?? internalSelection), [internalSelection, selectedKeys]);\n\n  const sortedRows = useMemo(() => {\n    if (!sort) return rows;\n    return rows\n      .map((row, index) => ({ row, index }))\n      .sort((a, b) => {\n        const left = comparable(a.row[sort.key]);\n        const right = comparable(b.row[sort.key]);\n        // Empty values stay at the bottom in both directions.\n        if (isEmpty(left) || isEmpty(right))\n          return isEmpty(left) === isEmpty(right) ? a.index - b.index : isEmpty(left) ? 1 : -1;\n        const result =\n          typeof left === \"number\" && typeof right === \"number\"\n            ? left - right\n            : collator.compare(text(left), text(right));\n        return (sort.direction === \"asc\" ? result : -result) || a.index - b.index;\n      })\n      .map((entry) => entry.row);\n  }, [rows, sort]);\n\n  const numeric = useMemo(\n    () =>\n      new Set(\n        columns\n          .filter(\n            (column) =>\n              column.numeric ??\n              (rows.some((row) => typeof row[column.key] === \"number\") &&\n                rows.every((row) => typeof row[column.key] === \"number\" || isEmpty(row[column.key]))),\n          )\n          .map((column) => column.key),\n      ),\n    [columns, rows],\n  );\n  const getRowKey = (row: T) => String(typeof rowKey === \"function\" ? rowKey(row) : row[rowKey]);\n  const keys = sortedRows.map(getRowKey);\n  const order = keys.join(\"\\u0000\");\n  const selectedCount = keys.filter((key) => selection.has(key)).length;\n  const allSelected = keys.length > 0 && selectedCount === keys.length;\n\n  // Measure the natural column widths once, then hold them with a fixed layout so nothing reflows while rows move.\n  const signature = [selectable ? \"select\" : \"\", ...columns.map((column) => column.key)].join(\"\\u0000\");\n  const [locked, setLocked] = useState<{ signature: string; widths: Record<string, number> } | null>(null);\n  const widths = locked?.signature === signature ? locked.widths : null;\n  useLayoutEffect(() => {\n    const table = tableRef.current;\n    if (!table || widths) return;\n    const measure = () => {\n      const total = table.getBoundingClientRect().width;\n      if (!total || getComputedStyle(table).display !== \"table\") return false;\n      const next: Record<string, number> = {};\n      table.querySelectorAll<HTMLElement>(\"thead th[data-key]\").forEach((cell) => {\n        next[cell.dataset.key ?? \"\"] = (cell.getBoundingClientRect().width / total) * 100;\n      });\n      setLocked({ signature, widths: next });\n      return true;\n    };\n    if (measure()) return;\n    const observer = new ResizeObserver(() => {\n      if (measure()) observer.disconnect();\n    });\n    observer.observe(table);\n    return () => observer.disconnect();\n  }, [signature, widths]);\n\n  // A still tint under the sorted column fills the gaps that open while rows pass each other.\n  const [band, setBand] = useState<{ left: number; width: number } | null>(null);\n  const seenSort = useRef(false);\n  useLayoutEffect(() => {\n    const table = tableRef.current;\n    const key = sort?.key;\n    const update = () => {\n      const cell = key ? table?.querySelector<HTMLElement>(`thead th[data-key=\"${CSS.escape(key)}\"]`) : null;\n      setBand((current) =>\n        !cell\n          ? null\n          : current?.left === cell.offsetLeft && current.width === cell.offsetWidth\n            ? current\n            : { left: cell.offsetLeft, width: cell.offsetWidth },\n      );\n    };\n    update();\n    if (!table || !key) return;\n    // On a phone the header is a scrolling strip of sort controls; keep the active one in view.\n    const cell = table.querySelector<HTMLElement>(`thead th[data-key=\"${CSS.escape(key)}\"]`);\n    const strip = cell?.parentElement;\n    if (cell && strip && strip.scrollWidth > strip.clientWidth && getComputedStyle(table).display !== \"table\") {\n      const bounds = strip.getBoundingClientRect(),\n        target = cell.getBoundingClientRect(),\n        inset = 8;\n      const start =\n        Math.max(bounds.left, strip.querySelector(\"th:not([data-key])\")?.getBoundingClientRect().right ?? bounds.left) +\n        inset;\n      const shift =\n        target.right > bounds.right - inset\n          ? target.right - bounds.right + inset\n          : target.left < start\n            ? target.left - start\n            : 0;\n      if (shift)\n        strip.scrollTo({ left: strip.scrollLeft + shift, behavior: reduced || !seenSort.current ? \"auto\" : \"smooth\" });\n    }\n    seenSort.current = true;\n    const observer = new ResizeObserver(update);\n    observer.observe(table);\n    return () => observer.disconnect();\n  }, [reduced, sort?.key, widths]);\n\n  const shownCount = selectedCount || keys.length;\n  const noun = selectedCount ? \"selected\" : keys.length === 1 ? itemName.one : itemName.other;\n  const [lastCount, setLastCount] = useState(shownCount);\n  const [countDirection, setCountDirection] = useState(1);\n  if (shownCount !== lastCount) {\n    setLastCount(shownCount);\n    setCountDirection(shownCount > lastCount ? 1 : -1);\n  }\n\n  function sortBy(column: DataColumn<T>) {\n    const next: SortState = {\n      key: column.key,\n      direction: sort?.key === column.key && sort.direction === \"asc\" ? \"desc\" : \"asc\",\n    };\n    setSort(next);\n    onSortChange?.(next);\n    setAnnouncement(`Sorted by ${column.label}, ${next.direction === \"asc\" ? \"ascending\" : \"descending\"}`);\n  }\n\n  function commit(next: Set<string>) {\n    const list = keys.filter((key) => next.has(key));\n    if (selectedKeys === undefined) setInternalSelection(list);\n    onSelectionChange?.(list);\n    setAnnouncement(list.length ? `${list.length} of ${keys.length} selected` : \"Selection cleared\");\n  }\n\n  function toggleRow(key: string, extend: boolean) {\n    const next = new Set(selection);\n    const checked = !selection.has(key);\n    const from = extend && anchor.current ? keys.indexOf(anchor.current) : -1;\n    const to = keys.indexOf(key);\n    (from < 0 ? [key] : keys.slice(Math.min(from, to), Math.max(from, to) + 1)).forEach((item) =>\n      checked ? next.add(item) : next.delete(item),\n    );\n    anchor.current = key;\n    commit(next);\n  }\n\n  function clearSelection() {\n    commit(new Set());\n    selectAllRef.current?.focus();\n  }\n\n  function onRowClick(event: ReactMouseEvent<HTMLTableRowElement>, key: string) {\n    if (\n      !selectable ||\n      (event.target as HTMLElement).closest(\n        \"a, button, input, label, select, textarea, [role='button'], [contenteditable='true']\",\n      ) ||\n      window.getSelection()?.toString()\n    )\n      return;\n    toggleRow(key, event.shiftKey);\n    // Keep the keyboard path where the pointer left off, so arrows continue from this row.\n    event.currentTarget.querySelector<HTMLInputElement>(\"input[type='checkbox']\")?.focus({ preventScroll: true });\n  }\n\n  /** Arrows walk the header controls and the row checkboxes; Escape clears the selection. */\n  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    const target = event.target as HTMLElement;\n    if (event.key === \"Escape\" && selectedCount) {\n      event.preventDefault();\n      event.stopPropagation();\n      if (target.dataset.clear !== undefined) clearSelection();\n      else commit(new Set());\n      return;\n    }\n    const nav = target.dataset.nav;\n    if (!nav) return;\n    const list = (name: string) => [...event.currentTarget.querySelectorAll<HTMLElement>(`[data-nav=\"${name}\"]`)];\n    const items = list(nav);\n    const index = items.indexOf(target);\n    const steps: Record<string, [HTMLElement[], number]> =\n      nav === \"head\"\n        ? {\n            ArrowRight: [items, index + 1],\n            ArrowLeft: [items, index - 1],\n            Home: [items, 0],\n            End: [items, items.length - 1],\n            ...(target === selectAllRef.current ? { ArrowDown: [list(\"row\"), 0] } : {}),\n          }\n        : {\n            ArrowDown: [items, index + 1],\n            ArrowUp: index === 0 && selectAllRef.current ? [[selectAllRef.current], 0] : [items, index - 1],\n            Home: [items, 0],\n            End: [items, items.length - 1],\n          };\n    const step = steps[event.key];\n    const next = step?.[0][step[1]];\n    if (!next) return;\n    event.preventDefault();\n    next.focus();\n  }\n\n  /** Selected rows take an accent tint; pointing at a row deepens whatever fill it has. */\n  const fill = (selected: boolean) =>\n    selected\n      ? \"[--cell-fill:color-mix(in_oklab,var(--accent)_8%,var(--cell-base))] group-hover/row:[--cell-fill:color-mix(in_oklab,var(--accent)_11%,var(--cell-base))]\"\n      : \"group-hover/row:[--cell-fill:color-mix(in_oklab,var(--ink)_3%,var(--cell-base))]\";\n  /** On a phone the sorted value lifts to full contrast, echoing the tinted column on wider screens. */\n  const foldedCell = (columnIndex: number, isNumeric: boolean, sorted: boolean) => {\n    if (columnIndex === 0)\n      return \"max-[620px]:order-0 max-[620px]:flex-[1_1_0] max-[620px]:text-sm/[inherit] max-[620px]:font-medium max-[620px]:text-ink\";\n    if (isNumeric && columns.slice(0, columnIndex).some((column) => numeric.has(column.key)))\n      return \"max-[620px]:order-2 max-[620px]:ml-0 max-[620px]:pl-0 max-[620px]:text-xs/[inherit] max-[620px]:text-ink-2\";\n    if (isNumeric)\n      return \"max-[620px]:order-0 max-[620px]:flex-none max-[620px]:ml-auto max-[620px]:pl-2 max-[620px]:text-sm/[inherit] max-[620px]:text-ink\";\n    return sorted ? \"max-[620px]:text-ink\" : \"\";\n  };\n\n  return (\n    // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- delegates arrow keys between the header controls and row checkboxes, and Escape to clear\n    <div\n      className=\"relative w-full max-w-full min-w-0 overflow-hidden rounded-control border border-line bg-surface font-body text-ink [--column-tint:var(--hover)]\"\n      onKeyDown={onKeyDown}\n    >\n      <div className=\"relative overflow-x-auto overscroll-x-contain max-[620px]:overflow-visible\">\n        {band ? (\n          <span\n            className=\"pointer-events-none absolute inset-y-0 bg-(--column-tint) max-[620px]:hidden\"\n            style={{ left: band.left, width: band.width }}\n            aria-hidden=\"true\"\n          />\n        ) : null}\n        {/* The explicit table roles are deliberate: below 620px the table, rows and cells are laid out as blocks and flex rows, which strips their implicit roles. */}\n        {/* oxlint-disable jsx-a11y/no-redundant-roles, jsx-a11y/no-interactive-element-to-noninteractive-role */}\n        <table\n          ref={tableRef}\n          role=\"table\"\n          className={cn(\n            \"relative z-1 w-full min-w-[560px] border-separate border-spacing-0 text-left text-sm leading-body tracking-body\",\n            widths && \"table-fixed\",\n            \"max-[620px]:block max-[620px]:min-w-0 max-[620px]:max-w-full\",\n          )}\n          data-fixed={widths ? \"\" : undefined}\n          data-selectable={selectable || undefined}\n        >\n          <caption className=\"sr-only\">{caption}</caption>\n          <colgroup>\n            {selectable ? <col className=\"w-11\" /> : null}\n            {columns.map((column, index) => (\n              <col\n                key={column.key}\n                style={{\n                  width:\n                    column.width !== undefined\n                      ? typeof column.width === \"number\"\n                        ? `${column.width}px`\n                        : column.width\n                      : widths && index > 0\n                        ? `${widths[column.key]}%`\n                        : undefined,\n                }}\n              />\n            ))}\n          </colgroup>\n          <thead role=\"rowgroup\" className={\"max-[620px]:block\"}>\n            <tr\n              role=\"row\"\n              className={\n                \"max-[620px]:flex max-[620px]:items-center max-[620px]:gap-1 max-[620px]:overflow-x-auto max-[620px]:border-b max-[620px]:border-line max-[620px]:px-2 max-[620px]:py-1 max-[620px]:[scrollbar-width:none] max-[620px]:[&::-webkit-scrollbar]:hidden\"\n              }\n            >\n              {/* On a phone select all stays pinned while the sort controls scroll beneath it; once the strip scrolls, a fade off the pinned cell hides the\n              word sliding under it. Browsers without scroll-driven animations would play the fade once and leave it on, so they keep it off. */}\n              {selectable ? (\n                <th\n                  scope=\"col\"\n                  role=\"columnheader\"\n                  className={cn(\n                    cell,\n                    \"h-11 w-11 border-b border-line py-0 pr-0 pl-4\",\n                    \"max-[620px]:sticky max-[620px]:-left-2 max-[620px]:z-1 max-[620px]:-ml-2 max-[620px]:w-10 max-[620px]:bg-surface max-[620px]:py-0 max-[620px]:pr-0 max-[620px]:pl-3 max-[620px]:after:pointer-events-none max-[620px]:after:absolute max-[620px]:after:inset-y-0 max-[620px]:after:left-full max-[620px]:after:w-9 max-[620px]:after:bg-[linear-gradient(to_right,var(--surface)_45%,transparent)] max-[620px]:after:opacity-0 max-[620px]:after:content-['']\",\n                    \"max-[620px]:supports-[animation-timeline:scroll()]:after:animate-sortable-data-table-strip-fade max-[620px]:supports-[animation-timeline:scroll()]:after:[animation-timeline:scroll(nearest_inline)]! max-[620px]:supports-[animation-timeline:scroll()]:after:[animation-range:0_24px]!\",\n                  )}\n                >\n                  <SelectBox\n                    inputRef={selectAllRef}\n                    checked={allSelected}\n                    mixed={selectedCount > 0 && !allSelected}\n                    label=\"Select all rows\"\n                    nav=\"head\"\n                    reduced={reduced}\n                    onToggle={() => commit(allSelected ? new Set() : new Set(keys))}\n                  />\n                </th>\n              ) : null}\n              {columns.map((column, index) => {\n                const active = sort?.key === column.key;\n                const sortable = column.sortable !== false;\n                const isNumeric = numeric.has(column.key);\n                return (\n                  <th\n                    key={column.key}\n                    scope=\"col\"\n                    role=\"columnheader\"\n                    className={cn(\n                      cell,\n                      \"h-11 border-b border-line px-4 text-left text-sm/[inherit] font-medium whitespace-nowrap\",\n                      active ? \"text-ink [--cell-base:var(--column-tint)]\" : \"text-ink-3\",\n                      sortable && \"p-0\",\n                      isNumeric && numericCell,\n                      selectable && index === 0 && !sortable && \"pl-2\",\n                    )}\n                    data-key={column.key}\n                    data-primary={index === 0 || undefined}\n                    data-sorted={active || undefined}\n                    data-numeric={isNumeric || undefined}\n                    data-sortable={sortable || undefined}\n                    aria-sort={\n                      !sortable ? undefined : active ? (sort.direction === \"asc\" ? \"ascending\" : \"descending\") : \"none\"\n                    }\n                  >\n                    {sortable ? (\n                      <button\n                        className={cn(\n                          \"group/sort flex h-11 w-full cursor-pointer items-center justify-start border-0 bg-transparent px-4 [-webkit-tap-highlight-color:transparent] motion-reduce:transition-none!\",\n                          isNumeric && \"justify-end\",\n                          selectable && index === 0 && \"pl-2\",\n                          \"max-[620px]:h-8 max-[620px]:w-auto max-[620px]:rounded-button max-[620px]:px-3 max-[620px]:transition-[background-color,color] max-[620px]:duration-standard max-[620px]:ease-standard max-[620px]:focus-visible:[--focus-outline-offset:1px]\",\n                          active && \"max-[620px]:bg-hover\",\n                        )}\n                        type=\"button\"\n                        data-nav=\"head\"\n                        onClick={() => sortBy(column)}\n                        aria-label={`Sort by ${column.label}${active ? `, currently ${sort.direction === \"asc\" ? \"ascending\" : \"descending\"}` : \"\"}`}\n                      >\n                        {/* Right-aligned columns lead with the arrow so the label lines up with the digits below it. */}\n                        <span\n                          className={cn(\n                            \"inline-flex items-center gap-1.5 rounded-control transition-[color] duration-fast ease-standard group-hover/sort:text-ink motion-reduce:transition-none!\",\n                            isNumeric && \"flex-row-reverse max-[620px]:flex-row\",\n                          )}\n                        >\n                          <span>{column.label}</span>\n                          <SortGlyph\n                            active={active}\n                            descending={active && sort.direction === \"desc\"}\n                            reduced={reduced}\n                          />\n                        </span>\n                      </button>\n                    ) : (\n                      column.label\n                    )}\n                  </th>\n                );\n              })}\n            </tr>\n          </thead>\n          <tbody role=\"rowgroup\" className={\"max-[620px]:block max-[620px]:w-full\"}>\n            {sortedRows.length ? (\n              sortedRows.map((row, index) => {\n                const key = keys[index]!;\n                const selected = selectable && selection.has(key);\n                return (\n                  <motion.tr\n                    key={key}\n                    role=\"row\"\n                    className={cn(\n                      \"group/row motion-reduce:transition-none!\",\n                      selectable && \"cursor-pointer [-webkit-tap-highlight-color:transparent]\",\n                      rowFold,\n                      selectable && \"max-[620px]:pl-11\",\n                      selected\n                        ? \"max-[620px]:[--row-fill:color-mix(in_oklab,var(--accent)_8%,var(--surface))] max-[620px]:hover:[--row-fill:color-mix(in_oklab,var(--accent)_11%,var(--surface))]\"\n                        : \"max-[620px]:hover:[--row-fill:color-mix(in_oklab,var(--ink)_3%,var(--surface))]\",\n                    )}\n                    layout={reduced ? false : \"position\"}\n                    layoutDependency={order}\n                    transition={motionTokens.spring.smooth}\n                    data-selected={selected || undefined}\n                    onClick={(event) => onRowClick(event, key)}\n                    onMouseDown={(event) => {\n                      if (selectable && event.shiftKey) event.preventDefault();\n                    }}\n                  >\n                    {selectable ? (\n                      <td\n                        role=\"cell\"\n                        className={cn(\n                          cell,\n                          \"h-[52px] w-11 border-b border-line-soft py-0 pr-0 pl-4 align-middle group-last/row:border-b-0\",\n                          fill(selected),\n                          \"max-[620px]:absolute max-[620px]:inset-y-0 max-[620px]:left-1 max-[620px]:flex max-[620px]:w-9 max-[620px]:items-center max-[620px]:p-0\",\n                        )}\n                      >\n                        <SelectBox\n                          checked={selected}\n                          label={`Select ${text(row[columns[0]?.key as string] ?? key)}`}\n                          nav=\"row\"\n                          reduced={reduced}\n                          onToggle={(extend) => toggleRow(key, extend)}\n                        />\n                      </td>\n                    ) : null}\n                    {columns.map((column, columnIndex) => {\n                      const sorted = sort?.key === column.key;\n                      const isNumeric = numeric.has(column.key);\n                      return (\n                        <td\n                          key={column.key}\n                          role=\"cell\"\n                          className={cn(\n                            cell,\n                            \"h-[52px] border-b border-line-soft px-4 align-middle font-normal wrap-anywhere text-ink group-last/row:border-b-0\",\n                            sorted && \"[--cell-base:var(--column-tint)]\",\n                            isNumeric && numericCell,\n                            selectable && columnIndex === 0 && \"pl-2\",\n                            fill(selected),\n                            \"max-[620px]:order-2 max-[620px]:min-w-0 max-[620px]:text-left max-[620px]:text-xs/[inherit] max-[620px]:text-ink-2\",\n                            foldedCell(columnIndex, isNumeric, sorted),\n                          )}\n                          data-label={column.label}\n                          data-primary={columnIndex === 0 || undefined}\n                          data-sorted={sorted || undefined}\n                          data-numeric={isNumeric || undefined}\n                        >\n                          {column.render ? column.render(row[column.key], row) : text(row[column.key] ?? \"–\")}\n                        </td>\n                      );\n                    })}\n                  </motion.tr>\n                );\n              })\n            ) : (\n              <tr\n                role=\"row\"\n                className={cn(\n                  \"group/row\",\n                  rowFold,\n                  \"max-[620px]:hover:[--row-fill:color-mix(in_oklab,var(--ink)_3%,var(--surface))]\",\n                )}\n              >\n                <td\n                  role=\"cell\"\n                  className={cn(\n                    cell,\n                    \"h-[120px] border-b border-line-soft px-4 text-center align-middle text-ink-3 group-last/row:border-b-0\",\n                    \"max-[620px]:h-auto max-[620px]:flex-[1_0_100%] max-[620px]:px-0 max-[620px]:py-6\",\n                  )}\n                  colSpan={columns.length + (selectable ? 1 : 0)}\n                >\n                  {emptyMessage}\n                </td>\n              </tr>\n            )}\n          </tbody>\n        </table>\n        {/* oxlint-enable jsx-a11y/no-redundant-roles, jsx-a11y/no-interactive-element-to-noninteractive-role */}\n      </div>\n      {selectable ? (\n        <div className=\"flex h-11 items-center justify-between gap-3 border-t border-line pr-2 pl-4 text-sm leading-body tracking-body text-ink-3\">\n          <span className=\"sr-only\">\n            {shownCount} {noun}\n          </span>\n          <span\n            className={cn(\n              \"inline-flex min-w-0 items-baseline gap-[.28em] whitespace-nowrap transition-[color] duration-standard ease-standard motion-reduce:transition-none!\",\n              selectedCount > 0 && \"text-ink\",\n            )}\n            data-active={selectedCount > 0 || undefined}\n            aria-hidden=\"true\"\n          >\n            <Swap className=\"tabular-nums\" value={String(shownCount)} direction={countDirection} reduced={reduced} />\n            <Swap value={noun} direction={selectedCount ? 1 : -1} reduced={reduced} />\n          </span>\n          <AnimatePresence initial={false}>\n            {selectedCount ? (\n              <motion.button\n                key=\"clear\"\n                type=\"button\"\n                className=\"h-[30px] flex-none cursor-pointer rounded-button border-0 bg-transparent px-3 font-medium text-ink transition-[background-color] duration-fast ease-standard [-webkit-tap-highlight-color:transparent] hover:bg-hover motion-reduce:transition-none!\"\n                data-clear=\"\"\n                onClick={clearSelection}\n                initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96, filter: blur(motionTokens.blur.soft) }}\n                animate={{ opacity: 1, scale: 1, filter: blur(0) }}\n                exit={\n                  reduced\n                    ? { opacity: 0, transition: instant }\n                    : { opacity: 0, scale: 0.98, filter: blur(motionTokens.blur.subtle), transition: leave }\n                }\n                transition={reduced ? instant : enter}\n                whileTap={\n                  reduced ? undefined : { scale: 0.97, transition: { duration: motionTokens.duration.instant } }\n                }\n              >\n                Clear selection\n              </motion.button>\n            ) : null}\n          </AnimatePresence>\n        </div>\n      ) : null}\n      <p className=\"sr-only\" role=\"status\">\n        {announcement}\n      </p>\n    </div>\n  );\n}\n\nexport default SortableDataTable;\n",
      "type": "registry:component",
      "target": "@components/iris/sortable-data-table/sortable-data-table.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/sortable-data-table",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "SortableDataTable",
    "tags": [
      "data",
      "table"
    ],
    "docs": "https://irisui.dev/components/sortable-data-table"
  }
}
