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 { |
| 6 | IconAlertTriangle, |
| 7 | IconCircleCheck, |
| 8 | IconCircleX, |
| 9 | IconInfoCircle, |
| 10 | IconX, |
| 11 | } from "@tabler/icons-react"; |
| 12 | import { cn } from "../lib/utils"; |
| 13 | import { transitions } from "../lib/motion"; |
| 14 |
| 15 | type BannerTone = "info" | "success" | "warning" | "danger"; |
| 16 |
| 17 | // Annotated so the published declarations don't need React's internal types. |
| 18 | const TONES: Record<BannerTone, { icon: React.ComponentType<{ className?: string }>; box: string; text: string }> = { |
| 19 | info: { icon: IconInfoCircle, box: "border-action/25 bg-action/[0.06]", text: "text-action" }, |
| 20 | success: { icon: IconCircleCheck, box: "border-positive/25 bg-positive/[0.06]", text: "text-positive-fg" }, |
| 21 | warning: { icon: IconAlertTriangle, box: "border-attention/30 bg-attention/[0.07]", text: "text-attention-fg" }, |
| 22 | danger: { icon: IconCircleX, box: "border-risk/25 bg-risk/[0.06]", text: "text-risk-fg" }, |
| 23 | }; |
| 24 |
| 25 | /** An inline message with a tone, an optional action, and dismissal. */ |
| 26 | export function Banner({ |
| 27 | tone = "info", |
| 28 | title, |
| 29 | children, |
| 30 | action, |
| 31 | onDismiss, |
| 32 | className, |
| 33 | }: { |
| 34 | tone?: keyof typeof TONES; |
| 35 | title: React.ReactNode; |
| 36 | children?: React.ReactNode; |
| 37 | action?: React.ReactNode; |
| 38 | onDismiss?: () => void; |
| 39 | className?: string; |
| 40 | }) { |
| 41 | const meta = TONES[tone]; |
| 42 | const Icon = meta.icon; |
| 43 | return ( |
| 44 | <div |
| 45 | role={tone === "danger" || tone === "warning" ? "alert" : "status"} |
| 46 | className={cn("flex gap-3 rounded-2xl border p-3.5", meta.box, className)} |
| 47 | > |
| 48 | <Icon aria-hidden="true" className={cn("mt-0.5 size-4 shrink-0", meta.text)} /> |
| 49 | <div className="min-w-0 flex-1"> |
| 50 | <p className={cn("text-sm font-medium", meta.text)}>{title}</p> |
| 51 | {children ? <div className="mt-0.5 text-sm text-muted-foreground">{children}</div> : null} |
| 52 | {action ? <div className="mt-2.5 flex flex-wrap gap-2">{action}</div> : null} |
| 53 | </div> |
| 54 | {onDismiss ? ( |
| 55 | <button |
| 56 | type="button" |
| 57 | aria-label="Dismiss" |
| 58 | onClick={onDismiss} |
| 59 | className="-m-1 flex size-6 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground" |
| 60 | > |
| 61 | <IconX className="size-3.5" /> |
| 62 | </button> |
| 63 | ) : null} |
| 64 | </div> |
| 65 | ); |
| 66 | } |
| 67 |
| 68 | /** Animates a banner (or anything) in and out. */ |
| 69 | export function Presence({ show, children }: { show: boolean; children: React.ReactNode }) { |
| 70 | return ( |
| 71 | <AnimatePresence initial={false}> |
| 72 | {show ? ( |
| 73 | <motion.div |
| 74 | initial={{ opacity: 0, height: 0 }} |
| 75 | animate={{ opacity: 1, height: "auto" }} |
| 76 | exit={{ opacity: 0, height: 0 }} |
| 77 | transition={transitions.enter} |
| 78 | className="overflow-hidden" |
| 79 | > |
| 80 | {children} |
| 81 | </motion.div> |
| 82 | ) : null} |
| 83 | </AnimatePresence> |
| 84 | ); |
| 85 | } |
| 86 |