@spunto/design-system 0.2.1 → 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 +1 -1
- package/src/components/spunto-provider.tsx +77 -0
- package/src/components/toast.tsx +199 -0
- package/src/index.ts +7 -0
- 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
|
@@ -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
|
+
}
|
|
@@ -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
|
@@ -14,3 +14,10 @@ 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
|
+
|
|
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
|
+
}
|