Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/neat-pigs-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@zag-js/toast": patch
---

Fix issue where toasts collapse immediately when dismissing while hovering, by tracking pointer state and temporarily
ignoring spurious mouse events during DOM mutations using requestAnimationFrame.
6 changes: 6 additions & 0 deletions packages/machines/toast/src/toast-group.connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,16 @@ export function groupConnect<T extends PropTypes, O = any>(
"aria-live": "polite",
role: "region",
style: getGroupPlacementStyle(service, placement),
onMouseEnter() {
if (refs.get("ignoreMouseTimer").isActive()) return
send({ type: "REGION.POINTER_ENTER", placement })
},
onMouseMove() {
if (refs.get("ignoreMouseTimer").isActive()) return
send({ type: "REGION.POINTER_ENTER", placement })
},
onMouseLeave() {
if (refs.get("ignoreMouseTimer").isActive()) return
send({ type: "REGION.POINTER_LEAVE", placement })
},
onFocus(event) {
Expand Down
57 changes: 43 additions & 14 deletions packages/machines/toast/src/toast-group.machine.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { createMachine } from "@zag-js/core"
import { setup } from "@zag-js/core"
import { trackDismissableBranch } from "@zag-js/dismissable"
import { addDomEvent } from "@zag-js/dom-query"
import { addDomEvent, AnimationFrame } from "@zag-js/dom-query"
import { uuid } from "@zag-js/utils"
import * as dom from "./toast.dom"
import type { ToastGroupSchema } from "./toast.types"

export const groupMachine = createMachine<ToastGroupSchema>({
const { guards, createMachine } = setup<ToastGroupSchema>()
const { and } = guards

export const groupMachine = createMachine({
props({ props }) {
return {
dir: "ltr",
Expand All @@ -23,6 +26,8 @@ export const groupMachine = createMachine<ToastGroupSchema>({
return {
lastFocusedEl: null,
isFocusWithin: false,
isPointerWithin: false,
ignoreMouseTimer: AnimationFrame.create(),
dismissableCleanup: undefined,
}
},
Expand Down Expand Up @@ -57,25 +62,29 @@ export const groupMachine = createMachine<ToastGroupSchema>({
})
},

exit: ["clearDismissableBranch", "clearLastFocusedEl"],
exit: ["clearDismissableBranch", "clearLastFocusedEl", "clearMouseEventTimer"],

on: {
"DOC.HOTKEY": {
actions: ["focusRegionEl"],
},
"REGION.BLUR": [
{
guard: "isOverlapping",
guard: and("isOverlapping", "isPointerOut"),
target: "overlap",
actions: ["collapseToasts", "resumeToasts", "restoreLastFocusedEl"],
actions: ["collapseToasts", "resumeToasts", "restoreFocusIfPointerOut"],
},
{
guard: "isPointerOut",
target: "stack",
actions: ["resumeToasts", "restoreLastFocusedEl"],
actions: ["resumeToasts", "restoreFocusIfPointerOut"],
},
{
actions: ["clearFocusWithin"],
},
],
"TOAST.REMOVE": {
actions: ["removeToast", "removeHeight"],
actions: ["removeToast", "removeHeight", "ignoreMouseEventsTemporarily"],
},
"TOAST.PAUSE": {
actions: ["pauseToasts"],
Expand All @@ -89,10 +98,10 @@ export const groupMachine = createMachine<ToastGroupSchema>({
{
guard: "isOverlapping",
target: "overlap",
actions: ["resumeToasts", "collapseToasts"],
actions: ["clearPointerWithin", "resumeToasts", "collapseToasts"],
},
{
actions: ["resumeToasts"],
actions: ["clearPointerWithin", "resumeToasts"],
},
],
"REGION.OVERLAP": {
Expand All @@ -103,7 +112,7 @@ export const groupMachine = createMachine<ToastGroupSchema>({
actions: ["setLastFocusedEl", "pauseToasts"],
},
"REGION.POINTER_ENTER": {
actions: ["pauseToasts"],
actions: ["setPointerWithin", "pauseToasts"],
},
},
},
Expand All @@ -116,7 +125,7 @@ export const groupMachine = createMachine<ToastGroupSchema>({
},
"REGION.POINTER_ENTER": {
target: "stack",
actions: ["pauseToasts", "expandToasts"],
actions: ["setPointerWithin", "pauseToasts", "expandToasts"],
},
"REGION.FOCUS": {
target: "stack",
Expand All @@ -129,6 +138,7 @@ export const groupMachine = createMachine<ToastGroupSchema>({
implementations: {
guards: {
isOverlapping: ({ computed }) => computed("overlap"),
isPointerOut: ({ refs }) => !refs.get("isPointerWithin"),
},

effects: {
Expand Down Expand Up @@ -227,18 +237,37 @@ export const groupMachine = createMachine<ToastGroupSchema>({
refs.set("isFocusWithin", true)
refs.set("lastFocusedEl", event.target)
},
restoreLastFocusedEl({ refs }) {
if (!refs.get("lastFocusedEl")) return
restoreFocusIfPointerOut({ refs }) {
if (!refs.get("lastFocusedEl") || refs.get("isPointerWithin")) return
refs.get("lastFocusedEl")?.focus({ preventScroll: true })
refs.set("lastFocusedEl", null)
refs.set("isFocusWithin", false)
},
setPointerWithin({ refs }) {
refs.set("isPointerWithin", true)
},
clearPointerWithin({ refs }) {
refs.set("isPointerWithin", false)
if (refs.get("lastFocusedEl") && !refs.get("isFocusWithin")) {
refs.get("lastFocusedEl")?.focus({ preventScroll: true })
refs.set("lastFocusedEl", null)
}
},
clearFocusWithin({ refs }) {
refs.set("isFocusWithin", false)
},
clearLastFocusedEl({ refs }) {
if (!refs.get("lastFocusedEl")) return
refs.get("lastFocusedEl")?.focus({ preventScroll: true })
refs.set("lastFocusedEl", null)
refs.set("isFocusWithin", false)
},
ignoreMouseEventsTemporarily({ refs }) {
refs.get("ignoreMouseTimer").request()
},
clearMouseEventTimer({ refs }) {
refs.get("ignoreMouseTimer").cancel()
},
},
},
})
3 changes: 3 additions & 0 deletions packages/machines/toast/src/toast.types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CommonProperties, Direction, DirectionProperty, PropTypes, Required, RequiredBy } from "@zag-js/types"
import type { EventObject, Machine, Service } from "@zag-js/core"
import type { AnimationFrame } from "@zag-js/dom-query"

/* -----------------------------------------------------------------------------
* Base types
Expand Down Expand Up @@ -246,6 +247,8 @@ export type ToastGroupSchema = {
dismissableCleanup?: VoidFunction | undefined
lastFocusedEl: HTMLElement | null
isFocusWithin: boolean
isPointerWithin: boolean
ignoreMouseTimer: AnimationFrame
}
guard: string
effect: string
Expand Down
51 changes: 40 additions & 11 deletions packages/utilities/dom-query/src/raf.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,43 @@
export class AnimationFrame {
static create() {
return new AnimationFrame()
}

private id: number | null = null
private fn_cleanup: VoidFunction | undefined | void

request(fn?: VoidFunction | (() => VoidFunction)) {
this.cancel()
this.id = globalThis.requestAnimationFrame(() => {
this.id = null
this.fn_cleanup = fn?.()
})
}

cancel() {
if (this.id !== null) {
globalThis.cancelAnimationFrame(this.id)
this.id = null
}
this.fn_cleanup?.()
this.fn_cleanup = undefined
}

isActive() {
return this.id !== null
}

cleanup = () => {
this.cancel()
}
}

export function raf(fn: VoidFunction | (() => VoidFunction)) {
const frame = AnimationFrame.create()
frame.request(fn)
return frame.cleanup
}

export function nextTick(fn: VoidFunction) {
const set = new Set<VoidFunction>()
function raf(fn: VoidFunction) {
Expand All @@ -10,17 +50,6 @@ export function nextTick(fn: VoidFunction) {
}
}

export function raf(fn: VoidFunction | (() => VoidFunction)) {
let cleanup: VoidFunction | undefined | void
const id = globalThis.requestAnimationFrame(() => {
cleanup = fn()
})
return () => {
globalThis.cancelAnimationFrame(id)
cleanup?.()
}
}

export function queueBeforeEvent(el: EventTarget, type: string, cb: () => void) {
const cancelTimer = raf(() => {
el.removeEventListener(type, exec, true)
Expand Down