@spunto/design-system 0.15.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ // The opening of a product page. Static shell (server-renderable) around the
2
+ // one interactive part, `CopyCommand`.
3
+
4
+ import type { ReactNode } from "react"
5
+
6
+ import { cn } from "../../utils"
7
+ import { CopyCommand } from "./copy-command"
8
+ import { NightBand, NightChip } from "./night-band"
9
+
10
+ /* ─── ProductHeader ────────────────────────────────────────────────────────
11
+ Mark, name, kind, headline, promise, the one command, the honest chips. The
12
+ same shape repeated across a catalogue is what makes several product pages
13
+ read as one family rather than N landing pages that happen to share a nav.
14
+
15
+ **Generalised on the way into the package.** The site's version took a
16
+ `Product` out of its own registry and looked its accent up in a table, so it
17
+ couldn't draw anything that wasn't a Spunto product. Here it takes a mark, a
18
+ name, a kind, an accent colour and (optionally) a command — it knows about no
19
+ product at all. Everything product-specific a page wants beside the command
20
+ (a Source link, a docs link, a signup button) goes through `actions`, which
21
+ is also where a link belongs: the package doesn't import a router. */
22
+
23
+ export interface ProductHeaderProps {
24
+ /** The product's logo — any node, usually an inline SVG sized by the caller. */
25
+ mark?: ReactNode
26
+ name: ReactNode
27
+ /** Mono qualifier beside the name: "self-hosted platform", "CLI"… */
28
+ kind?: ReactNode
29
+ headline: ReactNode
30
+ intro?: ReactNode
31
+ /** Short facts under the intro — "MIT", "no account", "one binary"… */
32
+ chips?: string[]
33
+ /** Any CSS colour. Lights the band, the mark, the chips and the `$` prompt. */
34
+ accent?: string
35
+ /** When set, the header draws the two-column layout with a copyable command. */
36
+ command?: string
37
+ commandCaption?: ReactNode
38
+ commandNote?: ReactNode
39
+ /** Buttons and links under the command — the caller renders its own. */
40
+ actions?: ReactNode
41
+ className?: string
42
+ }
43
+
44
+ export function ProductHeader({
45
+ mark,
46
+ name,
47
+ kind,
48
+ headline,
49
+ intro,
50
+ chips,
51
+ accent = "var(--flame)",
52
+ command,
53
+ commandCaption,
54
+ commandNote,
55
+ actions,
56
+ className,
57
+ }: ProductHeaderProps) {
58
+ const aside = command !== undefined || actions !== undefined
59
+
60
+ return (
61
+ <NightBand glow={accent} className={className}>
62
+ <div className="mx-auto max-w-6xl px-5 pb-16 pt-28 md:px-10 md:pb-20 md:pt-36">
63
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
64
+ {mark && <span style={{ color: accent }}>{mark}</span>}
65
+ <span className="font-display text-lg font-bold tracking-tight text-white">{name}</span>
66
+ {kind && <span className="font-mono text-[10.5px] uppercase tracking-[0.16em] text-stone-400">{kind}</span>}
67
+ </div>
68
+
69
+ {/* Full width — the product's one line shouldn't have to share a column
70
+ with the command block and wrap four times.
71
+
72
+ The phone size is fluid, not a fixed 2.5rem: these headlines are
73
+ hand-broken with a `<br />`, so a line is never allowed to wrap, and
74
+ at a fixed 40px a long word ran past the content box of a 320px
75
+ screen — letters off the edge of the band, clipped by its
76
+ `overflow-hidden`. 10vw keeps the longest word inside the gutters at
77
+ every phone width (and leaves room for the fallback face while the
78
+ display font loads), then stops growing at the same 2.5rem. */}
79
+ <h1 className="font-display mt-6 max-w-[19ch] text-[clamp(2rem,10vw,2.5rem)] font-bold leading-[1.0] tracking-[-0.045em] text-white sm:text-5xl xl:text-[3.9rem]">
80
+ {headline}
81
+ </h1>
82
+
83
+ {/* `min-w-0` on both cells, and it is not cosmetic. A grid item's
84
+ automatic minimum size is its min-content width, and the command
85
+ block's `<pre>` doesn't shrink below its longest line: on a phone
86
+ (one column) the track grows past the page and the band clips
87
+ everything in it — invisibly. Capping the track lets the `<pre>`
88
+ scroll on its own, which is what `overflow-x-auto` was there for. */}
89
+ <div className={cn("mt-9 grid gap-10", aside && "lg:grid-cols-[1.05fr_0.95fr] lg:items-start lg:gap-14")}>
90
+ <div className="min-w-0">
91
+ {intro && <p className="max-w-[56ch] text-[15px] leading-relaxed text-stone-300 md:text-base">{intro}</p>}
92
+ {chips && chips.length > 0 && (
93
+ <div className="mt-6 flex flex-wrap gap-2">
94
+ {chips.map((c) => (
95
+ <NightChip key={c} accent={accent}>
96
+ {c}
97
+ </NightChip>
98
+ ))}
99
+ </div>
100
+ )}
101
+ </div>
102
+
103
+ {aside && (
104
+ <div className="min-w-0 lg:pb-1">
105
+ {command !== undefined && (
106
+ <CopyCommand tone="night" command={command} caption={commandCaption} note={commandNote} accent={accent} />
107
+ )}
108
+ {actions && <div className="mt-5 flex flex-wrap items-center gap-3">{actions}</div>}
109
+ </div>
110
+ )}
111
+ </div>
112
+ </div>
113
+ </NightBand>
114
+ )
115
+ }
@@ -0,0 +1,71 @@
1
+ "use client"
2
+
3
+ import { useEffect, useRef, useState, type ReactNode } from "react"
4
+
5
+ import { cn } from "../../utils"
6
+ import { usePrefersReducedMotion } from "./motion"
7
+
8
+ /* ─── Reveal — fade-and-lift into view on scroll ───────────────────────────
9
+ The one animated primitive that made it into the package, and the reason it
10
+ could is that it carries **no motion library**. The site's version was
11
+ `framer-motion` (`useInView` + a `motion.div`); adding that as a hard
12
+ dependency of the design system would weigh on every consumer, including the
13
+ dashboards that never draw a landing page. An IntersectionObserver and a CSS
14
+ transition do the same job here, in ~20 lines.
15
+
16
+ Reduced motion short-circuits to a plain `<div>`: no observer, no transition,
17
+ the content is simply there. */
18
+
19
+ export interface RevealProps {
20
+ children: ReactNode
21
+ className?: string
22
+ /** Stagger, in seconds — give siblings 0, 0.08, 0.16… */
23
+ delay?: number
24
+ /** How far it lifts, in pixels. */
25
+ y?: number
26
+ /** `false` re-hides the element when it leaves the viewport. */
27
+ once?: boolean
28
+ }
29
+
30
+ export function Reveal({ children, className, delay = 0, y = 28, once = true }: RevealProps) {
31
+ const reduced = usePrefersReducedMotion()
32
+ const ref = useRef<HTMLDivElement>(null)
33
+ const [shown, setShown] = useState(false)
34
+
35
+ useEffect(() => {
36
+ if (reduced) return
37
+ const el = ref.current
38
+ if (!el) return
39
+ const io = new IntersectionObserver(
40
+ ([entry]) => {
41
+ if (entry.isIntersecting) {
42
+ setShown(true)
43
+ if (once) io.disconnect()
44
+ } else if (!once) {
45
+ setShown(false)
46
+ }
47
+ },
48
+ // Same threshold as the framer-motion original: the element has to be
49
+ // 80px inside the viewport before it counts as seen.
50
+ { rootMargin: "-80px" }
51
+ )
52
+ io.observe(el)
53
+ return () => io.disconnect()
54
+ }, [reduced, once])
55
+
56
+ if (reduced) return <div className={className}>{children}</div>
57
+
58
+ return (
59
+ <div
60
+ ref={ref}
61
+ className={cn("transition-[opacity,transform] duration-[600ms] ease-[cubic-bezier(0.22,1,0.36,1)]", className)}
62
+ style={{
63
+ opacity: shown ? 1 : 0,
64
+ transform: shown ? undefined : `translateY(${y}px)`,
65
+ transitionDelay: delay ? `${delay}s` : undefined,
66
+ }}
67
+ >
68
+ {children}
69
+ </div>
70
+ )
71
+ }
@@ -0,0 +1,161 @@
1
+ // Marketing page scaffolding — the static half of the vocabulary.
2
+ //
3
+ // No `"use client"` on purpose: `Section`, `H2`, `Kicker`, `SpecList` and
4
+ // `Disclosure` render no state and hold no effect, so they stay callable from a
5
+ // React Server Component. The package already splits this way for
6
+ // `buttonVariants` / `alertVariants`; the marketing entry keeps the same line —
7
+ // everything interactive lives in its own file with the directive on it
8
+ // (`copy-command.tsx`, `reveal.tsx`, `use-loop-clock.ts`).
9
+
10
+ import type { ReactNode } from "react"
11
+
12
+ import { cn } from "../../utils"
13
+
14
+ /* ─── Section — every chapter opens the same way ────────────────────────────
15
+ A hairline rule, a mono label ("01 — WHAT IT IS"), an optional note pushed to
16
+ the right margin, then the content. Repeating that shape down a page is what
17
+ makes a marketing page read as one document rather than a stack of unrelated
18
+ blocks. */
19
+
20
+ export interface SectionProps {
21
+ /** Anchor id — the section scrolls under a sticky header (`scroll-mt-20`). */
22
+ id?: string
23
+ /** Chapter number, drawn in the accent colour before the title. */
24
+ n?: string
25
+ title: string
26
+ /** Right-hand margin note, hidden below `sm` where there is no margin. */
27
+ note?: ReactNode
28
+ children: ReactNode
29
+ className?: string
30
+ }
31
+
32
+ export function Section({ id, n, title, note, children, className }: SectionProps) {
33
+ return (
34
+ <section id={id} className={cn("scroll-mt-20 border-t border-border px-5 py-16 md:px-10 md:py-24", className)}>
35
+ <div className="mb-10 flex items-baseline justify-between gap-4 border-b border-border pb-3 font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground md:mb-14">
36
+ <span>
37
+ {n && <span className="text-primary">{n}</span>}
38
+ {n && " — "}
39
+ {title}
40
+ </span>
41
+ {note && <span className="hidden text-muted-foreground/50 sm:block">{note}</span>}
42
+ </div>
43
+ {children}
44
+ </section>
45
+ )
46
+ }
47
+
48
+ /* ─── Headline ──────────────────────────────────────────────────────────────
49
+ Display face (Syne) via the `font-display` utility the tokens already
50
+ generate — the kits carried a `style={{ fontFamily: "var(--font-display)" }}`
51
+ object around because the site had no such utility; the package does. */
52
+
53
+ export interface H2Props {
54
+ children: ReactNode
55
+ className?: string
56
+ }
57
+
58
+ export function H2({ children, className }: H2Props) {
59
+ return (
60
+ <h2
61
+ className={cn(
62
+ "font-display max-w-[24ch] text-3xl font-bold leading-[1.05] tracking-[-0.03em] md:text-[2.6rem]",
63
+ className
64
+ )}
65
+ >
66
+ {children}
67
+ </h2>
68
+ )
69
+ }
70
+
71
+ /* ─── Kicker — the mono line above a headline ───────────────────────────────
72
+ One component, not two: the site had `Kicker` (home + product pages) and
73
+ `Eyebrow` (Ads landings) doing the same job at 0.22em and 0.25em tracking.
74
+ The package publishes a single `Kicker`; a page that wants the wider letter
75
+ spacing passes it through `className`. */
76
+
77
+ export interface KickerProps {
78
+ children: ReactNode
79
+ className?: string
80
+ }
81
+
82
+ export function Kicker({ children, className }: KickerProps) {
83
+ return <p className={cn("font-mono text-[11px] uppercase tracking-[0.22em] text-primary", className)}>{children}</p>
84
+ }
85
+
86
+ /* ─── SpecList — facts without bullets ─────────────────────────────────────
87
+ Key/value rows, the default way to list what something *is* on a marketing
88
+ page: denser than a bulleted list and it never invents a verb. */
89
+
90
+ export interface SpecListProps {
91
+ rows: [string, ReactNode][]
92
+ className?: string
93
+ }
94
+
95
+ export function SpecList({ rows, className }: SpecListProps) {
96
+ return (
97
+ <dl className={cn("border-t border-border", className)}>
98
+ {rows.map(([k, v]) => (
99
+ <div
100
+ key={k}
101
+ className="grid grid-cols-[6.5rem_1fr] gap-4 border-b border-border py-2.5 md:grid-cols-[8.5rem_1fr]"
102
+ >
103
+ <dt className="pt-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground/70">{k}</dt>
104
+ {/* `min-w-0 break-words` — the value column carries the odd API name
105
+ (`Input.dispatchMouseEvent`), and a token longer than the track
106
+ would otherwise widen the grid and push the row off a narrow
107
+ phone rather than wrap. */}
108
+ <dd className="min-w-0 break-words text-sm leading-relaxed text-foreground/85">{v}</dd>
109
+ </div>
110
+ ))}
111
+ </dl>
112
+ )
113
+ }
114
+
115
+ /* ─── Disclosure — a row you open ──────────────────────────────────────────
116
+ A marketing page that lays every argument out at once (five timeline entries
117
+ and seven FAQ answers, all expanded) is a wall a visitor bounces off. Closed,
118
+ a chapter is five headlines you scan in four seconds; open, it's the same
119
+ text as before.
120
+
121
+ `<details name>` gives the exclusive-accordion behaviour natively: no state,
122
+ no hydration surface, and it works before the JS lands — which is also why
123
+ this component stays server-renderable. Browsers that don't know `name` yet
124
+ just allow several rows open at once: the same page, less tidy. */
125
+
126
+ export interface DisclosureProps {
127
+ /** Shared by the rows of one accordion — only one of them stays open. */
128
+ group: string
129
+ summary: ReactNode
130
+ /** Small left-hand column: a time, a product, a number. */
131
+ aside?: ReactNode
132
+ defaultOpen?: boolean
133
+ children: ReactNode
134
+ className?: string
135
+ }
136
+
137
+ export function Disclosure({ group, summary, aside, defaultOpen = false, children, className }: DisclosureProps) {
138
+ return (
139
+ <details name={group} open={defaultOpen} className={cn("group border-b border-border", className)}>
140
+ <summary className="flex cursor-pointer list-none items-baseline gap-4 py-4 transition-colors hover:text-primary [&::-webkit-details-marker]:hidden">
141
+ {/* On a phone the aside sits *above* the headline rather than in a
142
+ column beside it: a 6rem gutter on a 393pt screen wraps every title
143
+ onto two lines. */}
144
+ {aside && <span className="hidden w-[9rem] shrink-0 md:block">{aside}</span>}
145
+ <span className="min-w-0 flex-1">
146
+ {aside && <span className="mb-1.5 block md:hidden">{aside}</span>}
147
+ <span className="font-display block text-[17px] font-bold leading-snug tracking-[-0.02em] md:text-xl">
148
+ {summary}
149
+ </span>
150
+ </span>
151
+ <span
152
+ aria-hidden
153
+ className="mt-1 shrink-0 font-mono text-lg leading-none text-muted-foreground/60 transition-transform group-open:rotate-45"
154
+ >
155
+ +
156
+ </span>
157
+ </summary>
158
+ <div className={cn("pb-6", aside && "md:pl-[10rem]")}>{children}</div>
159
+ </details>
160
+ )
161
+ }
@@ -0,0 +1,173 @@
1
+ "use client"
2
+
3
+ import type { ComponentProps } from "react"
4
+ import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
5
+
6
+ import { cn } from "../utils"
7
+ import { useOverlayContainer } from "./spunto-provider"
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // A sheet is a Dialog that enters from an edge. Same primitive (`@base-ui/react/
11
+ // dialog` — focus trap, Escape, backdrop, focus restore), same portal target as
12
+ // every other overlay here (the `SpuntoProvider` container), only the geometry
13
+ // changes.
14
+ //
15
+ // Position and size are two separate props on purpose. `side` decides which edge
16
+ // the panel is glued to; `size` decides how far it comes in. Both resolve to
17
+ // plain, unprefixed utilities computed in JS — *not* `data-[side=right]:`-style
18
+ // selectors — so a `className` on `SheetContent` still wins on specificity, and
19
+ // an app can go from a 32rem panel to full-bleed with `size="full"` or with its
20
+ // own `sm:max-w-*` without reaching for `!important`.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export type SheetSide = "right" | "left" | "top" | "bottom"
24
+
25
+ /** How far the panel comes in. Widths for left/right, heights for top/bottom. */
26
+ export type SheetSize = "sm" | "md" | "lg" | "xl" | "full"
27
+
28
+ const SIDE_CLASSES: Record<SheetSide, string> = {
29
+ right:
30
+ "inset-y-0 right-0 h-full w-full border-l data-ending-style:translate-x-full data-starting-style:translate-x-full",
31
+ left:
32
+ "inset-y-0 left-0 h-full w-full border-r data-ending-style:-translate-x-full data-starting-style:-translate-x-full",
33
+ top:
34
+ "inset-x-0 top-0 w-full border-b data-ending-style:-translate-y-full data-starting-style:-translate-y-full",
35
+ bottom:
36
+ "inset-x-0 bottom-0 w-full border-t data-ending-style:translate-y-full data-starting-style:translate-y-full",
37
+ }
38
+
39
+ // Below `sm` a side panel always takes the full width — a 24rem panel on a phone
40
+ // is a modal with a useless 3rem gutter.
41
+ const HORIZONTAL_SIZE_CLASSES: Record<SheetSize, string> = {
42
+ sm: "sm:max-w-sm",
43
+ md: "sm:max-w-lg",
44
+ lg: "sm:max-w-3xl",
45
+ xl: "sm:max-w-5xl",
46
+ full: "sm:max-w-none",
47
+ }
48
+
49
+ const VERTICAL_SIZE_CLASSES: Record<SheetSize, string> = {
50
+ sm: "h-auto max-h-[16rem]",
51
+ md: "h-auto max-h-[24rem]",
52
+ lg: "h-auto max-h-[32rem]",
53
+ xl: "h-auto max-h-[80dvh]",
54
+ full: "h-dvh",
55
+ }
56
+
57
+ /** Groups the sheet parts. Controlled via `open`/`onOpenChange`, or uncontrolled. */
58
+ function Sheet(props: SheetPrimitive.Root.Props) {
59
+ return <SheetPrimitive.Root data-slot="sheet" {...props} />
60
+ }
61
+
62
+ /** Element that opens the sheet. Pass `render` to reuse an existing button. */
63
+ function SheetTrigger(props: SheetPrimitive.Trigger.Props) {
64
+ return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
65
+ }
66
+
67
+ /** Imperatively closes the sheet. Wrap a `Button` via `render` for a footer action. */
68
+ function SheetClose(props: SheetPrimitive.Close.Props) {
69
+ return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
70
+ }
71
+
72
+ export interface SheetContentProps extends SheetPrimitive.Popup.Props {
73
+ /** Which edge the panel slides in from. @default "right" */
74
+ side?: SheetSide
75
+ /** Width (left/right) or height (top/bottom) of the panel. @default "md" */
76
+ size?: SheetSize
77
+ /** Draws the ✕ in the top corner. @default true */
78
+ showClose?: boolean
79
+ }
80
+
81
+ /**
82
+ * Portal + backdrop + the sliding panel, in one styled wrapper. Renders into the
83
+ * `SpuntoProvider` overlay container (falls back to `document.body`).
84
+ *
85
+ * The panel is a `flex flex-col` by default; `className` is the layout escape
86
+ * hatch and is merged last — pass `p-0` to lay out your own padding, `grid
87
+ * grid-cols-2` to split it in two columns, `overflow-y-auto` to scroll the whole
88
+ * panel rather than an inner region.
89
+ */
90
+ function SheetContent({
91
+ className,
92
+ children,
93
+ side = "right",
94
+ size = "md",
95
+ showClose = true,
96
+ ...props
97
+ }: SheetContentProps) {
98
+ const container = useOverlayContainer()
99
+ const horizontal = side === "left" || side === "right"
100
+ return (
101
+ <SheetPrimitive.Portal container={container ?? undefined}>
102
+ <SheetPrimitive.Backdrop
103
+ data-slot="sheet-backdrop"
104
+ className="fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 dark:bg-black/60"
105
+ />
106
+ <SheetPrimitive.Popup
107
+ data-slot="sheet-content"
108
+ data-side={side}
109
+ className={cn(
110
+ "fixed z-50 flex flex-col gap-4 border-border bg-background bg-clip-padding text-sm text-foreground shadow-xl shadow-black/[0.08] outline-none",
111
+ "transition-transform duration-200 ease-out",
112
+ SIDE_CLASSES[side],
113
+ horizontal ? HORIZONTAL_SIZE_CLASSES[size] : VERTICAL_SIZE_CLASSES[size],
114
+ className
115
+ )}
116
+ {...props}
117
+ >
118
+ {children}
119
+ {showClose && (
120
+ <SheetPrimitive.Close
121
+ aria-label="Fermer"
122
+ className="absolute top-3.5 right-3.5 flex size-7 items-center justify-center rounded-md text-muted-foreground/70 transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
123
+ >
124
+ <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
125
+ <path d="M18 6 6 18M6 6l12 12" />
126
+ </svg>
127
+ </SheetPrimitive.Close>
128
+ )}
129
+ </SheetPrimitive.Popup>
130
+ </SheetPrimitive.Portal>
131
+ )
132
+ }
133
+
134
+ /** Title + description stack at the top of the panel. */
135
+ function SheetHeader({ className, ...props }: ComponentProps<"div">) {
136
+ return <div data-slot="sheet-header" className={cn("flex flex-col gap-0.5 p-4", className)} {...props} />
137
+ }
138
+
139
+ /** Action row pinned to the bottom of the panel. */
140
+ function SheetFooter({ className, ...props }: ComponentProps<"div">) {
141
+ return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
142
+ }
143
+
144
+ function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
145
+ return (
146
+ <SheetPrimitive.Title
147
+ data-slot="sheet-title"
148
+ className={cn("text-base leading-none font-semibold text-foreground", className)}
149
+ {...props}
150
+ />
151
+ )
152
+ }
153
+
154
+ function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
155
+ return (
156
+ <SheetPrimitive.Description
157
+ data-slot="sheet-description"
158
+ className={cn("text-sm text-muted-foreground", className)}
159
+ {...props}
160
+ />
161
+ )
162
+ }
163
+
164
+ export {
165
+ Sheet,
166
+ SheetTrigger,
167
+ SheetClose,
168
+ SheetContent,
169
+ SheetHeader,
170
+ SheetFooter,
171
+ SheetTitle,
172
+ SheetDescription,
173
+ }
@@ -0,0 +1,98 @@
1
+ "use client"
2
+
3
+ import type { ComponentProps } from "react"
4
+
5
+ import { cn } from "../utils"
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Presentational only, on purpose: no sorting, no pagination, no column model.
9
+ // Apps that show tables already own that logic (and disagree on it) — what they
10
+ // were re-writing every time is the markup and the token choices, so that's all
11
+ // this ships. Every part is the plain HTML element with a `data-slot` and a
12
+ // class string, `className` merged last.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface TableProps extends ComponentProps<"table"> {
16
+ /**
17
+ * Class for the scroll container wrapping the `<table>` — the only place a
18
+ * `max-height`, a sticky-header context or a border/rounding can live, since
19
+ * it's the element that scrolls.
20
+ */
21
+ containerClassName?: string
22
+ }
23
+
24
+ /** The `<table>` plus its horizontal scroll container. */
25
+ function Table({ className, containerClassName, ...props }: TableProps) {
26
+ return (
27
+ <div data-slot="table-container" className={cn("relative w-full overflow-x-auto", containerClassName)}>
28
+ <table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
29
+ </div>
30
+ )
31
+ }
32
+
33
+ function TableHeader({ className, ...props }: ComponentProps<"thead">) {
34
+ return <thead data-slot="table-header" className={cn("[&_tr]:border-b [&_tr]:border-border", className)} {...props} />
35
+ }
36
+
37
+ function TableBody({ className, ...props }: ComponentProps<"tbody">) {
38
+ return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />
39
+ }
40
+
41
+ /** Totals row. Sits under the body with its own tinted background. */
42
+ function TableFooter({ className, ...props }: ComponentProps<"tfoot">) {
43
+ return (
44
+ <tfoot
45
+ data-slot="table-footer"
46
+ className={cn("border-t border-border bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
47
+ {...props}
48
+ />
49
+ )
50
+ }
51
+
52
+ /** A row. Hover tint by default; `data-[state=selected]` keeps it tinted. */
53
+ function TableRow({ className, ...props }: ComponentProps<"tr">) {
54
+ return (
55
+ <tr
56
+ data-slot="table-row"
57
+ className={cn(
58
+ "border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
59
+ className
60
+ )}
61
+ {...props}
62
+ />
63
+ )
64
+ }
65
+
66
+ /** A header cell (`<th>`). */
67
+ function TableHead({ className, ...props }: ComponentProps<"th">) {
68
+ return (
69
+ <th
70
+ data-slot="table-head"
71
+ className={cn(
72
+ "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
73
+ className
74
+ )}
75
+ {...props}
76
+ />
77
+ )
78
+ }
79
+
80
+ /** A body cell (`<td>`). */
81
+ function TableCell({ className, ...props }: ComponentProps<"td">) {
82
+ return (
83
+ <td
84
+ data-slot="table-cell"
85
+ className={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
86
+ {...props}
87
+ />
88
+ )
89
+ }
90
+
91
+ /** Caption under the table — its accessible description. */
92
+ function TableCaption({ className, ...props }: ComponentProps<"caption">) {
93
+ return (
94
+ <caption data-slot="table-caption" className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
95
+ )
96
+ }
97
+
98
+ export { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption }