@zag-js/interact-outside 0.9.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zag-js/interact-outside",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Track interations or focus outside an element",
5
5
  "keywords": [
6
6
  "js",
@@ -13,13 +13,14 @@
13
13
  "repository": "https://github.com/chakra-ui/zag/tree/main/packages/utilities/interact-outside",
14
14
  "sideEffects": false,
15
15
  "files": [
16
- "dist/**/*"
16
+ "dist",
17
+ "src"
17
18
  ],
18
19
  "dependencies": {
19
- "@zag-js/dom-query": "0.9.2",
20
- "@zag-js/dom-event": "0.9.2",
21
- "@zag-js/tabbable": "0.9.2",
22
- "@zag-js/utils": "0.9.2"
20
+ "@zag-js/dom-query": "0.10.0",
21
+ "@zag-js/dom-event": "0.10.0",
22
+ "@zag-js/tabbable": "0.10.0",
23
+ "@zag-js/utils": "0.10.0"
23
24
  },
24
25
  "devDependencies": {
25
26
  "clean-package": "2.2.0"
@@ -0,0 +1,36 @@
1
+ export function getWindowFrames(win: Window) {
2
+ const frames = {
3
+ each(cb: (win: Window) => void) {
4
+ for (let i = 0; i < win.frames?.length; i += 1) {
5
+ const frame = win.frames[i]
6
+ if (frame) cb(frame)
7
+ }
8
+ },
9
+ addEventListener(event: string, listener: any, options?: any) {
10
+ frames.each((frame) => {
11
+ try {
12
+ frame.document.addEventListener(event, listener, options)
13
+ } catch (err) {
14
+ console.warn(err)
15
+ }
16
+ })
17
+ return () => {
18
+ try {
19
+ frames.removeEventListener(event, listener, options)
20
+ } catch (err) {
21
+ console.warn(err)
22
+ }
23
+ }
24
+ },
25
+ removeEventListener(event: string, listener: any, options?: any) {
26
+ frames.each((frame) => {
27
+ try {
28
+ frame.document.removeEventListener(event, listener, options)
29
+ } catch (err) {
30
+ console.warn(err)
31
+ }
32
+ })
33
+ },
34
+ }
35
+ return frames
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,155 @@
1
+ import { addDomEvent, fireCustomEvent, isContextMenuEvent } from "@zag-js/dom-event"
2
+ import { contains, getDocument, getEventTarget, getWindow, isHTMLElement, raf } from "@zag-js/dom-query"
3
+ import { isFocusable } from "@zag-js/tabbable"
4
+ import { callAll } from "@zag-js/utils"
5
+ import { getWindowFrames } from "./get-window-frames"
6
+
7
+ export type InteractOutsideHandlers = {
8
+ onPointerDownOutside?: (event: PointerDownOutsideEvent) => void
9
+ onFocusOutside?: (event: FocusOutsideEvent) => void
10
+ onInteractOutside?: (event: InteractOutsideEvent) => void
11
+ }
12
+
13
+ export type InteractOutsideOptions = InteractOutsideHandlers & {
14
+ exclude?: (target: HTMLElement) => boolean
15
+ defer?: boolean
16
+ }
17
+
18
+ type EventDetails<T> = {
19
+ originalEvent: T
20
+ contextmenu: boolean
21
+ focusable: boolean
22
+ }
23
+
24
+ const POINTER_OUTSIDE_EVENT = "pointerdown.outside"
25
+ const FOCUS_OUTSIDE_EVENT = "focus.outside"
26
+
27
+ export type PointerDownOutsideEvent = CustomEvent<EventDetails<PointerEvent>>
28
+
29
+ export type FocusOutsideEvent = CustomEvent<EventDetails<FocusEvent>>
30
+
31
+ export type InteractOutsideEvent = PointerDownOutsideEvent | FocusOutsideEvent
32
+
33
+ type MaybeElement = HTMLElement | null | undefined
34
+ type NodeOrFn = MaybeElement | (() => MaybeElement)
35
+
36
+ function isComposedPathFocusable(event: Event) {
37
+ const composedPath = event.composedPath() ?? [event.target as HTMLElement]
38
+ for (const node of composedPath) {
39
+ if (isHTMLElement(node) && isFocusable(node)) return true
40
+ }
41
+ return false
42
+ }
43
+
44
+ function trackInteractOutsideImpl(node: MaybeElement, options: InteractOutsideOptions) {
45
+ const { exclude, onFocusOutside, onPointerDownOutside, onInteractOutside } = options
46
+
47
+ if (!node) return
48
+
49
+ const doc = getDocument(node)
50
+ const win = getWindow(node)
51
+ const frames = getWindowFrames(win)
52
+
53
+ function isEventOutside(event: Event): boolean {
54
+ const target = getEventTarget(event)
55
+
56
+ if (!isHTMLElement(target)) {
57
+ return false
58
+ }
59
+
60
+ if (contains(node, target)) {
61
+ return false
62
+ }
63
+
64
+ return !exclude?.(target)
65
+ }
66
+
67
+ let clickHandler: VoidFunction
68
+
69
+ function onPointerDown(event: PointerEvent) {
70
+ //
71
+ function handler() {
72
+ if (!node || !isEventOutside(event)) return
73
+
74
+ if (onPointerDownOutside || onInteractOutside) {
75
+ const handler = callAll(onPointerDownOutside, onInteractOutside) as EventListener
76
+ node.addEventListener(POINTER_OUTSIDE_EVENT, handler, { once: true })
77
+ }
78
+
79
+ fireCustomEvent(node, POINTER_OUTSIDE_EVENT, {
80
+ bubbles: false,
81
+ cancelable: true,
82
+ detail: {
83
+ originalEvent: event,
84
+ contextmenu: isContextMenuEvent(event),
85
+ focusable: isComposedPathFocusable(event),
86
+ },
87
+ })
88
+ }
89
+
90
+ if (event.pointerType === "touch") {
91
+ frames.removeEventListener("click", handler)
92
+ doc.removeEventListener("click", handler)
93
+
94
+ clickHandler = handler
95
+
96
+ doc.addEventListener("click", handler, { once: true })
97
+ frames.addEventListener("click", handler, { once: true })
98
+ } else {
99
+ handler()
100
+ }
101
+ }
102
+ const cleanups = new Set<VoidFunction>()
103
+
104
+ const timer = setTimeout(() => {
105
+ cleanups.add(frames.addEventListener("pointerdown", onPointerDown, true))
106
+ cleanups.add(addDomEvent(doc, "pointerdown", onPointerDown, true))
107
+ }, 0)
108
+
109
+ function onFocusin(event: FocusEvent) {
110
+ //
111
+ if (!node || !isEventOutside(event)) return
112
+
113
+ if (onFocusOutside || onInteractOutside) {
114
+ const handler = callAll(onFocusOutside, onInteractOutside) as EventListener
115
+ node.addEventListener(FOCUS_OUTSIDE_EVENT, handler, { once: true })
116
+ }
117
+
118
+ fireCustomEvent(node, FOCUS_OUTSIDE_EVENT, {
119
+ bubbles: false,
120
+ cancelable: true,
121
+ detail: {
122
+ originalEvent: event,
123
+ contextmenu: false,
124
+ focusable: isFocusable(getEventTarget(event)),
125
+ },
126
+ })
127
+ }
128
+
129
+ cleanups.add(addDomEvent(doc, "focusin", onFocusin, true))
130
+ cleanups.add(frames.addEventListener("focusin", onFocusin, true))
131
+
132
+ return () => {
133
+ clearTimeout(timer)
134
+ if (clickHandler) {
135
+ frames.removeEventListener("click", clickHandler)
136
+ doc.removeEventListener("click", clickHandler)
137
+ }
138
+ cleanups.forEach((fn) => fn())
139
+ }
140
+ }
141
+
142
+ export function trackInteractOutside(nodeOrFn: NodeOrFn, options: InteractOutsideOptions) {
143
+ const { defer } = options
144
+ const func = defer ? raf : (v: any) => v()
145
+ const cleanups: (VoidFunction | undefined)[] = []
146
+ cleanups.push(
147
+ func(() => {
148
+ const node = typeof nodeOrFn === "function" ? nodeOrFn() : nodeOrFn
149
+ cleanups.push(trackInteractOutsideImpl(node, options))
150
+ }),
151
+ )
152
+ return () => {
153
+ cleanups.forEach((fn) => fn?.())
154
+ }
155
+ }