@spunto/design-system 0.1.0 → 0.3.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 +47 -0
- package/package.json +4 -1
- package/src/components/badge.tsx +39 -16
- package/src/components/button-variants.ts +39 -0
- package/src/components/button.tsx +21 -33
- package/src/components/card.tsx +89 -12
- package/src/components/input.tsx +13 -9
- package/src/components/label.tsx +17 -6
- package/src/components/spunto-provider.tsx +77 -0
- package/src/components/switch.tsx +19 -28
- package/src/components/textarea.tsx +10 -8
- package/src/components/toast.tsx +199 -0
- package/src/index.ts +12 -2
- package/styles.css +151 -0
package/README.md
CHANGED
|
@@ -41,6 +41,53 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
|
|
|
41
41
|
- **Primitives** — `Button` (+`buttonVariants`), `Card` (+parts), `Input`, `Textarea`,
|
|
42
42
|
`Label`, `Badge` (+`badgeVariants`), `Switch`. Dependency-light (cva + clsx +
|
|
43
43
|
tailwind-merge), no component framework required.
|
|
44
|
+
- **`SpuntoProvider` + `toast()`** — the imperative toast system (see below).
|
|
45
|
+
|
|
46
|
+
## Toasts — `SpuntoProvider` + `toast()`
|
|
47
|
+
|
|
48
|
+
`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
|
|
51
|
+
API callable from **anywhere** — event handlers, async code, even outside the
|
|
52
|
+
React tree (it's backed by Base UI's `createToastManager`).
|
|
53
|
+
|
|
54
|
+
In a Next.js App Router app, the root layout stays a Server Component — just
|
|
55
|
+
render the (client) provider around `{children}`:
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
// app/layout.tsx
|
|
59
|
+
import { SpuntoProvider } from "@spunto/design-system"
|
|
60
|
+
import "@spunto/design-system/styles.css"
|
|
61
|
+
|
|
62
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
63
|
+
return (
|
|
64
|
+
<html lang="en">
|
|
65
|
+
<body>
|
|
66
|
+
<SpuntoProvider toastPosition="top-right">{children}</SpuntoProvider>
|
|
67
|
+
</body>
|
|
68
|
+
</html>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Then fire toasts from anywhere:
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
import { toast } from "@spunto/design-system"
|
|
77
|
+
|
|
78
|
+
toast.success("Worker ready", { description: "spunto-abc is up" })
|
|
79
|
+
toast.error("Deploy failed", { action: { label: "Retry", onClick: redeploy } })
|
|
80
|
+
|
|
81
|
+
// Persistent (no auto-dismiss) + manual control:
|
|
82
|
+
const id = toast.info("Deploying…", { duration: 0 })
|
|
83
|
+
toast.dismiss(id)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`toast(message, options)` and the `toast.success/error/warning/info` helpers take
|
|
87
|
+
`{ title?, description?, variant?, duration?, action? }` and return the toast id;
|
|
88
|
+
`toast.dismiss(id?)` closes one toast (or all). `SpuntoProvider` accepts
|
|
89
|
+
`toastPosition` (default `"top-right"`), `toastDuration` (default `5000`, `0` =
|
|
90
|
+
persistent) and `toastLimit` (default `3`).
|
|
44
91
|
|
|
45
92
|
## Peer requirements
|
|
46
93
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spunto/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -49,11 +49,14 @@
|
|
|
49
49
|
"tailwind-merge": "^3.6.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
+
"@base-ui/react": "^1.6.0",
|
|
52
53
|
"react": ">=19",
|
|
53
54
|
"react-dom": ">=19"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
57
|
+
"@base-ui/react": "^1.6.0",
|
|
56
58
|
"@types/react": "^19",
|
|
59
|
+
"@types/react-dom": "^19",
|
|
57
60
|
"typescript": "^5"
|
|
58
61
|
}
|
|
59
62
|
}
|
package/src/components/badge.tsx
CHANGED
|
@@ -1,29 +1,52 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { mergeProps } from "@base-ui/react/merge-props"
|
|
2
|
+
import { useRender } from "@base-ui/react/use-render"
|
|
2
3
|
import { cva, type VariantProps } from "class-variance-authority"
|
|
4
|
+
|
|
3
5
|
import { cn } from "../utils"
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
"inline-flex items-center gap-1 rounded-
|
|
7
|
+
const badgeVariants = cva(
|
|
8
|
+
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
|
7
9
|
{
|
|
8
10
|
variants: {
|
|
9
11
|
variant: {
|
|
10
|
-
default: "
|
|
11
|
-
secondary:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
|
13
|
+
secondary:
|
|
14
|
+
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
|
15
|
+
destructive:
|
|
16
|
+
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
|
17
|
+
outline:
|
|
18
|
+
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
|
19
|
+
ghost:
|
|
20
|
+
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
|
21
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
17
22
|
},
|
|
18
23
|
},
|
|
19
|
-
defaultVariants: {
|
|
20
|
-
|
|
24
|
+
defaultVariants: {
|
|
25
|
+
variant: "default",
|
|
26
|
+
},
|
|
27
|
+
}
|
|
21
28
|
)
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
function Badge({
|
|
24
31
|
className,
|
|
25
|
-
variant,
|
|
32
|
+
variant = "default",
|
|
33
|
+
render,
|
|
26
34
|
...props
|
|
27
|
-
}:
|
|
28
|
-
return
|
|
35
|
+
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
|
36
|
+
return useRender({
|
|
37
|
+
defaultTagName: "span",
|
|
38
|
+
props: mergeProps<"span">(
|
|
39
|
+
{
|
|
40
|
+
className: cn(badgeVariants({ variant }), className),
|
|
41
|
+
},
|
|
42
|
+
props
|
|
43
|
+
),
|
|
44
|
+
render,
|
|
45
|
+
state: {
|
|
46
|
+
slot: "badge",
|
|
47
|
+
variant,
|
|
48
|
+
},
|
|
49
|
+
})
|
|
29
50
|
}
|
|
51
|
+
|
|
52
|
+
export { Badge, badgeVariants }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { cva } from "class-variance-authority"
|
|
2
|
+
|
|
3
|
+
export const buttonVariants = cva(
|
|
4
|
+
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/40 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
variant: {
|
|
8
|
+
default:
|
|
9
|
+
"bg-primary text-primary-foreground hover:bg-primary/85 border-primary/20",
|
|
10
|
+
outline:
|
|
11
|
+
"border-border bg-background hover:bg-accent hover:text-accent-foreground aria-expanded:bg-accent dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
|
12
|
+
secondary:
|
|
13
|
+
"bg-secondary text-secondary-foreground hover:bg-secondary/70 border-secondary/50",
|
|
14
|
+
ghost:
|
|
15
|
+
"hover:bg-accent hover:text-accent-foreground aria-expanded:bg-accent dark:hover:bg-muted/50",
|
|
16
|
+
destructive:
|
|
17
|
+
"bg-destructive/10 text-destructive hover:bg-destructive/20 border-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30",
|
|
18
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
19
|
+
},
|
|
20
|
+
size: {
|
|
21
|
+
default:
|
|
22
|
+
"h-8 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
23
|
+
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
24
|
+
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
25
|
+
lg: "h-9 gap-1.5 px-4",
|
|
26
|
+
icon: "size-8",
|
|
27
|
+
"icon-xs":
|
|
28
|
+
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
29
|
+
"icon-sm":
|
|
30
|
+
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
|
31
|
+
"icon-lg": "size-9",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
defaultVariants: {
|
|
35
|
+
variant: "default",
|
|
36
|
+
size: "default",
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
)
|
|
@@ -1,36 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
|
4
|
+
import { type VariantProps } from "class-variance-authority"
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
{
|
|
8
|
-
variants: {
|
|
9
|
-
variant: {
|
|
10
|
-
default: "bg-primary text-primary-foreground hover:opacity-90",
|
|
11
|
-
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
12
|
-
outline: "border border-border bg-transparent hover:bg-accent hover:text-accent-foreground",
|
|
13
|
-
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
14
|
-
destructive: "bg-destructive text-white hover:opacity-90",
|
|
15
|
-
},
|
|
16
|
-
size: {
|
|
17
|
-
default: "h-9 px-4 py-2",
|
|
18
|
-
sm: "h-8 px-3 text-xs",
|
|
19
|
-
lg: "h-10 px-6",
|
|
20
|
-
icon: "size-9",
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
defaultVariants: { variant: "default", size: "default" },
|
|
24
|
-
},
|
|
25
|
-
)
|
|
6
|
+
import { cn } from "../utils"
|
|
7
|
+
import { buttonVariants } from "./button-variants"
|
|
26
8
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
9
|
+
function Button({
|
|
10
|
+
className,
|
|
11
|
+
variant = "default",
|
|
12
|
+
size = "default",
|
|
13
|
+
...props
|
|
14
|
+
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
|
15
|
+
return (
|
|
16
|
+
<ButtonPrimitive
|
|
17
|
+
data-slot="button"
|
|
18
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
30
23
|
|
|
31
|
-
export
|
|
32
|
-
({ className, variant, size, ...props }, ref) => (
|
|
33
|
-
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
|
34
|
-
),
|
|
35
|
-
)
|
|
36
|
-
Button.displayName = "Button"
|
|
24
|
+
export { Button, buttonVariants }
|
package/src/components/card.tsx
CHANGED
|
@@ -1,26 +1,103 @@
|
|
|
1
1
|
import * as React from "react"
|
|
2
|
+
|
|
2
3
|
import { cn } from "../utils"
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
function Card({
|
|
6
|
+
className,
|
|
7
|
+
size = "default",
|
|
8
|
+
...props
|
|
9
|
+
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
data-slot="card"
|
|
13
|
+
data-size={size}
|
|
14
|
+
className={cn(
|
|
15
|
+
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
|
16
|
+
className
|
|
17
|
+
)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
data-slot="card-header"
|
|
27
|
+
className={cn(
|
|
28
|
+
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
|
29
|
+
className
|
|
30
|
+
)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
data-slot="card-title"
|
|
40
|
+
className={cn(
|
|
41
|
+
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
|
42
|
+
className
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
6
47
|
}
|
|
7
48
|
|
|
8
|
-
|
|
9
|
-
return
|
|
49
|
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
data-slot="card-description"
|
|
53
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
54
|
+
{...props}
|
|
55
|
+
/>
|
|
56
|
+
)
|
|
10
57
|
}
|
|
11
58
|
|
|
12
|
-
|
|
13
|
-
return
|
|
59
|
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
data-slot="card-action"
|
|
63
|
+
className={cn(
|
|
64
|
+
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
65
|
+
className
|
|
66
|
+
)}
|
|
67
|
+
{...props}
|
|
68
|
+
/>
|
|
69
|
+
)
|
|
14
70
|
}
|
|
15
71
|
|
|
16
|
-
|
|
17
|
-
return
|
|
72
|
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
data-slot="card-content"
|
|
76
|
+
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
)
|
|
18
80
|
}
|
|
19
81
|
|
|
20
|
-
|
|
21
|
-
return
|
|
82
|
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
data-slot="card-footer"
|
|
86
|
+
className={cn(
|
|
87
|
+
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
|
88
|
+
className
|
|
89
|
+
)}
|
|
90
|
+
{...props}
|
|
91
|
+
/>
|
|
92
|
+
)
|
|
22
93
|
}
|
|
23
94
|
|
|
24
|
-
export
|
|
25
|
-
|
|
95
|
+
export {
|
|
96
|
+
Card,
|
|
97
|
+
CardHeader,
|
|
98
|
+
CardFooter,
|
|
99
|
+
CardTitle,
|
|
100
|
+
CardAction,
|
|
101
|
+
CardDescription,
|
|
102
|
+
CardContent,
|
|
26
103
|
}
|
package/src/components/input.tsx
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import * as React from "react"
|
|
2
|
+
import { Input as InputPrimitive } from "@base-ui/react/input"
|
|
3
|
+
|
|
2
4
|
import { cn } from "../utils"
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
<
|
|
7
|
-
|
|
6
|
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
7
|
+
return (
|
|
8
|
+
<InputPrimitive
|
|
9
|
+
type={type}
|
|
10
|
+
data-slot="input"
|
|
8
11
|
className={cn(
|
|
9
|
-
"
|
|
10
|
-
className
|
|
12
|
+
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
13
|
+
className
|
|
11
14
|
)}
|
|
12
15
|
{...props}
|
|
13
16
|
/>
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { Input }
|
package/src/components/label.tsx
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
1
3
|
import * as React from "react"
|
|
4
|
+
|
|
2
5
|
import { cn } from "../utils"
|
|
3
6
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
<label
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
|
8
|
+
return (
|
|
9
|
+
<label
|
|
10
|
+
data-slot="label"
|
|
11
|
+
className={cn(
|
|
12
|
+
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
|
13
|
+
className
|
|
14
|
+
)}
|
|
15
|
+
{...props}
|
|
16
|
+
/>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { Label }
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { Toast as ToastPrimitive } from "@base-ui/react/toast"
|
|
5
|
+
|
|
6
|
+
import { ToastList, toastManager, type ToastSwipeDirection } from "./toast"
|
|
7
|
+
|
|
8
|
+
export type ToastPosition =
|
|
9
|
+
| "top-left"
|
|
10
|
+
| "top-center"
|
|
11
|
+
| "top-right"
|
|
12
|
+
| "bottom-left"
|
|
13
|
+
| "bottom-center"
|
|
14
|
+
| "bottom-right"
|
|
15
|
+
|
|
16
|
+
export interface SpuntoProviderProps {
|
|
17
|
+
/** App tree. */
|
|
18
|
+
children?: ReactNode
|
|
19
|
+
/**
|
|
20
|
+
* Where the toast stack docks.
|
|
21
|
+
* @default "top-right"
|
|
22
|
+
*/
|
|
23
|
+
toastPosition?: ToastPosition
|
|
24
|
+
/**
|
|
25
|
+
* Default auto-dismiss delay (ms) for toasts that don't set their own.
|
|
26
|
+
* `0` keeps toasts until dismissed.
|
|
27
|
+
* @default 5000
|
|
28
|
+
*/
|
|
29
|
+
toastDuration?: number
|
|
30
|
+
/**
|
|
31
|
+
* Maximum number of toasts stacked at once. Older toasts collapse behind the
|
|
32
|
+
* newest and animate out past this count.
|
|
33
|
+
* @default 3
|
|
34
|
+
*/
|
|
35
|
+
toastLimit?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Toasts swipe toward the nearest screen edge for the current position.
|
|
39
|
+
const SWIPE_DIRECTIONS: Record<ToastPosition, ToastSwipeDirection[]> = {
|
|
40
|
+
"top-left": ["up", "left"],
|
|
41
|
+
"top-center": ["up"],
|
|
42
|
+
"top-right": ["up", "right"],
|
|
43
|
+
"bottom-left": ["down", "left"],
|
|
44
|
+
"bottom-center": ["down"],
|
|
45
|
+
"bottom-right": ["down", "right"],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Client-side umbrella provider for the Spunto design system. Wrap your app once
|
|
50
|
+
* (in a Next.js `app/layout.tsx`, it's a client component — render it around
|
|
51
|
+
* `{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.
|
|
54
|
+
*
|
|
55
|
+
* ```tsx
|
|
56
|
+
* <SpuntoProvider toastPosition="bottom-right">
|
|
57
|
+
* {children}
|
|
58
|
+
* </SpuntoProvider>
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function SpuntoProvider({
|
|
62
|
+
children,
|
|
63
|
+
toastPosition = "top-right",
|
|
64
|
+
toastDuration = 5000,
|
|
65
|
+
toastLimit = 3,
|
|
66
|
+
}: SpuntoProviderProps) {
|
|
67
|
+
return (
|
|
68
|
+
<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>
|
|
75
|
+
</ToastPrimitive.Provider>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -1,41 +1,32 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
|
4
|
+
|
|
4
5
|
import { cn } from "../utils"
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
checked,
|
|
8
|
-
onCheckedChange,
|
|
7
|
+
function Switch({
|
|
9
8
|
className,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}: {
|
|
13
|
-
|
|
14
|
-
onCheckedChange: (v: boolean) => void
|
|
15
|
-
className?: string
|
|
16
|
-
id?: string
|
|
17
|
-
disabled?: boolean
|
|
9
|
+
size = "default",
|
|
10
|
+
...props
|
|
11
|
+
}: SwitchPrimitive.Root.Props & {
|
|
12
|
+
size?: "sm" | "default"
|
|
18
13
|
}) {
|
|
19
14
|
return (
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
id={id}
|
|
24
|
-
aria-checked={checked}
|
|
25
|
-
disabled={disabled}
|
|
26
|
-
onClick={() => onCheckedChange(!checked)}
|
|
15
|
+
<SwitchPrimitive.Root
|
|
16
|
+
data-slot="switch"
|
|
17
|
+
data-size={size}
|
|
27
18
|
className={cn(
|
|
28
|
-
"relative inline-flex
|
|
29
|
-
|
|
30
|
-
className,
|
|
19
|
+
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
|
20
|
+
className
|
|
31
21
|
)}
|
|
22
|
+
{...props}
|
|
32
23
|
>
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
checked ? "translate-x-4" : "translate-x-0.5",
|
|
37
|
-
)}
|
|
24
|
+
<SwitchPrimitive.Thumb
|
|
25
|
+
data-slot="switch-thumb"
|
|
26
|
+
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
|
38
27
|
/>
|
|
39
|
-
</
|
|
28
|
+
</SwitchPrimitive.Root>
|
|
40
29
|
)
|
|
41
30
|
}
|
|
31
|
+
|
|
32
|
+
export { Switch }
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import * as React from "react"
|
|
2
|
+
|
|
2
3
|
import { cn } from "../utils"
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|
6
|
+
return (
|
|
6
7
|
<textarea
|
|
7
|
-
|
|
8
|
+
data-slot="textarea"
|
|
8
9
|
className={cn(
|
|
9
|
-
"flex min-h-
|
|
10
|
-
className
|
|
10
|
+
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
11
|
+
className
|
|
11
12
|
)}
|
|
12
13
|
{...props}
|
|
13
14
|
/>
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { Textarea }
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { Toast as ToastPrimitive } from "@base-ui/react/toast"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../utils"
|
|
7
|
+
|
|
8
|
+
export type ToastVariant = "success" | "error" | "warning" | "info"
|
|
9
|
+
|
|
10
|
+
/** Optional call-to-action rendered as a button inside the toast. */
|
|
11
|
+
export interface ToastAction {
|
|
12
|
+
label: string
|
|
13
|
+
onClick: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ToastOptions {
|
|
17
|
+
/** Overrides the first positional argument as the toast's heading. */
|
|
18
|
+
title?: ReactNode
|
|
19
|
+
/** Secondary line under the title. */
|
|
20
|
+
description?: ReactNode
|
|
21
|
+
/** Colour + icon. Defaults to a neutral toast when omitted. */
|
|
22
|
+
variant?: ToastVariant
|
|
23
|
+
/**
|
|
24
|
+
* Auto-dismiss delay in ms. `0` keeps the toast until dismissed.
|
|
25
|
+
* Falls back to the `SpuntoProvider` `toastDuration` when omitted.
|
|
26
|
+
*/
|
|
27
|
+
duration?: number
|
|
28
|
+
/** Optional action button. Clicking it also dismisses the toast. */
|
|
29
|
+
action?: ToastAction
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Payload we stash on each Base UI toast so the renderer can style it. */
|
|
33
|
+
export interface SpuntoToastData {
|
|
34
|
+
variant?: ToastVariant
|
|
35
|
+
action?: ToastAction
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Singleton manager shared by the imperative `toast()` API and the
|
|
40
|
+
* `SpuntoProvider` that renders it. Created with Base UI's `createToastManager`
|
|
41
|
+
* so `toast()` is callable from anywhere — event handlers, async code, even
|
|
42
|
+
* outside the React tree.
|
|
43
|
+
*/
|
|
44
|
+
export const toastManager = ToastPrimitive.createToastManager<SpuntoToastData>()
|
|
45
|
+
|
|
46
|
+
function emit(message: ReactNode, options: ToastOptions = {}, variant?: ToastVariant): string {
|
|
47
|
+
const resolved = variant ?? options.variant
|
|
48
|
+
return toastManager.add({
|
|
49
|
+
title: options.title ?? message,
|
|
50
|
+
description: options.description,
|
|
51
|
+
// `undefined` lets the provider's default timeout apply.
|
|
52
|
+
timeout: options.duration,
|
|
53
|
+
type: resolved,
|
|
54
|
+
data: { variant: resolved, action: options.action },
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ToastFn {
|
|
59
|
+
(message: ReactNode, options?: ToastOptions): string
|
|
60
|
+
success: (message: ReactNode, options?: ToastOptions) => string
|
|
61
|
+
error: (message: ReactNode, options?: ToastOptions) => string
|
|
62
|
+
warning: (message: ReactNode, options?: ToastOptions) => string
|
|
63
|
+
info: (message: ReactNode, options?: ToastOptions) => string
|
|
64
|
+
/** Dismiss a specific toast by id, or all toasts when called without one. */
|
|
65
|
+
dismiss: (id?: string) => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Imperative, sonner-style toast API. Import and call from anywhere.
|
|
70
|
+
*
|
|
71
|
+
* ```ts
|
|
72
|
+
* toast.success("Worker ready", { description: "spunto-abc is up" })
|
|
73
|
+
* const id = toast("Deploying…", { duration: 0 })
|
|
74
|
+
* toast.dismiss(id)
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export const toast: ToastFn = Object.assign(
|
|
78
|
+
(message: ReactNode, options?: ToastOptions) => emit(message, options),
|
|
79
|
+
{
|
|
80
|
+
success: (message: ReactNode, options?: ToastOptions) => emit(message, options, "success"),
|
|
81
|
+
error: (message: ReactNode, options?: ToastOptions) => emit(message, options, "error"),
|
|
82
|
+
warning: (message: ReactNode, options?: ToastOptions) => emit(message, options, "warning"),
|
|
83
|
+
info: (message: ReactNode, options?: ToastOptions) => emit(message, options, "info"),
|
|
84
|
+
dismiss: (id?: string) => toastManager.close(id),
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
/** Per-variant accent (icon + left border). Derived from the status tokens. */
|
|
89
|
+
const VARIANT_ACCENT: Record<ToastVariant, string> = {
|
|
90
|
+
success: "text-success border-l-success",
|
|
91
|
+
error: "text-destructive border-l-destructive",
|
|
92
|
+
warning: "text-warning border-l-warning",
|
|
93
|
+
info: "text-info border-l-info",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function VariantIcon({ variant }: { variant?: ToastVariant }) {
|
|
97
|
+
const common = { width: 18, height: 18, viewBox: "0 0 24 24", fill: "none", "aria-hidden": true } as const
|
|
98
|
+
switch (variant) {
|
|
99
|
+
case "success":
|
|
100
|
+
return (
|
|
101
|
+
<svg {...common} stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
102
|
+
<circle cx="12" cy="12" r="9" />
|
|
103
|
+
<path d="m8.5 12 2.5 2.5 4.5-5" />
|
|
104
|
+
</svg>
|
|
105
|
+
)
|
|
106
|
+
case "error":
|
|
107
|
+
return (
|
|
108
|
+
<svg {...common} stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
109
|
+
<circle cx="12" cy="12" r="9" />
|
|
110
|
+
<path d="M15 9l-6 6M9 9l6 6" />
|
|
111
|
+
</svg>
|
|
112
|
+
)
|
|
113
|
+
case "warning":
|
|
114
|
+
return (
|
|
115
|
+
<svg {...common} stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
116
|
+
<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" />
|
|
117
|
+
<path d="M12 9v4M12 17h.01" />
|
|
118
|
+
</svg>
|
|
119
|
+
)
|
|
120
|
+
case "info":
|
|
121
|
+
return (
|
|
122
|
+
<svg {...common} stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
123
|
+
<circle cx="12" cy="12" r="9" />
|
|
124
|
+
<path d="M12 11v5M12 8h.01" />
|
|
125
|
+
</svg>
|
|
126
|
+
)
|
|
127
|
+
default:
|
|
128
|
+
return (
|
|
129
|
+
<svg {...common} stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
130
|
+
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
|
131
|
+
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
|
132
|
+
</svg>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function CloseIcon() {
|
|
138
|
+
return (
|
|
139
|
+
<svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
140
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
141
|
+
</svg>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Renders the live toast stack. Consumes the manager via `useToastManager`, so
|
|
147
|
+
* it must live inside a `Toast.Provider` — `SpuntoProvider` handles that.
|
|
148
|
+
* @internal
|
|
149
|
+
*/
|
|
150
|
+
export type ToastSwipeDirection = "up" | "down" | "left" | "right"
|
|
151
|
+
|
|
152
|
+
export function ToastList({ swipeDirection }: { swipeDirection?: ToastSwipeDirection[] }) {
|
|
153
|
+
const { toasts } = ToastPrimitive.useToastManager<SpuntoToastData>()
|
|
154
|
+
|
|
155
|
+
return toasts.map((item) => {
|
|
156
|
+
const variant = item.data?.variant
|
|
157
|
+
const action = item.data?.action
|
|
158
|
+
return (
|
|
159
|
+
<ToastPrimitive.Root
|
|
160
|
+
key={item.id}
|
|
161
|
+
toast={item}
|
|
162
|
+
swipeDirection={swipeDirection}
|
|
163
|
+
data-slot="toast"
|
|
164
|
+
className={cn(
|
|
165
|
+
"pointer-events-auto rounded-lg border border-border border-l-4 bg-popover text-popover-foreground shadow-lg shadow-black/[0.06] outline-none",
|
|
166
|
+
"focus-visible:ring-2 focus-visible:ring-ring/50",
|
|
167
|
+
variant ? VARIANT_ACCENT[variant] : "border-l-muted-foreground/40"
|
|
168
|
+
)}
|
|
169
|
+
>
|
|
170
|
+
<ToastPrimitive.Content className="flex h-full items-start gap-3 p-3.5 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100">
|
|
171
|
+
<span className={cn("mt-px shrink-0", variant ? VARIANT_ACCENT[variant] : "text-muted-foreground")}>
|
|
172
|
+
<VariantIcon variant={variant} />
|
|
173
|
+
</span>
|
|
174
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
175
|
+
<ToastPrimitive.Title className="text-sm leading-snug font-semibold text-foreground" />
|
|
176
|
+
<ToastPrimitive.Description className="text-sm leading-snug text-muted-foreground" />
|
|
177
|
+
{action && (
|
|
178
|
+
<ToastPrimitive.Action
|
|
179
|
+
className="mt-1.5 inline-flex h-7 w-fit items-center rounded-md border border-border bg-background px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50 outline-none"
|
|
180
|
+
onClick={() => {
|
|
181
|
+
action.onClick()
|
|
182
|
+
toastManager.close(item.id)
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
{action.label}
|
|
186
|
+
</ToastPrimitive.Action>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
<ToastPrimitive.Close
|
|
190
|
+
aria-label="Fermer"
|
|
191
|
+
className="-mt-1 -mr-1 flex size-6 shrink-0 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"
|
|
192
|
+
>
|
|
193
|
+
<CloseIcon />
|
|
194
|
+
</ToastPrimitive.Close>
|
|
195
|
+
</ToastPrimitive.Content>
|
|
196
|
+
</ToastPrimitive.Root>
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,10 +4,20 @@
|
|
|
4
4
|
export { cn } from "./utils"
|
|
5
5
|
export * from "./colors"
|
|
6
6
|
|
|
7
|
-
export { Button
|
|
8
|
-
|
|
7
|
+
export { Button } from "./components/button"
|
|
8
|
+
// `buttonVariants` is exported from its own directive-free module (not through the
|
|
9
|
+
// "use client" button.tsx) so it stays callable from React Server Components.
|
|
10
|
+
export { buttonVariants } from "./components/button-variants"
|
|
11
|
+
export { Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter } from "./components/card"
|
|
9
12
|
export { Input } from "./components/input"
|
|
10
13
|
export { Textarea } from "./components/textarea"
|
|
11
14
|
export { Label } from "./components/label"
|
|
12
15
|
export { Badge, badgeVariants } from "./components/badge"
|
|
13
16
|
export { Switch } from "./components/switch"
|
|
17
|
+
|
|
18
|
+
// Toasts — imperative `toast()` API + the umbrella client provider that renders
|
|
19
|
+
// them. `SpuntoProvider` is the extension point for future overlay features.
|
|
20
|
+
export { SpuntoProvider } from "./components/spunto-provider"
|
|
21
|
+
export type { SpuntoProviderProps, ToastPosition } from "./components/spunto-provider"
|
|
22
|
+
export { toast } from "./components/toast"
|
|
23
|
+
export type { ToastFn, ToastOptions, ToastVariant, ToastAction } from "./components/toast"
|
package/styles.css
CHANGED
|
@@ -33,6 +33,13 @@
|
|
|
33
33
|
--color-input: var(--input);
|
|
34
34
|
--color-border: var(--border);
|
|
35
35
|
--color-destructive: var(--destructive);
|
|
36
|
+
/* Status semantics — used by feedback surfaces (Toast…) */
|
|
37
|
+
--color-success: var(--success);
|
|
38
|
+
--color-success-foreground: var(--success-foreground);
|
|
39
|
+
--color-warning: var(--warning);
|
|
40
|
+
--color-warning-foreground: var(--warning-foreground);
|
|
41
|
+
--color-info: var(--info);
|
|
42
|
+
--color-info-foreground: var(--info-foreground);
|
|
36
43
|
--color-accent-foreground: var(--accent-foreground);
|
|
37
44
|
--color-accent: var(--accent);
|
|
38
45
|
--color-muted-foreground: var(--muted-foreground);
|
|
@@ -77,6 +84,13 @@
|
|
|
77
84
|
--accent: oklch(0.935 0.024 72);
|
|
78
85
|
--accent-foreground: oklch(0.34 0.045 52);
|
|
79
86
|
--destructive: oklch(0.577 0.245 27.325);
|
|
87
|
+
/* Status semantics — warm-leaning green / amber / blue, tuned for the cream bg */
|
|
88
|
+
--success: oklch(0.62 0.145 152);
|
|
89
|
+
--success-foreground: oklch(0.99 0.012 86);
|
|
90
|
+
--warning: oklch(0.74 0.16 68);
|
|
91
|
+
--warning-foreground: oklch(0.24 0.05 60);
|
|
92
|
+
--info: oklch(0.58 0.13 245);
|
|
93
|
+
--info-foreground: oklch(0.99 0.012 86);
|
|
80
94
|
--border: oklch(0.89 0.016 76);
|
|
81
95
|
--input: oklch(0.89 0.016 76);
|
|
82
96
|
--ring: oklch(0.64 0.205 41);
|
|
@@ -122,6 +136,13 @@
|
|
|
122
136
|
--accent: oklch(0.28 0.018 54);
|
|
123
137
|
--accent-foreground: oklch(0.95 0.009 78);
|
|
124
138
|
--destructive: oklch(0.704 0.191 22.216);
|
|
139
|
+
/* Status semantics — lifted for the warm charcoal bg */
|
|
140
|
+
--success: oklch(0.72 0.15 154);
|
|
141
|
+
--success-foreground: oklch(0.16 0.012 52);
|
|
142
|
+
--warning: oklch(0.80 0.155 72);
|
|
143
|
+
--warning-foreground: oklch(0.16 0.012 52);
|
|
144
|
+
--info: oklch(0.68 0.13 240);
|
|
145
|
+
--info-foreground: oklch(0.16 0.012 52);
|
|
125
146
|
--border: oklch(0.92 0.03 70 / 11%);
|
|
126
147
|
--input: oklch(0.92 0.03 70 / 14%);
|
|
127
148
|
--ring: oklch(0.70 0.20 43);
|
|
@@ -184,3 +205,133 @@
|
|
|
184
205
|
linear-gradient(to right, oklch(0.92 0.04 70 / 0.04) 1px, transparent 1px),
|
|
185
206
|
linear-gradient(to bottom, oklch(0.92 0.04 70 / 0.04) 1px, transparent 1px);
|
|
186
207
|
}
|
|
208
|
+
|
|
209
|
+
/* ─── Toast — stacking + enter/exit motion (mounted by SpuntoProvider) ─────
|
|
210
|
+
*
|
|
211
|
+
* The look (colors, spacing, typography) lives on the components as Tailwind
|
|
212
|
+
* utilities; this block owns only the parts Tailwind can't express cleanly —
|
|
213
|
+
* the position-aware stacking math driven by the CSS vars Base UI sets on each
|
|
214
|
+
* toast (`--toast-index`, `--toast-offset-y`, `--toast-frontmost-height`,
|
|
215
|
+
* `--toast-swipe-movement-*`). `data-position` on the viewport flips the whole
|
|
216
|
+
* system between the top and bottom anchors. Adapted from the Base UI toast
|
|
217
|
+
* reference example. */
|
|
218
|
+
[data-slot="toast-viewport"] {
|
|
219
|
+
--toast-gap: 0.75rem;
|
|
220
|
+
--toast-peek: 0.6rem;
|
|
221
|
+
position: fixed;
|
|
222
|
+
z-index: 60;
|
|
223
|
+
display: flex;
|
|
224
|
+
width: min(24rem, calc(100vw - 2rem));
|
|
225
|
+
}
|
|
226
|
+
[data-slot="toast-viewport"][data-position$="right"] { right: 1.25rem; left: auto; }
|
|
227
|
+
[data-slot="toast-viewport"][data-position$="left"] { left: 1.25rem; right: auto; }
|
|
228
|
+
[data-slot="toast-viewport"][data-position$="center"] {
|
|
229
|
+
left: 50%;
|
|
230
|
+
right: auto;
|
|
231
|
+
transform: translateX(-50%);
|
|
232
|
+
}
|
|
233
|
+
[data-slot="toast-viewport"][data-position^="top"] { top: 1.25rem; bottom: auto; }
|
|
234
|
+
[data-slot="toast-viewport"][data-position^="bottom"] { bottom: 1.25rem; top: auto; }
|
|
235
|
+
|
|
236
|
+
[data-slot="toast"] {
|
|
237
|
+
--scale: max(0, 1 - (var(--toast-index) * 0.1));
|
|
238
|
+
--shrink: calc(1 - var(--scale));
|
|
239
|
+
--height: var(--toast-frontmost-height, var(--toast-height));
|
|
240
|
+
position: absolute;
|
|
241
|
+
right: 0;
|
|
242
|
+
left: auto;
|
|
243
|
+
width: 100%;
|
|
244
|
+
box-sizing: border-box;
|
|
245
|
+
z-index: calc(1000 - var(--toast-index));
|
|
246
|
+
height: var(--height);
|
|
247
|
+
transition:
|
|
248
|
+
transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
|
|
249
|
+
opacity 0.4s ease,
|
|
250
|
+
height 0.2s ease;
|
|
251
|
+
}
|
|
252
|
+
/* Invisible bridge so the pointer can travel across the gap between toasts
|
|
253
|
+
without the stack collapsing. */
|
|
254
|
+
[data-slot="toast"]::after {
|
|
255
|
+
content: "";
|
|
256
|
+
position: absolute;
|
|
257
|
+
left: 0;
|
|
258
|
+
width: 100%;
|
|
259
|
+
height: calc(var(--toast-gap) + 1px);
|
|
260
|
+
}
|
|
261
|
+
[data-slot="toast"][data-limited],
|
|
262
|
+
[data-slot="toast"][data-starting-style],
|
|
263
|
+
[data-slot="toast"][data-ending-style] { opacity: 0; }
|
|
264
|
+
|
|
265
|
+
/* Bottom-anchored (default) — newest at the bottom, older peek upward. */
|
|
266
|
+
[data-position^="bottom"] [data-slot="toast"] {
|
|
267
|
+
bottom: 0;
|
|
268
|
+
transform-origin: bottom center;
|
|
269
|
+
--offset-y: calc(
|
|
270
|
+
var(--toast-offset-y) * -1 + (var(--toast-index) * var(--toast-gap) * -1) +
|
|
271
|
+
var(--toast-swipe-movement-y)
|
|
272
|
+
);
|
|
273
|
+
transform:
|
|
274
|
+
translateX(var(--toast-swipe-movement-x))
|
|
275
|
+
translateY(
|
|
276
|
+
calc(
|
|
277
|
+
var(--toast-swipe-movement-y) - (var(--toast-index) * var(--toast-peek)) -
|
|
278
|
+
(var(--shrink) * var(--height))
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
scale(var(--scale));
|
|
282
|
+
}
|
|
283
|
+
[data-position^="bottom"] [data-slot="toast"]::after { top: 100%; }
|
|
284
|
+
[data-position^="bottom"] [data-slot="toast"][data-expanded] {
|
|
285
|
+
transform: translateX(var(--toast-swipe-movement-x)) translateY(var(--offset-y));
|
|
286
|
+
height: var(--toast-height);
|
|
287
|
+
}
|
|
288
|
+
[data-position^="bottom"] [data-slot="toast"][data-starting-style],
|
|
289
|
+
[data-position^="bottom"] [data-slot="toast"][data-ending-style]:not([data-swipe-direction]) {
|
|
290
|
+
transform: translateY(150%);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* Top-anchored — newest at the top, older peek downward. */
|
|
294
|
+
[data-position^="top"] [data-slot="toast"] {
|
|
295
|
+
top: 0;
|
|
296
|
+
transform-origin: top center;
|
|
297
|
+
--offset-y: calc(
|
|
298
|
+
var(--toast-offset-y) + (var(--toast-index) * var(--toast-gap)) +
|
|
299
|
+
var(--toast-swipe-movement-y)
|
|
300
|
+
);
|
|
301
|
+
transform:
|
|
302
|
+
translateX(var(--toast-swipe-movement-x))
|
|
303
|
+
translateY(
|
|
304
|
+
calc(
|
|
305
|
+
var(--toast-swipe-movement-y) + (var(--toast-index) * var(--toast-peek)) +
|
|
306
|
+
(var(--shrink) * var(--height))
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
scale(var(--scale));
|
|
310
|
+
}
|
|
311
|
+
[data-position^="top"] [data-slot="toast"]::after { bottom: 100%; }
|
|
312
|
+
[data-position^="top"] [data-slot="toast"][data-expanded] {
|
|
313
|
+
transform: translateX(var(--toast-swipe-movement-x)) translateY(var(--offset-y));
|
|
314
|
+
height: var(--toast-height);
|
|
315
|
+
}
|
|
316
|
+
[data-position^="top"] [data-slot="toast"][data-starting-style],
|
|
317
|
+
[data-position^="top"] [data-slot="toast"][data-ending-style]:not([data-swipe-direction]) {
|
|
318
|
+
transform: translateY(-150%);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* Swipe-to-dismiss exits — fly out toward the swiped edge (both anchors). */
|
|
322
|
+
[data-slot="toast"][data-ending-style][data-swipe-direction="up"] {
|
|
323
|
+
transform: translateY(calc(var(--toast-swipe-movement-y) - 150%));
|
|
324
|
+
}
|
|
325
|
+
[data-slot="toast"][data-ending-style][data-swipe-direction="down"] {
|
|
326
|
+
transform: translateY(calc(var(--toast-swipe-movement-y) + 150%));
|
|
327
|
+
}
|
|
328
|
+
[data-slot="toast"][data-ending-style][data-swipe-direction="left"] {
|
|
329
|
+
transform: translateX(calc(var(--toast-swipe-movement-x) - 150%)) translateY(var(--offset-y));
|
|
330
|
+
}
|
|
331
|
+
[data-slot="toast"][data-ending-style][data-swipe-direction="right"] {
|
|
332
|
+
transform: translateX(calc(var(--toast-swipe-movement-x) + 150%)) translateY(var(--offset-y));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
@media (prefers-reduced-motion: reduce) {
|
|
336
|
+
[data-slot="toast"] { transition-duration: 0.01ms; }
|
|
337
|
+
}
|