Skip to content

Commit 2230b5c

Browse files
alii13segunadebayo
andauthored
fix(toast): prevent toasts from collapsing when pointer is hovering (#2771)
* fix(toast): prevent toasts from collapsing when pointer is hovering When dismissing a toast by clicking the close button while the mouse is hovering over the toast group, the toasts would immediately collapse even though the cursor was still within the group. This was caused by focus restoration triggering browser hover state recalculation. This fix adds pointer tracking to only restore focus and collapse toasts when the pointer has actually left the toast group, maintaining the expected hover behavior. Changes: - Add isPointerWithin ref to track mouse position over toast group - Update REGION.BLUR handler to check pointer position before collapsing - Add guards: isPointerOut, isOverlappingAndPointerOut - Add actions: setPointerWithin, clearPointerWithin, clearFocusWithin - Only restore focus when pointer has left the group Fixes chakra-ui/ark#3628 * chore: add changeset * chore: reformat changeset * fix: toast issue in firefox * chore: cleanup * refactor: setTimeout -> raf * refactor: use animation frame class --------- Co-authored-by: Segun Adebayo <[email protected]>
1 parent 56728d7 commit 2230b5c

File tree

5 files changed

+98
-25
lines changed

5 files changed

+98
-25
lines changed

.changeset/neat-pigs-dance.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@zag-js/toast": patch
3+
---
4+
5+
Fix issue where toasts collapse immediately when dismissing while hovering, by tracking pointer state and temporarily
6+
ignoring spurious mouse events during DOM mutations using requestAnimationFrame.

packages/machines/toast/src/toast-group.connect.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,16 @@ export function groupConnect<T extends PropTypes, O = any>(
3939
"aria-live": "polite",
4040
role: "region",
4141
style: getGroupPlacementStyle(service, placement),
42+
onMouseEnter() {
43+
if (refs.get("ignoreMouseTimer").isActive()) return
44+
send({ type: "REGION.POINTER_ENTER", placement })
45+
},
4246
onMouseMove() {
47+
if (refs.get("ignoreMouseTimer").isActive()) return
4348
send({ type: "REGION.POINTER_ENTER", placement })
4449
},
4550
onMouseLeave() {
51+
if (refs.get("ignoreMouseTimer").isActive()) return
4652
send({ type: "REGION.POINTER_LEAVE", placement })
4753
},
4854
onFocus(event) {

packages/machines/toast/src/toast-group.machine.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import { createMachine } from "@zag-js/core"
1+
import { setup } from "@zag-js/core"
22
import { trackDismissableBranch } from "@zag-js/dismissable"
3-
import { addDomEvent } from "@zag-js/dom-query"
3+
import { addDomEvent, AnimationFrame } from "@zag-js/dom-query"
44
import { uuid } from "@zag-js/utils"
55
import * as dom from "./toast.dom"
66
import type { ToastGroupSchema } from "./toast.types"
77

8-
export const groupMachine = createMachine<ToastGroupSchema>({
8+
const { guards, createMachine } = setup<ToastGroupSchema>()
9+
const { and } = guards
10+
11+
export const groupMachine = createMachine({
912
props({ props }) {
1013
return {
1114
dir: "ltr",
@@ -23,6 +26,8 @@ export const groupMachine = createMachine<ToastGroupSchema>({
2326
return {
2427
lastFocusedEl: null,
2528
isFocusWithin: false,
29+
isPointerWithin: false,
30+
ignoreMouseTimer: AnimationFrame.create(),
2631
dismissableCleanup: undefined,
2732
}
2833
},
@@ -57,25 +62,29 @@ export const groupMachine = createMachine<ToastGroupSchema>({
5762
})
5863
},
5964

60-
exit: ["clearDismissableBranch", "clearLastFocusedEl"],
65+
exit: ["clearDismissableBranch", "clearLastFocusedEl", "clearMouseEventTimer"],
6166

6267
on: {
6368
"DOC.HOTKEY": {
6469
actions: ["focusRegionEl"],
6570
},
6671
"REGION.BLUR": [
6772
{
68-
guard: "isOverlapping",
73+
guard: and("isOverlapping", "isPointerOut"),
6974
target: "overlap",
70-
actions: ["collapseToasts", "resumeToasts", "restoreLastFocusedEl"],
75+
actions: ["collapseToasts", "resumeToasts", "restoreFocusIfPointerOut"],
7176
},
7277
{
78+
guard: "isPointerOut",
7379
target: "stack",
74-
actions: ["resumeToasts", "restoreLastFocusedEl"],
80+
actions: ["resumeToasts", "restoreFocusIfPointerOut"],
81+
},
82+
{
83+
actions: ["clearFocusWithin"],
7584
},
7685
],
7786
"TOAST.REMOVE": {
78-
actions: ["removeToast", "removeHeight"],
87+
actions: ["removeToast", "removeHeight", "ignoreMouseEventsTemporarily"],
7988
},
8089
"TOAST.PAUSE": {
8190
actions: ["pauseToasts"],
@@ -89,10 +98,10 @@ export const groupMachine = createMachine<ToastGroupSchema>({
8998
{
9099
guard: "isOverlapping",
91100
target: "overlap",
92-
actions: ["resumeToasts", "collapseToasts"],
101+
actions: ["clearPointerWithin", "resumeToasts", "collapseToasts"],
93102
},
94103
{
95-
actions: ["resumeToasts"],
104+
actions: ["clearPointerWithin", "resumeToasts"],
96105
},
97106
],
98107
"REGION.OVERLAP": {
@@ -103,7 +112,7 @@ export const groupMachine = createMachine<ToastGroupSchema>({
103112
actions: ["setLastFocusedEl", "pauseToasts"],
104113
},
105114
"REGION.POINTER_ENTER": {
106-
actions: ["pauseToasts"],
115+
actions: ["setPointerWithin", "pauseToasts"],
107116
},
108117
},
109118
},
@@ -116,7 +125,7 @@ export const groupMachine = createMachine<ToastGroupSchema>({
116125
},
117126
"REGION.POINTER_ENTER": {
118127
target: "stack",
119-
actions: ["pauseToasts", "expandToasts"],
128+
actions: ["setPointerWithin", "pauseToasts", "expandToasts"],
120129
},
121130
"REGION.FOCUS": {
122131
target: "stack",
@@ -129,6 +138,7 @@ export const groupMachine = createMachine<ToastGroupSchema>({
129138
implementations: {
130139
guards: {
131140
isOverlapping: ({ computed }) => computed("overlap"),
141+
isPointerOut: ({ refs }) => !refs.get("isPointerWithin"),
132142
},
133143

134144
effects: {
@@ -227,18 +237,37 @@ export const groupMachine = createMachine<ToastGroupSchema>({
227237
refs.set("isFocusWithin", true)
228238
refs.set("lastFocusedEl", event.target)
229239
},
230-
restoreLastFocusedEl({ refs }) {
231-
if (!refs.get("lastFocusedEl")) return
240+
restoreFocusIfPointerOut({ refs }) {
241+
if (!refs.get("lastFocusedEl") || refs.get("isPointerWithin")) return
232242
refs.get("lastFocusedEl")?.focus({ preventScroll: true })
233243
refs.set("lastFocusedEl", null)
234244
refs.set("isFocusWithin", false)
235245
},
246+
setPointerWithin({ refs }) {
247+
refs.set("isPointerWithin", true)
248+
},
249+
clearPointerWithin({ refs }) {
250+
refs.set("isPointerWithin", false)
251+
if (refs.get("lastFocusedEl") && !refs.get("isFocusWithin")) {
252+
refs.get("lastFocusedEl")?.focus({ preventScroll: true })
253+
refs.set("lastFocusedEl", null)
254+
}
255+
},
256+
clearFocusWithin({ refs }) {
257+
refs.set("isFocusWithin", false)
258+
},
236259
clearLastFocusedEl({ refs }) {
237260
if (!refs.get("lastFocusedEl")) return
238261
refs.get("lastFocusedEl")?.focus({ preventScroll: true })
239262
refs.set("lastFocusedEl", null)
240263
refs.set("isFocusWithin", false)
241264
},
265+
ignoreMouseEventsTemporarily({ refs }) {
266+
refs.get("ignoreMouseTimer").request()
267+
},
268+
clearMouseEventTimer({ refs }) {
269+
refs.get("ignoreMouseTimer").cancel()
270+
},
242271
},
243272
},
244273
})

packages/machines/toast/src/toast.types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { CommonProperties, Direction, DirectionProperty, PropTypes, Required, RequiredBy } from "@zag-js/types"
22
import type { EventObject, Machine, Service } from "@zag-js/core"
3+
import type { AnimationFrame } from "@zag-js/dom-query"
34

45
/* -----------------------------------------------------------------------------
56
* Base types
@@ -246,6 +247,8 @@ export type ToastGroupSchema = {
246247
dismissableCleanup?: VoidFunction | undefined
247248
lastFocusedEl: HTMLElement | null
248249
isFocusWithin: boolean
250+
isPointerWithin: boolean
251+
ignoreMouseTimer: AnimationFrame
249252
}
250253
guard: string
251254
effect: string

packages/utilities/dom-query/src/raf.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,43 @@
1+
export class AnimationFrame {
2+
static create() {
3+
return new AnimationFrame()
4+
}
5+
6+
private id: number | null = null
7+
private fn_cleanup: VoidFunction | undefined | void
8+
9+
request(fn?: VoidFunction | (() => VoidFunction)) {
10+
this.cancel()
11+
this.id = globalThis.requestAnimationFrame(() => {
12+
this.id = null
13+
this.fn_cleanup = fn?.()
14+
})
15+
}
16+
17+
cancel() {
18+
if (this.id !== null) {
19+
globalThis.cancelAnimationFrame(this.id)
20+
this.id = null
21+
}
22+
this.fn_cleanup?.()
23+
this.fn_cleanup = undefined
24+
}
25+
26+
isActive() {
27+
return this.id !== null
28+
}
29+
30+
cleanup = () => {
31+
this.cancel()
32+
}
33+
}
34+
35+
export function raf(fn: VoidFunction | (() => VoidFunction)) {
36+
const frame = AnimationFrame.create()
37+
frame.request(fn)
38+
return frame.cleanup
39+
}
40+
141
export function nextTick(fn: VoidFunction) {
242
const set = new Set<VoidFunction>()
343
function raf(fn: VoidFunction) {
@@ -10,17 +50,6 @@ export function nextTick(fn: VoidFunction) {
1050
}
1151
}
1252

13-
export function raf(fn: VoidFunction | (() => VoidFunction)) {
14-
let cleanup: VoidFunction | undefined | void
15-
const id = globalThis.requestAnimationFrame(() => {
16-
cleanup = fn()
17-
})
18-
return () => {
19-
globalThis.cancelAnimationFrame(id)
20-
cleanup?.()
21-
}
22-
}
23-
2453
export function queueBeforeEvent(el: EventTarget, type: string, cb: () => void) {
2554
const cancelTimer = raf(() => {
2655
el.removeEventListener(type, exec, true)

0 commit comments

Comments
 (0)