The Coline design system: accessible React components for Coline Apps, themed live by the host workspace, and for Coline Sites in light and dark. Tailwind v4 stylesheet included. MIT.
| 5 | import { IconArrowDown, IconArrowUp, IconSelector } from "@tabler/icons-react"; |
| 6 | import { cn } from "../lib/utils"; |
| 7 | import { transitions } from "../lib/motion"; |
| 8 |
| 9 | /** |
| 10 | * DataGrid: the table for real record sets. Sortable columns, row |
| 11 | * selection with a floating bulk-action bar, roving keyboard focus |
| 12 | * (↑/↓ move, Space selects, Enter opens), a sticky header, skeleton and |
| 13 | * empty states, and windowed rendering past a few hundred rows so 50k |
| 14 | * records scroll smoothly. |
| 15 | */ |
| 16 |
| 17 | export interface DataGridColumn<T> { |
| 18 | key: string; |
| 19 | header: React.ReactNode; |
| 20 | cell: (row: T) => React.ReactNode; |
| 21 | /** Enables sorting on this column. */ |
| 22 | sortValue?: (row: T) => string | number | boolean | null | undefined; |
| 23 | align?: "start" | "end" | "center"; |
| 24 | /** CSS width, e.g. "12rem" or "30%". */ |
| 25 | width?: string; |
| 26 | /** Hide below the `sm` breakpoint. */ |
| 27 | hideOnMobile?: boolean; |
| 28 | } |
| 29 |
| 30 | export interface DataGridSort { |
| 31 | key: string; |
| 32 | direction: "asc" | "desc"; |
| 33 | } |
| 34 |
| 35 | export interface DataGridProps<T> { |
| 36 | rows: readonly T[]; |
| 37 | columns: ReadonlyArray<DataGridColumn<T>>; |
| 38 | getRowId: (row: T) => string; |
| 39 | label: string; |
| 40 | onRowOpen?: (row: T) => void; |
| 41 | selectable?: boolean; |
| 42 | selectedIds?: ReadonlySet<string>; |
| 43 | onSelectionChange?: (ids: Set<string>) => void; |
| 44 | /** Rendered in a floating bar while rows are selected. */ |
| 45 | selectionActions?: (ids: Set<string>, clear: () => void) => React.ReactNode; |
| 46 | sort?: DataGridSort | null; |
| 47 | onSortChange?: (sort: DataGridSort | null) => void; |
| 48 | loading?: boolean; |
| 49 | empty?: React.ReactNode; |
| 50 | density?: "comfortable" | "compact"; |
| 51 | /** Scroll container max height; the header stays pinned. */ |
| 52 | maxHeight?: string; |
| 53 | className?: string; |
| 54 | } |
| 55 |
| 56 | const WINDOW_THRESHOLD = 300; |
| 57 | const OVERSCAN = 12; |
| 58 |
| 59 | function compare(a: unknown, b: unknown): number { |
| 60 | if (a === b) return 0; |
| 61 | if (a === null || a === undefined) return 1; |
| 62 | if (b === null || b === undefined) return -1; |
| 63 | if (typeof a === "number" && typeof b === "number") return a - b; |
| 64 | if (typeof a === "boolean" && typeof b === "boolean") return Number(a) - Number(b); |
| 65 | return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" }); |
| 66 | } |
| 67 |
| 68 | function Checkbox({ |
| 69 | checked, |
| 70 | indeterminate, |
| 71 | onChange, |
| 72 | label, |
| 73 | }: { |
| 74 | checked: boolean; |
| 75 | indeterminate?: boolean; |
| 76 | onChange: (checked: boolean) => void; |
| 77 | label: string; |
| 78 | }) { |
| 79 | return ( |
| 80 | <button |
| 81 | type="button" |
| 82 | role="checkbox" |
| 83 | aria-checked={indeterminate ? "mixed" : checked} |
| 84 | aria-label={label} |
| 85 | onClick={(event) => { |
| 86 | event.stopPropagation(); |
| 87 | onChange(!checked); |
| 88 | }} |
| 89 | className={cn( |
| 90 | "flex size-4 items-center justify-center rounded-[5px] border transition-[background-color,border-color] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50", |
| 91 | checked || indeterminate ? "border-action bg-action text-background" : "border-border bg-background hover:border-foreground/30", |
| 92 | )} |
| 93 | > |
| 94 | <AnimatePresence initial={false}> |
| 95 | {checked || indeterminate ? ( |
| 96 | <motion.svg |
| 97 | key={indeterminate ? "mixed" : "check"} |
| 98 | viewBox="0 0 12 12" |
| 99 | className="size-2.5" |
| 100 | initial={{ scale: 0.4, opacity: 0 }} |
| 101 | animate={{ scale: 1, opacity: 1 }} |
| 102 | exit={{ scale: 0.4, opacity: 0 }} |
| 103 | transition={transitions.quick} |
| 104 | > |
| 105 | {indeterminate ? ( |
| 106 | <path d="M2.5 6h7" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /> |
| 107 | ) : ( |
| 108 | <path d="M2.5 6.2l2.2 2.3 4.8-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /> |
| 109 | )} |
| 110 | </motion.svg> |
| 111 | ) : null} |
| 112 | </AnimatePresence> |
| 113 | </button> |
| 114 | ); |
| 115 | } |
| 116 |
| 117 | export function DataGrid<T>({ |
| 118 | rows, |
| 119 | columns, |
| 120 | getRowId, |
| 121 | label, |
| 122 | onRowOpen, |
| 123 | selectable = false, |
| 124 | selectedIds, |
| 125 | onSelectionChange, |
| 126 | selectionActions, |
| 127 | sort: controlledSort, |
| 128 | onSortChange, |
| 129 | loading = false, |
| 130 | empty, |
| 131 | density = "comfortable", |
| 132 | maxHeight = "70vh", |
| 133 | className, |
| 134 | }: DataGridProps<T>) { |
| 135 | const reduceMotion = useReducedMotion(); |
| 136 | const [localSort, setLocalSort] = React.useState<DataGridSort | null>(null); |
| 137 | const sort = controlledSort === undefined ? localSort : controlledSort; |
| 138 | const [localSelection, setLocalSelection] = React.useState<Set<string>>(new Set()); |
| 139 | const selection = selectedIds ? new Set(selectedIds) : localSelection; |
| 140 | const [focusIndex, setFocusIndex] = React.useState(0); |
| 141 | const scrollRef = React.useRef<HTMLDivElement | null>(null); |
| 142 | const rowRefs = React.useRef(new Map<number, HTMLTableRowElement>()); |
| 143 | const [scrollTop, setScrollTop] = React.useState(0); |
| 144 | const [viewport, setViewport] = React.useState(600); |
| 145 | const rowHeight = density === "compact" ? 36 : 46; |
| 146 |
| 147 | const sorted = React.useMemo(() => { |
| 148 | if (!sort) return rows; |
| 149 | const column = columns.find((candidate) => candidate.key === sort.key); |
| 150 | if (!column?.sortValue) return rows; |
| 151 | const direction = sort.direction === "asc" ? 1 : -1; |
| 152 | return [...rows].sort((a, b) => compare(column.sortValue!(a), column.sortValue!(b)) * direction); |
| 153 | }, [rows, columns, sort]); |
| 154 |
| 155 | const setSort = (next: DataGridSort | null) => { |
| 156 | if (controlledSort === undefined) setLocalSort(next); |
| 157 | onSortChange?.(next); |
| 158 | }; |
| 159 | const setSelection = (next: Set<string>) => { |
| 160 | if (!selectedIds) setLocalSelection(next); |
| 161 | onSelectionChange?.(next); |
| 162 | }; |
| 163 | const toggleRow = (id: string) => { |
| 164 | const next = new Set(selection); |
| 165 | if (next.has(id)) next.delete(id); |
| 166 | else next.add(id); |
| 167 | setSelection(next); |
| 168 | }; |
| 169 | const allIds = sorted.map(getRowId); |
| 170 | const selectedCount = allIds.filter((id) => selection.has(id)).length; |
| 171 |
| 172 | const windowed = sorted.length > WINDOW_THRESHOLD; |
| 173 | React.useEffect(() => { |
| 174 | const element = scrollRef.current; |
| 175 | if (!element || !windowed) return; |
| 176 | setViewport(element.clientHeight); |
| 177 | const observer = new ResizeObserver(() => setViewport(element.clientHeight)); |
| 178 | observer.observe(element); |
| 179 | return () => observer.disconnect(); |
| 180 | }, [windowed]); |
| 181 | const start = windowed ? Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN) : 0; |
| 182 | const end = windowed |
| 183 | ? Math.min(sorted.length, Math.ceil((scrollTop + viewport) / rowHeight) + OVERSCAN) |
| 184 | : sorted.length; |
| 185 | const visible = sorted.slice(start, end); |
| 186 | const animateLayout = !reduceMotion && sorted.length <= 120; |
| 187 |
| 188 | const focusRow = (index: number) => { |
| 189 | const clamped = Math.max(0, Math.min(sorted.length - 1, index)); |
| 190 | setFocusIndex(clamped); |
| 191 | const element = scrollRef.current; |
| 192 | if (element && windowed) { |
| 193 | const top = clamped * rowHeight; |
| 194 | if (top < element.scrollTop) element.scrollTop = top; |
| 195 | else if (top + rowHeight > element.scrollTop + element.clientHeight - 40) { |
| 196 | element.scrollTop = top + rowHeight - element.clientHeight + 40; |
| 197 | } |
| 198 | } |
| 199 | requestAnimationFrame(() => rowRefs.current.get(clamped)?.focus()); |
| 200 | }; |
| 201 |
| 202 | const cellPadding = density === "compact" ? "px-3 py-1.5" : "px-4 py-2.5"; |
| 203 |
| 204 | return ( |
| 205 | <div className={cn("relative", className)}> |
| 206 | <div |
| 207 | ref={scrollRef} |
| 208 | onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)} |
| 209 | className="relative overflow-auto rounded-2xl border border-border/60 bg-card" |
| 210 | style={{ maxHeight }} |
| 211 | > |
| 212 | <table aria-label={label} aria-rowcount={sorted.length} className="w-full table-fixed border-separate border-spacing-0 text-sm"> |
| 213 | <thead className="sticky top-0 z-10"> |
| 214 | <tr> |
| 215 | {selectable ? ( |
| 216 | <th scope="col" className="w-10 border-b border-border/60 bg-card/95 pl-4 backdrop-blur"> |
| 217 | <Checkbox |
| 218 | label={selectedCount === allIds.length ? "Clear selection" : "Select all"} |
| 219 | checked={allIds.length > 0 && selectedCount === allIds.length} |
| 220 | indeterminate={selectedCount > 0 && selectedCount < allIds.length} |
| 221 | onChange={(checked) => setSelection(checked ? new Set(allIds) : new Set())} |
| 222 | /> |
| 223 | </th> |
| 224 | ) : null} |
| 225 | {columns.map((column) => { |
| 226 | const active = sort?.key === column.key; |
| 227 | const ariaSort = active ? (sort.direction === "asc" ? "ascending" : "descending") : "none"; |
| 228 | return ( |
| 229 | <th |
| 230 | key={column.key} |
| 231 | scope="col" |
| 232 | aria-sort={column.sortValue ? ariaSort : undefined} |
| 233 | style={column.width ? { width: column.width } : undefined} |
| 234 | className={cn( |
| 235 | "border-b border-border/60 bg-card/95 text-xs font-medium text-muted-foreground backdrop-blur", |
| 236 | cellPadding, |
| 237 | column.align === "end" ? "text-right" : column.align === "center" ? "text-center" : "text-left", |
| 238 | column.hideOnMobile && "hidden sm:table-cell", |
| 239 | )} |
| 240 | > |
| 241 | {column.sortValue ? ( |
| 242 | <button |
| 243 | type="button" |
| 244 | onClick={() => |
| 245 | setSort( |
| 246 | !active |
| 247 | ? { key: column.key, direction: "asc" } |
| 248 | : sort.direction === "asc" |
| 249 | ? { key: column.key, direction: "desc" } |
| 250 | : null, |
| 251 | ) |
| 252 | } |
| 253 | className={cn( |
| 254 | "-mx-1 inline-flex items-center gap-1 rounded-md px-1 py-0.5 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50", |
| 255 | active && "text-foreground", |
| 256 | )} |
| 257 | > |
| 258 | {column.header} |
| 259 | <span className="relative flex size-3.5 items-center justify-center"> |
| 260 | <AnimatePresence initial={false} mode="popLayout"> |
| 261 | <motion.span |
| 262 | key={active ? sort.direction : "none"} |
| 263 | initial={{ opacity: 0, y: active && sort.direction === "asc" ? 3 : -3 }} |
| 264 | animate={{ opacity: 1, y: 0 }} |
| 265 | exit={{ opacity: 0 }} |
| 266 | transition={transitions.quick} |
| 267 | className="flex" |
| 268 | > |
| 269 | {!active ? ( |
| 270 | <IconSelector className="size-3.5 opacity-40" /> |
| 271 | ) : sort.direction === "asc" ? ( |
| 272 | <IconArrowUp className="size-3.5" /> |
| 273 | ) : ( |
| 274 | <IconArrowDown className="size-3.5" /> |
| 275 | )} |
| 276 | </motion.span> |
| 277 | </AnimatePresence> |
| 278 | </span> |
| 279 | </button> |
| 280 | ) : ( |
| 281 | column.header |
| 282 | )} |
| 283 | </th> |
| 284 | ); |
| 285 | })} |
| 286 | </tr> |
| 287 | </thead> |
| 288 | <tbody> |
| 289 | {loading |
| 290 | ? Array.from({ length: 6 }, (_, index) => ( |
| 291 | <tr key={`skeleton-${index}`}> |
| 292 | {selectable ? <td className="border-b border-border/40 pl-4" /> : null} |
| 293 | {columns.map((column, columnIndex) => ( |
| 294 | <td key={column.key} className={cn("border-b border-border/40", cellPadding, column.hideOnMobile && "hidden sm:table-cell")}> |
| 295 | <span |
| 296 | className="block h-3.5 animate-pulse rounded bg-muted" |
| 297 | style={{ width: `${70 - ((index + columnIndex) % 3) * 15}%` }} |
| 298 | /> |
| 299 | </td> |
| 300 | ))} |
| 301 | </tr> |
| 302 | )) |
| 303 | : null} |
| 304 | {!loading && windowed && start > 0 ? ( |
| 305 | <tr aria-hidden="true" style={{ height: start * rowHeight }} /> |
| 306 | ) : null} |
| 307 | {!loading |
| 308 | ? visible.map((row, offset) => { |
| 309 | const index = start + offset; |
| 310 | const id = getRowId(row); |
| 311 | const selected = selection.has(id); |
| 312 | return ( |
| 313 | <motion.tr |
| 314 | key={id} |
| 315 | ref={(element) => { |
| 316 | if (element) rowRefs.current.set(index, element); |
| 317 | else rowRefs.current.delete(index); |
| 318 | }} |
| 319 | layout={animateLayout ? "position" : false} |
| 320 | transition={transitions.enter} |
| 321 | aria-rowindex={index + 2} |
| 322 | aria-selected={selectable ? selected : undefined} |
| 323 | tabIndex={index === focusIndex ? 0 : -1} |
| 324 | onFocus={() => setFocusIndex(index)} |
| 325 | onClick={() => onRowOpen?.(row)} |
| 326 | onKeyDown={(event) => { |
| 327 | if (event.key === "ArrowDown") { |
| 328 | event.preventDefault(); |
| 329 | focusRow(index + 1); |
| 330 | } else if (event.key === "ArrowUp") { |
| 331 | event.preventDefault(); |
| 332 | focusRow(index - 1); |
| 333 | } else if (event.key === "Home") { |
| 334 | event.preventDefault(); |
| 335 | focusRow(0); |
| 336 | } else if (event.key === "End") { |
| 337 | event.preventDefault(); |
| 338 | focusRow(sorted.length - 1); |
| 339 | } else if (event.key === " " && selectable) { |
| 340 | event.preventDefault(); |
| 341 | toggleRow(id); |
| 342 | } else if (event.key === "Enter" && onRowOpen) { |
| 343 | event.preventDefault(); |
| 344 | onRowOpen(row); |
| 345 | } |
| 346 | }} |
| 347 | className={cn( |
| 348 | "group/row outline-none transition-colors duration-100", |
| 349 | onRowOpen && "cursor-pointer", |
| 350 | selected ? "bg-action/[0.06]" : "hover:bg-muted/40 focus-visible:bg-muted/40", |
| 351 | )} |
| 352 | {...(windowed ? { style: { height: rowHeight } } : {})} |
| 353 | > |
| 354 | {selectable ? ( |
| 355 | <td className="w-10 border-b border-border/40 pl-4 group-last/row:border-b-0"> |
| 356 | <Checkbox |
| 357 | label={selected ? "Deselect row" : "Select row"} |
| 358 | checked={selected} |
| 359 | onChange={() => toggleRow(id)} |
| 360 | /> |
| 361 | </td> |
| 362 | ) : null} |
| 363 | {columns.map((column, columnIndex) => ( |
| 364 | <td |
| 365 | key={column.key} |
| 366 | className={cn( |
| 367 | "overflow-hidden border-b border-border/40 text-ellipsis whitespace-nowrap text-foreground group-last/row:border-b-0", |
| 368 | cellPadding, |
| 369 | column.align === "end" ? "text-right tabular-nums" : column.align === "center" ? "text-center" : "text-left", |
| 370 | columnIndex === 0 && "font-medium", |
| 371 | column.hideOnMobile && "hidden sm:table-cell", |
| 372 | )} |
| 373 | > |
| 374 | {column.cell(row)} |
| 375 | </td> |
| 376 | ))} |
| 377 | </motion.tr> |
| 378 | ); |
| 379 | }) |
| 380 | : null} |
| 381 | {!loading && windowed && end < sorted.length ? ( |
| 382 | <tr aria-hidden="true" style={{ height: (sorted.length - end) * rowHeight }} /> |
| 383 | ) : null} |
| 384 | </tbody> |
| 385 | </table> |
| 386 | {!loading && sorted.length === 0 ? ( |
| 387 | <div className="flex flex-col items-center gap-1 px-6 py-12 text-center"> |
| 388 | {empty ?? ( |
| 389 | <> |
| 390 | <p className="text-sm font-medium text-foreground">Nothing to show</p> |
| 391 | <p className="text-sm text-muted-foreground">Records appear here as they are added.</p> |
| 392 | </> |
| 393 | )} |
| 394 | </div> |
| 395 | ) : null} |
| 396 | </div> |
| 397 | <AnimatePresence> |
| 398 | {selectable && selectedCount > 0 && selectionActions ? ( |
| 399 | <motion.div |
| 400 | initial={{ opacity: 0, y: 12, scale: 0.97 }} |
| 401 | animate={{ opacity: 1, y: 0, scale: 1 }} |
| 402 | exit={{ opacity: 0, y: 12, scale: 0.97 }} |
| 403 | transition={transitions.spring} |
| 404 | role="toolbar" |
| 405 | aria-label={`${selectedCount} selected`} |
| 406 | className="sticky bottom-3 z-20 mx-auto mt-3 flex w-fit items-center gap-2 rounded-2xl border border-border/70 bg-popover/95 py-1.5 pl-4 pr-1.5 shadow-[0_8px_30px_-8px_rgba(0,0,0,0.25)] backdrop-blur" |
| 407 | > |
| 408 | <span className="text-sm font-medium tabular-nums text-foreground">{selectedCount} selected</span> |
| 409 | <span className="mx-1 h-4 w-px bg-border" aria-hidden="true" /> |
| 410 | {selectionActions( |
| 411 | new Set(allIds.filter((id) => selection.has(id))), |
| 412 | () => setSelection(new Set()), |
| 413 | )} |
| 414 | </motion.div> |
| 415 | ) : null} |
| 416 | </AnimatePresence> |
| 417 | </div> |
| 418 | ); |
| 419 | } |
| 420 |