@zag-js/toast 0.9.1 → 0.10.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/package.json +9 -8
- package/src/index.ts +31 -0
- package/src/toast-group.connect.ts +188 -0
- package/src/toast-group.machine.ts +105 -0
- package/src/toast.anatomy.ts +4 -0
- package/src/toast.connect.ts +143 -0
- package/src/toast.dom.ts +11 -0
- package/src/toast.machine.ts +155 -0
- package/src/toast.types.ts +180 -0
- package/src/toast.utils.ts +77 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zag-js/toast",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Core logic for the toast widget implemented as a state machine",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"js",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"repository": "https://github.com/chakra-ui/zag/tree/main/packages/toast",
|
|
18
18
|
"sideEffects": false,
|
|
19
19
|
"files": [
|
|
20
|
-
"dist
|
|
20
|
+
"dist",
|
|
21
|
+
"src"
|
|
21
22
|
],
|
|
22
23
|
"publishConfig": {
|
|
23
24
|
"access": "public"
|
|
@@ -26,12 +27,12 @@
|
|
|
26
27
|
"url": "https://github.com/chakra-ui/zag/issues"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
|
-
"@zag-js/anatomy": "0.
|
|
30
|
-
"@zag-js/core": "0.
|
|
31
|
-
"@zag-js/dom-query": "0.
|
|
32
|
-
"@zag-js/dom-event": "0.
|
|
33
|
-
"@zag-js/utils": "0.
|
|
34
|
-
"@zag-js/types": "0.
|
|
30
|
+
"@zag-js/anatomy": "0.10.0",
|
|
31
|
+
"@zag-js/core": "0.10.0",
|
|
32
|
+
"@zag-js/dom-query": "0.10.0",
|
|
33
|
+
"@zag-js/dom-event": "0.10.0",
|
|
34
|
+
"@zag-js/utils": "0.10.0",
|
|
35
|
+
"@zag-js/types": "0.10.0"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
38
|
"clean-package": "2.2.0"
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { isDom } from "@zag-js/dom-query"
|
|
2
|
+
import { warn } from "@zag-js/utils"
|
|
3
|
+
import { groupConnect, toaster } from "./toast-group.connect"
|
|
4
|
+
import { groupMachine } from "./toast-group.machine"
|
|
5
|
+
import { createToastMachine as createMachine } from "./toast.machine"
|
|
6
|
+
|
|
7
|
+
export { anatomy } from "./toast.anatomy"
|
|
8
|
+
export { connect } from "./toast.connect"
|
|
9
|
+
export type {
|
|
10
|
+
GroupMachineContext,
|
|
11
|
+
MachineContext,
|
|
12
|
+
MachineState,
|
|
13
|
+
Placement,
|
|
14
|
+
Service,
|
|
15
|
+
ToastOptions,
|
|
16
|
+
Type,
|
|
17
|
+
} from "./toast.types"
|
|
18
|
+
export { createMachine }
|
|
19
|
+
|
|
20
|
+
export const group = {
|
|
21
|
+
connect: groupConnect,
|
|
22
|
+
machine: groupMachine,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function api() {
|
|
26
|
+
if (!isDom()) {
|
|
27
|
+
warn("toast.api() is only available in the browser")
|
|
28
|
+
} else {
|
|
29
|
+
return toaster
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { subscribe } from "@zag-js/core"
|
|
2
|
+
import type { NormalizeProps, PropTypes } from "@zag-js/types"
|
|
3
|
+
import { runIfFn, uuid } from "@zag-js/utils"
|
|
4
|
+
import { parts } from "./toast.anatomy"
|
|
5
|
+
import { dom } from "./toast.dom"
|
|
6
|
+
import type {
|
|
7
|
+
GroupMachineContext,
|
|
8
|
+
GroupProps,
|
|
9
|
+
GroupSend,
|
|
10
|
+
GroupState,
|
|
11
|
+
Placement,
|
|
12
|
+
PromiseOptions,
|
|
13
|
+
Toaster,
|
|
14
|
+
Options,
|
|
15
|
+
} from "./toast.types"
|
|
16
|
+
import { getGroupPlacementStyle, getToastsByPlacement } from "./toast.utils"
|
|
17
|
+
|
|
18
|
+
export let toaster = {} as Toaster
|
|
19
|
+
|
|
20
|
+
export function groupConnect<T extends PropTypes>(state: GroupState, send: GroupSend, normalize: NormalizeProps<T>) {
|
|
21
|
+
const group = {
|
|
22
|
+
/**
|
|
23
|
+
* The total number of toasts
|
|
24
|
+
*/
|
|
25
|
+
count: state.context.count,
|
|
26
|
+
/**
|
|
27
|
+
* The active toasts
|
|
28
|
+
*/
|
|
29
|
+
toasts: state.context.toasts,
|
|
30
|
+
/**
|
|
31
|
+
* The active toasts by placement
|
|
32
|
+
*/
|
|
33
|
+
toastsByPlacement: getToastsByPlacement(state.context.toasts),
|
|
34
|
+
/**
|
|
35
|
+
* Returns whether the toast id is visible
|
|
36
|
+
*/
|
|
37
|
+
isVisible(id: string) {
|
|
38
|
+
if (!state.context.toasts.length) return false
|
|
39
|
+
return !!state.context.toasts.find((toast) => toast.id == id)
|
|
40
|
+
},
|
|
41
|
+
/**
|
|
42
|
+
* Function to create a toast.
|
|
43
|
+
*/
|
|
44
|
+
create(options: Options) {
|
|
45
|
+
const uid = `toast:${uuid()}`
|
|
46
|
+
const id = options.id ? options.id : uid
|
|
47
|
+
|
|
48
|
+
if (group.isVisible(id)) return
|
|
49
|
+
send({ type: "ADD_TOAST", toast: { ...options, id } })
|
|
50
|
+
|
|
51
|
+
return id
|
|
52
|
+
},
|
|
53
|
+
/**
|
|
54
|
+
* Function to create or update a toast.
|
|
55
|
+
*/
|
|
56
|
+
upsert(options: Options) {
|
|
57
|
+
const { id } = options
|
|
58
|
+
const isVisible = id ? group.isVisible(id) : false
|
|
59
|
+
if (isVisible && id != null) {
|
|
60
|
+
return group.update(id, options)
|
|
61
|
+
} else {
|
|
62
|
+
return group.create(options)
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
/**
|
|
66
|
+
* Function to dismiss a toast by id.
|
|
67
|
+
* If no id is provided, all toasts will be dismissed.
|
|
68
|
+
*/
|
|
69
|
+
dismiss(id?: string) {
|
|
70
|
+
if (id == null) {
|
|
71
|
+
send("DISMISS_ALL")
|
|
72
|
+
} else if (group.isVisible(id)) {
|
|
73
|
+
send({ type: "DISMISS_TOAST", id })
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
/**
|
|
77
|
+
* Function to remove a toast by id.
|
|
78
|
+
* If no id is provided, all toasts will be removed.
|
|
79
|
+
*/
|
|
80
|
+
remove(id?: string) {
|
|
81
|
+
if (id == null) {
|
|
82
|
+
send("REMOVE_ALL")
|
|
83
|
+
} else if (group.isVisible(id)) {
|
|
84
|
+
send({ type: "REMOVE_TOAST", id })
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
/**
|
|
88
|
+
* Function to dismiss all toasts by placement.
|
|
89
|
+
*/
|
|
90
|
+
dismissByPlacement(placement: Placement) {
|
|
91
|
+
const toasts = group.toastsByPlacement[placement]
|
|
92
|
+
if (toasts) {
|
|
93
|
+
toasts.forEach((toast) => group.dismiss(toast.id))
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
/**
|
|
97
|
+
* Function to update a toast's options by id.
|
|
98
|
+
*/
|
|
99
|
+
update(id: string, options: Options) {
|
|
100
|
+
if (!group.isVisible(id)) return
|
|
101
|
+
send({ type: "UPDATE_TOAST", id, toast: options })
|
|
102
|
+
return id
|
|
103
|
+
},
|
|
104
|
+
/**
|
|
105
|
+
* Function to create a loading toast.
|
|
106
|
+
*/
|
|
107
|
+
loading(options: Options) {
|
|
108
|
+
options.type = "loading"
|
|
109
|
+
return group.upsert(options)
|
|
110
|
+
},
|
|
111
|
+
/**
|
|
112
|
+
* Function to create a success toast.
|
|
113
|
+
*/
|
|
114
|
+
success(options: Options) {
|
|
115
|
+
options.type = "success"
|
|
116
|
+
return group.upsert(options)
|
|
117
|
+
},
|
|
118
|
+
/**
|
|
119
|
+
* Function to create an error toast.
|
|
120
|
+
*/
|
|
121
|
+
error(options: Options) {
|
|
122
|
+
options.type = "error"
|
|
123
|
+
return group.upsert(options)
|
|
124
|
+
},
|
|
125
|
+
/**
|
|
126
|
+
* Function to create a toast from a promise.
|
|
127
|
+
* - When the promise resolves, the toast will be updated with the success options.
|
|
128
|
+
* - When the promise rejects, the toast will be updated with the error options.
|
|
129
|
+
*/
|
|
130
|
+
promise<T>(promise: Promise<T>, options: PromiseOptions<T>, shared: Options = {}) {
|
|
131
|
+
const id = group.loading({ ...shared, ...options.loading })
|
|
132
|
+
|
|
133
|
+
promise
|
|
134
|
+
.then((response) => {
|
|
135
|
+
const successOptions = runIfFn(options.success, response)
|
|
136
|
+
group.success({ ...shared, ...successOptions, id })
|
|
137
|
+
})
|
|
138
|
+
.catch((error) => {
|
|
139
|
+
const errorOptions = runIfFn(options.error, error)
|
|
140
|
+
group.error({ ...shared, ...errorOptions, id })
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
return promise
|
|
144
|
+
},
|
|
145
|
+
/**
|
|
146
|
+
* Function to pause a toast by id.
|
|
147
|
+
*/
|
|
148
|
+
pause(id?: string) {
|
|
149
|
+
if (id == null) {
|
|
150
|
+
send("PAUSE_ALL")
|
|
151
|
+
} else if (group.isVisible(id)) {
|
|
152
|
+
send({ type: "PAUSE_TOAST", id })
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
/**
|
|
156
|
+
* Function to resume a toast by id.
|
|
157
|
+
*/
|
|
158
|
+
resume(id?: string) {
|
|
159
|
+
if (id == null) {
|
|
160
|
+
send("RESUME_ALL")
|
|
161
|
+
} else if (group.isVisible(id)) {
|
|
162
|
+
send({ type: "RESUME_TOAST", id })
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
getGroupProps(options: GroupProps) {
|
|
167
|
+
const { placement, label = "Notifications" } = options
|
|
168
|
+
return normalize.element({
|
|
169
|
+
...parts.group.attrs,
|
|
170
|
+
tabIndex: -1,
|
|
171
|
+
"aria-label": label,
|
|
172
|
+
id: dom.getGroupId(placement),
|
|
173
|
+
"data-placement": placement,
|
|
174
|
+
"aria-live": "polite",
|
|
175
|
+
role: "region",
|
|
176
|
+
style: getGroupPlacementStyle(state.context, placement),
|
|
177
|
+
})
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
subscribe(fn: (toasts: GroupMachineContext["toasts"]) => void) {
|
|
181
|
+
return subscribe(state.context.toasts, () => fn(state.context.toasts))
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
Object.assign(toaster, group)
|
|
186
|
+
|
|
187
|
+
return group
|
|
188
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createMachine } from "@zag-js/core"
|
|
2
|
+
import { MAX_Z_INDEX } from "@zag-js/dom-query"
|
|
3
|
+
import { compact } from "@zag-js/utils"
|
|
4
|
+
import { createToastMachine } from "./toast.machine"
|
|
5
|
+
import type { GroupMachineContext, UserDefinedGroupContext } from "./toast.types"
|
|
6
|
+
|
|
7
|
+
export function groupMachine(userContext: UserDefinedGroupContext) {
|
|
8
|
+
const ctx = compact(userContext)
|
|
9
|
+
return createMachine<GroupMachineContext>({
|
|
10
|
+
id: "toaster",
|
|
11
|
+
initial: "active",
|
|
12
|
+
context: {
|
|
13
|
+
dir: "ltr",
|
|
14
|
+
max: Number.MAX_SAFE_INTEGER,
|
|
15
|
+
toasts: [],
|
|
16
|
+
gutter: "1rem",
|
|
17
|
+
zIndex: MAX_Z_INDEX,
|
|
18
|
+
pauseOnPageIdle: false,
|
|
19
|
+
pauseOnInteraction: true,
|
|
20
|
+
offsets: { left: "0px", right: "0px", top: "0px", bottom: "0px" },
|
|
21
|
+
...ctx,
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
computed: {
|
|
25
|
+
count: (ctx) => ctx.toasts.length,
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
on: {
|
|
29
|
+
SETUP: {},
|
|
30
|
+
|
|
31
|
+
PAUSE_TOAST: {
|
|
32
|
+
actions: (_ctx, evt, { self }) => {
|
|
33
|
+
self.sendChild("PAUSE", evt.id)
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
PAUSE_ALL: {
|
|
38
|
+
actions: (ctx) => {
|
|
39
|
+
ctx.toasts.forEach((toast) => toast.send("PAUSE"))
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
RESUME_TOAST: {
|
|
44
|
+
actions: (_ctx, evt, { self }) => {
|
|
45
|
+
self.sendChild("RESUME", evt.id)
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
RESUME_ALL: {
|
|
50
|
+
actions: (ctx) => {
|
|
51
|
+
ctx.toasts.forEach((toast) => toast.send("RESUME"))
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
ADD_TOAST: {
|
|
56
|
+
guard: (ctx) => ctx.toasts.length < ctx.max,
|
|
57
|
+
actions: (ctx, evt, { self }) => {
|
|
58
|
+
const options = {
|
|
59
|
+
...evt.toast,
|
|
60
|
+
pauseOnPageIdle: ctx.pauseOnPageIdle,
|
|
61
|
+
pauseOnInteraction: ctx.pauseOnInteraction,
|
|
62
|
+
dir: ctx.dir,
|
|
63
|
+
getRootNode: ctx.getRootNode,
|
|
64
|
+
}
|
|
65
|
+
const toast = createToastMachine(options)
|
|
66
|
+
const actor = self.spawn(toast)
|
|
67
|
+
ctx.toasts.push(actor)
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
UPDATE_TOAST: {
|
|
72
|
+
actions: (_ctx, evt, { self }) => {
|
|
73
|
+
self.sendChild({ type: "UPDATE", toast: evt.toast }, evt.id)
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
DISMISS_TOAST: {
|
|
78
|
+
actions: (_ctx, evt, { self }) => {
|
|
79
|
+
self.sendChild("DISMISS", evt.id)
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
DISMISS_ALL: {
|
|
84
|
+
actions: (ctx) => {
|
|
85
|
+
ctx.toasts.forEach((toast) => toast.send("DISMISS"))
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
REMOVE_TOAST: {
|
|
90
|
+
actions: (ctx, evt, { self }) => {
|
|
91
|
+
self.stopChild(evt.id)
|
|
92
|
+
const index = ctx.toasts.findIndex((toast) => toast.id === evt.id)
|
|
93
|
+
ctx.toasts.splice(index, 1)
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
REMOVE_ALL: {
|
|
98
|
+
actions: (ctx, _evt, { self }) => {
|
|
99
|
+
ctx.toasts.forEach((toast) => self.stopChild(toast.id))
|
|
100
|
+
while (ctx.toasts.length) ctx.toasts.pop()
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { dataAttr } from "@zag-js/dom-query"
|
|
2
|
+
import type { NormalizeProps, PropTypes } from "@zag-js/types"
|
|
3
|
+
import { parts } from "./toast.anatomy"
|
|
4
|
+
import { dom } from "./toast.dom"
|
|
5
|
+
import type { Send, State } from "./toast.types"
|
|
6
|
+
|
|
7
|
+
export function connect<T extends PropTypes>(state: State, send: Send, normalize: NormalizeProps<T>) {
|
|
8
|
+
const isVisible = state.hasTag("visible")
|
|
9
|
+
const isPaused = state.hasTag("paused")
|
|
10
|
+
|
|
11
|
+
const pauseOnInteraction = state.context.pauseOnInteraction
|
|
12
|
+
const placement = state.context.placement
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
/**
|
|
16
|
+
* The type of the toast.
|
|
17
|
+
*/
|
|
18
|
+
type: state.context.type,
|
|
19
|
+
/**
|
|
20
|
+
* The title of the toast.
|
|
21
|
+
*/
|
|
22
|
+
title: state.context.title,
|
|
23
|
+
/**
|
|
24
|
+
* The description of the toast.
|
|
25
|
+
*/
|
|
26
|
+
description: state.context.description,
|
|
27
|
+
/**
|
|
28
|
+
* The current placement of the toast.
|
|
29
|
+
*/
|
|
30
|
+
placement,
|
|
31
|
+
/**
|
|
32
|
+
* Whether the toast is visible.
|
|
33
|
+
*/
|
|
34
|
+
isVisible,
|
|
35
|
+
/**
|
|
36
|
+
* Whether the toast is paused.
|
|
37
|
+
*/
|
|
38
|
+
isPaused,
|
|
39
|
+
/**
|
|
40
|
+
* Whether the toast is in RTL mode.
|
|
41
|
+
*/
|
|
42
|
+
isRtl: state.context.dir === "rtl",
|
|
43
|
+
/**
|
|
44
|
+
* Function to pause the toast (keeping it visible).
|
|
45
|
+
*/
|
|
46
|
+
pause() {
|
|
47
|
+
send("PAUSE")
|
|
48
|
+
},
|
|
49
|
+
/**
|
|
50
|
+
* Function to resume the toast dismissing.
|
|
51
|
+
*/
|
|
52
|
+
resume() {
|
|
53
|
+
send("RESUME")
|
|
54
|
+
},
|
|
55
|
+
/**
|
|
56
|
+
* Function to instantly dismiss the toast.
|
|
57
|
+
*/
|
|
58
|
+
dismiss() {
|
|
59
|
+
send("DISMISS")
|
|
60
|
+
},
|
|
61
|
+
/**
|
|
62
|
+
* Function render the toast in the DOM (based on the defined `render` property)
|
|
63
|
+
*/
|
|
64
|
+
render() {
|
|
65
|
+
return state.context.render?.({
|
|
66
|
+
id: state.context.id,
|
|
67
|
+
type: state.context.type,
|
|
68
|
+
duration: state.context.duration,
|
|
69
|
+
title: state.context.title,
|
|
70
|
+
placement: state.context.placement,
|
|
71
|
+
description: state.context.description,
|
|
72
|
+
dismiss() {
|
|
73
|
+
send("DISMISS")
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
rootProps: normalize.element({
|
|
79
|
+
...parts.root.attrs,
|
|
80
|
+
dir: state.context.dir,
|
|
81
|
+
id: dom.getRootId(state.context),
|
|
82
|
+
"data-open": dataAttr(isVisible),
|
|
83
|
+
"data-type": state.context.type,
|
|
84
|
+
"data-placement": placement,
|
|
85
|
+
role: "status",
|
|
86
|
+
"aria-atomic": "true",
|
|
87
|
+
tabIndex: 0,
|
|
88
|
+
style: {
|
|
89
|
+
position: "relative",
|
|
90
|
+
pointerEvents: "auto",
|
|
91
|
+
margin: "calc(var(--toast-gutter) / 2)",
|
|
92
|
+
"--remove-delay": `${state.context.removeDelay}ms`,
|
|
93
|
+
"--duration": `${state.context.duration}ms`,
|
|
94
|
+
},
|
|
95
|
+
onKeyDown(event) {
|
|
96
|
+
if (event.key == "Escape") {
|
|
97
|
+
send("DISMISS")
|
|
98
|
+
event.preventDefault()
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
onFocus() {
|
|
102
|
+
if (pauseOnInteraction) {
|
|
103
|
+
send("PAUSE")
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
onBlur() {
|
|
107
|
+
if (pauseOnInteraction) {
|
|
108
|
+
send("RESUME")
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
onPointerEnter() {
|
|
112
|
+
if (pauseOnInteraction) {
|
|
113
|
+
send("PAUSE")
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
onPointerLeave() {
|
|
117
|
+
if (pauseOnInteraction) {
|
|
118
|
+
send("RESUME")
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
|
|
123
|
+
titleProps: normalize.element({
|
|
124
|
+
...parts.title.attrs,
|
|
125
|
+
id: dom.getTitleId(state.context),
|
|
126
|
+
}),
|
|
127
|
+
|
|
128
|
+
descriptionProps: normalize.element({
|
|
129
|
+
...parts.description.attrs,
|
|
130
|
+
id: dom.getDescriptionId(state.context),
|
|
131
|
+
}),
|
|
132
|
+
|
|
133
|
+
closeTriggerProps: normalize.button({
|
|
134
|
+
id: dom.getCloseTriggerId(state.context),
|
|
135
|
+
...parts.closeTrigger.attrs,
|
|
136
|
+
type: "button",
|
|
137
|
+
"aria-label": "Dismiss notification",
|
|
138
|
+
onClick() {
|
|
139
|
+
send("DISMISS")
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/toast.dom.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createScope } from "@zag-js/dom-query"
|
|
2
|
+
import type { GroupMachineContext as GroupCtx, MachineContext as Ctx, Placement } from "./toast.types"
|
|
3
|
+
|
|
4
|
+
export const dom = createScope({
|
|
5
|
+
getGroupId: (placement: Placement) => `toast-group:${placement}`,
|
|
6
|
+
getRootId: (ctx: Ctx) => `toast:${ctx.id}`,
|
|
7
|
+
getTitleId: (ctx: Ctx) => `toast:${ctx.id}:title`,
|
|
8
|
+
getDescriptionId: (ctx: Ctx) => `toast:${ctx.id}:description`,
|
|
9
|
+
getCloseTriggerId: (ctx: Ctx) => `toast${ctx.id}:close`,
|
|
10
|
+
getPortalId: (ctx: GroupCtx) => `toast-portal:${ctx.id}`,
|
|
11
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createMachine, guards } from "@zag-js/core"
|
|
2
|
+
import { addDomEvent } from "@zag-js/dom-event"
|
|
3
|
+
import { compact } from "@zag-js/utils"
|
|
4
|
+
import { dom } from "./toast.dom"
|
|
5
|
+
import type { MachineContext, MachineState, Options } from "./toast.types"
|
|
6
|
+
import { getToastDuration } from "./toast.utils"
|
|
7
|
+
|
|
8
|
+
const { not, and, or } = guards
|
|
9
|
+
|
|
10
|
+
export function createToastMachine(options: Options = {}) {
|
|
11
|
+
const { type = "info", duration, id = "toast", placement = "bottom", removeDelay = 0, ...restProps } = options
|
|
12
|
+
const ctx = compact(restProps)
|
|
13
|
+
|
|
14
|
+
const computedDuration = getToastDuration(duration, type)
|
|
15
|
+
|
|
16
|
+
return createMachine<MachineContext, MachineState>(
|
|
17
|
+
{
|
|
18
|
+
id,
|
|
19
|
+
entry: "invokeOnOpen",
|
|
20
|
+
initial: type === "loading" ? "persist" : "active",
|
|
21
|
+
context: {
|
|
22
|
+
id,
|
|
23
|
+
type,
|
|
24
|
+
remaining: computedDuration,
|
|
25
|
+
duration: computedDuration,
|
|
26
|
+
removeDelay,
|
|
27
|
+
createdAt: Date.now(),
|
|
28
|
+
placement,
|
|
29
|
+
...ctx,
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
on: {
|
|
33
|
+
UPDATE: [
|
|
34
|
+
{
|
|
35
|
+
guard: and("hasTypeChanged", "isChangingToLoading"),
|
|
36
|
+
target: "persist",
|
|
37
|
+
actions: ["setContext", "invokeOnUpdate"],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
guard: or("hasDurationChanged", "hasTypeChanged"),
|
|
41
|
+
target: "active:temp",
|
|
42
|
+
actions: ["setContext", "invokeOnUpdate"],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
actions: ["setContext", "invokeOnUpdate"],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
states: {
|
|
51
|
+
"active:temp": {
|
|
52
|
+
tags: ["visible", "updating"],
|
|
53
|
+
after: {
|
|
54
|
+
0: "active",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
persist: {
|
|
59
|
+
tags: ["visible", "paused"],
|
|
60
|
+
activities: "trackDocumentVisibility",
|
|
61
|
+
on: {
|
|
62
|
+
RESUME: {
|
|
63
|
+
guard: not("isLoadingType"),
|
|
64
|
+
target: "active",
|
|
65
|
+
actions: ["setCreatedAt"],
|
|
66
|
+
},
|
|
67
|
+
DISMISS: "dismissing",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
active: {
|
|
72
|
+
tags: ["visible"],
|
|
73
|
+
activities: "trackDocumentVisibility",
|
|
74
|
+
after: {
|
|
75
|
+
VISIBLE_DURATION: "dismissing",
|
|
76
|
+
},
|
|
77
|
+
on: {
|
|
78
|
+
DISMISS: "dismissing",
|
|
79
|
+
PAUSE: {
|
|
80
|
+
target: "persist",
|
|
81
|
+
actions: "setRemainingDuration",
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
dismissing: {
|
|
87
|
+
entry: "invokeOnClosing",
|
|
88
|
+
after: {
|
|
89
|
+
REMOVE_DELAY: {
|
|
90
|
+
target: "inactive",
|
|
91
|
+
actions: "notifyParentToRemove",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
inactive: {
|
|
97
|
+
entry: "invokeOnClose",
|
|
98
|
+
type: "final",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
activities: {
|
|
104
|
+
trackDocumentVisibility(ctx, _evt, { send }) {
|
|
105
|
+
if (!ctx.pauseOnPageIdle) return
|
|
106
|
+
const doc = dom.getDoc(ctx)
|
|
107
|
+
return addDomEvent(doc, "visibilitychange", () => {
|
|
108
|
+
send(doc.visibilityState === "hidden" ? "PAUSE" : "RESUME")
|
|
109
|
+
})
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
guards: {
|
|
114
|
+
isChangingToLoading: (_, evt) => evt.toast?.type === "loading",
|
|
115
|
+
isLoadingType: (ctx) => ctx.type === "loading",
|
|
116
|
+
hasTypeChanged: (ctx, evt) => evt.toast?.type !== ctx.type,
|
|
117
|
+
hasDurationChanged: (ctx, evt) => evt.toast?.duration !== ctx.duration,
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
delays: {
|
|
121
|
+
VISIBLE_DURATION: (ctx) => ctx.remaining,
|
|
122
|
+
REMOVE_DELAY: (ctx) => ctx.removeDelay,
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
actions: {
|
|
126
|
+
setRemainingDuration(ctx) {
|
|
127
|
+
ctx.remaining -= Date.now() - ctx.createdAt
|
|
128
|
+
},
|
|
129
|
+
setCreatedAt(ctx) {
|
|
130
|
+
ctx.createdAt = Date.now()
|
|
131
|
+
},
|
|
132
|
+
notifyParentToRemove(_ctx, _evt, { self }) {
|
|
133
|
+
self.sendParent({ type: "REMOVE_TOAST", id: self.id })
|
|
134
|
+
},
|
|
135
|
+
invokeOnClosing(ctx) {
|
|
136
|
+
ctx.onClosing?.()
|
|
137
|
+
},
|
|
138
|
+
invokeOnClose(ctx) {
|
|
139
|
+
ctx.onClose?.()
|
|
140
|
+
},
|
|
141
|
+
invokeOnOpen(ctx) {
|
|
142
|
+
ctx.onOpen?.()
|
|
143
|
+
},
|
|
144
|
+
invokeOnUpdate(ctx) {
|
|
145
|
+
ctx.onUpdate?.()
|
|
146
|
+
},
|
|
147
|
+
setContext(ctx, evt) {
|
|
148
|
+
const { duration, type } = evt.toast
|
|
149
|
+
const time = getToastDuration(duration, type)
|
|
150
|
+
Object.assign(ctx, { ...evt.toast, duration: time, remaining: time })
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { Machine, StateMachine as S } from "@zag-js/core"
|
|
2
|
+
import type { CommonProperties, Context, Direction, DirectionProperty, RequiredBy, RootProperties } from "@zag-js/types"
|
|
3
|
+
|
|
4
|
+
export type Type = "success" | "error" | "loading" | "info" | "custom"
|
|
5
|
+
|
|
6
|
+
export type Placement = "top-start" | "top" | "top-end" | "bottom-start" | "bottom" | "bottom-end"
|
|
7
|
+
|
|
8
|
+
type SharedContext = {
|
|
9
|
+
/**
|
|
10
|
+
* Whether to pause toast when the user leaves the browser tab
|
|
11
|
+
*/
|
|
12
|
+
pauseOnPageIdle?: boolean
|
|
13
|
+
/**
|
|
14
|
+
* Whether to pause the toast when interacted with
|
|
15
|
+
*/
|
|
16
|
+
pauseOnInteraction?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ToastOptions = {
|
|
20
|
+
/**
|
|
21
|
+
* The unique id of the toast
|
|
22
|
+
*/
|
|
23
|
+
id: string
|
|
24
|
+
/**
|
|
25
|
+
* The type of the toast
|
|
26
|
+
*/
|
|
27
|
+
type: Type
|
|
28
|
+
/**
|
|
29
|
+
* The placement of the toast
|
|
30
|
+
*/
|
|
31
|
+
placement: Placement
|
|
32
|
+
/**
|
|
33
|
+
* The message of the toast
|
|
34
|
+
*/
|
|
35
|
+
title?: string
|
|
36
|
+
/**
|
|
37
|
+
* The description of the toast
|
|
38
|
+
*/
|
|
39
|
+
description?: string
|
|
40
|
+
/**
|
|
41
|
+
* The duration the toast will be visible
|
|
42
|
+
*/
|
|
43
|
+
duration: number
|
|
44
|
+
/**
|
|
45
|
+
* Custom function to render the toast element.
|
|
46
|
+
*/
|
|
47
|
+
render?: (options: RenderOptions) => any
|
|
48
|
+
/**
|
|
49
|
+
* The duration for the toast to kept alive before it is removed.
|
|
50
|
+
* Useful for exit transitions.
|
|
51
|
+
*/
|
|
52
|
+
removeDelay?: number
|
|
53
|
+
/**
|
|
54
|
+
* Function called when the toast has been closed and removed
|
|
55
|
+
*/
|
|
56
|
+
onClose?: VoidFunction
|
|
57
|
+
/**
|
|
58
|
+
* Function called when the toast is leaving
|
|
59
|
+
*/
|
|
60
|
+
onClosing?: VoidFunction
|
|
61
|
+
/**
|
|
62
|
+
* Function called when the toast is shown
|
|
63
|
+
*/
|
|
64
|
+
onOpen?: VoidFunction
|
|
65
|
+
/**
|
|
66
|
+
* Function called when the toast is updated
|
|
67
|
+
*/
|
|
68
|
+
onUpdate?: VoidFunction
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type Options = Partial<ToastOptions>
|
|
72
|
+
|
|
73
|
+
export type RenderOptions = Omit<ToastOptions, "render"> & {
|
|
74
|
+
dismiss(): void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type MachineContext = SharedContext &
|
|
78
|
+
RootProperties &
|
|
79
|
+
CommonProperties &
|
|
80
|
+
Omit<ToastOptions, "removeDelay"> & {
|
|
81
|
+
/**
|
|
82
|
+
* The duration for the toast to kept alive before it is removed.
|
|
83
|
+
* Useful for exit transitions.
|
|
84
|
+
*/
|
|
85
|
+
removeDelay: number
|
|
86
|
+
/**
|
|
87
|
+
* The document's text/writing direction.
|
|
88
|
+
*/
|
|
89
|
+
dir?: Direction
|
|
90
|
+
/**
|
|
91
|
+
* The time the toast was created
|
|
92
|
+
*/
|
|
93
|
+
createdAt: number
|
|
94
|
+
/**
|
|
95
|
+
* The time left before the toast is removed
|
|
96
|
+
*/
|
|
97
|
+
remaining: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type MachineState = {
|
|
101
|
+
value: "active" | "active:temp" | "dismissing" | "inactive" | "persist"
|
|
102
|
+
tags: "visible" | "paused" | "updating"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type State = S.State<MachineContext, MachineState>
|
|
106
|
+
|
|
107
|
+
export type Send = S.Send
|
|
108
|
+
|
|
109
|
+
export type Service = Machine<MachineContext, MachineState>
|
|
110
|
+
|
|
111
|
+
type GroupPublicContext = SharedContext &
|
|
112
|
+
DirectionProperty &
|
|
113
|
+
CommonProperties & {
|
|
114
|
+
/**
|
|
115
|
+
* The gutter or spacing between toasts
|
|
116
|
+
*/
|
|
117
|
+
gutter: string
|
|
118
|
+
/**
|
|
119
|
+
* The z-index applied to each toast group
|
|
120
|
+
*/
|
|
121
|
+
zIndex: number
|
|
122
|
+
/**
|
|
123
|
+
* The maximum number of toasts that can be shown at once
|
|
124
|
+
*/
|
|
125
|
+
max: number
|
|
126
|
+
/**
|
|
127
|
+
* The offset from the safe environment edge of the viewport
|
|
128
|
+
*/
|
|
129
|
+
offsets: string | Record<"left" | "right" | "bottom" | "top", string>
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type UserDefinedGroupContext = RequiredBy<GroupPublicContext, "id">
|
|
133
|
+
|
|
134
|
+
type GroupComputedContext = Readonly<{
|
|
135
|
+
/**
|
|
136
|
+
* @computed
|
|
137
|
+
* The total number of toasts in the group
|
|
138
|
+
*/
|
|
139
|
+
readonly count: number
|
|
140
|
+
}>
|
|
141
|
+
|
|
142
|
+
type GroupPrivateContext = Context<{
|
|
143
|
+
/**
|
|
144
|
+
* @internal
|
|
145
|
+
* The child toast machines (spawned by the toast group)
|
|
146
|
+
*/
|
|
147
|
+
toasts: Service[]
|
|
148
|
+
}>
|
|
149
|
+
|
|
150
|
+
export type GroupMachineContext = GroupPublicContext & GroupComputedContext & GroupPrivateContext
|
|
151
|
+
|
|
152
|
+
export type GroupState = S.State<GroupMachineContext>
|
|
153
|
+
|
|
154
|
+
export type GroupSend = (event: S.Event<S.AnyEventObject>) => void
|
|
155
|
+
|
|
156
|
+
type MaybeFunction<Value, Args> = Value | ((arg: Args) => Value)
|
|
157
|
+
|
|
158
|
+
export type PromiseOptions<Value> = {
|
|
159
|
+
loading: ToastOptions
|
|
160
|
+
success: MaybeFunction<ToastOptions, Value>
|
|
161
|
+
error: MaybeFunction<ToastOptions, Error>
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export type GroupProps = {
|
|
165
|
+
placement: Placement
|
|
166
|
+
label?: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type Toaster = {
|
|
170
|
+
count: number
|
|
171
|
+
isVisible(id: string): boolean
|
|
172
|
+
upsert(options: ToastOptions): string | undefined
|
|
173
|
+
create(options: ToastOptions): string | undefined
|
|
174
|
+
success(options: ToastOptions): string | undefined
|
|
175
|
+
error(options: ToastOptions): string | undefined
|
|
176
|
+
loading(options: ToastOptions): string | undefined
|
|
177
|
+
dismiss(id?: string | undefined): void
|
|
178
|
+
remove(id?: string | undefined): void
|
|
179
|
+
promise<T>(promise: Promise<T>, options: PromiseOptions<T>, shared?: ToastOptions): Promise<T>
|
|
180
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Style } from "@zag-js/types"
|
|
2
|
+
import type { GroupMachineContext, MachineContext, Placement, Service, Type } from "./toast.types"
|
|
3
|
+
|
|
4
|
+
export function getToastsByPlacement(toasts: Service[]) {
|
|
5
|
+
const result: Partial<Record<Placement, Service[]>> = {}
|
|
6
|
+
|
|
7
|
+
for (const toast of toasts) {
|
|
8
|
+
const placement = toast.state.context.placement!
|
|
9
|
+
result[placement] ||= []
|
|
10
|
+
result[placement]!.push(toast)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return result
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const defaultTimeouts: Record<Type, number> = {
|
|
17
|
+
info: 5000,
|
|
18
|
+
error: 5000,
|
|
19
|
+
success: 2000,
|
|
20
|
+
loading: Infinity,
|
|
21
|
+
custom: 5000,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getToastDuration(duration: number | undefined, type: MachineContext["type"]) {
|
|
25
|
+
return duration ?? defaultTimeouts[type]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getGroupPlacementStyle(ctx: GroupMachineContext, placement: Placement): Style {
|
|
29
|
+
const offset = ctx.offsets
|
|
30
|
+
const computedOffset =
|
|
31
|
+
typeof offset === "string" ? { left: offset, right: offset, bottom: offset, top: offset } : offset
|
|
32
|
+
|
|
33
|
+
const rtl = ctx.dir === "rtl"
|
|
34
|
+
const computedPlacement = placement
|
|
35
|
+
.replace("-start", rtl ? "-right" : "-left")
|
|
36
|
+
.replace("-end", rtl ? "-left" : "-right")
|
|
37
|
+
|
|
38
|
+
const isRighty = computedPlacement.includes("right")
|
|
39
|
+
const isLefty = computedPlacement.includes("left")
|
|
40
|
+
|
|
41
|
+
const styles: Style = {
|
|
42
|
+
position: "fixed",
|
|
43
|
+
pointerEvents: ctx.count > 0 ? undefined : "none",
|
|
44
|
+
display: "flex",
|
|
45
|
+
flexDirection: "column",
|
|
46
|
+
"--toast-gutter": ctx.gutter,
|
|
47
|
+
zIndex: ctx.zIndex,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let alignItems: Style["alignItems"] = "center"
|
|
51
|
+
if (isRighty) alignItems = "flex-end"
|
|
52
|
+
if (isLefty) alignItems = "flex-start"
|
|
53
|
+
|
|
54
|
+
styles.alignItems = alignItems
|
|
55
|
+
|
|
56
|
+
if (computedPlacement.includes("top")) {
|
|
57
|
+
const offset = computedOffset.top
|
|
58
|
+
styles.top = `calc(env(safe-area-inset-top, 0px) + ${offset})`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (computedPlacement.includes("bottom")) {
|
|
62
|
+
const offset = computedOffset.bottom
|
|
63
|
+
styles.bottom = `calc(env(safe-area-inset-bottom, 0px) + ${offset})`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!computedPlacement.includes("left")) {
|
|
67
|
+
const offset = computedOffset.right
|
|
68
|
+
styles.right = `calc(env(safe-area-inset-right, 0px) + ${offset})`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!computedPlacement.includes("right")) {
|
|
72
|
+
const offset = computedOffset.left
|
|
73
|
+
styles.left = `calc(env(safe-area-inset-left, 0px) + ${offset})`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return styles
|
|
77
|
+
}
|