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 |
| 6 | export function AppPage({ |
| 7 | title, |
| 8 | description, |
| 9 | eyebrow, |
| 10 | actions, |
| 11 | children, |
| 12 | width = "wide", |
| 13 | className, |
| 14 | }: { |
| 15 | title: string; |
| 16 | description?: React.ReactNode; |
| 17 | eyebrow?: React.ReactNode; |
| 18 | actions?: React.ReactNode; |
| 19 | children: React.ReactNode; |
| 20 | width?: "wide" | "reading"; |
| 21 | className?: string; |
| 22 | }) { |
| 23 | return ( |
| 24 | <main className={cn("coline-page", className)} data-width={width}> |
| 25 | <header className="coline-page-header"> |
| 26 | <div className="coline-page-heading"> |
| 27 | {eyebrow && <div className="coline-eyebrow">{eyebrow}</div>} |
| 28 | <h1>{title}</h1> |
| 29 | {description && <p className="coline-page-description">{description}</p>} |
| 30 | </div> |
| 31 | {actions && <div className="coline-page-actions">{actions}</div>} |
| 32 | </header> |
| 33 | {children} |
| 34 | </main> |
| 35 | ); |
| 36 | } |
| 37 | export function PageToolbar({ |
| 38 | children, |
| 39 | label = "View controls", |
| 40 | className, |
| 41 | }: { |
| 42 | children: React.ReactNode; |
| 43 | label?: string; |
| 44 | className?: string; |
| 45 | }) { |
| 46 | return ( |
| 47 | <div className={cn("coline-toolbar", className)} role="group" aria-label={label}> |
| 48 | {children} |
| 49 | </div> |
| 50 | ); |
| 51 | } |
| 52 | export function EmptyState({ |
| 53 | title, |
| 54 | description, |
| 55 | action, |
| 56 | icon, |
| 57 | }: { |
| 58 | title: string; |
| 59 | description?: React.ReactNode; |
| 60 | action?: React.ReactNode; |
| 61 | icon?: React.ReactNode; |
| 62 | }) { |
| 63 | return ( |
| 64 | <section className="coline-empty"> |
| 65 | {icon && ( |
| 66 | <div className="coline-empty-icon" aria-hidden="true"> |
| 67 | {icon} |
| 68 | </div> |
| 69 | )} |
| 70 | <h2>{title}</h2> |
| 71 | {description && <p>{description}</p>} |
| 72 | {action && <div>{action}</div>} |
| 73 | </section> |
| 74 | ); |
| 75 | } |
| 76 | export function AppErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) { |
| 77 | return ( |
| 78 | <section className="coline-error" role="alert"> |
| 79 | <div> |
| 80 | <h2>Something went wrong</h2> |
| 81 | <p>{message}</p> |
| 82 | </div> |
| 83 | {onRetry && ( |
| 84 | <Button variant="outline" onClick={onRetry}> |
| 85 | Try again |
| 86 | </Button> |
| 87 | )} |
| 88 | </section> |
| 89 | ); |
| 90 | } |
| 91 | export function AppLoadingState({ |
| 92 | label = "Loading", |
| 93 | rows = 5, |
| 94 | }: { |
| 95 | label?: string; |
| 96 | rows?: number; |
| 97 | }) { |
| 98 | return ( |
| 99 | <div className="coline-loading" role="status" aria-label={label}> |
| 100 | {Array.from({ length: Math.max(1, Math.min(rows, 12)) }, (_, i) => ( |
| 101 | <div key={i} style={{ width: `${92 - (i % 3) * 14}%` }} /> |
| 102 | ))} |
| 103 | <span className="coline-sr-only">{label}</span> |
| 104 | </div> |
| 105 | ); |
| 106 | } |
| 107 | export function StatusBadge({ |
| 108 | children, |
| 109 | tone = "neutral", |
| 110 | }: { |
| 111 | children: React.ReactNode; |
| 112 | tone?: "neutral" | "info" | "success" | "warning" | "danger"; |
| 113 | }) { |
| 114 | return ( |
| 115 | <span className="coline-status" data-tone={tone}> |
| 116 | <span aria-hidden="true" /> |
| 117 | {children} |
| 118 | </span> |
| 119 | ); |
| 120 | } |
| 121 | export function SplitView({ |
| 122 | list, |
| 123 | detail, |
| 124 | label = "Details", |
| 125 | }: { |
| 126 | list: React.ReactNode; |
| 127 | detail?: React.ReactNode; |
| 128 | label?: string; |
| 129 | }) { |
| 130 | return ( |
| 131 | <div className="coline-split" data-open={Boolean(detail)}> |
| 132 | <section className="coline-split-list">{list}</section> |
| 133 | {detail && ( |
| 134 | <aside className="coline-split-detail" aria-label={label}> |
| 135 | {detail} |
| 136 | </aside> |
| 137 | )} |
| 138 | </div> |
| 139 | ); |
| 140 | } |
| 141 | export function DetailPanel({ |
| 142 | title, |
| 143 | subtitle, |
| 144 | onClose, |
| 145 | children, |
| 146 | actions, |
| 147 | }: { |
| 148 | title: string; |
| 149 | subtitle?: React.ReactNode; |
| 150 | onClose?: () => void; |
| 151 | children: React.ReactNode; |
| 152 | actions?: React.ReactNode; |
| 153 | }) { |
| 154 | return ( |
| 155 | <section className="coline-detail"> |
| 156 | <header> |
| 157 | <div> |
| 158 | {subtitle && <div className="coline-eyebrow">{subtitle}</div>} |
| 159 | <h2>{title}</h2> |
| 160 | </div> |
| 161 | {onClose && ( |
| 162 | <Button variant="ghost" size="sm" onClick={onClose} aria-label="Close details"> |
| 163 | Close |
| 164 | </Button> |
| 165 | )} |
| 166 | </header> |
| 167 | <div className="coline-detail-body">{children}</div> |
| 168 | {actions && <footer>{actions}</footer>} |
| 169 | </section> |
| 170 | ); |
| 171 | } |
| 172 | export interface RecordColumn<T> { |
| 173 | key: string; |
| 174 | label: string; |
| 175 | render: (record: T) => React.ReactNode; |
| 176 | secondary?: boolean; |
| 177 | align?: "start" | "end"; |
| 178 | } |
| 179 | export function RecordTable<T>({ |
| 180 | records, |
| 181 | columns, |
| 182 | rowKey, |
| 183 | onOpen, |
| 184 | label = "Records", |
| 185 | empty, |
| 186 | }: { |
| 187 | records: readonly T[]; |
| 188 | columns: readonly RecordColumn<T>[]; |
| 189 | rowKey: (record: T) => string; |
| 190 | onOpen?: (record: T) => void; |
| 191 | label?: string; |
| 192 | empty?: React.ReactNode; |
| 193 | }) { |
| 194 | if (!records.length) |
| 195 | return ( |
| 196 | <>{empty ?? <EmptyState title="No results" description="Try changing your filters." />}</> |
| 197 | ); |
| 198 | return ( |
| 199 | <div className="coline-table-scroll"> |
| 200 | <table className="coline-record-table" aria-label={label}> |
| 201 | <thead> |
| 202 | <tr> |
| 203 | {columns.map((column) => ( |
| 204 | <th |
| 205 | key={column.key} |
| 206 | scope="col" |
| 207 | data-secondary={column.secondary || undefined} |
| 208 | data-align={column.align} |
| 209 | > |
| 210 | {column.label} |
| 211 | </th> |
| 212 | ))} |
| 213 | </tr> |
| 214 | </thead> |
| 215 | <tbody> |
| 216 | {records.map((record) => ( |
| 217 | <tr key={rowKey(record)}> |
| 218 | {columns.map((column, i) => ( |
| 219 | <td |
| 220 | key={column.key} |
| 221 | data-secondary={column.secondary || undefined} |
| 222 | data-align={column.align} |
| 223 | > |
| 224 | {i === 0 && onOpen ? ( |
| 225 | <button |
| 226 | type="button" |
| 227 | className="coline-row-open" |
| 228 | onClick={() => onOpen(record)} |
| 229 | > |
| 230 | {column.render(record)} |
| 231 | </button> |
| 232 | ) : ( |
| 233 | column.render(record) |
| 234 | )} |
| 235 | </td> |
| 236 | ))} |
| 237 | </tr> |
| 238 | ))} |
| 239 | </tbody> |
| 240 | </table> |
| 241 | </div> |
| 242 | ); |
| 243 | } |
| 244 | export function Board({ |
| 245 | children, |
| 246 | label = "Board", |
| 247 | }: { |
| 248 | children: React.ReactNode; |
| 249 | label?: string; |
| 250 | }) { |
| 251 | return ( |
| 252 | <div className="coline-board" role="region" aria-label={label} tabIndex={0}> |
| 253 | {children} |
| 254 | </div> |
| 255 | ); |
| 256 | } |
| 257 | export function BoardColumn({ |
| 258 | title, |
| 259 | count, |
| 260 | children, |
| 261 | action, |
| 262 | }: { |
| 263 | title: string; |
| 264 | count?: number; |
| 265 | children: React.ReactNode; |
| 266 | action?: React.ReactNode; |
| 267 | }) { |
| 268 | return ( |
| 269 | <section className="coline-board-column"> |
| 270 | <header> |
| 271 | <h2>{title}</h2> |
| 272 | {count !== undefined && <span className="coline-count">{count}</span>} |
| 273 | {action} |
| 274 | </header> |
| 275 | <div className="coline-board-items">{children}</div> |
| 276 | </section> |
| 277 | ); |
| 278 | } |
| 279 | export function BoardCard({ |
| 280 | title, |
| 281 | children, |
| 282 | footer, |
| 283 | onOpen, |
| 284 | }: { |
| 285 | title: string; |
| 286 | children?: React.ReactNode; |
| 287 | footer?: React.ReactNode; |
| 288 | onOpen?: () => void; |
| 289 | }) { |
| 290 | return ( |
| 291 | <article className="coline-board-card"> |
| 292 | <h3> |
| 293 | {onOpen ? ( |
| 294 | <button type="button" className="coline-row-open" onClick={onOpen}> |
| 295 | {title} |
| 296 | </button> |
| 297 | ) : ( |
| 298 | title |
| 299 | )} |
| 300 | </h3> |
| 301 | {children && <div className="coline-board-card-body">{children}</div>} |
| 302 | {footer && <footer>{footer}</footer>} |
| 303 | </article> |
| 304 | ); |
| 305 | } |
| 306 | export function SettingsSection({ |
| 307 | title, |
| 308 | description, |
| 309 | children, |
| 310 | }: { |
| 311 | title: string; |
| 312 | description?: string; |
| 313 | children: React.ReactNode; |
| 314 | }) { |
| 315 | const id = React.useId(); |
| 316 | return ( |
| 317 | <section className="coline-settings-section" aria-labelledby={id}> |
| 318 | <div> |
| 319 | <h2 id={id}>{title}</h2> |
| 320 | {description && <p>{description}</p>} |
| 321 | </div> |
| 322 | <div className="coline-settings-fields">{children}</div> |
| 323 | </section> |
| 324 | ); |
| 325 | } |
| 326 |