@zag-js/dismissable 0.9.2 → 0.10.1
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 +7 -6
- package/src/dismissable-layer.ts +111 -0
- package/src/escape-keydown.ts +9 -0
- package/src/index.ts +2 -0
- package/src/layer-stack.ts +75 -0
- package/src/pointer-event-outside.ts +33 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zag-js/dismissable",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Dismissable layer utilities for the DOM",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"js",
|
|
@@ -17,16 +17,17 @@
|
|
|
17
17
|
"repository": "https://github.com/chakra-ui/zag/tree/main/packages/utilities/interact-outside",
|
|
18
18
|
"sideEffects": false,
|
|
19
19
|
"files": [
|
|
20
|
-
"dist
|
|
20
|
+
"dist",
|
|
21
|
+
"src"
|
|
21
22
|
],
|
|
22
23
|
"publishConfig": {
|
|
23
24
|
"access": "public"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|
|
26
|
-
"@zag-js/interact-outside": "0.
|
|
27
|
-
"@zag-js/dom-query": "0.
|
|
28
|
-
"@zag-js/dom-event": "0.
|
|
29
|
-
"@zag-js/utils": "0.
|
|
27
|
+
"@zag-js/interact-outside": "0.10.1",
|
|
28
|
+
"@zag-js/dom-query": "0.10.1",
|
|
29
|
+
"@zag-js/dom-event": "0.10.1",
|
|
30
|
+
"@zag-js/utils": "0.10.1"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"clean-package": "2.2.0"
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { contains, getEventTarget, raf } from "@zag-js/dom-query"
|
|
2
|
+
import {
|
|
3
|
+
FocusOutsideEvent,
|
|
4
|
+
InteractOutsideHandlers,
|
|
5
|
+
PointerDownOutsideEvent,
|
|
6
|
+
trackInteractOutside,
|
|
7
|
+
} from "@zag-js/interact-outside"
|
|
8
|
+
import { warn } from "@zag-js/utils"
|
|
9
|
+
import { trackEscapeKeydown } from "./escape-keydown"
|
|
10
|
+
import { Layer, layerStack } from "./layer-stack"
|
|
11
|
+
import { assignPointerEventToLayers, clearPointerEvent, disablePointerEventsOutside } from "./pointer-event-outside"
|
|
12
|
+
|
|
13
|
+
type MaybeElement = HTMLElement | null
|
|
14
|
+
type Container = MaybeElement | Array<MaybeElement>
|
|
15
|
+
type NodeOrFn = MaybeElement | (() => MaybeElement)
|
|
16
|
+
|
|
17
|
+
export type DismissableElementHandlers = InteractOutsideHandlers & {
|
|
18
|
+
onEscapeKeyDown?: (event: KeyboardEvent) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type DismissableElementOptions = DismissableElementHandlers & {
|
|
22
|
+
debug?: boolean
|
|
23
|
+
pointerBlocking?: boolean
|
|
24
|
+
onDismiss: () => void
|
|
25
|
+
exclude?: Container | (() => Container)
|
|
26
|
+
defer?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function trackDismissableElementImpl(node: MaybeElement, options: DismissableElementOptions) {
|
|
30
|
+
if (!node) {
|
|
31
|
+
warn("[@zag-js/dismissable] node is `null` or `undefined`")
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { onDismiss, pointerBlocking, exclude: excludeContainers, debug } = options
|
|
36
|
+
|
|
37
|
+
const layer: Layer = { dismiss: onDismiss, node, pointerBlocking }
|
|
38
|
+
|
|
39
|
+
layerStack.add(layer)
|
|
40
|
+
assignPointerEventToLayers()
|
|
41
|
+
|
|
42
|
+
function onPointerDownOutside(event: PointerDownOutsideEvent) {
|
|
43
|
+
const target = getEventTarget(event.detail.originalEvent)
|
|
44
|
+
if (layerStack.isBelowPointerBlockingLayer(node!) || layerStack.isInBranch(target)) return
|
|
45
|
+
options.onPointerDownOutside?.(event)
|
|
46
|
+
options.onInteractOutside?.(event)
|
|
47
|
+
if (event.defaultPrevented) return
|
|
48
|
+
if (debug) {
|
|
49
|
+
console.log("onPointerDownOutside:", event.detail.originalEvent)
|
|
50
|
+
}
|
|
51
|
+
onDismiss?.()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function onFocusOutside(event: FocusOutsideEvent) {
|
|
55
|
+
const target = getEventTarget(event.detail.originalEvent)
|
|
56
|
+
if (layerStack.isInBranch(target)) return
|
|
57
|
+
options.onFocusOutside?.(event)
|
|
58
|
+
options.onInteractOutside?.(event)
|
|
59
|
+
if (event.defaultPrevented) return
|
|
60
|
+
if (debug) {
|
|
61
|
+
console.log("onFocusOutside:", event.detail.originalEvent)
|
|
62
|
+
}
|
|
63
|
+
onDismiss?.()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function onEscapeKeyDown(event: KeyboardEvent) {
|
|
67
|
+
if (!layerStack.isTopMost(node!)) return
|
|
68
|
+
options.onEscapeKeyDown?.(event)
|
|
69
|
+
if (!event.defaultPrevented && onDismiss) {
|
|
70
|
+
event.preventDefault()
|
|
71
|
+
onDismiss()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function exclude(target: Element) {
|
|
76
|
+
if (!node) return false
|
|
77
|
+
const containers = typeof excludeContainers === "function" ? excludeContainers() : excludeContainers
|
|
78
|
+
const _containers = Array.isArray(containers) ? containers : [containers]
|
|
79
|
+
return _containers.some((node) => contains(node, target)) || layerStack.isInNestedLayer(node, target)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const cleanups = [
|
|
83
|
+
pointerBlocking ? disablePointerEventsOutside(node) : undefined,
|
|
84
|
+
trackEscapeKeydown(node, onEscapeKeyDown),
|
|
85
|
+
trackInteractOutside(node, { exclude, onFocusOutside, onPointerDownOutside }),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
return () => {
|
|
89
|
+
layerStack.remove(node!)
|
|
90
|
+
// re-assign pointer event to remaining layers
|
|
91
|
+
assignPointerEventToLayers()
|
|
92
|
+
// remove pointer event from removed layer
|
|
93
|
+
clearPointerEvent(node!)
|
|
94
|
+
cleanups.forEach((fn) => fn?.())
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function trackDismissableElement(nodeOrFn: NodeOrFn, options: DismissableElementOptions) {
|
|
99
|
+
const { defer } = options
|
|
100
|
+
const func = defer ? raf : (v: any) => v()
|
|
101
|
+
const cleanups: (VoidFunction | undefined)[] = []
|
|
102
|
+
cleanups.push(
|
|
103
|
+
func(() => {
|
|
104
|
+
const node = typeof nodeOrFn === "function" ? nodeOrFn() : nodeOrFn
|
|
105
|
+
cleanups.push(trackDismissableElementImpl(node, options))
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
return () => {
|
|
109
|
+
cleanups.forEach((fn) => fn?.())
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { addDomEvent } from "@zag-js/dom-event"
|
|
2
|
+
import { getDocument } from "@zag-js/dom-query"
|
|
3
|
+
|
|
4
|
+
export function trackEscapeKeydown(node: HTMLElement, fn?: (event: KeyboardEvent) => void) {
|
|
5
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
6
|
+
if (event.key === "Escape") fn?.(event)
|
|
7
|
+
}
|
|
8
|
+
return addDomEvent(getDocument(node), "keydown", handleKeyDown)
|
|
9
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { contains } from "@zag-js/dom-query"
|
|
2
|
+
|
|
3
|
+
export type Layer = {
|
|
4
|
+
dismiss: VoidFunction
|
|
5
|
+
node: HTMLElement
|
|
6
|
+
pointerBlocking?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const layerStack = {
|
|
10
|
+
layers: [] as Layer[],
|
|
11
|
+
branches: [] as HTMLElement[],
|
|
12
|
+
count(): number {
|
|
13
|
+
return this.layers.length
|
|
14
|
+
},
|
|
15
|
+
pointerBlockingLayers(): Layer[] {
|
|
16
|
+
return this.layers.filter((layer) => layer.pointerBlocking)
|
|
17
|
+
},
|
|
18
|
+
topMostPointerBlockingLayer(): Layer | undefined {
|
|
19
|
+
return [...this.pointerBlockingLayers()].slice(-1)[0]
|
|
20
|
+
},
|
|
21
|
+
hasPointerBlockingLayer(): boolean {
|
|
22
|
+
return this.pointerBlockingLayers().length > 0
|
|
23
|
+
},
|
|
24
|
+
isBelowPointerBlockingLayer(node: HTMLElement) {
|
|
25
|
+
const index = this.indexOf(node)
|
|
26
|
+
const highestBlockingIndex = this.topMostPointerBlockingLayer()
|
|
27
|
+
? this.indexOf(this.topMostPointerBlockingLayer()?.node)
|
|
28
|
+
: -1
|
|
29
|
+
return index < highestBlockingIndex
|
|
30
|
+
},
|
|
31
|
+
isTopMost(node: HTMLElement | null) {
|
|
32
|
+
const layer = this.layers[this.count() - 1]
|
|
33
|
+
return layer?.node === node
|
|
34
|
+
},
|
|
35
|
+
getNestedLayers(node: HTMLElement) {
|
|
36
|
+
return Array.from(this.layers).slice(this.indexOf(node) + 1)
|
|
37
|
+
},
|
|
38
|
+
isInNestedLayer(node: HTMLElement, target: HTMLElement | EventTarget | null) {
|
|
39
|
+
return this.getNestedLayers(node).some((layer) => contains(layer.node, target))
|
|
40
|
+
},
|
|
41
|
+
isInBranch(target: HTMLElement | EventTarget | null) {
|
|
42
|
+
return Array.from(this.branches).some((branch) => contains(branch, target))
|
|
43
|
+
},
|
|
44
|
+
add(layer: Layer) {
|
|
45
|
+
this.layers.push(layer)
|
|
46
|
+
},
|
|
47
|
+
addBranch(node: HTMLElement) {
|
|
48
|
+
this.branches.push(node)
|
|
49
|
+
},
|
|
50
|
+
remove(node: HTMLElement) {
|
|
51
|
+
const index = this.indexOf(node)
|
|
52
|
+
if (index < 0) return
|
|
53
|
+
|
|
54
|
+
// dismiss nested layers
|
|
55
|
+
if (index < this.count() - 1) {
|
|
56
|
+
const _layers = this.getNestedLayers(node)
|
|
57
|
+
_layers.forEach((layer) => layer.dismiss())
|
|
58
|
+
}
|
|
59
|
+
// remove this layer
|
|
60
|
+
this.layers.splice(index, 1)
|
|
61
|
+
},
|
|
62
|
+
removeBranch(node: HTMLElement) {
|
|
63
|
+
const index = this.branches.indexOf(node)
|
|
64
|
+
if (index >= 0) this.branches.splice(index, 1)
|
|
65
|
+
},
|
|
66
|
+
indexOf(node: HTMLElement | undefined) {
|
|
67
|
+
return this.layers.findIndex((layer) => layer.node === node)
|
|
68
|
+
},
|
|
69
|
+
dismiss(node: HTMLElement) {
|
|
70
|
+
this.layers[this.indexOf(node)]?.dismiss()
|
|
71
|
+
},
|
|
72
|
+
clear() {
|
|
73
|
+
this.remove(this.layers[0].node)
|
|
74
|
+
},
|
|
75
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getDocument } from "@zag-js/dom-query"
|
|
2
|
+
import { layerStack } from "./layer-stack"
|
|
3
|
+
|
|
4
|
+
let originalBodyPointerEvents: string
|
|
5
|
+
|
|
6
|
+
export function assignPointerEventToLayers() {
|
|
7
|
+
layerStack.layers.forEach(({ node }) => {
|
|
8
|
+
node.style.pointerEvents = layerStack.isBelowPointerBlockingLayer(node) ? "none" : "auto"
|
|
9
|
+
})
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function clearPointerEvent(node: HTMLElement) {
|
|
13
|
+
node.style.pointerEvents = ""
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DATA_ATTR = "data-inert"
|
|
17
|
+
|
|
18
|
+
export function disablePointerEventsOutside(node: HTMLElement) {
|
|
19
|
+
const doc = getDocument(node)
|
|
20
|
+
|
|
21
|
+
if (layerStack.hasPointerBlockingLayer() && !doc.body.hasAttribute(DATA_ATTR)) {
|
|
22
|
+
originalBodyPointerEvents = document.body.style.pointerEvents
|
|
23
|
+
doc.body.style.pointerEvents = "none"
|
|
24
|
+
doc.body.setAttribute(DATA_ATTR, "")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return () => {
|
|
28
|
+
if (layerStack.hasPointerBlockingLayer()) return
|
|
29
|
+
doc.body.style.pointerEvents = originalBodyPointerEvents
|
|
30
|
+
doc.body.removeAttribute(DATA_ATTR)
|
|
31
|
+
if (doc.body.style.length === 0) doc.body.removeAttribute("style")
|
|
32
|
+
}
|
|
33
|
+
}
|