@zag-js/aria-hidden 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.
Files changed (2) hide show
  1. package/package.json +4 -3
  2. package/src/index.ts +179 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zag-js/aria-hidden",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Hide targets from screen readers",
5
5
  "keywords": [
6
6
  "js",
@@ -13,7 +13,8 @@
13
13
  "repository": "https://github.com/chakra-ui/zag/tree/main/packages/utilities/aria-hidden",
14
14
  "sideEffects": false,
15
15
  "files": [
16
- "dist/**/*"
16
+ "dist",
17
+ "src"
17
18
  ],
18
19
  "publishConfig": {
19
20
  "access": "public"
@@ -24,7 +25,7 @@
24
25
  "clean-package": "../../../clean-package.config.json",
25
26
  "main": "dist/index.js",
26
27
  "dependencies": {
27
- "@zag-js/dom-query": "0.9.2"
28
+ "@zag-js/dom-query": "0.10.0"
28
29
  },
29
30
  "devDependencies": {
30
31
  "clean-package": "2.2.0"
package/src/index.ts ADDED
@@ -0,0 +1,179 @@
1
+ // Credits: https://github.com/adobe/react-spectrum/blob/main/packages/@react-aria/overlays/src/ariaHideOutside.ts
2
+ import { raf } from "@zag-js/dom-query"
3
+
4
+ const refCountMap = new WeakMap<Element, number>()
5
+ const observerStack: any[] = []
6
+
7
+ export type AriaHiddenOptions = {
8
+ rootEl?: HTMLElement
9
+ defer?: boolean
10
+ }
11
+
12
+ type MaybeElement = HTMLElement | null
13
+ type Targets = Array<MaybeElement>
14
+ type TargetsOrFn = Targets | (() => Targets)
15
+
16
+ function ariaHiddenImpl(targets: Targets, options: AriaHiddenOptions = {}) {
17
+ const { rootEl } = options
18
+
19
+ const exclude = targets.filter(Boolean) as HTMLElement[]
20
+ if (exclude.length === 0) return
21
+
22
+ const doc = exclude[0].ownerDocument || document
23
+ const win = doc.defaultView ?? window
24
+
25
+ const visibleNodes = new Set<Element>(exclude)
26
+ const hiddenNodes = new Set<Element>()
27
+
28
+ const root = rootEl ?? doc.body
29
+
30
+ let walk = (root: Element) => {
31
+ // Keep live announcer and top layer elements (e.g. toasts) visible.
32
+ for (let element of root.querySelectorAll("[data-live-announcer], [data-zag-top-layer]")) {
33
+ visibleNodes.add(element)
34
+ }
35
+
36
+ let acceptNode = (node: Element) => {
37
+ // Skip this node and its children if it is one of the target nodes, or a live announcer.
38
+ // Also skip children of already hidden nodes, as aria-hidden is recursive. An exception is
39
+ // made for elements with role="row" since VoiceOver on iOS has issues hiding elements with role="row".
40
+ // For that case we want to hide the cells inside as well (https://bugs.webkit.org/show_bug.cgi?id=222623).
41
+ if (
42
+ visibleNodes.has(node) ||
43
+ (hiddenNodes.has(node.parentElement!) && node.parentElement!.getAttribute("role") !== "row")
44
+ ) {
45
+ return NodeFilter.FILTER_REJECT
46
+ }
47
+
48
+ // Skip this node but continue to children if one of the targets is inside the node.
49
+ for (let target of visibleNodes) {
50
+ if (node.contains(target)) {
51
+ return NodeFilter.FILTER_SKIP
52
+ }
53
+ }
54
+
55
+ return NodeFilter.FILTER_ACCEPT
56
+ }
57
+
58
+ let walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, { acceptNode })
59
+
60
+ // TreeWalker does not include the root.
61
+ let acceptRoot = acceptNode(root)
62
+ if (acceptRoot === NodeFilter.FILTER_ACCEPT) {
63
+ hide(root)
64
+ }
65
+
66
+ if (acceptRoot !== NodeFilter.FILTER_REJECT) {
67
+ let node = walker.nextNode() as Element
68
+ while (node != null) {
69
+ hide(node)
70
+ node = walker.nextNode() as Element
71
+ }
72
+ }
73
+ }
74
+
75
+ let hide = (node: Element) => {
76
+ let refCount = refCountMap.get(node) ?? 0
77
+
78
+ // If already aria-hidden, and the ref count is zero, then this element
79
+ // was already hidden and there's nothing for us to do.
80
+ if (node.getAttribute("aria-hidden") === "true" && refCount === 0) {
81
+ return
82
+ }
83
+
84
+ if (refCount === 0) {
85
+ node.setAttribute("aria-hidden", "true")
86
+ }
87
+
88
+ hiddenNodes.add(node)
89
+ refCountMap.set(node, refCount + 1)
90
+ }
91
+
92
+ if (observerStack.length) {
93
+ observerStack[observerStack.length - 1].disconnect()
94
+ }
95
+
96
+ walk(root)
97
+
98
+ const observer = new win.MutationObserver((changes) => {
99
+ for (let change of changes) {
100
+ if (change.type !== "childList" || change.addedNodes.length === 0) {
101
+ continue
102
+ }
103
+
104
+ // If the parent element of the added nodes is not within one of the targets,
105
+ // and not already inside a hidden node, hide all of the new children.
106
+ if (![...visibleNodes, ...hiddenNodes].some((node) => node.contains(change.target))) {
107
+ for (let node of change.removedNodes) {
108
+ if (node instanceof win.Element) {
109
+ visibleNodes.delete(node)
110
+ hiddenNodes.delete(node)
111
+ }
112
+ }
113
+
114
+ for (let node of change.addedNodes) {
115
+ if (
116
+ (node instanceof win.HTMLElement || node instanceof win.SVGElement) &&
117
+ (node.dataset.liveAnnouncer === "true" || node.dataset.zagTopLayer === "true")
118
+ ) {
119
+ visibleNodes.add(node)
120
+ } else if (node instanceof win.Element) {
121
+ walk(node)
122
+ }
123
+ }
124
+ }
125
+ }
126
+ })
127
+
128
+ observer.observe(root, { childList: true, subtree: true })
129
+
130
+ let observerWrapper = {
131
+ observe() {
132
+ observer.observe(root, { childList: true, subtree: true })
133
+ },
134
+ disconnect() {
135
+ observer.disconnect()
136
+ },
137
+ }
138
+
139
+ observerStack.push(observerWrapper)
140
+
141
+ return () => {
142
+ observer.disconnect()
143
+
144
+ for (let node of hiddenNodes) {
145
+ let count = refCountMap.get(node)
146
+ if (count === 1) {
147
+ node.removeAttribute("aria-hidden")
148
+ refCountMap.delete(node)
149
+ } else {
150
+ refCountMap.set(node, count! - 1)
151
+ }
152
+ }
153
+
154
+ // Remove this observer from the stack, and start the previous one.
155
+ if (observerWrapper === observerStack[observerStack.length - 1]) {
156
+ observerStack.pop()
157
+ if (observerStack.length) {
158
+ observerStack[observerStack.length - 1].observe()
159
+ }
160
+ } else {
161
+ observerStack.splice(observerStack.indexOf(observerWrapper), 1)
162
+ }
163
+ }
164
+ }
165
+
166
+ export function ariaHidden(targetsOrFn: TargetsOrFn, options: AriaHiddenOptions = {}) {
167
+ const { defer } = options
168
+ const func = defer ? raf : (v: any) => v()
169
+ const cleanups: (VoidFunction | undefined)[] = []
170
+ cleanups.push(
171
+ func(() => {
172
+ const targets = typeof targetsOrFn === "function" ? targetsOrFn() : targetsOrFn
173
+ cleanups.push(ariaHiddenImpl(targets, options))
174
+ }),
175
+ )
176
+ return () => {
177
+ cleanups.forEach((fn) => fn?.())
178
+ }
179
+ }