{"$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://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/sortable-data-table/sortable-data-table.tsx","type":"registry:component","target":"@components/arc/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 = {\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> = {\n rows: T[];\n columns: DataColumn[];\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(null);\n const width = useMotionValue(\"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 \n \n \n {value}\n \n \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 ? instant : active ? { opacity: enter, filter: enter, scale: motionTokens.spring.snappy } : { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] };\n return \n \n \n ;\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 }) {\n const local = useRef(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 ;\n}\n\nexport function SortableDataTable>({ rows, columns, rowKey, caption = \"Data table\", emptyMessage = \"No rows to show\", defaultSort, onSortChange, selectable = false, selectedKeys, defaultSelectedKeys, onSelectionChange, itemName = { one: \"row\", other: \"rows\" } }: SortableDataTableProps) {\n const reduced = useReducedMotion() ?? false;\n const tableRef = useRef(null);\n const selectAllRef = useRef(null);\n const anchor = useRef(null);\n const [sort, setSort] = useState(defaultSort ?? null);\n const [announcement, setAnnouncement] = useState(\"\");\n const [internalSelection, setInternalSelection] = useState(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 } | 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 = {};\n table.querySelectorAll(\"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(`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(`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) {\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) {\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, 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(\"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) {\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(`[data-nav=\"${name}\"]`)];\n const items = list(nav);\n const index = items.indexOf(target);\n const steps: Record = 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
\n
\n {band ? : null}\n \n \n \n {selectable ? : null}\n {columns.map((column, index) => 0 ? `${widths[column.key]}%` : undefined }} />)}\n \n \n {selectable ? : null}\n {columns.map((column, index) => {\n const active = sort?.key === column.key;\n const sortable = column.sortable !== false;\n return ;\n })}\n \n {sortedRows.length ? sortedRows.map((row, index) => {\n const key = keys[index];\n const selected = selectable && selection.has(key);\n return onRowClick(event, key)} onMouseDown={event => { if (selectable && event.shiftKey) event.preventDefault(); }}>\n {selectable ? : null}\n {columns.map((column, columnIndex) => )}\n ;\n }) : }\n
{caption}
0 && !allSelected} label=\"Select all rows\" nav=\"head\" reduced={reduced} onToggle={() => commit(allSelected ? new Set() : new Set(keys))} />\n {sortable ? : column.label}\n
toggleRow(key, extend)} />{column.render ? column.render(row[column.key], row) : String(row[column.key] ?? \"–\")}
{emptyMessage}
\n
\n {selectable ?
\n {shownCount} {noun}\n 0 || undefined} aria-hidden=\"true\">\n \n \n \n {selectedCount ? Clear selection : null}\n
: null}\n

{announcement}

\n
;\n}\n\nexport default SortableDataTable;\n"},{"path":"registry/components/sortable-data-table/sortable-data-table.module.css","type":"registry:component","target":"@components/arc/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 { outline: none; }\n.sortButton:focus-visible .sortInner { outline: 2px solid var(--focus-ring); outline-offset: 4px; }\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.selectInput:focus-visible + .selectBox { outline: 2px solid var(--focus-ring); 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.clear:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }\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; 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 { outline: 2px solid var(--focus-ring); outline-offset: 1px; }\n .sortButton:focus-visible .sortInner { outline: none; }\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://uiarc.dev/components/sortable-data-table","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/sortable-data-table","markdown":"https://uiarc.dev/components/sortable-data-table/markdown","tags":["data","table"]}}