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 | const INTERACTIVE = "button, a[href], input, select, textarea, label, [role='button'], [role='checkbox'], [role='menuitem'], [contenteditable='true']"; |
| 60 |
| 61 | /** True when the event started on a control inside the row, not the row. */ |
| 62 | function fromInteractiveChild(event: React.SyntheticEvent<HTMLElement>): boolean { |
| 63 | const target = event.target as HTMLElement | null; |
| 64 | const control = target?.closest(INTERACTIVE); |
| 65 | return Boolean(control && control !== event.currentTarget && event.currentTarget.contains(control)); |
| 66 | } |
| 67 |
| 68 | function compare(a: unknown, b: unknown): number { |
| 69 | if (a === b) return 0; |
| 70 | if (a === null || a === undefined) return 1; |
| 71 | if (b === null || b === undefined) return -1; |
| 72 | if (typeof a === "number" && typeof b === "number") return a - b; |
| 73 | if (typeof a === "boolean" && typeof b === "boolean") return Number(a) - Number(b); |
| 74 | return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" }); |
| 75 | } |
| 76 |
| 77 | function Checkbox({ |
| 78 | checked, |
| 79 | indeterminate, |
| 80 | onChange, |
| 81 | label, |
| 82 | }: { |
| 83 | checked: boolean; |
| 84 | indeterminate?: boolean; |
| 85 | onChange: (checked: boolean) => void; |
| 86 | label: string; |
| 87 | }) { |
| 88 | return ( |
| 89 | <button |
| 90 | type="button" |
| 91 | role="checkbox" |
| 92 | aria-checked={indeterminate ? "mixed" : checked} |
| 93 | aria-label={label} |
| 94 | onClick={(event) => { |
| 95 | event.stopPropagation(); |
| 96 | onChange(!checked); |
| 97 | }} |
| 98 | className={cn( |
| 99 | "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", |
| 100 | checked || indeterminate ? "border-action bg-action text-background" : "border-border bg-background hover:border-foreground/30", |
| 101 | )} |
| 102 | > |
| 103 | <AnimatePresence initial={false}> |
| 104 | {checked || indeterminate ? ( |
| 105 | <motion.svg |
| 106 | key={indeterminate ? "mixed" : "check"} |
| 107 | viewBox="0 0 12 12" |
| 108 | className="size-2.5" |
| 109 | initial={{ scale: 0.4, opacity: 0 }} |
| 110 | animate={{ scale: 1, opacity: 1 }} |
| 111 | exit={{ scale: 0.4, opacity: 0 }} |
| 112 | transition={transitions.quick} |
| 113 | > |
| 114 | {indeterminate ? ( |
| 115 | <path d="M2.5 6h7" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /> |
| 116 | ) : ( |
| 117 | <path d="M2.5 6.2l2.2 2.3 4.8-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /> |
| 118 | )} |
| 119 | </motion.svg> |
| 120 | ) : null} |
| 121 | </AnimatePresence> |
| 122 | </button> |
| 123 | ); |
| 124 | } |
| 125 |
| 126 | export function DataGrid<T>({ |
| 127 | rows, |
| 128 | columns, |
| 129 | getRowId, |
| 130 | label, |
| 131 | onRowOpen, |
| 132 | selectable = false, |
| 133 | selectedIds, |
| 134 | onSelectionChange, |
| 135 | selectionActions, |
| 136 | sort: controlledSort, |
| 137 | onSortChange, |
| 138 | loading = false, |
| 139 | empty, |
| 140 | density = "comfortable", |
| 141 | maxHeight = "70vh", |
| 142 | className, |
| 143 | }: DataGridProps<T>) { |
| 144 | const reduceMotion = useReducedMotion(); |
| 145 | const [localSort, setLocalSort] = React.useState<DataGridSort | null>(null); |
| 146 | const sort = controlledSort === undefined ? localSort : controlledSort; |
| 147 | const [localSelection, setLocalSelection] = React.useState<Set<string>>(new Set()); |
| 148 | const selection = selectedIds ? new Set(selectedIds) : localSelection; |
| 149 | const [focusIndex, setFocusIndex] = React.useState(0); |
| 150 | const scrollRef = React.useRef<HTMLDivElement | null>(null); |
| 151 | const rowRefs = React.useRef(new Map<number, HTMLTableRowElement>()); |
| 152 | const [scrollTop, setScrollTop] = React.useState(0); |
| 153 | const [viewport, setViewport] = React.useState(600); |
| 154 | const rowHeight = density === "compact" ? 36 : 46; |
| 155 |
| 156 | const sorted = React.useMemo(() => { |
| 157 | if (!sort) return rows; |
| 158 | const column = columns.find((candidate) => candidate.key === sort.key); |
| 159 | if (!column?.sortValue) return rows; |
| 160 | const direction = sort.direction === "asc" ? 1 : -1; |
| 161 | return [...rows].sort((a, b) => compare(column.sortValue!(a), column.sortValue!(b)) * direction); |
| 162 | }, [rows, columns, sort]); |
| 163 |
| 164 | const setSort = (next: DataGridSort | null) => { |
| 165 | if (controlledSort === undefined) setLocalSort(next); |
| 166 | onSortChange?.(next); |
| 167 | }; |
| 168 | const setSelection = (next: Set<string>) => { |
| 169 | if (!selectedIds) setLocalSelection(next); |
| 170 | onSelectionChange?.(next); |
| 171 | }; |
| 172 | const toggleRow = (id: string) => { |
| 173 | const next = new Set(selection); |
| 174 | if (next.has(id)) next.delete(id); |
| 175 | else next.add(id); |
| 176 | setSelection(next); |
| 177 | }; |
| 178 | const allIds = sorted.map(getRowId); |
| 179 | const selectedCount = allIds.filter((id) => selection.has(id)).length; |
| 180 |
| 181 | const windowed = sorted.length > WINDOW_THRESHOLD; |
| 182 | React.useEffect(() => { |
| 183 | const element = scrollRef.current; |
| 184 | if (!element || !windowed) return; |
| 185 | setViewport(element.clientHeight); |
| 186 | const observer = new ResizeObserver(() => setViewport(element.clientHeight)); |
| 187 | observer.observe(element); |
| 188 | return () => observer.disconnect(); |
| 189 | }, [windowed]); |
| 190 | const start = windowed ? Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN) : 0; |
| 191 | const end = windowed |
| 192 | ? Math.min(sorted.length, Math.ceil((scrollTop + viewport) / rowHeight) + OVERSCAN) |
| 193 | : sorted.length; |
| 194 | const visible = sorted.slice(start, end); |
| 195 | const animateLayout = !reduceMotion && sorted.length <= 120; |
| 196 |
| 197 | const focusRow = (index: number) => { |
| 198 | const clamped = Math.max(0, Math.min(sorted.length - 1, index)); |
| 199 | setFocusIndex(clamped); |
| 200 | const element = scrollRef.current; |
| 201 | if (element && windowed) { |
| 202 | const top = clamped * rowHeight; |
| 203 | if (top < element.scrollTop) element.scrollTop = top; |
| 204 | else if (top + rowHeight > element.scrollTop + element.clientHeight - 40) { |
| 205 | element.scrollTop = top + rowHeight - element.clientHeight + 40; |
| 206 | } |
| 207 | } |
| 208 | requestAnimationFrame(() => rowRefs.current.get(clamped)?.focus()); |
| 209 | }; |
| 210 |
| 211 | const cellPadding = density === "compact" ? "px-3 py-1.5" : "px-4 py-2.5"; |
| 212 |
| 213 | return ( |
| 214 | <div className={cn("relative", className)}> |
| 215 | <div |
| 216 | ref={scrollRef} |
| 217 | onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)} |
| 218 | className="relative overflow-auto rounded-2xl border border-border/60 bg-card" |
| 219 | style={{ maxHeight }} |
| 220 | > |
| 221 | <table |
| 222 | aria-label={label} |
| 223 | aria-rowcount={sorted.length} |
| 224 | className="w-full table-fixed border-separate border-spacing-0 text-sm" |
| 225 | // Fixed layout keeps columns steady, but on a phone it would squeeze |
| 226 | // every column to a few letters: keep a readable width per column |
| 227 | // and let the card scroll sideways instead. |
| 228 | style={{ minWidth: `${columns.filter((column) => !column.hideOnMobile).length * 7 + (selectable ? 2.5 : 0)}rem` }} |
| 229 | > |
| 230 | <thead className="sticky top-0 z-10"> |
| 231 | <tr> |
| 232 | {selectable ? ( |
| 233 | <th scope="col" className="w-10 border-b border-border/60 bg-card/95 pl-4 backdrop-blur"> |
| 234 | <Checkbox |
| 235 | label={selectedCount === allIds.length ? "Clear selection" : "Select all"} |
| 236 | checked={allIds.length > 0 && selectedCount === allIds.length} |
| 237 | indeterminate={selectedCount > 0 && selectedCount < allIds.length} |
| 238 | onChange={(checked) => setSelection(checked ? new Set(allIds) : new Set())} |
| 239 | /> |
| 240 | </th> |
| 241 | ) : null} |
| 242 | {columns.map((column) => { |
| 243 | const active = sort?.key === column.key; |
| 244 | const ariaSort = active ? (sort.direction === "asc" ? "ascending" : "descending") : "none"; |
| 245 | return ( |
| 246 | <th |
| 247 | key={column.key} |
| 248 | scope="col" |
| 249 | aria-sort={column.sortValue ? ariaSort : undefined} |
| 250 | style={column.width ? { width: column.width } : undefined} |
| 251 | className={cn( |
| 252 | "border-b border-border/60 bg-card/95 text-xs font-medium text-muted-foreground backdrop-blur", |
| 253 | cellPadding, |
| 254 | column.align === "end" ? "text-right" : column.align === "center" ? "text-center" : "text-left", |
| 255 | column.hideOnMobile && "hidden sm:table-cell", |
| 256 | )} |
| 257 | > |
| 258 | {column.sortValue ? ( |
| 259 | <button |
| 260 | type="button" |
| 261 | onClick={() => |
| 262 | setSort( |
| 263 | !active |
| 264 | ? { key: column.key, direction: "asc" } |
| 265 | : sort.direction === "asc" |
| 266 | ? { key: column.key, direction: "desc" } |
| 267 | : null, |
| 268 | ) |
| 269 | } |
| 270 | className={cn( |
| 271 | "-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", |
| 272 | active && "text-foreground", |
| 273 | )} |
| 274 | > |
| 275 | {column.header} |
| 276 | <span className="relative flex size-3.5 items-center justify-center"> |
| 277 | <AnimatePresence initial={false} mode="popLayout"> |
| 278 | <motion.span |
| 279 | key={active ? sort.direction : "none"} |
| 280 | initial={{ opacity: 0, y: active && sort.direction === "asc" ? 3 : -3 }} |
| 281 | animate={{ opacity: 1, y: 0 }} |
| 282 | exit={{ opacity: 0 }} |
| 283 | transition={transitions.quick} |
| 284 | className="flex" |
| 285 | > |
| 286 | {!active ? ( |
| 287 | <IconSelector className="size-3.5 opacity-40" /> |
| 288 | ) : sort.direction === "asc" ? ( |
| 289 | <IconArrowUp className="size-3.5" /> |
| 290 | ) : ( |
| 291 | <IconArrowDown className="size-3.5" /> |
| 292 | )} |
| 293 | </motion.span> |
| 294 | </AnimatePresence> |
| 295 | </span> |
| 296 | </button> |
| 297 | ) : ( |
| 298 | column.header |
| 299 | )} |
| 300 | </th> |
| 301 | ); |
| 302 | })} |
| 303 | </tr> |
| 304 | </thead> |
| 305 | <tbody> |
| 306 | {loading |
| 307 | ? Array.from({ length: 6 }, (_, index) => ( |
| 308 | <tr key={`skeleton-${index}`}> |
| 309 | {selectable ? <td className="border-b border-border/40 pl-4" /> : null} |
| 310 | {columns.map((column, columnIndex) => ( |
| 311 | <td key={column.key} className={cn("border-b border-border/40", cellPadding, column.hideOnMobile && "hidden sm:table-cell")}> |
| 312 | <span |
| 313 | className="block h-3.5 animate-pulse rounded bg-muted" |
| 314 | style={{ width: `${70 - ((index + columnIndex) % 3) * 15}%` }} |
| 315 | /> |
| 316 | </td> |
| 317 | ))} |
| 318 | </tr> |
| 319 | )) |
| 320 | : null} |
| 321 | {!loading && windowed && start > 0 ? ( |
| 322 | <tr aria-hidden="true" style={{ height: start * rowHeight }} /> |
| 323 | ) : null} |
| 324 | {!loading |
| 325 | ? visible.map((row, offset) => { |
| 326 | const index = start + offset; |
| 327 | const id = getRowId(row); |
| 328 | const selected = selection.has(id); |
| 329 | return ( |
| 330 | <motion.tr |
| 331 | key={id} |
| 332 | ref={(element) => { |
| 333 | if (element) rowRefs.current.set(index, element); |
| 334 | else rowRefs.current.delete(index); |
| 335 | }} |
| 336 | layout={animateLayout ? "position" : false} |
| 337 | transition={transitions.enter} |
| 338 | aria-rowindex={index + 2} |
| 339 | aria-selected={selectable ? selected : undefined} |
| 340 | tabIndex={index === focusIndex ? 0 : -1} |
| 341 | onFocus={() => setFocusIndex(index)} |
| 342 | onClick={(event) => { |
| 343 | // A button, link, or checkbox inside a cell is its own action. |
| 344 | if (fromInteractiveChild(event)) return; |
| 345 | onRowOpen?.(row); |
| 346 | }} |
| 347 | onKeyDown={(event) => { |
| 348 | // Row keys apply only while the row itself has focus. |
| 349 | if (event.target !== event.currentTarget) return; |
| 350 | if (event.key === "ArrowDown") { |
| 351 | event.preventDefault(); |
| 352 | focusRow(index + 1); |
| 353 | } else if (event.key === "ArrowUp") { |
| 354 | event.preventDefault(); |
| 355 | focusRow(index - 1); |
| 356 | } else if (event.key === "Home") { |
| 357 | event.preventDefault(); |
| 358 | focusRow(0); |
| 359 | } else if (event.key === "End") { |
| 360 | event.preventDefault(); |
| 361 | focusRow(sorted.length - 1); |
| 362 | } else if (event.key === " " && selectable) { |
| 363 | event.preventDefault(); |
| 364 | toggleRow(id); |
| 365 | } else if (event.key === "Enter" && onRowOpen) { |
| 366 | event.preventDefault(); |
| 367 | onRowOpen(row); |
| 368 | } |
| 369 | }} |
| 370 | className={cn( |
| 371 | "group/row outline-none transition-colors duration-100", |
| 372 | onRowOpen && "cursor-pointer", |
| 373 | selected ? "bg-action/[0.06]" : "hover:bg-muted/40 focus-visible:bg-muted/40", |
| 374 | )} |
| 375 | {...(windowed ? { style: { height: rowHeight } } : {})} |
| 376 | > |
| 377 | {selectable ? ( |
| 378 | <td className="w-10 border-b border-border/40 pl-4 group-last/row:border-b-0"> |
| 379 | <Checkbox |
| 380 | label={selected ? "Deselect row" : "Select row"} |
| 381 | checked={selected} |
| 382 | onChange={() => toggleRow(id)} |
| 383 | /> |
| 384 | </td> |
| 385 | ) : null} |
| 386 | {columns.map((column, columnIndex) => ( |
| 387 | <td |
| 388 | key={column.key} |
| 389 | className={cn( |
| 390 | "overflow-hidden border-b border-border/40 text-ellipsis whitespace-nowrap text-foreground group-last/row:border-b-0", |
| 391 | cellPadding, |
| 392 | column.align === "end" ? "text-right tabular-nums" : column.align === "center" ? "text-center" : "text-left", |
| 393 | columnIndex === 0 && "font-medium", |
| 394 | column.hideOnMobile && "hidden sm:table-cell", |
| 395 | )} |
| 396 | > |
| 397 | {column.cell(row)} |
| 398 | </td> |
| 399 | ))} |
| 400 | </motion.tr> |
| 401 | ); |
| 402 | }) |
| 403 | : null} |
| 404 | {!loading && windowed && end < sorted.length ? ( |
| 405 | <tr aria-hidden="true" style={{ height: (sorted.length - end) * rowHeight }} /> |
| 406 | ) : null} |
| 407 | </tbody> |
| 408 | </table> |
| 409 | {!loading && sorted.length === 0 ? ( |
| 410 | <div className="flex flex-col items-center gap-1 px-6 py-12 text-center"> |
| 411 | {empty ?? ( |
| 412 | <> |
| 413 | <p className="text-sm font-medium text-foreground">Nothing to show</p> |
| 414 | <p className="text-sm text-muted-foreground">Records appear here as they are added.</p> |
| 415 | </> |
| 416 | )} |
| 417 | </div> |
| 418 | ) : null} |
| 419 | </div> |
| 420 | <AnimatePresence> |
| 421 | {selectable && selectedCount > 0 && selectionActions ? ( |
| 422 | <motion.div |
| 423 | initial={{ opacity: 0, y: 12, scale: 0.97 }} |
| 424 | animate={{ opacity: 1, y: 0, scale: 1 }} |
| 425 | exit={{ opacity: 0, y: 12, scale: 0.97 }} |
| 426 | transition={transitions.spring} |
| 427 | role="toolbar" |
| 428 | aria-label={`${selectedCount} selected`} |
| 429 | 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" |
| 430 | > |
| 431 | <span className="text-sm font-medium tabular-nums text-foreground">{selectedCount} selected</span> |
| 432 | <span className="mx-1 h-4 w-px bg-border" aria-hidden="true" /> |
| 433 | {selectionActions( |
| 434 | new Set(allIds.filter((id) => selection.has(id))), |
| 435 | () => setSelection(new Set()), |
| 436 | )} |
| 437 | </motion.div> |
| 438 | ) : null} |
| 439 | </AnimatePresence> |
| 440 | </div> |
| 441 | ); |
| 442 | } |
| 443 |