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 { cn } from "../lib/utils"; |
| 6 | import { Button } from "./button"; |
| 7 |
| 8 | /** Page through results; shows the visible range and previous/next. */ |
| 9 | export function Pagination({ |
| 10 | page, |
| 11 | pageSize, |
| 12 | total, |
| 13 | onPageChange, |
| 14 | hasNext = false, |
| 15 | className, |
| 16 | }: { |
| 17 | /** Zero-based page index. */ |
| 18 | page: number; |
| 19 | pageSize: number; |
| 20 | /** Total rows when known; null for cursor-based paging. */ |
| 21 | total: number | null; |
| 22 | onPageChange: (page: number) => void; |
| 23 | /** For cursor paging: whether another page exists. */ |
| 24 | hasNext?: boolean; |
| 25 | className?: string; |
| 26 | }) { |
| 27 | const from = page * pageSize + 1; |
| 28 | const to = total === null ? (page + 1) * pageSize : Math.min(total, (page + 1) * pageSize); |
| 29 | const last = total === null ? !hasNext : to >= total; |
| 30 | return ( |
| 31 | <nav aria-label="Pagination" className={cn("flex items-center justify-between gap-3 text-sm", className)}> |
| 32 | <p className="text-muted-foreground tabular-nums"> |
| 33 | {total === 0 ? "No results" : `${from.toLocaleString()}–${to.toLocaleString()}${total === null ? "" : ` of ${total.toLocaleString()}`}`} |
| 34 | </p> |
| 35 | <div className="flex items-center gap-1"> |
| 36 | <Button variant="ghost" size="icon-sm" aria-label="Previous page" disabled={page === 0} onClick={() => onPageChange(page - 1)}> |
| 37 | <IconChevronLeft /> |
| 38 | </Button> |
| 39 | <Button variant="ghost" size="icon-sm" aria-label="Next page" disabled={last} onClick={() => onPageChange(page + 1)}> |
| 40 | <IconChevronRight /> |
| 41 | </Button> |
| 42 | </div> |
| 43 | </nav> |
| 44 | ); |
| 45 | } |
| 46 |
| 47 | /** Where you are: a trail of links ending in the current page. */ |
| 48 | export function Breadcrumbs({ |
| 49 | items, |
| 50 | className, |
| 51 | }: { |
| 52 | items: Array<{ label: React.ReactNode; onSelect?: () => void }>; |
| 53 | className?: string; |
| 54 | }) { |
| 55 | return ( |
| 56 | <nav aria-label="Breadcrumb" className={className}> |
| 57 | <ol className="flex flex-wrap items-center gap-1 text-sm"> |
| 58 | {items.map((item, index) => { |
| 59 | const last = index === items.length - 1; |
| 60 | return ( |
| 61 | <li key={index} className="flex min-w-0 items-center gap-1"> |
| 62 | {last || !item.onSelect ? ( |
| 63 | <span aria-current={last ? "page" : undefined} className={cn("truncate", last ? "font-medium text-foreground" : "text-muted-foreground")}> |
| 64 | {item.label} |
| 65 | </span> |
| 66 | ) : ( |
| 67 | <button |
| 68 | type="button" |
| 69 | onClick={item.onSelect} |
| 70 | className="truncate rounded-md px-1 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" |
| 71 | > |
| 72 | {item.label} |
| 73 | </button> |
| 74 | )} |
| 75 | {!last ? <IconChevronRight aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground/60" /> : null} |
| 76 | </li> |
| 77 | ); |
| 78 | })} |
| 79 | </ol> |
| 80 | </nav> |
| 81 | ); |
| 82 | } |
| 83 |