@spunto/design-system 0.2.1 → 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 +65 -3
- 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 +121 -0
- package/src/components/tabs.tsx +68 -0
- package/src/components/toast.tsx +199 -0
- package/src/components/tooltip.tsx +90 -0
- package/src/index.ts +59 -0
- package/styles.css +151 -0
package/README.md
CHANGED
|
@@ -38,9 +38,71 @@ 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
|
-
|
|
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.
|
|
57
|
+
|
|
58
|
+
## Toasts — `SpuntoProvider` + `toast()`
|
|
59
|
+
|
|
60
|
+
`SpuntoProvider` is the design system's client umbrella provider. Mount it once
|
|
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
|
|
66
|
+
API callable from **anywhere** — event handlers, async code, even outside the
|
|
67
|
+
React tree (it's backed by Base UI's `createToastManager`).
|
|
68
|
+
|
|
69
|
+
In a Next.js App Router app, the root layout stays a Server Component — just
|
|
70
|
+
render the (client) provider around `{children}`:
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
// app/layout.tsx
|
|
74
|
+
import { SpuntoProvider } from "@spunto/design-system"
|
|
75
|
+
import "@spunto/design-system/styles.css"
|
|
76
|
+
|
|
77
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
78
|
+
return (
|
|
79
|
+
<html lang="en">
|
|
80
|
+
<body>
|
|
81
|
+
<SpuntoProvider toastPosition="top-right">{children}</SpuntoProvider>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Then fire toasts from anywhere:
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
import { toast } from "@spunto/design-system"
|
|
92
|
+
|
|
93
|
+
toast.success("Worker ready", { description: "spunto-abc is up" })
|
|
94
|
+
toast.error("Deploy failed", { action: { label: "Retry", onClick: redeploy } })
|
|
95
|
+
|
|
96
|
+
// Persistent (no auto-dismiss) + manual control:
|
|
97
|
+
const id = toast.info("Deploying…", { duration: 0 })
|
|
98
|
+
toast.dismiss(id)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`toast(message, options)` and the `toast.success/error/warning/info` helpers take
|
|
102
|
+
`{ title?, description?, variant?, duration?, action? }` and return the toast id;
|
|
103
|
+
`toast.dismiss(id?)` closes one toast (or all). `SpuntoProvider` accepts
|
|
104
|
+
`toastPosition` (default `"top-right"`), `toastDuration` (default `5000`, `0` =
|
|
105
|
+
persistent) and `toastLimit` (default `3`).
|
|
44
106
|
|
|
45
107
|
## Peer requirements
|
|
46
108
|
|
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 }
|