@spunto/design-system 0.3.0 → 0.4.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 CHANGED
@@ -38,16 +38,31 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
38
38
  radius base is `0.25rem`; `Build`/`Ship`/`Run` are semantic (`bg-build`, `text-run`…).
39
39
  - **`cn`** — `clsx` + `tailwind-merge`.
40
40
  - **`./colors`** — `cssVar`, `chartColors`, `chartRamp`, `segColors` for JS/chart contexts.
41
- - **Primitives** — `Button` (+`buttonVariants`), `Card` (+parts), `Input`, `Textarea`,
42
- `Label`, `Badge` (+`badgeVariants`), `Switch`. Dependency-light (cva + clsx +
43
- tailwind-merge), no component framework required.
44
- - **`SpuntoProvider` + `toast()`** the imperative toast system (see below).
41
+ - **Primitives** — form + layout building blocks styled on `@base-ui/react`:
42
+ - _Basics_ — `Button` (+`buttonVariants`), `Card` (+parts), `Badge` (+`badgeVariants`),
43
+ `Separator`, `Spinner`, `Skeleton`, `Avatar` (+`AvatarImage`/`AvatarFallback`).
44
+ - _Forms_ `Input`, `Textarea`, `Label`, `Switch`, `Checkbox`,
45
+ `RadioGroup` (+`RadioGroupItem`), `Select` (+`SelectTrigger`/`SelectValue`/
46
+ `SelectContent`/`SelectItem`/`SelectGroup`/`SelectGroupLabel`/`SelectSeparator`).
47
+ - _Navigation & feedback_ — `Tabs` (+`TabsList`/`TabsIndicator`/`TabsTab`/`TabsPanel`),
48
+ `Alert` (+`AlertTitle`/`AlertDescription`, +`alertVariants`) — an **inline, persistent,
49
+ declarative** status banner, the counterpart to the ephemeral imperative `toast()`.
50
+ - _Overlays_ (plug into the `SpuntoProvider` umbrella) — `Dialog`, `AlertDialog`
51
+ (a confirmation variant, non-dismissible), `Tooltip`. Their portals render into
52
+ the provider's overlay container; `Tooltip`'s shared delay group is mounted by the
53
+ provider too.
54
+ - **`SpuntoProvider` + `toast()`** — the imperative toast system (see below). The
55
+ provider is also the umbrella that mounts the tooltip delay group and the overlay
56
+ portal container the dialogs/selects render into.
45
57
 
46
58
  ## Toasts — `SpuntoProvider` + `toast()`
47
59
 
48
60
  `SpuntoProvider` is the design system's client umbrella provider. Mount it once
49
- around your app; today it renders the toast viewport, and it's the extension
50
- point future overlay features plug into. `toast()` is a sonner-style imperative
61
+ around your app; it renders the toast viewport, mounts the shared `Tooltip`
62
+ delay group, and hosts the overlay portal container that `Dialog`/`AlertDialog`/
63
+ `Select` render into — the single seam every overlay feature plugs into. It also
64
+ takes `tooltipDelay` (default `600`) and `tooltipCloseDelay` (default `0`).
65
+ `toast()` is a sonner-style imperative
51
66
  API callable from **anywhere** — event handlers, async code, even outside the
52
67
  React tree (it's backed by Base UI's `createToastManager`).
53
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,93 @@
1
+ "use client"
2
+
3
+ import type { ComponentProps } from "react"
4
+ import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
5
+
6
+ import { cn } from "../utils"
7
+ import { useOverlayContainer } from "./spunto-provider"
8
+
9
+ /**
10
+ * Confirmation dialog. Like `Dialog` but modal-by-default and *not* dismissible
11
+ * by an outside press or Escape — the user must pick an explicit action, so wire
12
+ * a cancel and a confirm `AlertDialogClose` in the footer.
13
+ */
14
+ function AlertDialog(props: AlertDialogPrimitive.Root.Props) {
15
+ return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
16
+ }
17
+
18
+ function AlertDialogTrigger(props: AlertDialogPrimitive.Trigger.Props) {
19
+ return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
20
+ }
21
+
22
+ /** Closes the alert dialog. Wrap a `Button` via `render` for cancel/confirm. */
23
+ function AlertDialogClose(props: AlertDialogPrimitive.Close.Props) {
24
+ return <AlertDialogPrimitive.Close data-slot="alert-dialog-close" {...props} />
25
+ }
26
+
27
+ function AlertDialogContent({ className, children, ...props }: AlertDialogPrimitive.Popup.Props) {
28
+ const container = useOverlayContainer()
29
+ return (
30
+ <AlertDialogPrimitive.Portal container={container ?? undefined}>
31
+ <AlertDialogPrimitive.Backdrop
32
+ data-slot="alert-dialog-backdrop"
33
+ 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"
34
+ />
35
+ <AlertDialogPrimitive.Popup
36
+ data-slot="alert-dialog-content"
37
+ className={cn(
38
+ "fixed top-1/2 left-1/2 z-50 grid w-[calc(100vw-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-border bg-popover p-6 text-popover-foreground shadow-xl shadow-black/[0.08] outline-none",
39
+ "transition-all duration-200 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
40
+ className
41
+ )}
42
+ {...props}
43
+ >
44
+ {children}
45
+ </AlertDialogPrimitive.Popup>
46
+ </AlertDialogPrimitive.Portal>
47
+ )
48
+ }
49
+
50
+ function AlertDialogHeader({ className, ...props }: ComponentProps<"div">) {
51
+ return <div data-slot="alert-dialog-header" className={cn("flex flex-col gap-1.5", className)} {...props} />
52
+ }
53
+
54
+ function AlertDialogFooter({ className, ...props }: ComponentProps<"div">) {
55
+ return (
56
+ <div
57
+ data-slot="alert-dialog-footer"
58
+ className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
59
+ {...props}
60
+ />
61
+ )
62
+ }
63
+
64
+ function AlertDialogTitle({ className, ...props }: AlertDialogPrimitive.Title.Props) {
65
+ return (
66
+ <AlertDialogPrimitive.Title
67
+ data-slot="alert-dialog-title"
68
+ className={cn("text-base leading-none font-semibold text-foreground", className)}
69
+ {...props}
70
+ />
71
+ )
72
+ }
73
+
74
+ function AlertDialogDescription({ className, ...props }: AlertDialogPrimitive.Description.Props) {
75
+ return (
76
+ <AlertDialogPrimitive.Description
77
+ data-slot="alert-dialog-description"
78
+ className={cn("text-sm text-muted-foreground", className)}
79
+ {...props}
80
+ />
81
+ )
82
+ }
83
+
84
+ export {
85
+ AlertDialog,
86
+ AlertDialogTrigger,
87
+ AlertDialogClose,
88
+ AlertDialogContent,
89
+ AlertDialogHeader,
90
+ AlertDialogFooter,
91
+ AlertDialogTitle,
92
+ AlertDialogDescription,
93
+ }
@@ -0,0 +1,104 @@
1
+ import type { ComponentProps, ReactNode } from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "../utils"
5
+
6
+ // Persistent, declarative status banner — the counterpart to the ephemeral,
7
+ // imperative `toast()`. Same four semantic variants and status tokens for a
8
+ // coherent visual language, but this one lives in the page flow and stays put.
9
+ const alertVariants = cva(
10
+ "relative grid w-full grid-cols-[auto_1fr] items-start gap-x-3 gap-y-1 rounded-lg border border-l-4 px-4 py-3 text-sm [&>svg]:mt-0.5 [&>svg]:size-[18px] [&>svg]:shrink-0",
11
+ {
12
+ variants: {
13
+ variant: {
14
+ success: "border-border border-l-success bg-success/8 text-foreground [&>svg]:text-success",
15
+ error: "border-border border-l-destructive bg-destructive/8 text-foreground [&>svg]:text-destructive",
16
+ warning: "border-border border-l-warning bg-warning/10 text-foreground [&>svg]:text-warning",
17
+ info: "border-border border-l-info bg-info/8 text-foreground [&>svg]:text-info",
18
+ },
19
+ },
20
+ defaultVariants: {
21
+ variant: "info",
22
+ },
23
+ }
24
+ )
25
+
26
+ export type AlertVariant = NonNullable<VariantProps<typeof alertVariants>["variant"]>
27
+
28
+ function AlertIcon({ variant }: { variant: AlertVariant }) {
29
+ const common = { width: 18, height: 18, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true } as const
30
+ switch (variant) {
31
+ case "success":
32
+ return (
33
+ <svg {...common}>
34
+ <circle cx="12" cy="12" r="9" />
35
+ <path d="m8.5 12 2.5 2.5 4.5-5" />
36
+ </svg>
37
+ )
38
+ case "error":
39
+ return (
40
+ <svg {...common}>
41
+ <circle cx="12" cy="12" r="9" />
42
+ <path d="M15 9l-6 6M9 9l6 6" />
43
+ </svg>
44
+ )
45
+ case "warning":
46
+ return (
47
+ <svg {...common}>
48
+ <path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
49
+ <path d="M12 9v4M12 17h.01" />
50
+ </svg>
51
+ )
52
+ case "info":
53
+ return (
54
+ <svg {...common}>
55
+ <circle cx="12" cy="12" r="9" />
56
+ <path d="M12 11v5M12 8h.01" />
57
+ </svg>
58
+ )
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Inline status banner rendered in the page flow — persistent and declarative,
64
+ * unlike the transient `toast()`. Four semantic variants sharing the status
65
+ * tokens. Pass `icon={false}` to drop the leading icon, or a node to override it.
66
+ *
67
+ * ```tsx
68
+ * <Alert variant="warning">
69
+ * <AlertTitle>Quota bientôt atteint</AlertTitle>
70
+ * <AlertDescription>80 % du CPU alloué est utilisé.</AlertDescription>
71
+ * </Alert>
72
+ * ```
73
+ */
74
+ function Alert({
75
+ className,
76
+ variant = "info",
77
+ icon,
78
+ children,
79
+ ...props
80
+ }: ComponentProps<"div"> & VariantProps<typeof alertVariants> & { icon?: ReactNode | false }) {
81
+ const resolved = variant ?? "info"
82
+ return (
83
+ <div data-slot="alert" role="alert" className={cn(alertVariants({ variant: resolved }), className)} {...props}>
84
+ {icon === false ? null : icon ?? <AlertIcon variant={resolved} />}
85
+ <div className={cn("grid gap-1", icon === false && "col-span-2")}>{children}</div>
86
+ </div>
87
+ )
88
+ }
89
+
90
+ function AlertTitle({ className, ...props }: ComponentProps<"div">) {
91
+ return <div data-slot="alert-title" className={cn("font-semibold text-foreground", className)} {...props} />
92
+ }
93
+
94
+ function AlertDescription({ className, ...props }: ComponentProps<"div">) {
95
+ return (
96
+ <div
97
+ data-slot="alert-description"
98
+ className={cn("text-sm text-muted-foreground [&_p]:leading-relaxed", className)}
99
+ {...props}
100
+ />
101
+ )
102
+ }
103
+
104
+ export { Alert, AlertTitle, AlertDescription, alertVariants }
@@ -0,0 +1,40 @@
1
+ "use client"
2
+
3
+ import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
4
+
5
+ import { cn } from "../utils"
6
+
7
+ /** Circular avatar container. Compose `AvatarImage` + `AvatarFallback` inside. */
8
+ function Avatar({ className, ...props }: AvatarPrimitive.Root.Props) {
9
+ return (
10
+ <AvatarPrimitive.Root
11
+ data-slot="avatar"
12
+ className={cn(
13
+ "relative flex size-9 shrink-0 overflow-hidden rounded-full bg-muted select-none",
14
+ className
15
+ )}
16
+ {...props}
17
+ />
18
+ )
19
+ }
20
+
21
+ /** The avatar image. Hides itself while loading or on error (fallback shows). */
22
+ function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
23
+ return <AvatarPrimitive.Image data-slot="avatar-image" className={cn("size-full object-cover", className)} {...props} />
24
+ }
25
+
26
+ /** Shown when the image is missing or still loading — usually initials. */
27
+ function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
28
+ return (
29
+ <AvatarPrimitive.Fallback
30
+ data-slot="avatar-fallback"
31
+ className={cn(
32
+ "flex size-full items-center justify-center bg-muted text-sm font-medium text-muted-foreground",
33
+ className
34
+ )}
35
+ {...props}
36
+ />
37
+ )
38
+ }
39
+
40
+ export { Avatar, AvatarImage, AvatarFallback }
@@ -0,0 +1,36 @@
1
+ "use client"
2
+
3
+ import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
4
+
5
+ import { cn } from "../utils"
6
+
7
+ /**
8
+ * A checkbox, styled to match `Switch`. Controlled via `checked`/`onCheckedChange`
9
+ * or uncontrolled via `defaultChecked`; supports `indeterminate`.
10
+ */
11
+ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
12
+ return (
13
+ <CheckboxPrimitive.Root
14
+ data-slot="checkbox"
15
+ className={cn(
16
+ "peer flex size-4 shrink-0 items-center justify-center rounded-[min(var(--radius-md),5px)] border border-input bg-transparent text-primary-foreground shadow-sm transition-colors outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-checked:border-primary data-checked:bg-primary data-indeterminate:border-primary data-indeterminate:bg-primary dark:bg-input/30 dark:data-checked:bg-primary",
17
+ className
18
+ )}
19
+ {...props}
20
+ >
21
+ <CheckboxPrimitive.Indicator className="flex items-center justify-center text-current transition-none data-unchecked:hidden">
22
+ {props.indeterminate ? (
23
+ <svg width={12} height={12} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" aria-hidden>
24
+ <path d="M5 12h14" />
25
+ </svg>
26
+ ) : (
27
+ <svg width={12} height={12} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
28
+ <path d="M20 6 9 17l-5-5" />
29
+ </svg>
30
+ )}
31
+ </CheckboxPrimitive.Indicator>
32
+ </CheckboxPrimitive.Root>
33
+ )
34
+ }
35
+
36
+ export { Checkbox }
@@ -0,0 +1,112 @@
1
+ "use client"
2
+
3
+ import type { ComponentProps } from "react"
4
+ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
5
+
6
+ import { cn } from "../utils"
7
+ import { useOverlayContainer } from "./spunto-provider"
8
+
9
+ /** Groups the dialog parts. Controlled via `open`/`onOpenChange`, or uncontrolled. */
10
+ function Dialog(props: DialogPrimitive.Root.Props) {
11
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />
12
+ }
13
+
14
+ /** Element that opens the dialog. Pass `render` to reuse an existing button. */
15
+ function DialogTrigger(props: DialogPrimitive.Trigger.Props) {
16
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
17
+ }
18
+
19
+ /** Imperatively closes the dialog. Wrap a `Button` via `render` for a footer action. */
20
+ function DialogClose(props: DialogPrimitive.Close.Props) {
21
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
22
+ }
23
+
24
+ /**
25
+ * Portal + dimmed backdrop + centered popup, in one styled wrapper. Renders into
26
+ * the `SpuntoProvider` overlay container (falls back to `document.body`). Compose
27
+ * `DialogHeader`/`DialogFooter`/`DialogTitle`/`DialogDescription` inside it.
28
+ */
29
+ function DialogContent({
30
+ className,
31
+ children,
32
+ showClose = true,
33
+ ...props
34
+ }: DialogPrimitive.Popup.Props & { showClose?: boolean }) {
35
+ const container = useOverlayContainer()
36
+ return (
37
+ <DialogPrimitive.Portal container={container ?? undefined}>
38
+ <DialogPrimitive.Backdrop
39
+ data-slot="dialog-backdrop"
40
+ 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"
41
+ />
42
+ <DialogPrimitive.Popup
43
+ data-slot="dialog-content"
44
+ className={cn(
45
+ "fixed top-1/2 left-1/2 z-50 grid w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-border bg-popover p-6 text-popover-foreground shadow-xl shadow-black/[0.08] outline-none",
46
+ "transition-all duration-200 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
47
+ className
48
+ )}
49
+ {...props}
50
+ >
51
+ {children}
52
+ {showClose && (
53
+ <DialogPrimitive.Close
54
+ aria-label="Fermer"
55
+ className="absolute top-4 right-4 flex size-7 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 outline-none"
56
+ >
57
+ <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
58
+ <path d="M18 6 6 18M6 6l12 12" />
59
+ </svg>
60
+ </DialogPrimitive.Close>
61
+ )}
62
+ </DialogPrimitive.Popup>
63
+ </DialogPrimitive.Portal>
64
+ )
65
+ }
66
+
67
+ /** Title + description stack at the top of the dialog. */
68
+ function DialogHeader({ className, ...props }: ComponentProps<"div">) {
69
+ return <div data-slot="dialog-header" className={cn("flex flex-col gap-1.5 pr-6", className)} {...props} />
70
+ }
71
+
72
+ /** Right-aligned action row (buttons) at the bottom of the dialog. */
73
+ function DialogFooter({ className, ...props }: ComponentProps<"div">) {
74
+ return (
75
+ <div
76
+ data-slot="dialog-footer"
77
+ className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
78
+ {...props}
79
+ />
80
+ )
81
+ }
82
+
83
+ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
84
+ return (
85
+ <DialogPrimitive.Title
86
+ data-slot="dialog-title"
87
+ className={cn("text-base leading-none font-semibold text-foreground", className)}
88
+ {...props}
89
+ />
90
+ )
91
+ }
92
+
93
+ function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
94
+ return (
95
+ <DialogPrimitive.Description
96
+ data-slot="dialog-description"
97
+ className={cn("text-sm text-muted-foreground", className)}
98
+ {...props}
99
+ />
100
+ )
101
+ }
102
+
103
+ export {
104
+ Dialog,
105
+ DialogTrigger,
106
+ DialogClose,
107
+ DialogContent,
108
+ DialogHeader,
109
+ DialogFooter,
110
+ DialogTitle,
111
+ DialogDescription,
112
+ }
@@ -0,0 +1,34 @@
1
+ "use client"
2
+
3
+ import { Radio as RadioPrimitive } from "@base-ui/react/radio"
4
+ import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
5
+
6
+ import { cn } from "../utils"
7
+
8
+ /**
9
+ * A set of mutually-exclusive radio buttons. Controlled via `value`/`onValueChange`
10
+ * or uncontrolled via `defaultValue`. Compose `RadioGroupItem`s inside it.
11
+ */
12
+ function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
13
+ return <RadioGroupPrimitive data-slot="radio-group" className={cn("grid gap-2.5", className)} {...props} />
14
+ }
15
+
16
+ /** A single radio button. `value` is what the group commits when selected. */
17
+ function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
18
+ return (
19
+ <RadioPrimitive.Root
20
+ data-slot="radio-group-item"
21
+ className={cn(
22
+ "flex size-4 shrink-0 items-center justify-center rounded-full border border-input bg-transparent shadow-sm transition-colors outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-checked:border-primary data-checked:bg-primary dark:bg-input/30 dark:data-checked:bg-primary",
23
+ className
24
+ )}
25
+ {...props}
26
+ >
27
+ <RadioPrimitive.Indicator className="flex items-center justify-center data-unchecked:hidden">
28
+ <span className="size-1.5 rounded-full bg-primary-foreground" />
29
+ </RadioPrimitive.Indicator>
30
+ </RadioPrimitive.Root>
31
+ )
32
+ }
33
+
34
+ export { RadioGroup, RadioGroupItem }
@@ -0,0 +1,120 @@
1
+ "use client"
2
+
3
+ import type { ComponentProps } from "react"
4
+ import { Select as SelectPrimitive } from "@base-ui/react/select"
5
+
6
+ import { cn } from "../utils"
7
+ import { useOverlayContainer } from "./spunto-provider"
8
+
9
+ /** Groups the select parts. Controlled via `value`/`onValueChange`, or uncontrolled. */
10
+ function Select<Value>(props: SelectPrimitive.Root.Props<Value>) {
11
+ return <SelectPrimitive.Root data-slot="select" {...props} />
12
+ }
13
+
14
+ /** The button that opens the listbox. Put a `SelectValue` inside it. */
15
+ function SelectTrigger({ className, children, ...props }: SelectPrimitive.Trigger.Props) {
16
+ return (
17
+ <SelectPrimitive.Trigger
18
+ data-slot="select-trigger"
19
+ className={cn(
20
+ "flex h-8 w-full items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm whitespace-nowrap transition-colors outline-none select-none data-[popup-open]:border-ring focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground dark:bg-input/30 [&>span]:truncate",
21
+ className
22
+ )}
23
+ {...props}
24
+ >
25
+ {children}
26
+ <SelectPrimitive.Icon className="text-muted-foreground">
27
+ <svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
28
+ <path d="m6 9 6 6 6-6" />
29
+ </svg>
30
+ </SelectPrimitive.Icon>
31
+ </SelectPrimitive.Trigger>
32
+ )
33
+ }
34
+
35
+ /** Displays the selected item's label (or `placeholder` when empty). */
36
+ function SelectValue(props: SelectPrimitive.Value.Props) {
37
+ return <SelectPrimitive.Value data-slot="select-value" {...props} />
38
+ }
39
+
40
+ /** Portal + positioned listbox popup. Wraps `SelectItem`/`SelectGroup` children. */
41
+ function SelectContent({ className, children, ...props }: SelectPrimitive.Popup.Props) {
42
+ const container = useOverlayContainer()
43
+ return (
44
+ <SelectPrimitive.Portal container={container ?? undefined}>
45
+ <SelectPrimitive.Positioner
46
+ data-slot="select-positioner"
47
+ className="z-50 outline-none"
48
+ side="bottom"
49
+ align="start"
50
+ sideOffset={4}
51
+ >
52
+ <SelectPrimitive.Popup
53
+ data-slot="select-content"
54
+ className={cn(
55
+ "max-h-[var(--available-height)] min-w-[max(8rem,var(--anchor-width))] overflow-y-auto overscroll-contain rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg shadow-black/[0.06] outline-none",
56
+ "origin-[var(--transform-origin)] transition-[transform,opacity] duration-150 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
57
+ className
58
+ )}
59
+ {...props}
60
+ >
61
+ {children}
62
+ </SelectPrimitive.Popup>
63
+ </SelectPrimitive.Positioner>
64
+ </SelectPrimitive.Portal>
65
+ )
66
+ }
67
+
68
+ /** A selectable option. `value` is what gets committed; children is the label. */
69
+ function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
70
+ return (
71
+ <SelectPrimitive.Item
72
+ data-slot="select-item"
73
+ className={cn(
74
+ "relative flex cursor-default items-center gap-2 rounded-md py-1.5 pr-2 pl-7 text-sm outline-none select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
75
+ className
76
+ )}
77
+ {...props}
78
+ >
79
+ <span className="absolute left-2 flex size-3.5 items-center justify-center">
80
+ <SelectPrimitive.ItemIndicator>
81
+ <svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
82
+ <path d="M20 6 9 17l-5-5" />
83
+ </svg>
84
+ </SelectPrimitive.ItemIndicator>
85
+ </span>
86
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
87
+ </SelectPrimitive.Item>
88
+ )
89
+ }
90
+
91
+ /** Groups related items under a `SelectGroupLabel`. */
92
+ function SelectGroup(props: SelectPrimitive.Group.Props) {
93
+ return <SelectPrimitive.Group data-slot="select-group" {...props} />
94
+ }
95
+
96
+ function SelectGroupLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
97
+ return (
98
+ <SelectPrimitive.GroupLabel
99
+ data-slot="select-group-label"
100
+ className={cn("px-2 py-1.5 text-xs font-medium text-muted-foreground", className)}
101
+ {...props}
102
+ />
103
+ )
104
+ }
105
+
106
+ /** A thin rule between item groups. */
107
+ function SelectSeparator({ className, ...props }: ComponentProps<"div">) {
108
+ return <div data-slot="select-separator" role="separator" className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
109
+ }
110
+
111
+ export {
112
+ Select,
113
+ SelectTrigger,
114
+ SelectValue,
115
+ SelectContent,
116
+ SelectItem,
117
+ SelectGroup,
118
+ SelectGroupLabel,
119
+ SelectSeparator,
120
+ }
@@ -0,0 +1,23 @@
1
+ "use client"
2
+
3
+ import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
4
+
5
+ import { cn } from "../utils"
6
+
7
+ /** A thin rule that separates content, horizontal or vertical. */
8
+ function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
9
+ return (
10
+ <SeparatorPrimitive
11
+ data-slot="separator"
12
+ orientation={orientation}
13
+ className={cn(
14
+ "shrink-0 bg-border",
15
+ orientation === "vertical" ? "h-full w-px" : "h-px w-full",
16
+ className
17
+ )}
18
+ {...props}
19
+ />
20
+ )
21
+ }
22
+
23
+ export { Separator }
@@ -0,0 +1,13 @@
1
+ import type { ComponentProps } from "react"
2
+
3
+ import { cn } from "../utils"
4
+
5
+ /**
6
+ * A pulsing placeholder for content that hasn't loaded yet. Size it with
7
+ * utilities (`className="h-4 w-32"`); use `rounded-full` for circular avatars.
8
+ */
9
+ function Skeleton({ className, ...props }: ComponentProps<"div">) {
10
+ return <div data-slot="skeleton" className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />
11
+ }
12
+
13
+ export { Skeleton }
@@ -0,0 +1,45 @@
1
+ import type { ComponentProps } from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "../utils"
5
+
6
+ const spinnerVariants = cva("animate-spin text-current", {
7
+ variants: {
8
+ size: {
9
+ xs: "size-3",
10
+ sm: "size-4",
11
+ default: "size-5",
12
+ lg: "size-6",
13
+ },
14
+ },
15
+ defaultVariants: {
16
+ size: "default",
17
+ },
18
+ })
19
+
20
+ /**
21
+ * An indeterminate loading spinner. Inherits the current text color, so drop it
22
+ * inside a `Button` or set `text-*` to tint it.
23
+ */
24
+ function Spinner({
25
+ className,
26
+ size,
27
+ ...props
28
+ }: ComponentProps<"svg"> & VariantProps<typeof spinnerVariants>) {
29
+ return (
30
+ <svg
31
+ data-slot="spinner"
32
+ role="status"
33
+ aria-label="Chargement"
34
+ viewBox="0 0 24 24"
35
+ fill="none"
36
+ className={cn(spinnerVariants({ size }), className)}
37
+ {...props}
38
+ >
39
+ <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth={2.5} className="opacity-20" />
40
+ <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" />
41
+ </svg>
42
+ )
43
+ }
44
+
45
+ export { Spinner, spinnerVariants }
@@ -1,7 +1,8 @@
1
1
  "use client"
2
2
 
3
- import type { ReactNode } from "react"
3
+ import { createContext, useContext, useState, type ReactNode } from "react"
4
4
  import { Toast as ToastPrimitive } from "@base-ui/react/toast"
5
+ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
5
6
 
6
7
  import { ToastList, toastManager, type ToastSwipeDirection } from "./toast"
7
8
 
@@ -33,6 +34,17 @@ export interface SpuntoProviderProps {
33
34
  * @default 3
34
35
  */
35
36
  toastLimit?: number
37
+ /**
38
+ * Delay (ms) before a hovered `Tooltip` opens. Shared across every tooltip via
39
+ * Base UI's delay group, so once one is showing its neighbours open instantly.
40
+ * @default 600
41
+ */
42
+ tooltipDelay?: number
43
+ /**
44
+ * Delay (ms) before a tooltip closes once the pointer leaves.
45
+ * @default 0
46
+ */
47
+ tooltipCloseDelay?: number
36
48
  }
37
49
 
38
50
  // Toasts swipe toward the nearest screen edge for the current position.
@@ -45,12 +57,33 @@ const SWIPE_DIRECTIONS: Record<ToastPosition, ToastSwipeDirection[]> = {
45
57
  "bottom-right": ["down", "right"],
46
58
  }
47
59
 
60
+ /**
61
+ * Shared DOM node the overlay components (`Dialog`, `AlertDialog`, `Select`,
62
+ * `Tooltip`) portal into. `SpuntoProvider` renders it as the last child of its
63
+ * subtree so every popup lands *inside* the themed tree — the `.dark` class and
64
+ * the token CSS vars cascade to portalled content for free. When no provider is
65
+ * mounted the value is `null`, and the primitives fall back to Base UI's default
66
+ * (`document.body`), so the components keep working standalone.
67
+ * @internal
68
+ */
69
+ const OverlayContainerContext = createContext<HTMLElement | null>(null)
70
+
71
+ /**
72
+ * Container element the design-system overlays portal into, provided by the
73
+ * nearest `SpuntoProvider` (or `null` → Base UI's `document.body` default).
74
+ * @internal
75
+ */
76
+ export function useOverlayContainer(): HTMLElement | null {
77
+ return useContext(OverlayContainerContext)
78
+ }
79
+
48
80
  /**
49
81
  * Client-side umbrella provider for the Spunto design system. Wrap your app once
50
82
  * (in a Next.js `app/layout.tsx`, it's a client component — render it around
51
83
  * `{children}` inside the server layout, no `"use client"` needed on the layout
52
- * itself). Today it mounts the toast viewport; it's the seam future overlay
53
- * features (dialogs, tooltips, theme config) plug into without a breaking change.
84
+ * itself). It mounts the toast viewport, the shared tooltip delay group, and the
85
+ * overlay portal container that dialogs/selects render into the single seam
86
+ * every overlay feature plugs into without a breaking change.
54
87
  *
55
88
  * ```tsx
56
89
  * <SpuntoProvider toastPosition="bottom-right">
@@ -63,15 +96,26 @@ export function SpuntoProvider({
63
96
  toastPosition = "top-right",
64
97
  toastDuration = 5000,
65
98
  toastLimit = 3,
99
+ tooltipDelay = 600,
100
+ tooltipCloseDelay = 0,
66
101
  }: SpuntoProviderProps) {
102
+ const [overlayContainer, setOverlayContainer] = useState<HTMLElement | null>(null)
103
+
67
104
  return (
68
105
  <ToastPrimitive.Provider toastManager={toastManager} timeout={toastDuration} limit={toastLimit}>
69
- {children}
70
- <ToastPrimitive.Portal>
71
- <ToastPrimitive.Viewport data-slot="toast-viewport" data-position={toastPosition}>
72
- <ToastList swipeDirection={SWIPE_DIRECTIONS[toastPosition]} />
73
- </ToastPrimitive.Viewport>
74
- </ToastPrimitive.Portal>
106
+ <TooltipPrimitive.Provider delay={tooltipDelay} closeDelay={tooltipCloseDelay}>
107
+ <OverlayContainerContext.Provider value={overlayContainer}>
108
+ {children}
109
+ <ToastPrimitive.Portal>
110
+ <ToastPrimitive.Viewport data-slot="toast-viewport" data-position={toastPosition}>
111
+ <ToastList swipeDirection={SWIPE_DIRECTIONS[toastPosition]} />
112
+ </ToastPrimitive.Viewport>
113
+ </ToastPrimitive.Portal>
114
+ {/* Overlays (Dialog/Select/Tooltip) portal into this node — kept last so
115
+ popups stack above the app, and inside the provider so tokens cascade. */}
116
+ <div ref={setOverlayContainer} data-slot="spunto-overlay-root" />
117
+ </OverlayContainerContext.Provider>
118
+ </TooltipPrimitive.Provider>
75
119
  </ToastPrimitive.Provider>
76
120
  )
77
121
  }
@@ -0,0 +1,68 @@
1
+ "use client"
2
+
3
+ import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
4
+
5
+ import { cn } from "../utils"
6
+
7
+ /** Groups the tab parts. Controlled via `value`/`onValueChange`, or uncontrolled. */
8
+ function Tabs({ className, ...props }: TabsPrimitive.Root.Props) {
9
+ return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-3", className)} {...props} />
10
+ }
11
+
12
+ /**
13
+ * The row of tabs. Contains `TabsTab`s and an optional `TabsIndicator`. Styled as
14
+ * a segmented control with a muted track.
15
+ */
16
+ function TabsList({ className, ...props }: TabsPrimitive.List.Props) {
17
+ return (
18
+ <TabsPrimitive.List
19
+ data-slot="tabs-list"
20
+ className={cn(
21
+ "relative isolate flex h-9 w-fit items-center gap-1 rounded-lg bg-muted p-1 text-muted-foreground",
22
+ className
23
+ )}
24
+ {...props}
25
+ />
26
+ )
27
+ }
28
+
29
+ /** The sliding highlight behind the active tab. Optional; place inside `TabsList`. */
30
+ function TabsIndicator({ className, ...props }: TabsPrimitive.Indicator.Props) {
31
+ return (
32
+ <TabsPrimitive.Indicator
33
+ data-slot="tabs-indicator"
34
+ className={cn(
35
+ "absolute top-1/2 left-0 z-[-1] h-[calc(100%-0.5rem)] w-[var(--active-tab-width)] -translate-y-1/2 translate-x-[var(--active-tab-left)] rounded-md bg-background shadow-sm transition-[width,transform] duration-200 ease-out",
36
+ className
37
+ )}
38
+ {...props}
39
+ />
40
+ )
41
+ }
42
+
43
+ /** A single tab button. `value` ties it to the matching `TabsPanel`. */
44
+ function TabsTab({ className, ...props }: TabsPrimitive.Tab.Props) {
45
+ return (
46
+ <TabsPrimitive.Tab
47
+ data-slot="tabs-tab"
48
+ className={cn(
49
+ "inline-flex h-full cursor-default items-center justify-center rounded-md px-3 text-sm font-medium whitespace-nowrap transition-colors outline-none select-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-disabled:pointer-events-none data-disabled:opacity-50 data-selected:text-foreground",
50
+ className
51
+ )}
52
+ {...props}
53
+ />
54
+ )
55
+ }
56
+
57
+ /** The content shown for the tab with the matching `value`. */
58
+ function TabsPanel({ className, ...props }: TabsPrimitive.Panel.Props) {
59
+ return (
60
+ <TabsPrimitive.Panel
61
+ data-slot="tabs-panel"
62
+ className={cn("outline-none focus-visible:ring-2 focus-visible:ring-ring/50", className)}
63
+ {...props}
64
+ />
65
+ )
66
+ }
67
+
68
+ export { Tabs, TabsList, TabsIndicator, TabsTab, TabsPanel }
@@ -0,0 +1,90 @@
1
+ "use client"
2
+
3
+ import type { ReactElement, ReactNode } from "react"
4
+ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
5
+
6
+ import { cn } from "../utils"
7
+ import { useOverlayContainer } from "./spunto-provider"
8
+
9
+ export interface TooltipProps {
10
+ /** The tooltip bubble content. When omitted, the child renders with no tooltip. */
11
+ content?: ReactNode
12
+ /** The element the tooltip is anchored to (used as the trigger). */
13
+ children: ReactElement<Record<string, unknown>>
14
+ /**
15
+ * Preferred side of the trigger to place the bubble.
16
+ * @default "top"
17
+ */
18
+ side?: TooltipPrimitive.Positioner.Props["side"]
19
+ /**
20
+ * Alignment along the chosen side.
21
+ * @default "center"
22
+ */
23
+ align?: TooltipPrimitive.Positioner.Props["align"]
24
+ /** Gap (px) between the trigger and the bubble. @default 6 */
25
+ sideOffset?: number
26
+ /** Show the little pointer triangle. @default true */
27
+ arrow?: boolean
28
+ /** Controlled open state. */
29
+ open?: boolean
30
+ /** Open-state change handler (controlled mode). */
31
+ onOpenChange?: TooltipPrimitive.Root.Props["onOpenChange"]
32
+ }
33
+
34
+ /**
35
+ * Hover/focus tooltip built on Base UI. Wrap any focusable element:
36
+ *
37
+ * ```tsx
38
+ * <Tooltip content="Copier l'ID">
39
+ * <Button size="icon" variant="ghost"><CopyIcon /></Button>
40
+ * </Tooltip>
41
+ * ```
42
+ *
43
+ * The shared open delay + "instant on neighbour" grouping comes from the
44
+ * `Tooltip.Provider` that `SpuntoProvider` mounts; it also works standalone.
45
+ */
46
+ function Tooltip({
47
+ content,
48
+ children,
49
+ side = "top",
50
+ align = "center",
51
+ sideOffset = 6,
52
+ arrow = true,
53
+ open,
54
+ onOpenChange,
55
+ }: TooltipProps) {
56
+ const container = useOverlayContainer()
57
+
58
+ if (content == null || content === false) return children
59
+
60
+ return (
61
+ <TooltipPrimitive.Root open={open} onOpenChange={onOpenChange}>
62
+ <TooltipPrimitive.Trigger data-slot="tooltip-trigger" render={children} />
63
+ <TooltipPrimitive.Portal container={container ?? undefined}>
64
+ <TooltipPrimitive.Positioner data-slot="tooltip-positioner" side={side} align={align} sideOffset={sideOffset}>
65
+ <TooltipPrimitive.Popup
66
+ data-slot="tooltip"
67
+ className={cn(
68
+ "z-50 max-w-xs rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md shadow-black/10",
69
+ "origin-[var(--transform-origin)] transition-[transform,opacity] duration-150 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
70
+ )}
71
+ >
72
+ {arrow && (
73
+ <TooltipPrimitive.Arrow
74
+ data-slot="tooltip-arrow"
75
+ className="data-[side=bottom]:top-[-6px] data-[side=left]:right-[-6px] data-[side=right]:left-[-6px] data-[side=top]:bottom-[-6px] data-[side=left]:rotate-90 data-[side=right]:-rotate-90 data-[side=bottom]:rotate-180"
76
+ >
77
+ <svg width={12} height={6} viewBox="0 0 12 6" fill="none" aria-hidden>
78
+ <path d="M0 0L6 6L12 0" className="fill-foreground" />
79
+ </svg>
80
+ </TooltipPrimitive.Arrow>
81
+ )}
82
+ {content}
83
+ </TooltipPrimitive.Popup>
84
+ </TooltipPrimitive.Positioner>
85
+ </TooltipPrimitive.Portal>
86
+ </TooltipPrimitive.Root>
87
+ )
88
+ }
89
+
90
+ export { Tooltip }
package/src/index.ts CHANGED
@@ -14,9 +14,61 @@ export { Textarea } from "./components/textarea"
14
14
  export { Label } from "./components/label"
15
15
  export { Badge, badgeVariants } from "./components/badge"
16
16
  export { Switch } from "./components/switch"
17
+ export { Checkbox } from "./components/checkbox"
18
+ export { RadioGroup, RadioGroupItem } from "./components/radio-group"
19
+ export { Separator } from "./components/separator"
20
+ export { Skeleton } from "./components/skeleton"
21
+ export { Spinner, spinnerVariants } from "./components/spinner"
22
+ export { Avatar, AvatarImage, AvatarFallback } from "./components/avatar"
23
+
24
+ // Inline, declarative status banner (persistent) — the counterpart to `toast()`.
25
+ // `alertVariants` ships from this directive-free module so it stays RSC-callable.
26
+ export { Alert, AlertTitle, AlertDescription, alertVariants } from "./components/alert"
27
+ export type { AlertVariant } from "./components/alert"
28
+
29
+ // Tabs — list + sliding indicator + tab + panel, on Base UI.
30
+ export { Tabs, TabsList, TabsIndicator, TabsTab, TabsPanel } from "./components/tabs"
31
+
32
+ // Select — dropdown listbox with items, groups, and separators, on Base UI.
33
+ export {
34
+ Select,
35
+ SelectTrigger,
36
+ SelectValue,
37
+ SelectContent,
38
+ SelectItem,
39
+ SelectGroup,
40
+ SelectGroupLabel,
41
+ SelectSeparator,
42
+ } from "./components/select"
43
+
44
+ // Overlays that plug into the `SpuntoProvider` umbrella (portal container +
45
+ // tooltip delay group).
46
+ export { Tooltip } from "./components/tooltip"
47
+ export type { TooltipProps } from "./components/tooltip"
48
+ export {
49
+ Dialog,
50
+ DialogTrigger,
51
+ DialogClose,
52
+ DialogContent,
53
+ DialogHeader,
54
+ DialogFooter,
55
+ DialogTitle,
56
+ DialogDescription,
57
+ } from "./components/dialog"
58
+ export {
59
+ AlertDialog,
60
+ AlertDialogTrigger,
61
+ AlertDialogClose,
62
+ AlertDialogContent,
63
+ AlertDialogHeader,
64
+ AlertDialogFooter,
65
+ AlertDialogTitle,
66
+ AlertDialogDescription,
67
+ } from "./components/alert-dialog"
17
68
 
18
69
  // Toasts — imperative `toast()` API + the umbrella client provider that renders
19
- // them. `SpuntoProvider` is the extension point for future overlay features.
70
+ // them. `SpuntoProvider` mounts the toast viewport, the tooltip delay group, and
71
+ // the overlay portal container that dialogs/selects render into.
20
72
  export { SpuntoProvider } from "./components/spunto-provider"
21
73
  export type { SpuntoProviderProps, ToastPosition } from "./components/spunto-provider"
22
74
  export { toast } from "./components/toast"