@shadow-js/ui 0.0.0-stage → 1.0.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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +598 -2
- package/dist/cli.mjs +2293 -0
- package/dist/dev/cli/schema.d.ts +73 -0
- package/dist/dev/index.d.ts +6 -0
- package/dist/dev/index.js +5 -0
- package/dist/dev/locales/ar.d.ts +141 -0
- package/dist/dev/locales/ar.js +128 -0
- package/dist/dev/locales/ar.js.map +1 -0
- package/dist/dev/locales/en.d.ts +141 -0
- package/dist/dev/locales/en.js +128 -0
- package/dist/dev/locales/en.js.map +1 -0
- package/dist/dev/registry/utils/utils.d.ts +29 -0
- package/dist/dev/registry/utils/utils.js +27 -0
- package/dist/dev/registry/utils/utils.js.map +1 -0
- package/dist/dev/theme/theme-logic.d.ts +187 -0
- package/dist/dev/theme/theme-logic.js +184 -0
- package/dist/dev/theme/theme-logic.js.map +1 -0
- package/dist/dev/theme/theme-provider.d.ts +90 -0
- package/dist/dev/theme/theme-provider.js +150 -0
- package/dist/dev/theme/theme-provider.js.map +1 -0
- package/dist/prod/index.js +1 -0
- package/dist/prod/locales/ar.js +2 -0
- package/dist/prod/locales/ar.js.map +1 -0
- package/dist/prod/locales/en.js +2 -0
- package/dist/prod/locales/en.js.map +1 -0
- package/dist/prod/registry/utils/utils.js +2 -0
- package/dist/prod/registry/utils/utils.js.map +1 -0
- package/dist/prod/theme/theme-logic.js +2 -0
- package/dist/prod/theme/theme-logic.js.map +1 -0
- package/dist/prod/theme/theme-provider.js +2 -0
- package/dist/prod/theme/theme-provider.js.map +1 -0
- package/package.json +102 -4
- package/registry/accordion/accordion.tsx +210 -0
- package/registry/accordion/item.json +4 -0
- package/registry/alert/alert.tsx +200 -0
- package/registry/alert/item.json +4 -0
- package/registry/alert-dialog/alert-dialog.tsx +530 -0
- package/registry/alert-dialog/item.json +4 -0
- package/registry/aspect-ratio/aspect-ratio.tsx +71 -0
- package/registry/aspect-ratio/item.json +4 -0
- package/registry/attachment/attachment.tsx +459 -0
- package/registry/attachment/item.json +4 -0
- package/registry/avatar/avatar.tsx +385 -0
- package/registry/avatar/item.json +4 -0
- package/registry/badge/badge.tsx +108 -0
- package/registry/badge/item.json +4 -0
- package/registry/breadcrumb/breadcrumb.tsx +354 -0
- package/registry/breadcrumb/item.json +4 -0
- package/registry/bubble/bubble.tsx +260 -0
- package/registry/bubble/item.json +4 -0
- package/registry/button/button.tsx +125 -0
- package/registry/button/item.json +4 -0
- package/registry/button-group/button-group.tsx +189 -0
- package/registry/button-group/item.json +4 -0
- package/registry/calendar/calendar.tsx +348 -0
- package/registry/calendar/item.json +4 -0
- package/registry/card/card.tsx +311 -0
- package/registry/card/item.json +4 -0
- package/registry/carousel/carousel.tsx +851 -0
- package/registry/carousel/item.json +4 -0
- package/registry/chart/chart.tsx +1956 -0
- package/registry/chart/item.json +4 -0
- package/registry/chart-scale/chart-scale.ts +670 -0
- package/registry/chart-scale/item.json +5 -0
- package/registry/checkbox/checkbox.tsx +79 -0
- package/registry/checkbox/item.json +4 -0
- package/registry/code-block/code-block.tsx +470 -0
- package/registry/code-block/item.json +4 -0
- package/registry/collapsible/collapsible.tsx +117 -0
- package/registry/collapsible/item.json +4 -0
- package/registry/combobox/combobox.tsx +977 -0
- package/registry/combobox/item.json +4 -0
- package/registry/command/command.tsx +757 -0
- package/registry/command/item.json +4 -0
- package/registry/context-menu/context-menu.tsx +697 -0
- package/registry/context-menu/item.json +4 -0
- package/registry/data-table/data-table.tsx +437 -0
- package/registry/data-table/item.json +4 -0
- package/registry/date-picker/date-picker.tsx +349 -0
- package/registry/date-picker/item.json +4 -0
- package/registry/dialog/dialog.tsx +412 -0
- package/registry/dialog/item.json +4 -0
- package/registry/direction/direction.tsx +88 -0
- package/registry/direction/item.json +4 -0
- package/registry/drawer/drawer.tsx +787 -0
- package/registry/drawer/item.json +4 -0
- package/registry/dropdown-menu/dropdown-menu.tsx +692 -0
- package/registry/dropdown-menu/item.json +4 -0
- package/registry/empty/empty.tsx +240 -0
- package/registry/empty/item.json +4 -0
- package/registry/field/field.tsx +624 -0
- package/registry/field/item.json +4 -0
- package/registry/form/form.tsx +1112 -0
- package/registry/form/item.json +4 -0
- package/registry/hover-card/hover-card.tsx +144 -0
- package/registry/hover-card/item.json +4 -0
- package/registry/icons/icons.tsx +1691 -0
- package/registry/icons/item.json +4 -0
- package/registry/input/input.tsx +43 -0
- package/registry/input/item.json +4 -0
- package/registry/input-group/input-group.tsx +315 -0
- package/registry/input-group/item.json +4 -0
- package/registry/input-otp/input-otp.tsx +329 -0
- package/registry/input-otp/item.json +4 -0
- package/registry/item/item.json +4 -0
- package/registry/item/item.tsx +439 -0
- package/registry/kbd/item.json +4 -0
- package/registry/kbd/kbd.tsx +65 -0
- package/registry/label/item.json +4 -0
- package/registry/label/label.tsx +60 -0
- package/registry/marker/item.json +4 -0
- package/registry/marker/marker.tsx +175 -0
- package/registry/menubar/item.json +4 -0
- package/registry/menubar/menubar.tsx +694 -0
- package/registry/message/item.json +4 -0
- package/registry/message/message.tsx +280 -0
- package/registry/message-scroller/item.json +4 -0
- package/registry/message-scroller/message-scroller.tsx +262 -0
- package/registry/native-select/item.json +4 -0
- package/registry/native-select/native-select.tsx +113 -0
- package/registry/navigation-menu/item.json +4 -0
- package/registry/navigation-menu/navigation-menu.tsx +362 -0
- package/registry/pagination/item.json +4 -0
- package/registry/pagination/pagination.tsx +460 -0
- package/registry/password-input/item.json +5 -0
- package/registry/password-input/password-input.tsx +494 -0
- package/registry/popover/item.json +4 -0
- package/registry/popover/popover.tsx +284 -0
- package/registry/progress/item.json +4 -0
- package/registry/progress/progress.tsx +190 -0
- package/registry/questionnaire/item.json +4 -0
- package/registry/questionnaire/questionnaire.tsx +569 -0
- package/registry/radio-group/item.json +4 -0
- package/registry/radio-group/radio-group.tsx +101 -0
- package/registry/resizable/item.json +4 -0
- package/registry/resizable/resizable.tsx +455 -0
- package/registry/scroll-area/item.json +4 -0
- package/registry/scroll-area/scroll-area.tsx +149 -0
- package/registry/select/item.json +4 -0
- package/registry/select/select.tsx +571 -0
- package/registry/separator/item.json +4 -0
- package/registry/separator/separator.tsx +52 -0
- package/registry/sheet/item.json +4 -0
- package/registry/sheet/sheet.tsx +476 -0
- package/registry/sidebar/item.json +4 -0
- package/registry/sidebar/sidebar.tsx +1870 -0
- package/registry/skeleton/item.json +4 -0
- package/registry/skeleton/skeleton.tsx +47 -0
- package/registry/slider/item.json +4 -0
- package/registry/slider/slider.tsx +149 -0
- package/registry/spinner/item.json +4 -0
- package/registry/spinner/spinner.tsx +51 -0
- package/registry/switch/item.json +4 -0
- package/registry/switch/switch.tsx +93 -0
- package/registry/table/item.json +4 -0
- package/registry/table/table.tsx +373 -0
- package/registry/tabs/item.json +4 -0
- package/registry/tabs/tabs.tsx +209 -0
- package/registry/textarea/item.json +4 -0
- package/registry/textarea/textarea.tsx +51 -0
- package/registry/toast/item.json +4 -0
- package/registry/toast/toast.tsx +949 -0
- package/registry/toggle/item.json +4 -0
- package/registry/toggle/toggle.tsx +110 -0
- package/registry/toggle-group/item.json +4 -0
- package/registry/toggle-group/toggle-group.tsx +189 -0
- package/registry/tooltip/item.json +4 -0
- package/registry/tooltip/tooltip.tsx +169 -0
- package/registry/trace/item.json +4 -0
- package/registry/trace/trace.tsx +356 -0
- package/registry/ui-strings/item.json +5 -0
- package/registry/ui-strings/ui-strings.tsx +454 -0
- package/registry/use-highlighted-code/item.json +5 -0
- package/registry/use-highlighted-code/use-highlighted-code.tsx +145 -0
- package/registry/use-mobile/item.json +5 -0
- package/registry/use-mobile/use-mobile.ts +46 -0
- package/registry/utils/item.json +5 -0
- package/registry/utils/utils.ts +38 -0
- package/registry.json +3274 -0
- package/styles/animations.css +321 -0
- package/styles/theme-core.css +55 -0
- package/styles/theme.css +12 -0
- package/styles/themes/gray.css +69 -0
- package/styles/themes/neutral.css +69 -0
- package/styles/themes/slate.css +69 -0
- package/styles/themes/stone.css +69 -0
- package/styles/themes/zinc.css +69 -0
- package/styles/typeset.css +251 -0
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Accessor,
|
|
3
|
+
createContext,
|
|
4
|
+
isServer,
|
|
5
|
+
onMount,
|
|
6
|
+
splitProps,
|
|
7
|
+
untrack,
|
|
8
|
+
useContext,
|
|
9
|
+
useEffect,
|
|
10
|
+
} from "@shadow-js/core";
|
|
11
|
+
import {
|
|
12
|
+
createToaster as createQueue,
|
|
13
|
+
type SwipeDirection,
|
|
14
|
+
type ToastItem,
|
|
15
|
+
type ToastPatch,
|
|
16
|
+
Toast as ToastPrimitive,
|
|
17
|
+
type Toaster as ToastQueue,
|
|
18
|
+
type ToastType,
|
|
19
|
+
useDirection,
|
|
20
|
+
} from "@shadow-js/primitives";
|
|
21
|
+
import type { ComponentProps, JSX, JsxChild } from "@shadow-js/web";
|
|
22
|
+
import { For, Show } from "@shadow-js/web";
|
|
23
|
+
import { buttonVariants } from "@/components/ui/button";
|
|
24
|
+
import {
|
|
25
|
+
CircleCheckIcon,
|
|
26
|
+
CircleXIcon,
|
|
27
|
+
InfoIcon,
|
|
28
|
+
LoaderIcon,
|
|
29
|
+
TriangleAlertIcon,
|
|
30
|
+
XIcon,
|
|
31
|
+
} from "@/components/ui/icons";
|
|
32
|
+
import { useUiStrings } from "@/lib/ui-strings";
|
|
33
|
+
import { cn } from "@/lib/utils";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The look of a toast: `default` has no icon, the others show their status icon.
|
|
37
|
+
* @since 1.0.0
|
|
38
|
+
* @see {@link ToastApi}
|
|
39
|
+
*/
|
|
40
|
+
export type ToastVariant = "default" | "success" | "error" | "warning" | "info" | "loading";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Where the toasts appear. `left` and `right` are the inline start and end, so they swap in
|
|
44
|
+
* right-to-left layouts.
|
|
45
|
+
* @since 1.0.0
|
|
46
|
+
* @see {@link ToasterProps}
|
|
47
|
+
*/
|
|
48
|
+
export type ToastPosition =
|
|
49
|
+
| "top-left"
|
|
50
|
+
| "top-center"
|
|
51
|
+
| "top-right"
|
|
52
|
+
| "bottom-left"
|
|
53
|
+
| "bottom-center"
|
|
54
|
+
| "bottom-right";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A button inside a toast.
|
|
58
|
+
* @since 1.0.0
|
|
59
|
+
* @see {@link ExternalToast}
|
|
60
|
+
*/
|
|
61
|
+
export interface ToastAction {
|
|
62
|
+
/** The text (or content) of the button. */
|
|
63
|
+
readonly label: JsxChild;
|
|
64
|
+
/**
|
|
65
|
+
* What screen readers hear in place of the button when the toast is announced. Default: the
|
|
66
|
+
* label when it is text.
|
|
67
|
+
*/
|
|
68
|
+
readonly altText?: string | undefined;
|
|
69
|
+
/** Called on click. Calling `event.preventDefault()` keeps the toast open. */
|
|
70
|
+
readonly onClick?: ((event: MouseEvent) => void) | undefined;
|
|
71
|
+
/** Native button attributes, including disabled state, names and classes. @default none */
|
|
72
|
+
readonly buttonProps?: Omit<ComponentProps<"button">, "children" | "onClick"> | undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Button props accepted by object-style toast actions. Calling `preventDefault` keeps the toast
|
|
77
|
+
* open. The action's text is used for announcements unless `altText` is supplied.
|
|
78
|
+
* @see {@link ToastAddOptions}
|
|
79
|
+
* @since 1.0.0
|
|
80
|
+
*/
|
|
81
|
+
export interface ToastActionProps extends Omit<ComponentProps<"button">, "children" | "onClick"> {
|
|
82
|
+
/** Content of the action button. */
|
|
83
|
+
readonly children: JsxChild;
|
|
84
|
+
/** Text used when announcing the action. @default the textual children */
|
|
85
|
+
readonly altText?: string | undefined;
|
|
86
|
+
/** Runs before dismissal; may prevent it. @default none */
|
|
87
|
+
readonly onClick?: ((event: MouseEvent) => void) | undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The toast handed to `onDismiss` and `onAutoClose`.
|
|
92
|
+
* @since 1.0.0
|
|
93
|
+
* @see {@link ExternalToast}
|
|
94
|
+
*/
|
|
95
|
+
export interface ToastSummary {
|
|
96
|
+
/** The id of the toast (the one the toast call returned). */
|
|
97
|
+
readonly id: string;
|
|
98
|
+
/** The message the toast was raised with. */
|
|
99
|
+
readonly title: JsxChild | undefined;
|
|
100
|
+
/** The second line the toast was raised with, if any. */
|
|
101
|
+
readonly description: JsxChild | undefined;
|
|
102
|
+
/** The variant the toast was raised with. */
|
|
103
|
+
readonly variant: ToastVariant;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Options of the toast calls and their variants.
|
|
108
|
+
* @since 1.0.0
|
|
109
|
+
* @see {@link ToastApi}
|
|
110
|
+
*/
|
|
111
|
+
export interface ExternalToast {
|
|
112
|
+
/** Use this id; calling again with the id of an open toast updates that toast. */
|
|
113
|
+
readonly id?: string | number | undefined;
|
|
114
|
+
/** Second line, below the message. */
|
|
115
|
+
readonly description?: JsxChild | undefined;
|
|
116
|
+
/**
|
|
117
|
+
* Milliseconds before it closes itself; `Infinity` keeps it until it is dismissed. Default: the
|
|
118
|
+
* `Toaster`'s `duration`, or `Infinity` for a `toast.loading` toast.
|
|
119
|
+
*/
|
|
120
|
+
readonly duration?: number | undefined;
|
|
121
|
+
/** A button that runs `onClick` and closes the toast. */
|
|
122
|
+
readonly action?: ToastAction | undefined;
|
|
123
|
+
/** Native action button props. `action` takes precedence when both are supplied. @default none */
|
|
124
|
+
readonly actionProps?: ToastActionProps | undefined;
|
|
125
|
+
/** A button that closes the toast, running `onClick` first. */
|
|
126
|
+
readonly cancel?: ToastAction | undefined;
|
|
127
|
+
/** Replaces the status icon. */
|
|
128
|
+
readonly icon?: JsxChild | undefined;
|
|
129
|
+
/** Show or hide the close button of this toast (default: the `Toaster`'s `closeButton`). */
|
|
130
|
+
readonly closeButton?: boolean | undefined;
|
|
131
|
+
/** Announce it assertively, interrupting the screen reader. */
|
|
132
|
+
readonly important?: boolean | undefined;
|
|
133
|
+
/** Classes added to this toast. */
|
|
134
|
+
readonly class?: string | undefined;
|
|
135
|
+
/** The user closed it (close button, swipe, Escape) or `toast.dismiss()` did. */
|
|
136
|
+
readonly onDismiss?: ((toast: ToastSummary) => void) | undefined;
|
|
137
|
+
/** The timer closed it. */
|
|
138
|
+
readonly onAutoClose?: ((toast: ToastSummary) => void) | undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Options of `toast.add`: the title, status type and the same options as the callable toast API.
|
|
143
|
+
* @see {@link ToastApi}
|
|
144
|
+
* @since 1.0.0
|
|
145
|
+
*/
|
|
146
|
+
export interface ToastAddOptions extends ExternalToast {
|
|
147
|
+
/** The main message. @default none */
|
|
148
|
+
readonly title?: JsxChild | undefined;
|
|
149
|
+
/** Status icon and announcement urgency. @default "default" */
|
|
150
|
+
readonly type?: ToastVariant | undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** What each toast carries through the queue. */
|
|
154
|
+
interface ToastData {
|
|
155
|
+
readonly variant: ToastVariant;
|
|
156
|
+
readonly action: ToastAction | undefined;
|
|
157
|
+
readonly cancel: ToastAction | undefined;
|
|
158
|
+
readonly icon: JsxChild | undefined;
|
|
159
|
+
readonly closeButton: boolean | undefined;
|
|
160
|
+
readonly class: string | undefined;
|
|
161
|
+
readonly onDismiss: ((toast: ToastSummary) => void) | undefined;
|
|
162
|
+
readonly onAutoClose: ((toast: ToastSummary) => void) | undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type Queue = ToastQueue<ToastData>;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Calls made on a store before a `Toaster` mounts on it wait, at most this many; the oldest are
|
|
169
|
+
* dropped first.
|
|
170
|
+
*/
|
|
171
|
+
const MAX_PENDING = 16;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* What one state of a promise toast is: a string, or an object with the message and more.
|
|
175
|
+
* @since 1.0.0
|
|
176
|
+
* @see {@link ToastPromiseData}
|
|
177
|
+
*/
|
|
178
|
+
export type ToastPromiseContent =
|
|
179
|
+
| string
|
|
180
|
+
| number
|
|
181
|
+
| (ExternalToast & {
|
|
182
|
+
/** The text (or content) of the toast. */
|
|
183
|
+
readonly message: JsxChild;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The messages of `toast.promise`.
|
|
188
|
+
* @since 1.0.0
|
|
189
|
+
* @see {@link ToastApi}
|
|
190
|
+
*/
|
|
191
|
+
export interface ToastPromiseData<T> {
|
|
192
|
+
/** Shown while the promise is pending. */
|
|
193
|
+
readonly loading: ToastPromiseContent;
|
|
194
|
+
/** Shown when it resolves; may be computed from the value. */
|
|
195
|
+
readonly success: ToastPromiseContent | ((value: T) => ToastPromiseContent);
|
|
196
|
+
/** Shown when it rejects; may be computed from the reason. */
|
|
197
|
+
readonly error: ToastPromiseContent | ((reason: unknown) => ToastPromiseContent);
|
|
198
|
+
/** Second line shown in every state that does not bring its own. */
|
|
199
|
+
readonly description?: JsxChild | undefined;
|
|
200
|
+
/** Runs after the promise settled, whatever the outcome. */
|
|
201
|
+
readonly finally?: (() => void) | undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* What `toast.promise` returns.
|
|
206
|
+
* @since 1.0.0
|
|
207
|
+
* @see {@link ToastApi}
|
|
208
|
+
*/
|
|
209
|
+
export interface ToastPromiseResult<T> {
|
|
210
|
+
/** The id of the toast that follows the promise. */
|
|
211
|
+
readonly id: string;
|
|
212
|
+
/** The promise that was passed in; a rejection reaches whoever awaits it. */
|
|
213
|
+
unwrap(): Promise<T>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The toast API: call it to show a toast, or use its variants. It is the object {@link useToast}
|
|
218
|
+
* returns and the `toast` of a {@link ToastStore}.
|
|
219
|
+
*
|
|
220
|
+
* `toast(message, options?)` shows a toast and returns its id. `toast.success`, `toast.error`,
|
|
221
|
+
* `toast.warning`, `toast.info`, `toast.loading` (which stays until it is replaced or dismissed,
|
|
222
|
+
* since its duration is infinite by default) and `toast.message` (the same as calling `toast`) set
|
|
223
|
+
* the look. `toast.promise(promise, { loading, success, error })` shows a loading toast that becomes
|
|
224
|
+
* a success or an error toast when the promise settles, and returns the `id` and `unwrap()`.
|
|
225
|
+
* `toast.add({ title, type, actionProps })` offers the object-style API; `toast.close(id?)` aliases
|
|
226
|
+
* `toast.dismiss(id?)`, closing one toast or all of them without an id. Calling a variant again with
|
|
227
|
+
* the `id` of an open toast updates that toast.
|
|
228
|
+
*
|
|
229
|
+
* Calls wait until a `Toaster` on the store is mounted (see {@link createToaster}) and do nothing on
|
|
230
|
+
* the server. Keyboard and ARIA: see {@link Toaster}.
|
|
231
|
+
*
|
|
232
|
+
* shadcn/ui differences: the API is the toast function itself, not a `{ toasts, toast, dismiss }`
|
|
233
|
+
* object, and there is no global store: each `Toaster` shows the toasts of its own store.
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* ```tsx
|
|
237
|
+
* import { Button } from "@/components/ui/button";
|
|
238
|
+
* import { createToaster, useToast } from "@/components/ui/toast";
|
|
239
|
+
*
|
|
240
|
+
* // Module-level code has no component to call useToast() from: it uses a store of its own.
|
|
241
|
+
* export const notifications = createToaster();
|
|
242
|
+
*
|
|
243
|
+
* export function saveDraft(): void {
|
|
244
|
+
* notifications.toast.success("Draft saved");
|
|
245
|
+
* }
|
|
246
|
+
*
|
|
247
|
+
* // Inside a component, useToast() returns the toast API of the nearest Toaster.
|
|
248
|
+
* export function SaveButton() {
|
|
249
|
+
* const toast = useToast();
|
|
250
|
+
* return (
|
|
251
|
+
* <Button
|
|
252
|
+
* onClick={() => {
|
|
253
|
+
* toast("Event created", { description: "Friday at 5:00 PM" });
|
|
254
|
+
* toast.promise(Promise.resolve("saved"), {
|
|
255
|
+
* loading: "Saving...",
|
|
256
|
+
* success: "Saved",
|
|
257
|
+
* error: "Could not save",
|
|
258
|
+
* });
|
|
259
|
+
* }}
|
|
260
|
+
* >
|
|
261
|
+
* Save
|
|
262
|
+
* </Button>
|
|
263
|
+
* );
|
|
264
|
+
* }
|
|
265
|
+
* ```
|
|
266
|
+
* @since 1.0.0
|
|
267
|
+
* @see {@link useToast}
|
|
268
|
+
* @see {@link createToaster}
|
|
269
|
+
* @see {@link Toaster}
|
|
270
|
+
*/
|
|
271
|
+
export interface ToastApi {
|
|
272
|
+
/** Shows a toast with the default look and returns its id. */
|
|
273
|
+
(message: JsxChild, options?: ExternalToast): string;
|
|
274
|
+
/** Shows or updates an object-style toast and returns its id. */
|
|
275
|
+
add(options: ToastAddOptions): string;
|
|
276
|
+
/** Closes a toast by id, or all toasts when omitted. Same behavior as `dismiss`. */
|
|
277
|
+
close(id?: string | number): void;
|
|
278
|
+
/** The same as calling the toast function. */
|
|
279
|
+
message(message: JsxChild, options?: ExternalToast): string;
|
|
280
|
+
/** Shows a toast with a status icon for success. */
|
|
281
|
+
success(message: JsxChild, options?: ExternalToast): string;
|
|
282
|
+
/** Shows a toast with a status icon for an error. Errors are announced assertively. */
|
|
283
|
+
error(message: JsxChild, options?: ExternalToast): string;
|
|
284
|
+
/** Shows a toast with a status icon for a warning. Warnings are announced assertively. */
|
|
285
|
+
warning(message: JsxChild, options?: ExternalToast): string;
|
|
286
|
+
/** Shows a toast with a status icon for information. */
|
|
287
|
+
info(message: JsxChild, options?: ExternalToast): string;
|
|
288
|
+
/** Shows a spinning toast that stays until it is replaced or dismissed. */
|
|
289
|
+
loading(message: JsxChild, options?: ExternalToast): string;
|
|
290
|
+
/**
|
|
291
|
+
* Shows a loading toast that becomes a success or an error toast when `promise` settles. A
|
|
292
|
+
* rejection still reaches whoever awaits `unwrap()`.
|
|
293
|
+
*/
|
|
294
|
+
promise<T>(
|
|
295
|
+
promise: Promise<T> | (() => Promise<T>),
|
|
296
|
+
data: ToastPromiseData<T>,
|
|
297
|
+
options?: ExternalToast,
|
|
298
|
+
): ToastPromiseResult<T>;
|
|
299
|
+
/** Closes the toast with this id, or every toast without an id. */
|
|
300
|
+
dismiss(id?: string | number): void;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* A toast store: the {@link ToastApi} it serves, and the queue it feeds. Each store has its own
|
|
305
|
+
* pending calls and id counter, so stores do not see each other. Made by {@link createToaster}; a
|
|
306
|
+
* {@link Toaster} shows the store it is given with `store`, or one it makes itself.
|
|
307
|
+
* @since 1.0.0
|
|
308
|
+
* @see {@link createToaster}
|
|
309
|
+
*/
|
|
310
|
+
export interface ToastStore {
|
|
311
|
+
/** The toast API of this store. Calls on it wait until a `Toaster` on the store mounts. */
|
|
312
|
+
readonly toast: ToastApi;
|
|
313
|
+
/**
|
|
314
|
+
* Connects a mounted `Toaster`'s queue to this store, runs the calls that waited for one, and
|
|
315
|
+
* returns the function that disconnects it on unmount. Calls made while no queue is connected
|
|
316
|
+
* wait, at most 16 of them.
|
|
317
|
+
*
|
|
318
|
+
* Used by {@link Toaster}; applications do not call it.
|
|
319
|
+
*
|
|
320
|
+
* @internal
|
|
321
|
+
* @param queue The queue a `Toaster` renders.
|
|
322
|
+
* @returns The function that disconnects `queue` again.
|
|
323
|
+
*/
|
|
324
|
+
attach(queue: Queue): () => void;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Errors and warnings interrupt the screen reader; everything else waits for a pause. */
|
|
328
|
+
function announcement(variant: ToastVariant, important: boolean | undefined): ToastType {
|
|
329
|
+
return important === true || variant === "error" || variant === "warning"
|
|
330
|
+
? "foreground"
|
|
331
|
+
: "background";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function dataOf(variant: ToastVariant, options: ExternalToast): ToastData {
|
|
335
|
+
const props = options.actionProps;
|
|
336
|
+
const action = (): ToastAction | undefined => {
|
|
337
|
+
if (options.action !== undefined || props === undefined) return options.action;
|
|
338
|
+
const [own, rest] = splitProps(props, ["children", "onClick", "altText"]);
|
|
339
|
+
return { label: own.children, onClick: own.onClick, altText: own.altText, buttonProps: rest };
|
|
340
|
+
};
|
|
341
|
+
return {
|
|
342
|
+
variant,
|
|
343
|
+
action: action(),
|
|
344
|
+
cancel: options.cancel,
|
|
345
|
+
icon: options.icon,
|
|
346
|
+
closeButton: options.closeButton,
|
|
347
|
+
class: options.class,
|
|
348
|
+
onDismiss: options.onDismiss,
|
|
349
|
+
onAutoClose: options.onAutoClose,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function patchOf(
|
|
354
|
+
variant: ToastVariant,
|
|
355
|
+
content: ToastPromiseContent,
|
|
356
|
+
fallbackDescription: JsxChild | undefined,
|
|
357
|
+
): ToastPatch<ToastData> {
|
|
358
|
+
const own: ExternalToast & { readonly message?: JsxChild } =
|
|
359
|
+
typeof content === "object" ? content : {};
|
|
360
|
+
const message = typeof content === "object" ? content.message : content;
|
|
361
|
+
return {
|
|
362
|
+
title: message,
|
|
363
|
+
description: own.description ?? fallbackDescription,
|
|
364
|
+
type: announcement(variant, own.important),
|
|
365
|
+
data: dataOf(variant, own),
|
|
366
|
+
duration: own.duration,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Makes a toast store: a {@link ToastApi} and the queue it feeds, with its own pending calls and
|
|
372
|
+
* id counter. A `Toaster` shows the toasts of the store it is given with `store`, or of one it makes
|
|
373
|
+
* itself. Calls on a store wait until a `Toaster` on that store mounts (at most 16, the oldest
|
|
374
|
+
* dropped first), and when that `Toaster` unmounts the store waits again. Ids count per store, so
|
|
375
|
+
* the first toast of every new store is `toast-1`.
|
|
376
|
+
*
|
|
377
|
+
* Use a store for module-level code, which has no component to call {@link useToast} from: create
|
|
378
|
+
* it once, render a `Toaster` with `store={...}`, and call `store.toast` from anywhere. Inside a
|
|
379
|
+
* component, use {@link useToast}. Calls do nothing on the server, and a store made at module level
|
|
380
|
+
* is shared by everything that imports it, so keep one per request where a server renders it.
|
|
381
|
+
*
|
|
382
|
+
* Keyboard and ARIA: see {@link Toaster}.
|
|
383
|
+
*
|
|
384
|
+
* @returns The store: its `toast` and the internal `attach`.
|
|
385
|
+
* @example
|
|
386
|
+
* ```tsx
|
|
387
|
+
* import { createToaster, Toaster } from "@/components/ui/toast";
|
|
388
|
+
*
|
|
389
|
+
* export const notifications = createToaster();
|
|
390
|
+
*
|
|
391
|
+
* export function saveDraft(): void {
|
|
392
|
+
* notifications.toast.success("Draft saved");
|
|
393
|
+
* }
|
|
394
|
+
*
|
|
395
|
+
* export function App() {
|
|
396
|
+
* return <Toaster store={notifications} richColors />;
|
|
397
|
+
* }
|
|
398
|
+
* ```
|
|
399
|
+
* @since 1.0.0
|
|
400
|
+
* @see {@link useToast}
|
|
401
|
+
* @see {@link Toaster}
|
|
402
|
+
*/
|
|
403
|
+
function createToaster(): ToastStore {
|
|
404
|
+
let attached: Queue | undefined;
|
|
405
|
+
const pending: ((queue: Queue) => void)[] = [];
|
|
406
|
+
let counter = 0;
|
|
407
|
+
|
|
408
|
+
function attach(queue: Queue): () => void {
|
|
409
|
+
attached = queue;
|
|
410
|
+
for (const run of pending.splice(0)) run(queue);
|
|
411
|
+
return () => {
|
|
412
|
+
if (attached === queue) attached = undefined;
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function dispatch(run: (queue: Queue) => void): void {
|
|
417
|
+
if (isServer || typeof document === "undefined") return;
|
|
418
|
+
if (attached !== undefined) {
|
|
419
|
+
run(attached);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (pending.length >= MAX_PENDING) pending.shift();
|
|
423
|
+
pending.push(run);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function nextId(): string {
|
|
427
|
+
counter += 1;
|
|
428
|
+
return `toast-${counter}`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function show(variant: ToastVariant, message: JsxChild, options: ExternalToast = {}): string {
|
|
432
|
+
const id = options.id === undefined ? nextId() : String(options.id);
|
|
433
|
+
dispatch((queue) => {
|
|
434
|
+
queue.add({
|
|
435
|
+
id,
|
|
436
|
+
title: message,
|
|
437
|
+
description: options.description,
|
|
438
|
+
type: announcement(variant, options.important),
|
|
439
|
+
duration:
|
|
440
|
+
options.duration ?? (variant === "loading" ? Number.POSITIVE_INFINITY : undefined),
|
|
441
|
+
data: dataOf(variant, options),
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
return id;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function track<T>(
|
|
448
|
+
source: Promise<T> | (() => Promise<T>),
|
|
449
|
+
data: ToastPromiseData<T>,
|
|
450
|
+
options: ExternalToast = {},
|
|
451
|
+
): ToastPromiseResult<T> {
|
|
452
|
+
const result = typeof source === "function" ? source() : source;
|
|
453
|
+
const id = options.id === undefined ? nextId() : String(options.id);
|
|
454
|
+
const description = data.description;
|
|
455
|
+
const finish = (): void => data.finally?.();
|
|
456
|
+
dispatch((queue) => {
|
|
457
|
+
queue.promise(
|
|
458
|
+
result,
|
|
459
|
+
{
|
|
460
|
+
loading: patchOf("loading", data.loading, description),
|
|
461
|
+
success: (value) =>
|
|
462
|
+
patchOf(
|
|
463
|
+
"success",
|
|
464
|
+
typeof data.success === "function" ? data.success(value) : data.success,
|
|
465
|
+
description,
|
|
466
|
+
),
|
|
467
|
+
error: (reason) =>
|
|
468
|
+
patchOf(
|
|
469
|
+
"error",
|
|
470
|
+
typeof data.error === "function" ? data.error(reason) : data.error,
|
|
471
|
+
description,
|
|
472
|
+
),
|
|
473
|
+
},
|
|
474
|
+
{ id, duration: options.duration },
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
if (attached === undefined && !isServer) {
|
|
478
|
+
// Nobody is watching yet, so a rejection would be reported as unhandled before a `Toaster`
|
|
479
|
+
// mounts and attaches its own handler. `unwrap()` still hands the rejection to the caller.
|
|
480
|
+
result.then(undefined, () => undefined);
|
|
481
|
+
}
|
|
482
|
+
result.then(finish, finish);
|
|
483
|
+
return { id, unwrap: () => result };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const dismiss = (id?: string | number): void => {
|
|
487
|
+
dispatch((queue) => queue.dismiss(id === undefined ? undefined : String(id)));
|
|
488
|
+
};
|
|
489
|
+
const toast: ToastApi = Object.assign(
|
|
490
|
+
(message: JsxChild, options?: ExternalToast) => show("default", message, options),
|
|
491
|
+
{
|
|
492
|
+
add: (options: ToastAddOptions) => show(options.type ?? "default", options.title, options),
|
|
493
|
+
close: dismiss,
|
|
494
|
+
message: (message: JsxChild, options?: ExternalToast) => show("default", message, options),
|
|
495
|
+
success: (message: JsxChild, options?: ExternalToast) => show("success", message, options),
|
|
496
|
+
error: (message: JsxChild, options?: ExternalToast) => show("error", message, options),
|
|
497
|
+
warning: (message: JsxChild, options?: ExternalToast) => show("warning", message, options),
|
|
498
|
+
info: (message: JsxChild, options?: ExternalToast) => show("info", message, options),
|
|
499
|
+
loading: (message: JsxChild, options?: ExternalToast) => show("loading", message, options),
|
|
500
|
+
promise: track,
|
|
501
|
+
dismiss,
|
|
502
|
+
},
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
return { toast, attach };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Provides the store of the nearest `Toaster` to the components under it. */
|
|
509
|
+
const toasterContext = createContext<ToastStore>(undefined, { name: "Toaster" });
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* The toast API of the nearest {@link Toaster}. Call it while a component runs, then use the
|
|
513
|
+
* returned object in its handlers and effects, where the toasts are raised.
|
|
514
|
+
*
|
|
515
|
+
* Keyboard and ARIA: this hook has none of its own. The toasts are announced by the `Toaster`, see
|
|
516
|
+
* {@link Toaster}.
|
|
517
|
+
*
|
|
518
|
+
* shadcn/ui differences: `useToast()` returns the toast function and its variants (`toast.success`,
|
|
519
|
+
* `toast.promise`...), not a `{ toasts, toast, dismiss }` object, and it reads the `Toaster` above
|
|
520
|
+
* the component rather than a global store.
|
|
521
|
+
*
|
|
522
|
+
* @returns The {@link ToastApi} of the nearest `Toaster`'s store.
|
|
523
|
+
* @throws Error `[ui] useToast() must be used inside <Toaster>.` when no `Toaster` is above the
|
|
524
|
+
* component, since there is no list to show its toasts in.
|
|
525
|
+
* @example
|
|
526
|
+
* ```tsx
|
|
527
|
+
* import { Button } from "@/components/ui/button";
|
|
528
|
+
* import { useToast } from "@/components/ui/toast";
|
|
529
|
+
*
|
|
530
|
+
* export function SaveButton() {
|
|
531
|
+
* const toast = useToast();
|
|
532
|
+
* return (
|
|
533
|
+
* <Button onClick={() => toast.success("Saved", { description: "Your changes are live." })}>
|
|
534
|
+
* Save
|
|
535
|
+
* </Button>
|
|
536
|
+
* );
|
|
537
|
+
* }
|
|
538
|
+
* ```
|
|
539
|
+
* @since 1.0.0
|
|
540
|
+
* @see {@link Toaster}
|
|
541
|
+
* @see {@link createToaster}
|
|
542
|
+
*/
|
|
543
|
+
function useToast(): ToastApi {
|
|
544
|
+
let store: ToastStore;
|
|
545
|
+
try {
|
|
546
|
+
store = useContext(toasterContext);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
throw new Error("[ui] useToast() must be used inside <Toaster>.", { cause: error });
|
|
549
|
+
}
|
|
550
|
+
return store.toast;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const POSITIONS: Record<ToastPosition, string> = {
|
|
554
|
+
"top-left": "start-0 top-0",
|
|
555
|
+
"top-center": "start-1/2 top-0 -translate-x-1/2 rtl:translate-x-1/2",
|
|
556
|
+
"top-right": "end-0 top-0",
|
|
557
|
+
"bottom-left": "start-0 bottom-0 flex-col-reverse",
|
|
558
|
+
"bottom-center": "start-1/2 bottom-0 -translate-x-1/2 flex-col-reverse rtl:translate-x-1/2",
|
|
559
|
+
"bottom-right": "end-0 bottom-0 flex-col-reverse",
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
/** Slide a little from (and back toward) the screen edge the toasts sit on. */
|
|
563
|
+
const EDGE_MOTION = {
|
|
564
|
+
top: "data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2",
|
|
565
|
+
bottom: "data-[state=closed]:slide-out-to-bottom-2 data-[state=open]:slide-in-from-bottom-2",
|
|
566
|
+
} as const;
|
|
567
|
+
|
|
568
|
+
const RICH_COLORS: Record<ToastVariant, string> = {
|
|
569
|
+
default: "",
|
|
570
|
+
success:
|
|
571
|
+
"border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100",
|
|
572
|
+
error: "border-destructive/30 bg-destructive/10 text-destructive",
|
|
573
|
+
warning:
|
|
574
|
+
"border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100",
|
|
575
|
+
info: "border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-100",
|
|
576
|
+
loading: "",
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Props of {@link Toaster}: the attributes of the toast list plus the options below.
|
|
581
|
+
* @since 1.0.0
|
|
582
|
+
* @see {@link Toaster}
|
|
583
|
+
*/
|
|
584
|
+
export type ToasterProps = Omit<ComponentProps<"ol">, "dir"> & {
|
|
585
|
+
/**
|
|
586
|
+
* Where the toasts appear. `left` and `right` are the inline start and end.
|
|
587
|
+
* @default "bottom-right"
|
|
588
|
+
*/
|
|
589
|
+
readonly position?: ToastPosition | undefined;
|
|
590
|
+
/**
|
|
591
|
+
* Tints success, error, warning and info toasts.
|
|
592
|
+
* @default false
|
|
593
|
+
*/
|
|
594
|
+
readonly richColors?: boolean | undefined;
|
|
595
|
+
/**
|
|
596
|
+
* Shows a close button on every toast (a toast can set its own).
|
|
597
|
+
* @default false
|
|
598
|
+
*/
|
|
599
|
+
readonly closeButton?: boolean | undefined;
|
|
600
|
+
/**
|
|
601
|
+
* Milliseconds before a toast closes itself. A toast can set its own `duration`.
|
|
602
|
+
* @default 4000
|
|
603
|
+
*/
|
|
604
|
+
readonly duration?: number | undefined;
|
|
605
|
+
/**
|
|
606
|
+
* How many toasts are shown at once. The rest wait their turn, oldest first. Read when the
|
|
607
|
+
* toaster mounts.
|
|
608
|
+
* @default 3
|
|
609
|
+
*/
|
|
610
|
+
readonly visibleToasts?: number | undefined;
|
|
611
|
+
/**
|
|
612
|
+
* Keeps the older toasts at full size. When `false`, they are shrunk until the list is hovered or
|
|
613
|
+
* focused.
|
|
614
|
+
* @default false
|
|
615
|
+
*/
|
|
616
|
+
readonly expand?: boolean | undefined;
|
|
617
|
+
/**
|
|
618
|
+
* `"dark"` forces the dark tokens on the toasts. `"light"` and `"system"` follow the page.
|
|
619
|
+
*/
|
|
620
|
+
readonly theme?: "light" | "dark" | "system" | undefined;
|
|
621
|
+
/**
|
|
622
|
+
* The keys that move focus into the list, as `KeyboardEvent.code` values or modifier names
|
|
623
|
+
* (`"altKey"`, `"ctrlKey"`, `"shiftKey"`, `"metaKey"`). All of them must be down.
|
|
624
|
+
* @default ["altKey", "KeyT"]
|
|
625
|
+
*/
|
|
626
|
+
readonly hotkey?: readonly string[] | undefined;
|
|
627
|
+
/**
|
|
628
|
+
* The accessible name of the list. The hotkey is appended to it.
|
|
629
|
+
* @default "Notifications"
|
|
630
|
+
*/
|
|
631
|
+
readonly label?: string | undefined;
|
|
632
|
+
/**
|
|
633
|
+
* The reading direction of the list. Left and right swap in `"rtl"`. Without it, a
|
|
634
|
+
* `DirectionProvider` above the toaster, else the document's direction, is used. The toaster is an
|
|
635
|
+
* app-level list in a portal, so a `dir` on a wrapper around it is not read: set this prop, or put
|
|
636
|
+
* `dir` on the document.
|
|
637
|
+
*/
|
|
638
|
+
readonly dir?: "ltr" | "rtl" | undefined;
|
|
639
|
+
/**
|
|
640
|
+
* Options applied to every toast. Its `class` is added to each toast.
|
|
641
|
+
*/
|
|
642
|
+
readonly toastOptions?: { readonly class?: string | undefined } | undefined;
|
|
643
|
+
/**
|
|
644
|
+
* The store whose toasts this toaster shows, from {@link createToaster}. Read when the toaster
|
|
645
|
+
* mounts. Without one, the toaster makes a store of its own, which its children reach with
|
|
646
|
+
* {@link useToast}.
|
|
647
|
+
*/
|
|
648
|
+
readonly store?: ToastStore | undefined;
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The list that shows the toasts of a store. Mount one `Toaster` near the root of the application;
|
|
653
|
+
* the components under it raise toasts with {@link useToast}. Each toast has a message, an optional
|
|
654
|
+
* description, a status icon for its variant, and optional action and cancel buttons. A toast closes
|
|
655
|
+
* itself after `duration` (4 seconds by default), and the countdown pauses while the list is hovered
|
|
656
|
+
* or focused. The list sits in the bottom-right corner by default. A toast can be swiped away toward
|
|
657
|
+
* the nearest screen edge.
|
|
658
|
+
*
|
|
659
|
+
* The children render inside the toaster, before the list, and they can call {@link useToast}. A
|
|
660
|
+
* toaster without a `store` makes one of its own, so separate toasters do not share toasts or ids. A
|
|
661
|
+
* `store` from {@link createToaster} lets module-level code raise toasts on this toaster.
|
|
662
|
+
*
|
|
663
|
+
* Keyboard and ARIA: the list is a `role="region"` named by `label` (default "Notifications"), with
|
|
664
|
+
* the hotkey appended. Press the hotkey (Alt+T by default; set `hotkey` to change it) to move focus
|
|
665
|
+
* into the list, then Tab walks through the toasts. Escape closes the focused toast, which counts as
|
|
666
|
+
* a dismissal. Each close button is named "Close toast", and an action button is announced by its
|
|
667
|
+
* `altText` (its label when the label is text). The text of a toast is read once when it appears.
|
|
668
|
+
* Errors and warnings are announced assertively, and the other variants politely. A toast raised with
|
|
669
|
+
* `important` is announced assertively too.
|
|
670
|
+
*
|
|
671
|
+
* shadcn/ui differences: there is no global store and no `toast` import. Raise toasts with
|
|
672
|
+
* {@link useToast} in components, or with the `toast` of a {@link createToaster} store in module code.
|
|
673
|
+
*
|
|
674
|
+
* @param props - The props, see {@link ToasterProps}.
|
|
675
|
+
* @returns The children, then the list of toasts in a region.
|
|
676
|
+
* @example
|
|
677
|
+
* ```tsx
|
|
678
|
+
* import { Button } from "@/components/ui/button";
|
|
679
|
+
* import { Toaster, useToast } from "@/components/ui/toast";
|
|
680
|
+
*
|
|
681
|
+
* function SaveButton() {
|
|
682
|
+
* const toast = useToast();
|
|
683
|
+
* return <Button onClick={() => toast.success("Saved")}>Save</Button>;
|
|
684
|
+
* }
|
|
685
|
+
*
|
|
686
|
+
* export function App() {
|
|
687
|
+
* return (
|
|
688
|
+
* <Toaster richColors closeButton position="top-center">
|
|
689
|
+
* <SaveButton />
|
|
690
|
+
* </Toaster>
|
|
691
|
+
* );
|
|
692
|
+
* }
|
|
693
|
+
* ```
|
|
694
|
+
* @since 1.0.0
|
|
695
|
+
* @see {@link useToast}
|
|
696
|
+
* @see {@link createToaster}
|
|
697
|
+
*/
|
|
698
|
+
function Toaster(props: ToasterProps): JSX.Element {
|
|
699
|
+
const t = useUiStrings();
|
|
700
|
+
const [local, rest] = splitProps(props, [
|
|
701
|
+
"class",
|
|
702
|
+
"position",
|
|
703
|
+
"richColors",
|
|
704
|
+
"closeButton",
|
|
705
|
+
"duration",
|
|
706
|
+
"visibleToasts",
|
|
707
|
+
"expand",
|
|
708
|
+
"theme",
|
|
709
|
+
"hotkey",
|
|
710
|
+
"label",
|
|
711
|
+
"dir",
|
|
712
|
+
"toastOptions",
|
|
713
|
+
"store",
|
|
714
|
+
"children",
|
|
715
|
+
]);
|
|
716
|
+
// The store and the queue are read once, when the toaster mounts: a toaster keeps what it mounted with.
|
|
717
|
+
const store: ToastStore = untrack(() => local.store) ?? createToaster();
|
|
718
|
+
const queue: Queue = createQueue<ToastData>({
|
|
719
|
+
limit: untrack(() => local.visibleToasts) ?? 3,
|
|
720
|
+
order: "newest-first",
|
|
721
|
+
});
|
|
722
|
+
onMount(() => store.attach(queue));
|
|
723
|
+
const direction = useDirection(() => local.dir);
|
|
724
|
+
const position = (): ToastPosition => local.position ?? "bottom-right";
|
|
725
|
+
const swipeDirection = (): SwipeDirection => {
|
|
726
|
+
const place = position();
|
|
727
|
+
if (place.endsWith("center")) return place.startsWith("top") ? "up" : "down";
|
|
728
|
+
// `left` and `right` name the inline start and end: in right-to-left text the start is on the right.
|
|
729
|
+
return place.endsWith("left") === (direction() === "ltr") ? "left" : "right";
|
|
730
|
+
};
|
|
731
|
+
return (
|
|
732
|
+
<toasterContext.Provider value={store}>
|
|
733
|
+
{local.children}
|
|
734
|
+
<ToastPrimitive.Provider
|
|
735
|
+
label={local.label ?? t("toast.notifications", "Notifications")}
|
|
736
|
+
duration={local.duration ?? 4000}
|
|
737
|
+
swipeDirection={swipeDirection()}
|
|
738
|
+
>
|
|
739
|
+
<ToastPrimitive.Viewport
|
|
740
|
+
data-slot="toaster"
|
|
741
|
+
data-position={position()}
|
|
742
|
+
data-expanded={local.expand === true ? "true" : "false"}
|
|
743
|
+
dir={direction()}
|
|
744
|
+
hotkey={local.hotkey ?? ["altKey", "KeyT"]}
|
|
745
|
+
class={cn(
|
|
746
|
+
"group/toaster pointer-events-none fixed z-[100] flex max-h-screen w-full max-w-[392px] flex-col gap-2 p-4",
|
|
747
|
+
POSITIONS[position()],
|
|
748
|
+
local.theme === "dark" && "dark",
|
|
749
|
+
local.class,
|
|
750
|
+
)}
|
|
751
|
+
{...rest}
|
|
752
|
+
>
|
|
753
|
+
<For each={queue.toasts()} key={(item: ToastItem<ToastData>) => item.id}>
|
|
754
|
+
{(item: Accessor<ToastItem<ToastData>>) => (
|
|
755
|
+
<ToastView
|
|
756
|
+
item={item}
|
|
757
|
+
queue={queue}
|
|
758
|
+
position={position}
|
|
759
|
+
richColors={() => local.richColors === true}
|
|
760
|
+
closeButton={() => local.closeButton === true}
|
|
761
|
+
class={() => local.toastOptions?.class}
|
|
762
|
+
/>
|
|
763
|
+
)}
|
|
764
|
+
</For>
|
|
765
|
+
</ToastPrimitive.Viewport>
|
|
766
|
+
</ToastPrimitive.Provider>
|
|
767
|
+
</toasterContext.Provider>
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const NO_DATA: ToastData = {
|
|
772
|
+
variant: "default",
|
|
773
|
+
action: undefined,
|
|
774
|
+
cancel: undefined,
|
|
775
|
+
icon: undefined,
|
|
776
|
+
closeButton: undefined,
|
|
777
|
+
class: undefined,
|
|
778
|
+
onDismiss: undefined,
|
|
779
|
+
onAutoClose: undefined,
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
interface ToastViewProps {
|
|
783
|
+
readonly item: Accessor<ToastItem<ToastData>>;
|
|
784
|
+
readonly queue: Queue;
|
|
785
|
+
readonly position: Accessor<ToastPosition>;
|
|
786
|
+
readonly richColors: Accessor<boolean>;
|
|
787
|
+
readonly closeButton: Accessor<boolean>;
|
|
788
|
+
readonly class: Accessor<string | undefined>;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** One toast of the list: the primitive's element with the look, icon, buttons and callbacks. */
|
|
792
|
+
function ToastView(props: ToastViewProps): JSX.Element {
|
|
793
|
+
const t = useUiStrings();
|
|
794
|
+
const data = (): ToastData => props.item().data ?? NO_DATA;
|
|
795
|
+
const summary = (): ToastSummary => ({
|
|
796
|
+
id: props.item().id,
|
|
797
|
+
title: props.item().title,
|
|
798
|
+
description: props.item().description,
|
|
799
|
+
variant: data().variant,
|
|
800
|
+
});
|
|
801
|
+
// Who closes the toast decides which callback runs: the handlers below name the intent just
|
|
802
|
+
// before the primitive closes it, anything else is the timer.
|
|
803
|
+
let intent: "user" | "silent" | undefined;
|
|
804
|
+
let settled = false;
|
|
805
|
+
const settle = (how: "auto" | "user" | "silent"): void => {
|
|
806
|
+
if (settled) return;
|
|
807
|
+
settled = true;
|
|
808
|
+
const current = summary();
|
|
809
|
+
if (how === "auto") data().onAutoClose?.(current);
|
|
810
|
+
else if (how === "user") data().onDismiss?.(current);
|
|
811
|
+
};
|
|
812
|
+
// `toast.dismiss()` closes the toast from outside, without the primitive asking.
|
|
813
|
+
useEffect(() => {
|
|
814
|
+
if (!props.item().open) untrack(() => settle("user"));
|
|
815
|
+
});
|
|
816
|
+
const edge = (): "top" | "bottom" => (props.position().startsWith("top") ? "top" : "bottom");
|
|
817
|
+
const showClose = (): boolean => data().closeButton ?? props.closeButton();
|
|
818
|
+
return (
|
|
819
|
+
<ToastPrimitive
|
|
820
|
+
data-slot="toast"
|
|
821
|
+
data-variant={data().variant}
|
|
822
|
+
open={props.item().open}
|
|
823
|
+
duration={props.item().duration}
|
|
824
|
+
type={props.item().type}
|
|
825
|
+
onOpenChange={(open: boolean) => {
|
|
826
|
+
if (open) return;
|
|
827
|
+
settle(intent ?? "auto");
|
|
828
|
+
props.queue.dismiss(props.item().id);
|
|
829
|
+
}}
|
|
830
|
+
onEscapeKeyDown={() => {
|
|
831
|
+
intent = "user";
|
|
832
|
+
}}
|
|
833
|
+
onSwipeEnd={() => {
|
|
834
|
+
intent = "user";
|
|
835
|
+
}}
|
|
836
|
+
onExitComplete={() => props.queue.remove(props.item().id)}
|
|
837
|
+
class={cn(
|
|
838
|
+
"group/toast pointer-events-auto relative flex w-full items-center gap-3 rounded-lg border bg-popover p-4 text-sm text-popover-foreground shadow-lg transition-[scale,opacity] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none forced-colors:border-[CanvasText] focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-transparent",
|
|
839
|
+
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
840
|
+
"data-[swipe=cancel]:translate-x-0 data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-transform data-[swipe=end]:translate-x-(--primitives-toast-swipe-end-x) data-[swipe=end]:translate-y-(--primitives-toast-swipe-end-y) data-[swipe=move]:translate-x-(--primitives-toast-swipe-move-x) data-[swipe=move]:translate-y-(--primitives-toast-swipe-move-y) data-[swipe=move]:transition-none",
|
|
841
|
+
"group-data-[expanded=false]/toaster:not-first:scale-[0.97] group-data-[expanded=false]/toaster:not-first:opacity-80 group-focus-within/toaster:scale-100 group-focus-within/toaster:opacity-100 group-hover/toaster:scale-100 group-hover/toaster:opacity-100",
|
|
842
|
+
EDGE_MOTION[edge()],
|
|
843
|
+
props.richColors() && RICH_COLORS[data().variant],
|
|
844
|
+
props.class(),
|
|
845
|
+
data().class,
|
|
846
|
+
)}
|
|
847
|
+
>
|
|
848
|
+
<Show when={data().icon !== undefined || data().variant !== "default"}>
|
|
849
|
+
<div data-slot="toast-icon" class="flex shrink-0 items-center [&_svg]:size-4">
|
|
850
|
+
<Show when={data().icon === undefined} fallback={data().icon}>
|
|
851
|
+
<VariantIcon variant={data().variant} />
|
|
852
|
+
</Show>
|
|
853
|
+
</div>
|
|
854
|
+
</Show>
|
|
855
|
+
<div data-slot="toast-content" class="grid flex-1 gap-0.5">
|
|
856
|
+
<ToastPrimitive.Title data-slot="toast-title" class="leading-snug font-medium">
|
|
857
|
+
{props.item().title}
|
|
858
|
+
</ToastPrimitive.Title>
|
|
859
|
+
<Show when={props.item().description !== undefined}>
|
|
860
|
+
<ToastPrimitive.Description
|
|
861
|
+
data-slot="toast-description"
|
|
862
|
+
class={cn(
|
|
863
|
+
"leading-snug",
|
|
864
|
+
props.richColors() && data().variant !== "default"
|
|
865
|
+
? "opacity-80"
|
|
866
|
+
: "text-muted-foreground",
|
|
867
|
+
)}
|
|
868
|
+
>
|
|
869
|
+
{props.item().description}
|
|
870
|
+
</ToastPrimitive.Description>
|
|
871
|
+
</Show>
|
|
872
|
+
</div>
|
|
873
|
+
<Show when={data().action}>
|
|
874
|
+
{(action) => (
|
|
875
|
+
<ToastPrimitive.Action
|
|
876
|
+
data-slot="toast-action"
|
|
877
|
+
altText={
|
|
878
|
+
action().altText ??
|
|
879
|
+
(typeof action().label === "string"
|
|
880
|
+
? String(action().label)
|
|
881
|
+
: t("toast.action", "Action"))
|
|
882
|
+
}
|
|
883
|
+
{...action().buttonProps}
|
|
884
|
+
class={cn(buttonVariants({ size: "sm" }), action().buttonProps?.class)}
|
|
885
|
+
onClick={(event: MouseEvent) => {
|
|
886
|
+
intent = "silent";
|
|
887
|
+
action().onClick?.(event);
|
|
888
|
+
if (event.defaultPrevented) intent = undefined;
|
|
889
|
+
}}
|
|
890
|
+
>
|
|
891
|
+
{action().label}
|
|
892
|
+
</ToastPrimitive.Action>
|
|
893
|
+
)}
|
|
894
|
+
</Show>
|
|
895
|
+
<Show when={data().cancel}>
|
|
896
|
+
{(cancel) => (
|
|
897
|
+
<ToastPrimitive.Close
|
|
898
|
+
data-slot="toast-cancel"
|
|
899
|
+
class={buttonVariants({ variant: "secondary", size: "sm" })}
|
|
900
|
+
onClick={(event: MouseEvent) => {
|
|
901
|
+
intent = "silent";
|
|
902
|
+
cancel().onClick?.(event);
|
|
903
|
+
if (event.defaultPrevented) intent = undefined;
|
|
904
|
+
}}
|
|
905
|
+
>
|
|
906
|
+
{cancel().label}
|
|
907
|
+
</ToastPrimitive.Close>
|
|
908
|
+
)}
|
|
909
|
+
</Show>
|
|
910
|
+
<Show when={showClose()}>
|
|
911
|
+
<ToastPrimitive.Close
|
|
912
|
+
data-slot="toast-close"
|
|
913
|
+
aria-label={t("toast.close", "Close toast")}
|
|
914
|
+
class="absolute -start-2 -top-2 flex size-5 items-center justify-center rounded-full border bg-popover text-popover-foreground opacity-0 shadow-xs transition-opacity group-focus-within/toast:opacity-100 group-hover/toast:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none motion-reduce:transition-none forced-colors:opacity-100 [&_svg]:size-3 outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-transparent"
|
|
915
|
+
onClick={() => {
|
|
916
|
+
intent = "user";
|
|
917
|
+
}}
|
|
918
|
+
>
|
|
919
|
+
<XIcon />
|
|
920
|
+
</ToastPrimitive.Close>
|
|
921
|
+
</Show>
|
|
922
|
+
</ToastPrimitive>
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** The status icon of a variant. */
|
|
927
|
+
function VariantIcon(props: { readonly variant: ToastVariant }): JSX.Element {
|
|
928
|
+
return (
|
|
929
|
+
<>
|
|
930
|
+
<Show when={props.variant === "success"}>
|
|
931
|
+
<CircleCheckIcon />
|
|
932
|
+
</Show>
|
|
933
|
+
<Show when={props.variant === "error"}>
|
|
934
|
+
<CircleXIcon />
|
|
935
|
+
</Show>
|
|
936
|
+
<Show when={props.variant === "warning"}>
|
|
937
|
+
<TriangleAlertIcon />
|
|
938
|
+
</Show>
|
|
939
|
+
<Show when={props.variant === "info"}>
|
|
940
|
+
<InfoIcon />
|
|
941
|
+
</Show>
|
|
942
|
+
<Show when={props.variant === "loading"}>
|
|
943
|
+
<LoaderIcon class="animate-spin motion-reduce:animate-none" />
|
|
944
|
+
</Show>
|
|
945
|
+
</>
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
export { createToaster, Toaster, useToast };
|