{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/sortable-data-table/sortable-data-table.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type Transition, type Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./sortable-data-table.module.css\";\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\" });\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: `${.3 * direction}em`, filter: blur(motionTokens.blur.soft) }),\n  shown: { opacity: 1, y: \"0em\", filter: blur(0), transition: enter },\n  gone: (direction: number) => ({ opacity: 0, y: `${-.3 * direction}em`, filter: blur(motionTokens.blur.subtle), transition: leave }),\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 = { enter: { opacity: 0, y: \"0em\", filter: blur(0) }, shown: { opacity: 1, y: \"0em\", filter: blur(0), transition: instant }, gone: { opacity: 0, y: \"0em\", filter: blur(0), transition: instant } };\n/** Check and dash share three points, so the header box morphs between all and some. */\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/** Text that changes in place. The slot eases to the new width, so the words beside it never jump. */\nfunction Swap({ value, direction, reduced, className }: { value: string; direction: number; reduced: boolean; className?: string }) {\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); 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 <motion.span className={[styles.swap, className].filter(Boolean).join(\" \")} style={{ width }}>\n    <span ref={inner} className={styles.swapInner}>\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n        <motion.span key={value} className={styles.swapText} custom={direction} variants={reduced ? fade : rise} initial=\"enter\" animate=\"shown\" exit=\"gone\">{value}</motion.span>\n      </AnimatePresence>\n    </span>\n  </motion.span>;\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 ? instant : active ? { opacity: enter, filter: enter, scale: motionTokens.spring.snappy } : { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] };\n  return <span className={styles.sortIcon} aria-hidden=\"true\">\n    <ArrowUp className={styles.sortHint} size={16} strokeWidth={1.75} />\n    <motion.span className={styles.sortArrow} style={{ rotate }} initial={false} animate={active ? { opacity: 1, scale: 1, filter: blur(0) } : { opacity: 0, scale: .6, filter: blur(motionTokens.blur.subtle) }} transition={transition}><ArrowUp size={16} strokeWidth={1.75} /></motion.span>\n  </span>;\n}\n\nfunction SelectBox({ checked, mixed = false, label, nav, reduced, onToggle, inputRef }: { checked: boolean; mixed?: boolean; label: string; nav: \"head\" | \"row\"; reduced: boolean; onToggle: (extend: boolean) => void; inputRef?: RefObject<HTMLInputElement | null> }) {\n  const local = useRef<HTMLInputElement>(null);\n  const ref = inputRef ?? local;\n  const on = checked || mixed;\n  useLayoutEffect(() => { if (ref.current) ref.current.indeterminate = mixed; }, [mixed, ref]);\n  const opacity: Transition = { duration: on ? motionTokens.duration.instant : motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };\n  return <label className={styles.selectHit}>\n    <input ref={ref} type=\"checkbox\" className={styles.selectInput} checked={checked} aria-label={label} data-nav={nav} onChange={event => onToggle((event.nativeEvent as MouseEvent).shiftKey === true)} />\n    <span className={styles.selectBox} data-on={on || undefined} aria-hidden=\"true\">\n      <motion.span className={styles.selectFill} initial={false} animate={{ opacity: on ? 1 : 0, scale: on ? 1 : .6 }} transition={reduced ? { duration: 0 } : { scale: motionTokens.spring.snappy, opacity }} />\n      <svg className={styles.selectMark} viewBox=\"0 0 18 18\" fill=\"none\" focusable=\"false\">\n        <motion.path initial={false} animate={{ d: mixed ? dashPath : checkPath, pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }} transition={reduced ? { duration: 0 } : { d: motionTokens.spring.morph, pathLength: motionTokens.spring.snappy, opacity }} stroke=\"currentColor\" strokeWidth={1.75} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n      </svg>\n    </span>\n  </label>;\n}\n\nexport function SortableDataTable<T extends Record<string, unknown>>({ rows, columns, rowKey, caption = \"Data table\", emptyMessage = \"No rows to show\", defaultSort, onSortChange, selectable = false, selectedKeys, defaultSelectedKeys, onSelectionChange, itemName = { one: \"row\", other: \"rows\" } }: 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.map((row, index) => ({ row, index })).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)) return isEmpty(left) === isEmpty(right) ? a.index - b.index : isEmpty(left) ? 1 : -1;\n      const result = typeof left === \"number\" && typeof right === \"number\" ? left - right : collator.compare(String(left), String(right));\n      return (sort.direction === \"asc\" ? result : -result) || a.index - b.index;\n    }).map(entry => entry.row);\n  }, [rows, sort]);\n\n  const numeric = useMemo(() => new Set(columns.filter(column => column.numeric ?? (rows.some(row => typeof row[column.key] === \"number\") && rows.every(row => typeof row[column.key] === \"number\" || isEmpty(row[column.key])))).map(column => column.key)), [columns, rows]);\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 => { next[cell.dataset.key ?? \"\"] = cell.getBoundingClientRect().width / total * 100; });\n      setLocked({ signature, widths: next });\n      return true;\n    };\n    if (measure()) return;\n    const observer = new ResizeObserver(() => { if (measure()) observer.disconnect(); });\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 => !cell ? null : current?.left === cell.offsetLeft && current.width === cell.offsetWidth ? current : { left: cell.offsetLeft, width: cell.offsetWidth });\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(), target = cell.getBoundingClientRect(), inset = 8;\n      const start = Math.max(bounds.left, strip.querySelector(\"th:not([data-key])\")?.getBoundingClientRect().right ?? bounds.left) + inset;\n      const shift = target.right > bounds.right - inset ? target.right - bounds.right + inset : target.left < start ? target.left - start : 0;\n      if (shift) 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) { setLastCount(shownCount); setCountDirection(shownCount > lastCount ? 1 : -1); }\n\n  function sortBy(column: DataColumn<T>) {\n    const next: SortState = { key: column.key, direction: sort?.key === column.key && sort.direction === \"asc\" ? \"desc\" : \"asc\" };\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 => checked ? next.add(item) : next.delete(item));\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 (!selectable || (event.target as HTMLElement).closest(\"a, button, input, label, select, textarea, [role='button'], [contenteditable='true']\") || window.getSelection()?.toString()) 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(); 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]> = nav === \"head\"\n      ? { ArrowRight: [items, index + 1], ArrowLeft: [items, index - 1], Home: [items, 0], End: [items, items.length - 1], ...(target === selectAllRef.current ? { ArrowDown: [list(\"row\"), 0] } : {}) }\n      : { ArrowDown: [items, index + 1], ArrowUp: index === 0 && selectAllRef.current ? [[selectAllRef.current], 0] : [items, index - 1], Home: [items, 0], End: [items, items.length - 1] };\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  return <div className={styles.wrapper} onKeyDown={onKeyDown}>\n    <div className={styles.scroller}>\n      {band ? <span className={styles.band} style={{ left: band.left, width: band.width }} aria-hidden=\"true\" /> : null}\n      <table ref={tableRef} role=\"table\" className={styles.table} data-fixed={widths ? \"\" : undefined} data-selectable={selectable || undefined}>\n        <caption>{caption}</caption>\n        <colgroup>\n          {selectable ? <col className={styles.selectCol} /> : null}\n          {columns.map((column, index) => <col key={column.key} style={{ width: column.width !== undefined ? (typeof column.width === \"number\" ? `${column.width}px` : column.width) : widths && index > 0 ? `${widths[column.key]}%` : undefined }} />)}\n        </colgroup>\n        <thead role=\"rowgroup\"><tr role=\"row\">\n          {selectable ? <th scope=\"col\" role=\"columnheader\" className={styles.selectCell}><SelectBox inputRef={selectAllRef} checked={allSelected} mixed={selectedCount > 0 && !allSelected} label=\"Select all rows\" nav=\"head\" reduced={reduced} onToggle={() => commit(allSelected ? new Set() : new Set(keys))} /></th> : null}\n          {columns.map((column, index) => {\n            const active = sort?.key === column.key;\n            const sortable = column.sortable !== false;\n            return <th key={column.key} scope=\"col\" role=\"columnheader\" data-key={column.key} data-primary={index === 0 || undefined} data-sorted={active || undefined} data-numeric={numeric.has(column.key) || undefined} data-sortable={sortable || undefined} aria-sort={!sortable ? undefined : active ? (sort.direction === \"asc\" ? \"ascending\" : \"descending\") : \"none\"}>\n              {sortable ? <button className={styles.sortButton} type=\"button\" data-nav=\"head\" onClick={() => sortBy(column)} aria-label={`Sort by ${column.label}${active ? `, currently ${sort.direction === \"asc\" ? \"ascending\" : \"descending\"}` : \"\"}`}>\n                <span className={styles.sortInner}><span>{column.label}</span><SortGlyph active={active} descending={active && sort.direction === \"desc\"} reduced={reduced} /></span>\n              </button> : column.label}\n            </th>;\n          })}\n        </tr></thead>\n        <tbody role=\"rowgroup\">{sortedRows.length ? sortedRows.map((row, index) => {\n          const key = keys[index]!;\n          const selected = selectable && selection.has(key);\n          return <motion.tr key={key} role=\"row\" layout={reduced ? false : \"position\"} layoutDependency={order} transition={motionTokens.spring.smooth} data-selected={selected || undefined} onClick={event => onRowClick(event, key)} onMouseDown={event => { if (selectable && event.shiftKey) event.preventDefault(); }}>\n            {selectable ? <td role=\"cell\" className={styles.selectCell}><SelectBox checked={selected} label={`Select ${String(row[columns[0]?.key as string] ?? key)}`} nav=\"row\" reduced={reduced} onToggle={extend => toggleRow(key, extend)} /></td> : null}\n            {columns.map((column, columnIndex) => <td key={column.key} role=\"cell\" data-label={column.label} data-primary={columnIndex === 0 || undefined} data-sorted={sort?.key === column.key || undefined} data-numeric={numeric.has(column.key) || undefined}>{column.render ? column.render(row[column.key], row) : String(row[column.key] ?? \"–\")}</td>)}\n          </motion.tr>;\n        }) : <tr role=\"row\"><td role=\"cell\" className={styles.empty} colSpan={columns.length + (selectable ? 1 : 0)}>{emptyMessage}</td></tr>}</tbody>\n      </table>\n    </div>\n    {selectable ? <div className={styles.footer}>\n      <span className={styles.srOnly}>{shownCount} {noun}</span>\n      <span className={styles.count} data-active={selectedCount > 0 || undefined} aria-hidden=\"true\">\n        <Swap className={styles.number} value={String(shownCount)} direction={countDirection} reduced={reduced} />\n        <Swap value={noun} direction={selectedCount ? 1 : -1} reduced={reduced} />\n      </span>\n      <AnimatePresence initial={false}>{selectedCount ? <motion.button key=\"clear\" type=\"button\" className={styles.clear} data-clear=\"\" onClick={clearSelection} initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .96, filter: blur(motionTokens.blur.soft) }} animate={{ opacity: 1, scale: 1, filter: blur(0) }} exit={reduced ? { opacity: 0, transition: instant } : { opacity: 0, scale: .98, filter: blur(motionTokens.blur.subtle), transition: leave }} transition={reduced ? instant : enter} whileTap={reduced ? undefined : { scale: .97, transition: { duration: motionTokens.duration.instant } }}>Clear selection</motion.button> : null}</AnimatePresence>\n    </div> : null}\n    <p className={styles.srOnly} role=\"status\">{announcement}</p>\n  </div>;\n}\n\nexport default SortableDataTable;\n"
    },
    {
      "path": "registry/components/sortable-data-table/sortable-data-table.module.css",
      "type": "registry:component",
      "target": "@components/xzero/sortable-data-table/sortable-data-table.module.css",
      "content": "/* Cells are opaque so rows passing each other during a sort cover one another cleanly instead of blending text. */\n.wrapper { --column-tint: var(--surface-muted); position: relative; width: 100%; min-width: 0; max-width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); font-family: var(--font-body); }\n.scroller { position: relative; overflow-x: auto; overscroll-behavior-x: contain; }\n.band { position: absolute; top: 0; bottom: 0; background: var(--column-tint); pointer-events: none; }\n.table { position: relative; z-index: 1; width: 100%; min-width: 560px; border-collapse: separate; border-spacing: 0; font-size: var(--text-sm); line-height: var(--leading-body); letter-spacing: var(--tracking-body); text-align: left; }\n.table[data-fixed] { table-layout: fixed; }\n.table caption, .srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }\n.selectCol { width: 44px; }\n\n.table th, .table td { --cell-base: var(--surface); --cell-fill: var(--cell-base); background-color: var(--cell-fill); transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }\n.table th[data-sorted], .table td[data-sorted] { --cell-base: var(--column-tint); }\n.table th { height: 44px; padding: 0 var(--space-4); border-bottom: 1px solid var(--border); color: var(--text-muted); font-size: var(--text-sm); font-weight: 500; text-align: left; white-space: nowrap; }\n.table th[data-sortable] { padding: 0; }\n.table th[data-sorted] { color: var(--foreground); }\n.table td { height: 52px; padding: 0 var(--space-4); border-bottom: 1px solid var(--border-subtle); color: var(--foreground); font-weight: 400; vertical-align: middle; overflow-wrap: anywhere; }\n.table tbody tr:last-child td { border-bottom: 0; }\n.table [data-numeric] { text-align: right; font-variant-numeric: tabular-nums; }\n.table[data-selectable] tbody tr { cursor: pointer; -webkit-tap-highlight-color: transparent; }\n.table[data-selectable] tbody tr[data-selected] td { --cell-fill: color-mix(in oklab, var(--accent) 8%, var(--cell-base)); }\n.table[data-selectable] [data-primary]:not([data-sortable]), .table[data-selectable] th[data-primary] .sortButton { padding-left: var(--space-2); }\n\n.sortButton { display: flex; width: 100%; height: 44px; align-items: center; justify-content: flex-start; border: 0; padding: 0 var(--space-4); background: transparent; color: inherit; font: inherit; letter-spacing: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }\n.table th[data-numeric] .sortButton { justify-content: flex-end; }\n.sortInner { display: inline-flex; align-items: center; gap: 6px; border-radius: var(--radius-control); transition: color var(--duration-fast) var(--ease-standard); }\n/* Right-aligned columns lead with the arrow so the label lines up with the digits below it. */\n.table th[data-numeric] .sortInner { flex-direction: row-reverse; }\n.sortIcon { position: relative; display: grid; width: 16px; height: 16px; flex: none; place-items: center; }\n.sortHint, .sortArrow { position: absolute; inset: 0; display: grid; place-items: center; }\n.sortHint { color: var(--text-muted); opacity: 0; transition: opacity var(--duration-fast) var(--ease-standard); }\n.sortArrow { color: var(--foreground); }\n.sortButton:focus-visible .sortHint { opacity: .6; }\n.table th[data-sorted] .sortHint { opacity: 0; }\n\n.selectCell { width: 44px; }\n.table .selectCell { padding: 0 0 0 var(--space-4); }\n.selectHit { position: relative; display: flex; width: 100%; height: 44px; align-items: center; cursor: pointer; }\n.selectInput { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }\n.selectBox { position: relative; display: block; width: 18px; height: 18px; flex: none; border: 1px solid var(--border-strong); border-radius: 5px; background: var(--surface); color: var(--control-glyph); pointer-events: none; transition: border-color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n.selectBox[data-on] { border-color: var(--control-on); }\n.selectFill { position: absolute; inset: -1px; border-radius: inherit; background: var(--control-on); }\n.selectMark { position: absolute; inset: -1px; width: 18px; height: 18px; overflow: visible; }\n/* The native checkbox is hidden, so its keyboard focus outline is drawn on the visible box. */\n.selectInput:focus-visible + .selectBox { outline: var(--focus-outline-width, 2px) solid var(--focus-outline, color-mix(in oklab, var(--accent-strong) 72%, transparent)); outline-offset: 2px; }\n.selectInput:active + .selectBox { transform: scale(.95); transition: border-color var(--duration-fast) var(--ease-standard), transform 110ms var(--ease-standard); }\n\n.footer { display: flex; height: 44px; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 0 var(--space-2) 0 var(--space-4); border-top: 1px solid var(--border); color: var(--text-muted); font-size: var(--text-sm); line-height: var(--leading-body); letter-spacing: var(--tracking-body); }\n.count { display: inline-flex; min-width: 0; align-items: baseline; gap: .28em; white-space: nowrap; transition: color var(--duration-standard) var(--ease-standard); }\n.count[data-active] { color: var(--foreground); }\n.swap { display: inline-block; }\n.swapInner { position: relative; display: inline-flex; width: max-content; }\n.swapText { display: inline-block; white-space: nowrap; }\n.number { font-variant-numeric: tabular-nums; }\n.clear { height: 30px; flex: none; border: 0; border-radius: var(--radius-control); padding: 0 var(--space-3); background: transparent; color: var(--foreground); font: inherit; font-weight: 500; letter-spacing: inherit; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard); -webkit-tap-highlight-color: transparent; }\n\n.empty { height: 120px !important; color: var(--text-muted) !important; text-align: center !important; }\n\n@media (hover: hover) and (pointer: fine) {\n  .table tbody tr:hover td { --cell-fill: color-mix(in oklab, var(--foreground) 3%, var(--cell-base)); }\n  .table[data-selectable] tbody tr[data-selected]:hover td { --cell-fill: color-mix(in oklab, var(--accent) 11%, var(--cell-base)); }\n  .table tbody tr:has(.empty):hover td { --cell-fill: var(--cell-base); }\n  .sortButton:hover .sortInner { color: var(--foreground); }\n  .sortButton:hover .sortHint { opacity: .6; }\n  .table th[data-sorted] .sortButton:hover .sortHint { opacity: 0; }\n  .selectHit:hover .selectBox:not([data-on]) { border-color: var(--text-muted); }\n  .clear:hover { background: var(--surface-muted); }\n}\n\n@media (max-width: 620px) {\n  .scroller { overflow: visible; }\n  .band { display: none; }\n  .table { display: block; width: 100%; min-width: 0; max-width: 100%; }\n  .table thead { display: block; }\n  /* The header becomes a strip of sort controls, so sorting stays one tap away on a phone. */\n  .table thead tr { display: flex; align-items: center; gap: var(--space-1); padding: var(--space-1) var(--space-2); overflow-x: auto; border-bottom: 1px solid var(--border); scrollbar-width: none; }\n  .table thead tr::-webkit-scrollbar { display: none; }\n  .table th { display: block; flex: none; height: auto; border: 0; padding: 0; background: transparent; }\n  /* Select all stays pinned while the sort controls scroll beneath it. */\n  .table th.selectCell { position: sticky; left: calc(-1 * var(--space-2)); z-index: 1; width: 40px; margin-left: calc(-1 * var(--space-2)); padding: 0 0 0 var(--space-3); background: var(--surface); }\n  .table th.selectCell .selectHit { height: 32px; }\n  /* Once the strip scrolls, a fade off the pinned cell hides the word sliding under it. */\n  .table th.selectCell::after { position: absolute; top: 0; bottom: 0; left: 100%; width: 36px; background: linear-gradient(to right, var(--surface) 45%, transparent); content: \"\"; opacity: 0; pointer-events: none; }\n  /* Browsers without scroll-driven animations would play the fade once, at zero duration, and leave it on; they keep it off. */\n  @supports (animation-timeline: scroll()) { .table th.selectCell::after { animation: stripFade linear both; animation-timeline: scroll(nearest inline); animation-range: 0 24px; } }\n  .table th .sortButton, .table[data-selectable] th[data-primary] .sortButton { width: auto; height: 32px; border-radius: var(--radius-control); padding: 0 10px; transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }\n  .table th[data-sorted] .sortButton { background: var(--surface-muted); }\n  .table th[data-numeric] .sortInner { flex-direction: row; }\n  .sortButton:focus-visible { --focus-outline-offset: 1px; }\n  .table tbody { display: block; width: 100%; }\n  /* Rows fold into two lines: the first column with its number, then the remaining details. */\n  .table tbody tr { --row-fill: var(--surface); position: relative; display: flex; width: 100%; min-height: 64px; box-sizing: border-box; flex-wrap: wrap; align-items: baseline; align-content: center; gap: 2px var(--space-2); padding: 10px var(--space-4); border-bottom: 1px solid var(--border-subtle); background-color: var(--row-fill); transition: background-color var(--duration-standard) var(--ease-standard); }\n  .table tbody tr::after { order: 1; flex-basis: 100%; content: \"\"; }\n  .table[data-selectable] tbody tr { padding-left: 44px; }\n  .table[data-selectable] tbody tr[data-selected] { --row-fill: color-mix(in oklab, var(--accent) 8%, var(--surface)); }\n  .table tbody tr:last-child { border-bottom: 0; }\n  .table td { order: 2; display: block; min-width: 0; height: auto; border: 0; padding: 0; background: transparent; color: var(--text-secondary); font-size: var(--text-xs); text-align: left; }\n  .table[data-selectable] td[data-primary]:not([data-sortable]) { padding-left: 0; }\n  .table td[data-primary] { order: 0; flex: 1 1 0; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; }\n  .table td[data-numeric]:not([data-primary]) { order: 0; flex: none; margin-left: auto; padding-left: var(--space-2); color: var(--foreground); font-size: var(--text-sm); }\n  .table td[data-numeric] ~ td[data-numeric] { order: 2; margin-left: 0; padding-left: 0; color: var(--text-secondary); font-size: var(--text-xs); }\n  /* The sorted value lifts to full contrast, echoing the tinted column on wider screens. */\n  .table td[data-sorted]:not([data-primary]) { color: var(--foreground); }\n  .table td.selectCell { position: absolute; top: 0; bottom: 0; left: var(--space-1); display: flex; width: 36px; align-items: center; padding: 0; }\n  .table td.selectCell .selectHit { justify-content: center; }\n  .table .empty { flex: 1 0 100%; height: auto !important; padding: var(--space-6) 0; }\n  .table[data-selectable] tbody tr:has(.empty) { padding-left: var(--space-4); }\n}\n@media (max-width: 620px) and (hover: hover) and (pointer: fine) {\n  .table tbody tr:hover { --row-fill: color-mix(in oklab, var(--foreground) 3%, var(--surface)); }\n  .table[data-selectable] tbody tr[data-selected]:hover { --row-fill: color-mix(in oklab, var(--accent) 11%, var(--surface)); }\n  .table tbody tr:hover td { background: transparent; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .table th, .table td, .table tbody tr, .sortInner, .sortHint, .selectBox, .count, .clear, .table th .sortButton { transition: none !important; }\n  .selectInput:active + .selectBox { transform: none; }\n}\n\n@keyframes stripFade { to { opacity: 1; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/sortable-data-table",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "table"
    ],
    "docs": "https://ui.xzero.co.uk/components/sortable-data-table",
    "markdown": "https://ui.xzero.co.uk/components/sortable-data-table/markdown"
  }
}