@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 +21 -6
- package/package.json +1 -1
- package/src/components/alert-dialog.tsx +93 -0
- package/src/components/alert.tsx +104 -0
- package/src/components/avatar.tsx +40 -0
- package/src/components/checkbox.tsx +36 -0
- package/src/components/dialog.tsx +112 -0
- package/src/components/radio-group.tsx +34 -0
- package/src/components/select.tsx +120 -0
- package/src/components/separator.tsx +23 -0
- package/src/components/skeleton.tsx +13 -0
- package/src/components/spinner.tsx +45 -0
- package/src/components/spunto-provider.tsx +53 -9
- package/src/components/tabs.tsx +68 -0
- package/src/components/tooltip.tsx +90 -0
- package/src/index.ts +53 -1
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** —
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
-
|
|
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;
|
|
50
|
-
|
|
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
|
@@ -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
|
|
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).
|
|
53
|
-
*
|
|
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
|
-
{
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
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`
|
|
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"
|