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.
| import { IconAlertTriangle, IconX } from "@tabler/icons-react"; |
| 5 | import { cn } from "../lib/utils"; |
| 6 | import { staggerDelay, transitions } from "../lib/motion"; |
| 7 | import { Button } from "../components/button"; |
| 8 |
| 9 | /** |
| 10 | * Page-level patterns for workspace apps: the frame, toolbars, the |
| 11 | * loading/empty/error trio, list + detail, boards, and settings. They |
| 12 | * carry Coline's spacing and type so apps look native without styling. |
| 13 | */ |
| 14 |
| 15 | export function AppPage({ |
| 16 | title, |
| 17 | description, |
| 18 | eyebrow, |
| 19 | actions, |
| 20 | children, |
| 21 | width = "wide", |
| 22 | className, |
| 23 | }: { |
| 24 | title: string; |
| 25 | description?: React.ReactNode; |
| 26 | eyebrow?: React.ReactNode; |
| 27 | actions?: React.ReactNode; |
| 28 | children: React.ReactNode; |
| 29 | width?: "wide" | "reading"; |
| 30 | className?: string; |
| 31 | }) { |
| 32 | const reduceMotion = useReducedMotion(); |
| 33 | return ( |
| 34 | <main |
| 35 | className={cn( |
| 36 | "mx-auto w-full min-w-0 px-4 pt-5 pb-10 text-sm text-foreground sm:px-6 sm:pt-6", |
| 37 | width === "reading" ? "max-w-[880px]" : "max-w-[1440px]", |
| 38 | className, |
| 39 | )} |
| 40 | data-width={width} |
| 41 | > |
| 42 | <motion.header |
| 43 | initial={reduceMotion ? false : { opacity: 0, y: 6 }} |
| 44 | animate={{ opacity: 1, y: 0 }} |
| 45 | transition={transitions.enter} |
| 46 | className="flex flex-wrap items-start justify-between gap-x-6 gap-y-3 pb-6" |
| 47 | > |
| 48 | <div className="min-w-0"> |
| 49 | {eyebrow ? <div className="mb-1.5 text-xs text-muted-foreground">{eyebrow}</div> : null} |
| 50 | <h1 className="text-2xl font-semibold tracking-tight text-balance break-words">{title}</h1> |
| 51 | {description ? <p className="mt-1.5 max-w-[64ch] text-sm text-muted-foreground">{description}</p> : null} |
| 52 | </div> |
| 53 | {actions ? <div className="flex shrink-0 flex-wrap items-center gap-2 max-sm:w-full">{actions}</div> : null} |
| 54 | </motion.header> |
| 55 | {children} |
| 56 | </main> |
| 57 | ); |
| 58 | } |
| 59 |
| 60 | export function PageToolbar({ |
| 61 | children, |
| 62 | label = "View controls", |
| 63 | className, |
| 64 | }: { |
| 65 | children: React.ReactNode; |
| 66 | label?: string; |
| 67 | className?: string; |
| 68 | }) { |
| 69 | return ( |
| 70 | <div |
| 71 | className={cn("mb-3 flex flex-wrap items-center gap-2 [&_[data-slot=input-group]]:max-w-80", className)} |
| 72 | role="group" |
| 73 | aria-label={label} |
| 74 | > |
| 75 | {children} |
| 76 | </div> |
| 77 | ); |
| 78 | } |
| 79 |
| 80 | export function EmptyState({ |
| 81 | title, |
| 82 | description, |
| 83 | action, |
| 84 | icon, |
| 85 | className, |
| 86 | }: { |
| 87 | title: string; |
| 88 | description?: React.ReactNode; |
| 89 | action?: React.ReactNode; |
| 90 | icon?: React.ReactNode; |
| 91 | className?: string; |
| 92 | }) { |
| 93 | const reduceMotion = useReducedMotion(); |
| 94 | return ( |
| 95 | <motion.section |
| 96 | initial={reduceMotion ? false : { opacity: 0, y: 8 }} |
| 97 | animate={{ opacity: 1, y: 0 }} |
| 98 | transition={transitions.enter} |
| 99 | className={cn("flex flex-col items-center gap-2 px-6 py-16 text-center", className)} |
| 100 | > |
| 101 | {icon ? ( |
| 102 | <motion.div |
| 103 | aria-hidden="true" |
| 104 | initial={reduceMotion ? false : { scale: 0.85, opacity: 0 }} |
| 105 | animate={{ scale: 1, opacity: 1 }} |
| 106 | transition={{ ...transitions.spring, delay: 0.05 }} |
| 107 | className="mb-2 flex size-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground [&_svg]:size-5" |
| 108 | > |
| 109 | {icon} |
| 110 | </motion.div> |
| 111 | ) : null} |
| 112 | <h2 className="text-[15px] font-semibold text-foreground">{title}</h2> |
| 113 | {description ? <p className="max-w-[40ch] text-sm text-muted-foreground">{description}</p> : null} |
| 114 | {action ? <div className="mt-3">{action}</div> : null} |
| 115 | </motion.section> |
| 116 | ); |
| 117 | } |
| 118 |
| 119 | export function AppErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) { |
| 120 | return ( |
| 121 | <motion.section |
| 122 | role="alert" |
| 123 | initial={{ opacity: 0, y: 6 }} |
| 124 | animate={{ opacity: 1, y: 0 }} |
| 125 | transition={transitions.enter} |
| 126 | className="flex flex-col items-start gap-4 rounded-2xl border border-risk/25 bg-risk/[0.05] p-4 sm:flex-row sm:items-center" |
| 127 | > |
| 128 | <span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-risk/10 text-risk-fg"> |
| 129 | <IconAlertTriangle className="size-4.5" /> |
| 130 | </span> |
| 131 | <div className="min-w-0 flex-1"> |
| 132 | <h2 className="text-sm font-semibold text-foreground">Something went wrong</h2> |
| 133 | <p className="mt-0.5 text-sm break-words text-muted-foreground">{message}</p> |
| 134 | </div> |
| 135 | {onRetry ? ( |
| 136 | <Button variant="outline" onClick={onRetry}> |
| 137 | Try again |
| 138 | </Button> |
| 139 | ) : null} |
| 140 | </motion.section> |
| 141 | ); |
| 142 | } |
| 143 |
| 144 | export function AppLoadingState({ label = "Loading", rows = 5 }: { label?: string; rows?: number }) { |
| 145 | return ( |
| 146 | <div className="flex flex-col gap-3 py-4" role="status" aria-label={label}> |
| 147 | {Array.from({ length: Math.max(1, Math.min(rows, 12)) }, (_, index) => ( |
| 148 | <motion.div |
| 149 | key={index} |
| 150 | initial={{ opacity: 0 }} |
| 151 | animate={{ opacity: 1, transition: { delay: staggerDelay(index, 0.05) } }} |
| 152 | className="flex items-center gap-3" |
| 153 | > |
| 154 | <div className="size-8 shrink-0 animate-pulse rounded-lg bg-muted" /> |
| 155 | <div className="flex flex-1 flex-col gap-1.5"> |
| 156 | <div className="h-3 animate-pulse rounded-full bg-muted" style={{ width: `${72 - (index % 3) * 14}%` }} /> |
| 157 | <div className="h-2.5 animate-pulse rounded-full bg-muted/70" style={{ width: `${44 - (index % 2) * 10}%` }} /> |
| 158 | </div> |
| 159 | </motion.div> |
| 160 | ))} |
| 161 | <span className="sr-only">{label}</span> |
| 162 | </div> |
| 163 | ); |
| 164 | } |
| 165 |
| 166 | const STATUS_TONE = { |
| 167 | neutral: "bg-muted text-muted-foreground", |
| 168 | info: "bg-action/10 text-action", |
| 169 | success: "bg-positive/10 text-positive-fg", |
| 170 | warning: "bg-attention/12 text-attention-fg", |
| 171 | danger: "bg-risk/10 text-risk-fg", |
| 172 | } as const; |
| 173 |
| 174 | export function StatusBadge({ |
| 175 | children, |
| 176 | tone = "neutral", |
| 177 | }: { |
| 178 | children: React.ReactNode; |
| 179 | tone?: keyof typeof STATUS_TONE; |
| 180 | }) { |
| 181 | return ( |
| 182 | <span |
| 183 | className={cn( |
| 184 | "inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-xs font-medium whitespace-nowrap transition-colors duration-200", |
| 185 | STATUS_TONE[tone], |
| 186 | )} |
| 187 | data-tone={tone} |
| 188 | > |
| 189 | <span aria-hidden="true" className="size-1.5 rounded-full bg-current" /> |
| 190 | {children} |
| 191 | </span> |
| 192 | ); |
| 193 | } |
| 194 |
| 195 | export function SplitView({ |
| 196 | list, |
| 197 | detail, |
| 198 | label = "Details", |
| 199 | }: { |
| 200 | list: React.ReactNode; |
| 201 | detail?: React.ReactNode; |
| 202 | label?: string; |
| 203 | }) { |
| 204 | const reduceMotion = useReducedMotion(); |
| 205 | return ( |
| 206 | <div className="flex min-h-[420px] flex-col gap-4 md:flex-row md:gap-0"> |
| 207 | <section className="min-w-0 flex-1">{list}</section> |
| 208 | <AnimatePresence initial={false}> |
| 209 | {detail ? ( |
| 210 | <motion.aside |
| 211 | key="detail" |
| 212 | aria-label={label} |
| 213 | initial={reduceMotion ? false : { opacity: 0, x: 16 }} |
| 214 | animate={{ opacity: 1, x: 0 }} |
| 215 | exit={{ opacity: 0, x: 16 }} |
| 216 | transition={transitions.enter} |
| 217 | className="min-w-0 border-border/70 max-md:border-t md:ml-4 md:w-[min(420px,38%)] md:shrink-0 md:border-l" |
| 218 | > |
| 219 | {detail} |
| 220 | </motion.aside> |
| 221 | ) : null} |
| 222 | </AnimatePresence> |
| 223 | </div> |
| 224 | ); |
| 225 | } |
| 226 |
| 227 | export function DetailPanel({ |
| 228 | title, |
| 229 | subtitle, |
| 230 | onClose, |
| 231 | children, |
| 232 | actions, |
| 233 | }: { |
| 234 | title: string; |
| 235 | subtitle?: React.ReactNode; |
| 236 | onClose?: () => void; |
| 237 | children: React.ReactNode; |
| 238 | actions?: React.ReactNode; |
| 239 | }) { |
| 240 | return ( |
| 241 | <section className="px-1 py-5 md:px-5"> |
| 242 | <header className="flex items-start justify-between gap-4"> |
| 243 | <div className="min-w-0"> |
| 244 | {subtitle ? <div className="mb-1 text-xs text-muted-foreground">{subtitle}</div> : null} |
| 245 | <h2 className="text-lg font-semibold tracking-tight break-words">{title}</h2> |
| 246 | </div> |
| 247 | {onClose ? ( |
| 248 | <Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="Close details"> |
| 249 | <IconX /> |
| 250 | </Button> |
| 251 | ) : null} |
| 252 | </header> |
| 253 | <div className="pt-5">{children}</div> |
| 254 | {actions ? <footer className="mt-6 flex flex-wrap gap-2">{actions}</footer> : null} |
| 255 | </section> |
| 256 | ); |
| 257 | } |
| 258 |
| 259 | export interface RecordColumn<T> { |
| 260 | key: string; |
| 261 | label: string; |
| 262 | render: (record: T) => React.ReactNode; |
| 263 | secondary?: boolean; |
| 264 | align?: "start" | "end"; |
| 265 | } |
| 266 |
| 267 | /** |
| 268 | * A light list-style table. For sorting, selection, keyboard navigation, |
| 269 | * and large data, use `DataGrid`. |
| 270 | */ |
| 271 | export function RecordTable<T>({ |
| 272 | records, |
| 273 | columns, |
| 274 | rowKey, |
| 275 | onOpen, |
| 276 | label = "Records", |
| 277 | empty, |
| 278 | }: { |
| 279 | records: readonly T[]; |
| 280 | columns: readonly RecordColumn<T>[]; |
| 281 | rowKey: (record: T) => string; |
| 282 | onOpen?: (record: T) => void; |
| 283 | label?: string; |
| 284 | empty?: React.ReactNode; |
| 285 | }) { |
| 286 | const reduceMotion = useReducedMotion(); |
| 287 | if (!records.length) return <>{empty ?? <EmptyState title="No results" description="Try changing your filters." />}</>; |
| 288 | return ( |
| 289 | <div className="w-full overflow-x-auto"> |
| 290 | <table className="w-full border-separate border-spacing-y-0.5 text-left text-[13px]" aria-label={label}> |
| 291 | <thead> |
| 292 | <tr> |
| 293 | {columns.map((column) => ( |
| 294 | <th |
| 295 | key={column.key} |
| 296 | scope="col" |
| 297 | className={cn( |
| 298 | "px-3.5 py-2.5 text-xs font-medium whitespace-nowrap text-muted-foreground", |
| 299 | column.align === "end" && "text-right", |
| 300 | column.secondary && "max-sm:hidden", |
| 301 | )} |
| 302 | > |
| 303 | {column.label} |
| 304 | </th> |
| 305 | ))} |
| 306 | </tr> |
| 307 | </thead> |
| 308 | <tbody> |
| 309 | <AnimatePresence initial={false}> |
| 310 | {records.map((record, index) => ( |
| 311 | <motion.tr |
| 312 | key={rowKey(record)} |
| 313 | layout={reduceMotion ? false : "position"} |
| 314 | initial={reduceMotion ? false : { opacity: 0, y: 4 }} |
| 315 | animate={{ opacity: 1, y: 0, transition: { ...transitions.enter, delay: staggerDelay(index) } }} |
| 316 | exit={{ opacity: 0 }} |
| 317 | className={cn("group", onOpen && "cursor-pointer")} |
| 318 | onClick={onOpen ? () => onOpen(record) : undefined} |
| 319 | > |
| 320 | {columns.map((column, columnIndex) => ( |
| 321 | <td |
| 322 | key={column.key} |
| 323 | className={cn( |
| 324 | "px-3.5 py-3 align-middle transition-colors duration-150 first:rounded-l-xl last:rounded-r-xl group-hover:bg-muted/50", |
| 325 | column.align === "end" && "text-right tabular-nums", |
| 326 | column.secondary && "max-sm:hidden", |
| 327 | )} |
| 328 | > |
| 329 | {columnIndex === 0 && onOpen ? ( |
| 330 | <button |
| 331 | type="button" |
| 332 | className="rounded-sm text-left font-medium break-words outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background" |
| 333 | onClick={(event) => { |
| 334 | event.stopPropagation(); |
| 335 | onOpen(record); |
| 336 | }} |
| 337 | > |
| 338 | {column.render(record)} |
| 339 | </button> |
| 340 | ) : ( |
| 341 | column.render(record) |
| 342 | )} |
| 343 | </td> |
| 344 | ))} |
| 345 | </motion.tr> |
| 346 | ))} |
| 347 | </AnimatePresence> |
| 348 | </tbody> |
| 349 | </table> |
| 350 | </div> |
| 351 | ); |
| 352 | } |
| 353 |
| 354 | /** |
| 355 | * Static board layout. For drag and drop between columns, use |
| 356 | * `KanbanBoard`. |
| 357 | */ |
| 358 | export function Board({ children, label = "Board" }: { children: React.ReactNode; label?: string }) { |
| 359 | return ( |
| 360 | <div |
| 361 | className="flex items-start gap-3 overflow-x-auto px-px pt-1 pb-4 outline-none focus-visible:ring-2 focus-visible:ring-ring/60" |
| 362 | role="region" |
| 363 | aria-label={label} |
| 364 | tabIndex={0} |
| 365 | > |
| 366 | {children} |
| 367 | </div> |
| 368 | ); |
| 369 | } |
| 370 |
| 371 | export function BoardColumn({ |
| 372 | title, |
| 373 | count, |
| 374 | children, |
| 375 | action, |
| 376 | }: { |
| 377 | title: string; |
| 378 | count?: number; |
| 379 | children: React.ReactNode; |
| 380 | action?: React.ReactNode; |
| 381 | }) { |
| 382 | return ( |
| 383 | <section className="flex min-w-0 flex-[1_0_260px] flex-col rounded-2xl bg-muted/40 p-2"> |
| 384 | <header className="flex items-center gap-2 px-1.5 pt-1 pb-2.5"> |
| 385 | <h2 className="truncate text-sm font-medium">{title}</h2> |
| 386 | {count !== undefined ? ( |
| 387 | <span className="rounded-md bg-background/70 px-1.5 text-xs font-medium tabular-nums text-muted-foreground">{count}</span> |
| 388 | ) : null} |
| 389 | {action ? <div className="ml-auto">{action}</div> : null} |
| 390 | </header> |
| 391 | <div className="flex flex-col gap-2">{children}</div> |
| 392 | </section> |
| 393 | ); |
| 394 | } |
| 395 |
| 396 | export function BoardCard({ |
| 397 | title, |
| 398 | children, |
| 399 | footer, |
| 400 | onOpen, |
| 401 | }: { |
| 402 | title: string; |
| 403 | children?: React.ReactNode; |
| 404 | footer?: React.ReactNode; |
| 405 | onOpen?: () => void; |
| 406 | }) { |
| 407 | return ( |
| 408 | <motion.article |
| 409 | layout="position" |
| 410 | initial={{ opacity: 0, y: 4 }} |
| 411 | animate={{ opacity: 1, y: 0 }} |
| 412 | transition={transitions.enter} |
| 413 | className="group rounded-xl border border-border/60 bg-card p-3 shadow-xs transition-[border-color,box-shadow,translate] duration-150 hover:-translate-y-px hover:border-border hover:shadow-sm" |
| 414 | > |
| 415 | <h3 className="text-[13px] font-medium"> |
| 416 | {onOpen ? ( |
| 417 | <button |
| 418 | type="button" |
| 419 | className="rounded-sm text-left break-words outline-none focus-visible:ring-2 focus-visible:ring-ring/60" |
| 420 | onClick={onOpen} |
| 421 | > |
| 422 | {title} |
| 423 | </button> |
| 424 | ) : ( |
| 425 | title |
| 426 | )} |
| 427 | </h3> |
| 428 | {children ? <div className="mt-1.5 text-xs text-muted-foreground">{children}</div> : null} |
| 429 | {footer ? ( |
| 430 | <footer className="mt-3 flex items-center justify-between gap-2 text-xs text-muted-foreground">{footer}</footer> |
| 431 | ) : null} |
| 432 | </motion.article> |
| 433 | ); |
| 434 | } |
| 435 |
| 436 | export function SettingsSection({ |
| 437 | title, |
| 438 | description, |
| 439 | children, |
| 440 | }: { |
| 441 | title: string; |
| 442 | description?: string; |
| 443 | children: React.ReactNode; |
| 444 | }) { |
| 445 | const id = React.useId(); |
| 446 | return ( |
| 447 | <section |
| 448 | className="grid gap-4 border-t border-border/70 py-6 first:border-t-0 md:grid-cols-[minmax(160px,1fr)_minmax(0,1.7fr)] md:gap-10" |
| 449 | aria-labelledby={id} |
| 450 | > |
| 451 | <div> |
| 452 | <h2 id={id} className="text-sm font-semibold"> |
| 453 | {title} |
| 454 | </h2> |
| 455 | {description ? <p className="mt-1 max-w-[32ch] text-xs text-muted-foreground">{description}</p> : null} |
| 456 | </div> |
| 457 | <div className="grid gap-4">{children}</div> |
| 458 | </section> |
| 459 | ); |
| 460 | } |
| 461 |