@zag-js/dismissable 0.9.1 → 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/dismissable",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
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.9.1",
27
- "@zag-js/dom-query": "0.9.1",
28
- "@zag-js/dom-event": "0.9.1",
29
- "@zag-js/utils": "0.9.1"
27
+ "@zag-js/interact-outside": "0.10.0",
28
+ "@zag-js/dom-query": "0.10.0",
29
+ "@zag-js/dom-event": "0.10.0",
30
+ "@zag-js/utils": "0.10.0"
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,2 @@
1
+ export * from "./dismissable-layer"
2
+ export type { InteractOutsideEvent, PointerDownOutsideEvent, FocusOutsideEvent } from "@zag-js/interact-outside"
@@ -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
+ }