@zag-js/toast 0.45.0 → 0.47.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,321 @@
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 currentToasts = getToastsByPlacement(ctx.toasts, ctx.placement)
168
+ const hasToasts = currentToasts.length > 0
169
+
170
+ if (!hasToasts) {
171
+ ctx._cleanup?.()
172
+ return
173
+ }
174
+
175
+ if (hasToasts && ctx._cleanup) {
176
+ return
177
+ }
52
178
 
53
- ADD_TOAST: {
54
- guard: (ctx) => ctx.toasts.length < ctx.max,
55
- actions: (ctx, evt, { self }) => {
179
+ // mark toast as a dismissable branch
180
+ // so that interacting with them will not close dismissable layers
181
+ const groupEl = () => dom.getRegionEl(ctx, ctx.placement)
182
+ ctx._cleanup = trackDismissableBranch(groupEl, { defer: true })
183
+ },
184
+ clearDismissableBranch(ctx) {
185
+ ctx._cleanup?.()
186
+ },
187
+ focusRegionEl(ctx) {
188
+ queueMicrotask(() => {
189
+ dom.getRegionEl(ctx, ctx.placement)?.focus()
190
+ })
191
+ },
192
+ expandToasts(ctx) {
193
+ each(ctx, (toast) => {
194
+ toast.state.context.stacked = true
195
+ })
196
+ },
197
+ collapseToasts(ctx) {
198
+ each(ctx, (toast) => {
199
+ toast.state.context.stacked = false
200
+ })
201
+ },
202
+ collapsedIfEmpty(ctx, _evt, { send }) {
203
+ if (!ctx.overlap || ctx.toasts.length > 1) return
204
+ send("REGION.OVERLAP")
205
+ },
206
+ pauseToast(_ctx, evt, { self }) {
207
+ self.sendChild("PAUSE", evt.id)
208
+ },
209
+ pauseToasts(ctx) {
210
+ ctx.toasts.forEach((toast) => toast.send("PAUSE"))
211
+ },
212
+ resumeToast(_ctx, evt, { self }) {
213
+ self.sendChild("RESUME", evt.id)
214
+ },
215
+ resumeToasts(ctx) {
216
+ ctx.toasts.forEach((toast) => toast.send("RESUME"))
217
+ },
218
+ measureToasts(ctx) {
219
+ ctx.toasts.forEach((toast) => toast.send("MEASURE"))
220
+ },
221
+ createToast(ctx, evt, { self, getState }) {
56
222
  const options: MachineContext<T> = {
57
223
  placement: ctx.placement,
58
224
  duration: ctx.duration,
59
225
  removeDelay: ctx.removeDelay,
60
- render: ctx.render,
61
226
  ...evt.toast,
62
- pauseOnPageIdle: ctx.pauseOnPageIdle,
63
- pauseOnInteraction: ctx.pauseOnInteraction,
64
227
  dir: ctx.dir,
65
228
  getRootNode: ctx.getRootNode,
229
+ stacked: getState().matches("stack"),
66
230
  }
231
+
67
232
  const toast = createToastMachine(options)
233
+
68
234
  const actor = self.spawn(toast)
69
- ctx.toasts.push(actor as any)
235
+ ctx.toasts = [actor, ...ctx.toasts]
70
236
  },
71
- },
72
-
73
- UPDATE_TOAST: {
74
- actions: (_ctx, evt, { self }) => {
237
+ updateToast(_ctx, evt, { self }) {
75
238
  self.sendChild({ type: "UPDATE", toast: evt.toast }, evt.id)
76
239
  },
77
- },
78
-
79
- DISMISS_TOAST: {
80
- actions: (_ctx, evt, { self }) => {
240
+ dismissToast(_ctx, evt, { self }) {
81
241
  self.sendChild("DISMISS", evt.id)
82
242
  },
83
- },
84
-
85
- DISMISS_ALL: {
86
- actions: (ctx) => {
243
+ dismissToasts(ctx) {
87
244
  ctx.toasts.forEach((toast) => toast.send("DISMISS"))
88
245
  },
89
- },
90
-
91
- REMOVE_TOAST: {
92
- actions: (ctx, evt, { self }) => {
246
+ removeToast(ctx, evt, { self }) {
93
247
  self.stopChild(evt.id)
94
- const index = ctx.toasts.findIndex((toast) => toast.id === evt.id)
95
- ctx.toasts.splice(index, 1)
248
+ ctx.toasts = ctx.toasts.filter((toast) => toast.id !== evt.id)
249
+ ctx.heights = ctx.heights.filter((height) => height.id !== evt.id)
96
250
  },
97
- },
98
-
99
- REMOVE_ALL: {
100
- actions: (ctx, _evt, { self }) => {
251
+ removeToasts(ctx, _evt, { self }) {
101
252
  ctx.toasts.forEach((toast) => self.stopChild(toast.id))
102
- while (ctx.toasts.length) ctx.toasts.pop()
253
+ ctx.toasts = []
254
+ ctx.heights = []
255
+ },
256
+ syncHeights(ctx, evt) {
257
+ const existing = ctx.heights.find((height) => height.id === evt.id)
258
+ if (existing) {
259
+ existing.height = evt.height
260
+ existing.placement = evt.placement
261
+ } else {
262
+ const newHeight = { id: evt.id, height: evt.height, placement: evt.placement }
263
+ ctx.heights = [newHeight, ...ctx.heights]
264
+ }
265
+ },
266
+ syncToastIndex(ctx) {
267
+ each(ctx, (toast, index, toasts) => {
268
+ // Note: This is an intentional side effect
269
+ // consider writing directly to the DOM (root element)
270
+ toast.state.context.index = index
271
+ toast.state.context.frontmost = index === 0
272
+ toast.state.context.zIndex = toasts.length - index
273
+ })
274
+ },
275
+ syncToastOffset(ctx, evt) {
276
+ const placement = evt.placement ?? ctx.placement
277
+
278
+ // Notify each toast of it's index
279
+ each({ ...ctx, placement }, (toast) => {
280
+ const heightIndex = Math.max(
281
+ ctx.heights.findIndex((height) => height.id === toast.id),
282
+ 0,
283
+ )
284
+
285
+ // calculate offset until toast
286
+ const toastsHeightBefore = ctx.heights.reduce((prev, curr, reducerIndex) => {
287
+ if (reducerIndex >= heightIndex) return prev
288
+ return prev + curr.height
289
+ }, 0)
290
+
291
+ // Note: This is an intentional side effect
292
+ // consider writing directly to the DOM (root element)
293
+ toast.state.context.offset = heightIndex * ctx.gap + toastsHeightBefore
294
+ })
295
+ },
296
+ setLastFocusedEl(ctx, evt) {
297
+ if (ctx.isFocusWithin || !evt.target) return
298
+ ctx.isFocusWithin = true
299
+ ctx.lastFocusedEl = ref(evt.target)
300
+ },
301
+ restoreLastFocusedEl(ctx) {
302
+ ctx.isFocusWithin = false
303
+ if (!ctx.lastFocusedEl) return
304
+ ctx.lastFocusedEl.focus({ preventScroll: true })
305
+ ctx.lastFocusedEl = null
306
+ },
307
+ clearLastFocusedEl(ctx) {
308
+ if (!ctx.lastFocusedEl) return
309
+ ctx.lastFocusedEl.focus({ preventScroll: true })
310
+ ctx.lastFocusedEl = null
311
+ ctx.isFocusWithin = false
103
312
  },
104
313
  },
105
314
  },
106
- })
315
+ )
316
+ }
317
+
318
+ function each(ctx: GroupMachineContext, fn: (toast: Service<any>, index: number, arr: Service<any>[]) => void) {
319
+ const currentToasts = getToastsByPlacement(ctx.toasts, ctx.placement)
320
+ currentToasts.forEach(fn)
107
321
  }
@@ -1,4 +1,12 @@
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
+ "title",
7
+ "description",
8
+ "actionTrigger",
9
+ "closeTrigger",
10
+ )
11
+
4
12
  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,40 @@ 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
+ "data-ghost": "before",
74
+ style: getGhostBeforeStyle(state.context, isVisible),
75
+ }),
76
+
77
+ /* Needed to avoid setting hover to false when in between toasts */
78
+ ghostAfterProps: normalize.element({
79
+ "data-ghost": "after",
80
+ style: getGhostAfterStyle(state.context, isVisible),
81
81
  }),
82
82
 
83
83
  titleProps: normalize.element({
@@ -90,6 +90,14 @@ export function connect<T extends PropTypes, O extends GenericOptions>(
90
90
  id: dom.getDescriptionId(state.context),
91
91
  }),
92
92
 
93
+ actionTriggerProps: normalize.button({
94
+ ...parts.actionTrigger.attrs,
95
+ type: "button",
96
+ onClick() {
97
+ send("DISMISS")
98
+ },
99
+ }),
100
+
93
101
  closeTriggerProps: normalize.button({
94
102
  id: dom.getCloseTriggerId(state.context),
95
103
  ...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`,