@zag-js/toast 0.44.0 → 0.46.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.
@@ -1,107 +1,324 @@
1
- import { createMachine } from "@zag-js/core"
2
- import { MAX_Z_INDEX } from "@zag-js/dom-query"
1
+ import { createMachine, ref } from "@zag-js/core"
2
+ import { trackDismissableBranch } from "@zag-js/dismissable"
3
+ import { addDomEvent } from "@zag-js/dom-event"
3
4
  import { compact } from "@zag-js/utils"
5
+ import { dom } from "./toast.dom"
4
6
  import { createToastMachine } from "./toast.machine"
5
- import type { GroupMachineContext, MachineContext, GenericOptions, UserDefinedGroupContext } from "./toast.types"
7
+ import type {
8
+ GroupMachineContext,
9
+ GroupMachineState,
10
+ MachineContext,
11
+ Service,
12
+ UserDefinedGroupContext,
13
+ } from "./toast.types"
14
+ import { getToastsByPlacement } from "./toast.utils"
6
15
 
7
- export function groupMachine<T extends GenericOptions>(userContext: UserDefinedGroupContext<T>) {
16
+ export function groupMachine<T = any>(userContext: UserDefinedGroupContext) {
8
17
  const ctx = compact(userContext)
9
- return createMachine<GroupMachineContext<T>>({
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
- },
18
+ return createMachine<GroupMachineContext<T>, GroupMachineState>(
19
+ {
20
+ id: "toaster",
21
+ initial: ctx.overlap ? "overlap" : "stack",
22
+ context: {
23
+ dir: "ltr",
24
+ max: Number.MAX_SAFE_INTEGER,
25
+ toasts: [],
26
+ gap: 16,
27
+ pauseOnPageIdle: false,
28
+ hotkey: ["altKey", "KeyT"],
29
+ offsets: "1rem",
30
+ placement: "bottom",
31
+ removeDelay: 200,
32
+ ...ctx,
33
+ lastFocusedEl: null,
34
+ isFocusWithin: false,
35
+ heights: [],
36
+ },
23
37
 
24
- computed: {
25
- count: (ctx) => ctx.toasts.length,
26
- },
38
+ computed: {
39
+ count: (ctx) => ctx.toasts.length,
40
+ },
27
41
 
28
- on: {
29
- PAUSE_TOAST: {
30
- actions: (_ctx, evt, { self }) => {
31
- self.sendChild("PAUSE", evt.id)
32
- },
42
+ activities: ["trackDocumentVisibility", "trackHotKeyPress"],
43
+
44
+ watch: {
45
+ toasts: ["collapsedIfEmpty", "setDismissableBranch"],
33
46
  },
34
47
 
35
- PAUSE_ALL: {
36
- actions: (ctx) => {
37
- ctx.toasts.forEach((toast) => toast.send("PAUSE"))
48
+ exit: ["removeToasts", "clearDismissableBranch", "clearLastFocusedEl"],
49
+
50
+ on: {
51
+ PAUSE_TOAST: {
52
+ actions: ["pauseToast"],
53
+ },
54
+ PAUSE_ALL: {
55
+ actions: ["pauseToasts"],
56
+ },
57
+ RESUME_TOAST: {
58
+ actions: ["resumeToast"],
59
+ },
60
+ RESUME_ALL: {
61
+ actions: ["resumeToasts"],
62
+ },
63
+ ADD_TOAST: {
64
+ guard: "isWithinRange",
65
+ actions: ["createToast", "syncToastIndex"],
66
+ },
67
+ UPDATE_TOAST: {
68
+ actions: ["updateToast"],
69
+ },
70
+ DISMISS_TOAST: {
71
+ actions: ["dismissToast"],
72
+ },
73
+ DISMISS_ALL: {
74
+ actions: ["dismissToasts"],
75
+ },
76
+ REMOVE_TOAST: {
77
+ actions: ["removeToast", "syncToastIndex", "syncToastOffset"],
78
+ },
79
+ REMOVE_ALL: {
80
+ actions: ["removeToasts"],
38
81
  },
82
+ UPDATE_HEIGHT: {
83
+ actions: ["syncHeights", "syncToastOffset"],
84
+ },
85
+ "DOC.HOTKEY": {
86
+ actions: ["focusRegionEl"],
87
+ },
88
+ "REGION.BLUR": [
89
+ {
90
+ guard: "isOverlapping",
91
+ target: "overlap",
92
+ actions: ["resumeToasts", "restoreLastFocusedEl"],
93
+ },
94
+ {
95
+ actions: ["resumeToasts", "restoreLastFocusedEl"],
96
+ },
97
+ ],
39
98
  },
40
99
 
41
- RESUME_TOAST: {
42
- actions: (_ctx, evt, { self }) => {
43
- self.sendChild("RESUME", evt.id)
100
+ states: {
101
+ stack: {
102
+ entry: ["expandToasts"],
103
+ on: {
104
+ "REGION.POINTER_LEAVE": [
105
+ {
106
+ guard: "isOverlapping",
107
+ target: "overlap",
108
+ actions: ["resumeToasts"],
109
+ },
110
+ {
111
+ actions: ["resumeToasts"],
112
+ },
113
+ ],
114
+ "REGION.OVERLAP": {
115
+ target: "overlap",
116
+ },
117
+ "REGION.FOCUS": {
118
+ actions: ["setLastFocusedEl", "pauseToasts"],
119
+ },
120
+ "REGION.POINTER_ENTER": {
121
+ actions: ["pauseToasts"],
122
+ },
123
+ },
124
+ },
125
+ overlap: {
126
+ entry: ["collapseToasts"],
127
+ on: {
128
+ "REGION.STACK": {
129
+ target: "stack",
130
+ },
131
+ "REGION.POINTER_ENTER": {
132
+ target: "stack",
133
+ actions: ["pauseToasts"],
134
+ },
135
+ "REGION.FOCUS": {
136
+ target: "stack",
137
+ actions: ["setLastFocusedEl", "pauseToasts"],
138
+ },
139
+ },
44
140
  },
45
141
  },
46
-
47
- RESUME_ALL: {
48
- actions: (ctx) => {
49
- ctx.toasts.forEach((toast) => toast.send("RESUME"))
142
+ },
143
+ {
144
+ guards: {
145
+ isWithinRange: (ctx) => ctx.toasts.length < ctx.max,
146
+ isOverlapping: (ctx) => !!ctx.overlap,
147
+ },
148
+ activities: {
149
+ trackHotKeyPress(ctx, _evt, { send }) {
150
+ const handleKeyDown = (event: KeyboardEvent) => {
151
+ const isHotkeyPressed = ctx.hotkey.every((key) => (event as any)[key] || event.code === key)
152
+ if (!isHotkeyPressed) return
153
+ send({ type: "DOC.HOTKEY" })
154
+ }
155
+ return addDomEvent(document, "keydown", handleKeyDown, { capture: true })
156
+ },
157
+ trackDocumentVisibility(ctx, _evt, { send }) {
158
+ if (!ctx.pauseOnPageIdle) return
159
+ const doc = dom.getDoc(ctx)
160
+ return addDomEvent(doc, "visibilitychange", () => {
161
+ send(doc.visibilityState === "hidden" ? "PAUSE_ALL" : "RESUME_ALL")
162
+ })
50
163
  },
51
164
  },
165
+ actions: {
166
+ setDismissableBranch(ctx) {
167
+ const toastsByPlacement = getToastsByPlacement(ctx.toasts)
168
+ const currentToasts = toastsByPlacement[ctx.placement] ?? []
169
+
170
+ const hasToasts = currentToasts.length > 0
52
171
 
53
- ADD_TOAST: {
54
- guard: (ctx) => ctx.toasts.length < ctx.max,
55
- actions: (ctx, evt, { self }) => {
172
+ if (!hasToasts) {
173
+ ctx._cleanup?.()
174
+ return
175
+ }
176
+
177
+ if (hasToasts && ctx._cleanup) {
178
+ return
179
+ }
180
+
181
+ // mark toast as a dismissable branch
182
+ // so that interacting with them will not close dismissable layers
183
+ const groupEl = () => dom.getRegionEl(ctx, ctx.placement)
184
+ ctx._cleanup = trackDismissableBranch(groupEl, { defer: true })
185
+ },
186
+ clearDismissableBranch(ctx) {
187
+ ctx._cleanup?.()
188
+ },
189
+ focusRegionEl(ctx) {
190
+ queueMicrotask(() => {
191
+ dom.getRegionEl(ctx, ctx.placement)?.focus()
192
+ })
193
+ },
194
+ expandToasts(ctx) {
195
+ each(ctx, (toast) => {
196
+ toast.state.context.stacked = true
197
+ })
198
+ },
199
+ collapseToasts(ctx) {
200
+ each(ctx, (toast) => {
201
+ toast.state.context.stacked = false
202
+ })
203
+ },
204
+ collapsedIfEmpty(ctx, _evt, { send }) {
205
+ if (!ctx.overlap || ctx.toasts.length > 1) return
206
+ send("REGION.OVERLAP")
207
+ },
208
+ pauseToast(_ctx, evt, { self }) {
209
+ self.sendChild("PAUSE", evt.id)
210
+ },
211
+ pauseToasts(ctx) {
212
+ ctx.toasts.forEach((toast) => toast.send("PAUSE"))
213
+ },
214
+ resumeToast(_ctx, evt, { self }) {
215
+ self.sendChild("RESUME", evt.id)
216
+ },
217
+ resumeToasts(ctx) {
218
+ ctx.toasts.forEach((toast) => toast.send("RESUME"))
219
+ },
220
+ measureToasts(ctx) {
221
+ ctx.toasts.forEach((toast) => toast.send("MEASURE"))
222
+ },
223
+ createToast(ctx, evt, { self, getState }) {
56
224
  const options: MachineContext<T> = {
57
225
  placement: ctx.placement,
58
226
  duration: ctx.duration,
59
227
  removeDelay: ctx.removeDelay,
60
- render: ctx.render,
61
228
  ...evt.toast,
62
- pauseOnPageIdle: ctx.pauseOnPageIdle,
63
- pauseOnInteraction: ctx.pauseOnInteraction,
64
229
  dir: ctx.dir,
65
230
  getRootNode: ctx.getRootNode,
231
+ stacked: getState().matches("stack"),
66
232
  }
233
+
67
234
  const toast = createToastMachine(options)
235
+
68
236
  const actor = self.spawn(toast)
69
- ctx.toasts.push(actor as any)
237
+ ctx.toasts = [actor, ...ctx.toasts]
70
238
  },
71
- },
72
-
73
- UPDATE_TOAST: {
74
- actions: (_ctx, evt, { self }) => {
239
+ updateToast(_ctx, evt, { self }) {
75
240
  self.sendChild({ type: "UPDATE", toast: evt.toast }, evt.id)
76
241
  },
77
- },
78
-
79
- DISMISS_TOAST: {
80
- actions: (_ctx, evt, { self }) => {
242
+ dismissToast(_ctx, evt, { self }) {
81
243
  self.sendChild("DISMISS", evt.id)
82
244
  },
83
- },
84
-
85
- DISMISS_ALL: {
86
- actions: (ctx) => {
245
+ dismissToasts(ctx) {
87
246
  ctx.toasts.forEach((toast) => toast.send("DISMISS"))
88
247
  },
89
- },
90
-
91
- REMOVE_TOAST: {
92
- actions: (ctx, evt, { self }) => {
248
+ removeToast(ctx, evt, { self }) {
93
249
  self.stopChild(evt.id)
94
- const index = ctx.toasts.findIndex((toast) => toast.id === evt.id)
95
- ctx.toasts.splice(index, 1)
250
+ ctx.toasts = ctx.toasts.filter((toast) => toast.id !== evt.id)
251
+ ctx.heights = ctx.heights.filter((height) => height.id !== evt.id)
96
252
  },
97
- },
98
-
99
- REMOVE_ALL: {
100
- actions: (ctx, _evt, { self }) => {
253
+ removeToasts(ctx, _evt, { self }) {
101
254
  ctx.toasts.forEach((toast) => self.stopChild(toast.id))
102
- while (ctx.toasts.length) ctx.toasts.pop()
255
+ ctx.toasts = []
256
+ ctx.heights = []
257
+ },
258
+ syncHeights(ctx, evt) {
259
+ const existing = ctx.heights.find((height) => height.id === evt.id)
260
+ if (existing) {
261
+ existing.height = evt.height
262
+ existing.placement = evt.placement
263
+ } else {
264
+ const newHeight = { id: evt.id, height: evt.height, placement: evt.placement }
265
+ ctx.heights = [newHeight, ...ctx.heights]
266
+ }
267
+ },
268
+ syncToastIndex(ctx) {
269
+ each(ctx, (toast, index, toasts) => {
270
+ // Note: This is an intentional side effect
271
+ // consider writing directly to the DOM (root element)
272
+ toast.state.context.index = index
273
+ toast.state.context.frontmost = index === 0
274
+ toast.state.context.zIndex = toasts.length - index
275
+ })
276
+ },
277
+ syncToastOffset(ctx, evt) {
278
+ const placement = evt.placement ?? ctx.placement
279
+
280
+ // Notify each toast of it's index
281
+ each({ ...ctx, placement }, (toast) => {
282
+ const heightIndex = Math.max(
283
+ ctx.heights.findIndex((height) => height.id === toast.id),
284
+ 0,
285
+ )
286
+
287
+ // calculate offset until toast
288
+ const toastsHeightBefore = ctx.heights.reduce((prev, curr, reducerIndex) => {
289
+ if (reducerIndex >= heightIndex) return prev
290
+ return prev + curr.height
291
+ }, 0)
292
+
293
+ // Note: This is an intentional side effect
294
+ // consider writing directly to the DOM (root element)
295
+ toast.state.context.offset = heightIndex * ctx.gap + toastsHeightBefore
296
+ })
297
+ },
298
+ setLastFocusedEl(ctx, evt) {
299
+ if (ctx.isFocusWithin || !evt.target) return
300
+ ctx.isFocusWithin = true
301
+ ctx.lastFocusedEl = ref(evt.target)
302
+ },
303
+ restoreLastFocusedEl(ctx) {
304
+ ctx.isFocusWithin = false
305
+ if (!ctx.lastFocusedEl) return
306
+ ctx.lastFocusedEl.focus({ preventScroll: true })
307
+ ctx.lastFocusedEl = null
308
+ },
309
+ clearLastFocusedEl(ctx) {
310
+ if (!ctx.lastFocusedEl) return
311
+ ctx.lastFocusedEl.focus({ preventScroll: true })
312
+ ctx.lastFocusedEl = null
313
+ ctx.isFocusWithin = false
103
314
  },
104
315
  },
105
316
  },
106
- })
317
+ )
318
+ }
319
+
320
+ function each(ctx: GroupMachineContext, fn: (toast: Service<any>, index: number, arr: Service<any>[]) => void) {
321
+ const toastsByPlacement = getToastsByPlacement(ctx.toasts)
322
+ const currentToasts = toastsByPlacement[ctx.placement] ?? []
323
+ currentToasts.forEach(fn)
107
324
  }
@@ -1,4 +1,13 @@
1
1
  import { createAnatomy } from "@zag-js/anatomy"
2
2
 
3
- export const anatomy = createAnatomy("toast").parts("group", "root", "title", "description", "closeTrigger")
3
+ export const anatomy = createAnatomy("toast").parts(
4
+ "group",
5
+ "root",
6
+ "ghost",
7
+ "title",
8
+ "description",
9
+ "actionTrigger",
10
+ "closeTrigger",
11
+ )
12
+
4
13
  export const parts = anatomy.build()
@@ -1,9 +1,11 @@
1
+ import { dataAttr } from "@zag-js/dom-query"
1
2
  import type { NormalizeProps, PropTypes } from "@zag-js/types"
2
3
  import { parts } from "./toast.anatomy"
3
4
  import { dom } from "./toast.dom"
4
- import type { MachineApi, Send, State, GenericOptions } from "./toast.types"
5
+ import type { MachineApi, Send, State } from "./toast.types"
6
+ import { getGhostAfterStyle, getGhostBeforeStyle, getPlacementStyle } from "./toast.utils"
5
7
 
6
- export function connect<T extends PropTypes, O extends GenericOptions>(
8
+ export function connect<T extends PropTypes, O>(
7
9
  state: State<O>,
8
10
  send: Send,
9
11
  normalize: NormalizeProps<T>,
@@ -11,11 +13,13 @@ export function connect<T extends PropTypes, O extends GenericOptions>(
11
13
  const isVisible = state.hasTag("visible")
12
14
  const isPaused = state.hasTag("paused")
13
15
 
14
- const pauseOnInteraction = state.context.pauseOnInteraction
15
16
  const placement = state.context.placement!
17
+ const type = state.context.type
18
+
19
+ const [side, align = "center"] = placement.split("-")
16
20
 
17
21
  return {
18
- type: state.context.type,
22
+ type: type,
19
23
  title: state.context.title,
20
24
  description: state.context.description,
21
25
  placement,
@@ -40,44 +44,42 @@ export function connect<T extends PropTypes, O extends GenericOptions>(
40
44
  dir: state.context.dir,
41
45
  id: dom.getRootId(state.context),
42
46
  "data-state": isVisible ? "open" : "closed",
43
- "data-type": state.context.type,
47
+ "data-type": type,
44
48
  "data-placement": placement,
49
+ "data-align": align,
50
+ "data-side": side,
51
+ "data-mounted": dataAttr(state.context.mounted),
52
+ "data-paused": dataAttr(isPaused),
53
+
54
+ "data-first": dataAttr(state.context.frontmost),
55
+ "data-sibling": dataAttr(!state.context.frontmost),
56
+ "data-stack": dataAttr(state.context.stacked),
57
+ "data-overlap": dataAttr(!state.context.stacked),
58
+
45
59
  role: "status",
46
60
  "aria-atomic": "true",
47
61
  tabIndex: 0,
48
- style: {
49
- position: "relative",
50
- pointerEvents: "auto",
51
- margin: "calc(var(--toast-gutter) / 2)",
52
- "--remove-delay": `${state.context.removeDelay}ms`,
53
- "--duration": `${state.context.duration}ms`,
54
- },
62
+ style: getPlacementStyle(state.context, isVisible),
55
63
  onKeyDown(event) {
56
64
  if (event.key == "Escape") {
57
65
  send("DISMISS")
58
66
  event.preventDefault()
59
67
  }
60
68
  },
61
- onFocus() {
62
- if (pauseOnInteraction) {
63
- send("PAUSE")
64
- }
65
- },
66
- onBlur() {
67
- if (pauseOnInteraction) {
68
- send("RESUME")
69
- }
70
- },
71
- onPointerEnter() {
72
- if (pauseOnInteraction) {
73
- send("PAUSE")
74
- }
75
- },
76
- onPointerLeave() {
77
- if (pauseOnInteraction) {
78
- send("RESUME")
79
- }
80
- },
69
+ }),
70
+
71
+ /* Leave a ghost div to avoid setting hover to false when transitioning out */
72
+ ghostBeforeProps: normalize.element({
73
+ ...parts.ghost.attrs,
74
+ "data-type": "before",
75
+ style: getGhostBeforeStyle(state.context, isVisible),
76
+ }),
77
+
78
+ /* Needed to avoid setting hover to false when in between toasts */
79
+ ghostAfterProps: normalize.element({
80
+ ...parts.ghost.attrs,
81
+ "data-type": "after",
82
+ style: getGhostAfterStyle(state.context, isVisible),
81
83
  }),
82
84
 
83
85
  titleProps: normalize.element({
@@ -90,6 +92,14 @@ export function connect<T extends PropTypes, O extends GenericOptions>(
90
92
  id: dom.getDescriptionId(state.context),
91
93
  }),
92
94
 
95
+ actionTriggerProps: normalize.button({
96
+ ...parts.actionTrigger.attrs,
97
+ type: "button",
98
+ onClick() {
99
+ send("DISMISS")
100
+ },
101
+ }),
102
+
93
103
  closeTriggerProps: normalize.button({
94
104
  id: dom.getCloseTriggerId(state.context),
95
105
  ...parts.closeTrigger.attrs,
package/src/toast.dom.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import { createScope } from "@zag-js/dom-query"
2
- import type { MachineContext as Ctx, Placement } from "./toast.types"
2
+ import type { MachineContext as Ctx, Placement, GroupMachineContext as GroupCtx } from "./toast.types"
3
3
 
4
4
  export const dom = createScope({
5
- getGroupId: (placement: Placement) => `toast-group:${placement}`,
5
+ getRegionId: (placement: Placement) => `toast-group:${placement}`,
6
+ getRegionEl: (ctx: GroupCtx, placement: Placement) => dom.getById(ctx, `toast-group:${placement}`),
7
+
6
8
  getRootId: (ctx: Ctx) => `toast:${ctx.id}`,
9
+ getRootEl: (ctx: Ctx) => dom.getById(ctx, dom.getRootId(ctx)),
7
10
  getTitleId: (ctx: Ctx) => `toast:${ctx.id}:title`,
8
11
  getDescriptionId: (ctx: Ctx) => `toast:${ctx.id}:description`,
9
12
  getCloseTriggerId: (ctx: Ctx) => `toast${ctx.id}:close`,