@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.
- package/README.md +99 -3
- package/package.json +5 -1
- package/src/components/command-menu.tsx +630 -0
- package/src/components/command-palette.tsx +7 -50
- package/src/components/command-shared.tsx +59 -0
- package/src/components/marketing/code-block.tsx +89 -0
- package/src/components/marketing/copy-command.tsx +139 -0
- package/src/components/marketing/index.ts +55 -0
- package/src/components/marketing/motion.ts +92 -0
- package/src/components/marketing/night-band.tsx +75 -0
- package/src/components/marketing/product-header.tsx +115 -0
- package/src/components/marketing/reveal.tsx +71 -0
- package/src/components/marketing/section.tsx +161 -0
- package/src/components/sheet.tsx +173 -0
- package/src/components/table.tsx +98 -0
- package/src/index.ts +56 -0
- package/styles.css +67 -0
|
@@ -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 }
|