@octanejs/aria 0.0.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/package.json +60 -0
  4. package/src/index.ts +52 -0
  5. package/src/interactions/PressResponder.ts +54 -0
  6. package/src/interactions/Pressable.ts +37 -0
  7. package/src/interactions/context.ts +20 -0
  8. package/src/interactions/createEventHandler.ts +94 -0
  9. package/src/interactions/focusSafely.ts +40 -0
  10. package/src/interactions/textSelection.ts +96 -0
  11. package/src/interactions/useFocus.ts +114 -0
  12. package/src/interactions/useFocusVisible.ts +456 -0
  13. package/src/interactions/useFocusWithin.ts +165 -0
  14. package/src/interactions/useFocusable.ts +205 -0
  15. package/src/interactions/useHover.ts +268 -0
  16. package/src/interactions/useInteractOutside.ts +161 -0
  17. package/src/interactions/useKeyboard.ts +50 -0
  18. package/src/interactions/useLongPress.ts +159 -0
  19. package/src/interactions/useMove.ts +281 -0
  20. package/src/interactions/usePress.ts +1249 -0
  21. package/src/interactions/useScrollWheel.ts +55 -0
  22. package/src/interactions/utils.ts +212 -0
  23. package/src/internal.ts +57 -0
  24. package/src/ssr/SSRProvider.ts +72 -0
  25. package/src/stately/flags.ts +21 -0
  26. package/src/stately/index.ts +7 -0
  27. package/src/stately/utils/number.ts +70 -0
  28. package/src/stately/utils/useControlledState.ts +75 -0
  29. package/src/utils/chain.ts +14 -0
  30. package/src/utils/constants.ts +5 -0
  31. package/src/utils/domHelpers.ts +36 -0
  32. package/src/utils/focusWithoutScrolling.ts +83 -0
  33. package/src/utils/getNonce.ts +53 -0
  34. package/src/utils/isElementVisible.ts +66 -0
  35. package/src/utils/isFocusable.ts +57 -0
  36. package/src/utils/isScrollable.ts +21 -0
  37. package/src/utils/isVirtualEvent.ts +47 -0
  38. package/src/utils/mergeProps.ts +76 -0
  39. package/src/utils/mergeRefs.ts +48 -0
  40. package/src/utils/openLink.ts +231 -0
  41. package/src/utils/platform.ts +74 -0
  42. package/src/utils/runAfterTransition.ts +114 -0
  43. package/src/utils/shadowdom/DOMFunctions.ts +100 -0
  44. package/src/utils/shadowdom/ShadowTreeWalker.ts +303 -0
  45. package/src/utils/useDescription.ts +62 -0
  46. package/src/utils/useEffectEvent.ts +34 -0
  47. package/src/utils/useEvent.ts +55 -0
  48. package/src/utils/useGlobalListeners.ts +91 -0
  49. package/src/utils/useId.ts +150 -0
  50. package/src/utils/useLayoutEffect.ts +7 -0
  51. package/src/utils/useObjectRef.ts +88 -0
  52. package/src/utils/useSyncRef.ts +39 -0
  53. package/src/utils/useValueEffect.ts +81 -0
@@ -0,0 +1,74 @@
1
+ // Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/platform.ts).
2
+ // octane adaptation: `navigator.userAgentData` is not in TS's DOM lib, so it is read through a cast.
3
+
4
+ function testUserAgent(re: RegExp) {
5
+ if (typeof window === 'undefined' || window.navigator == null) {
6
+ return false;
7
+ }
8
+ let brands = (window.navigator as any)['userAgentData']?.brands;
9
+ return (
10
+ (Array.isArray(brands) &&
11
+ brands.some((brand: { brand: string; version: string }) => re.test(brand.brand))) ||
12
+ re.test(window.navigator.userAgent)
13
+ );
14
+ }
15
+
16
+ function testPlatform(re: RegExp) {
17
+ return typeof window !== 'undefined' && window.navigator != null
18
+ ? re.test((window.navigator as any)['userAgentData']?.platform || window.navigator.platform)
19
+ : false;
20
+ }
21
+
22
+ function cached(fn: () => boolean) {
23
+ if (process.env.NODE_ENV === 'test') {
24
+ return fn;
25
+ }
26
+
27
+ let res: boolean | null = null;
28
+ return () => {
29
+ if (res == null) {
30
+ res = fn();
31
+ }
32
+ return res;
33
+ };
34
+ }
35
+
36
+ export const isMac: () => boolean = cached(function () {
37
+ return testPlatform(/^Mac/i);
38
+ });
39
+
40
+ export const isIPhone: () => boolean = cached(function () {
41
+ return testPlatform(/^iPhone/i);
42
+ });
43
+
44
+ export const isIPad: () => boolean = cached(function () {
45
+ return (
46
+ testPlatform(/^iPad/i) ||
47
+ // iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.
48
+ (isMac() && navigator.maxTouchPoints > 1)
49
+ );
50
+ });
51
+
52
+ export const isIOS: () => boolean = cached(function () {
53
+ return isIPhone() || isIPad();
54
+ });
55
+
56
+ export const isAppleDevice: () => boolean = cached(function () {
57
+ return isMac() || isIOS();
58
+ });
59
+
60
+ export const isWebKit: () => boolean = cached(function () {
61
+ return testUserAgent(/AppleWebKit/i) && !isChrome();
62
+ });
63
+
64
+ export const isChrome: () => boolean = cached(function () {
65
+ return testUserAgent(/Chrome/i);
66
+ });
67
+
68
+ export const isAndroid: () => boolean = cached(function () {
69
+ return testUserAgent(/Android/i);
70
+ });
71
+
72
+ export const isFirefox: () => boolean = cached(function () {
73
+ return testUserAgent(/Firefox/i);
74
+ });
@@ -0,0 +1,114 @@
1
+ // Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/runAfterTransition.ts).
2
+
3
+ // We store a global list of elements that are currently transitioning,
4
+ // mapped to a set of CSS properties that are transitioning for that element.
5
+ // This is necessary rather than a simple count of transitions because of browser
6
+ // bugs, e.g. Chrome sometimes fires both transitionend and transitioncancel rather
7
+ // than one or the other. So we need to track what's actually transitioning so that
8
+ // we can ignore these duplicate events.
9
+ import { getEventTarget } from './shadowdom/DOMFunctions';
10
+ let transitionsByElement = new Map<EventTarget, Set<string>>();
11
+
12
+ // A list of callbacks to call once there are no transitioning elements.
13
+ let transitionCallbacks = new Set<() => void>();
14
+
15
+ function setupGlobalEvents() {
16
+ if (typeof window === 'undefined') {
17
+ return;
18
+ }
19
+
20
+ function isTransitionEvent(event: Event): event is TransitionEvent {
21
+ return 'propertyName' in event;
22
+ }
23
+
24
+ let onTransitionStart = (e: Event) => {
25
+ let eventTarget = getEventTarget(e);
26
+ if (!isTransitionEvent(e) || !eventTarget) {
27
+ return;
28
+ }
29
+ // Add the transitioning property to the list for this element.
30
+ let transitions = transitionsByElement.get(eventTarget);
31
+ if (!transitions) {
32
+ transitions = new Set();
33
+ transitionsByElement.set(eventTarget, transitions);
34
+
35
+ // The transitioncancel event must be registered on the element itself, rather than as a global
36
+ // event. This enables us to handle when the node is deleted from the document while it is transitioning.
37
+ // In that case, the cancel event would have nowhere to bubble to so we need to handle it directly.
38
+ eventTarget.addEventListener('transitioncancel', onTransitionEnd, {
39
+ once: true,
40
+ });
41
+ }
42
+
43
+ transitions.add(e.propertyName);
44
+ };
45
+
46
+ let onTransitionEnd = (e: Event) => {
47
+ let eventTarget = getEventTarget(e);
48
+ if (!isTransitionEvent(e) || !eventTarget) {
49
+ return;
50
+ }
51
+ // Remove property from list of transitioning properties.
52
+ let properties = transitionsByElement.get(eventTarget);
53
+ if (!properties) {
54
+ return;
55
+ }
56
+
57
+ properties.delete(e.propertyName);
58
+
59
+ // If empty, remove transitioncancel event, and remove the element from the list of transitioning elements.
60
+ if (properties.size === 0) {
61
+ eventTarget.removeEventListener('transitioncancel', onTransitionEnd);
62
+ transitionsByElement.delete(eventTarget);
63
+ }
64
+
65
+ // If no transitioning elements, call all of the queued callbacks.
66
+ if (transitionsByElement.size === 0) {
67
+ for (let cb of transitionCallbacks) {
68
+ cb();
69
+ }
70
+
71
+ transitionCallbacks.clear();
72
+ }
73
+ };
74
+
75
+ document.body.addEventListener('transitionrun', onTransitionStart);
76
+ document.body.addEventListener('transitionend', onTransitionEnd);
77
+ }
78
+
79
+ if (typeof document !== 'undefined') {
80
+ if (document.readyState !== 'loading') {
81
+ setupGlobalEvents();
82
+ } else {
83
+ document.addEventListener('DOMContentLoaded', setupGlobalEvents);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Cleans up any elements that are no longer in the document.
89
+ * This is necessary because we can't rely on transitionend events to fire
90
+ * for elements that are removed from the document while transitioning.
91
+ */
92
+ function cleanupDetachedElements() {
93
+ for (const [eventTarget] of transitionsByElement) {
94
+ // Similar to `eventTarget instanceof Element && !eventTarget.isConnected`, but avoids
95
+ // the explicit instanceof check, since it may be different in different contexts.
96
+ if ('isConnected' in eventTarget && !eventTarget.isConnected) {
97
+ transitionsByElement.delete(eventTarget);
98
+ }
99
+ }
100
+ }
101
+
102
+ export function runAfterTransition(fn: () => void): void {
103
+ // Wait one frame to see if an animation starts, e.g. a transition on mount.
104
+ requestAnimationFrame(() => {
105
+ cleanupDetachedElements();
106
+ // If no transitions are running, call the function immediately.
107
+ // Otherwise, add it to a list of callbacks to run at the end of the animation.
108
+ if (transitionsByElement.size === 0) {
109
+ fn();
110
+ } else {
111
+ transitionCallbacks.add(fn);
112
+ }
113
+ });
114
+ }
@@ -0,0 +1,100 @@
1
+ // Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts).
2
+ // octane adaptation: events are native DOM events, so upstream's SyntheticEvent target
3
+ // inference and the `event.nativeEvent.composedPath()` branch collapse away in `getEventTarget`.
4
+ // Source: https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/DOMFunctions.ts#L16
5
+
6
+ import { getOwnerWindow, isShadowRoot } from '../domHelpers';
7
+ import { shadowDOM } from '../../stately/flags';
8
+
9
+ /**
10
+ * ShadowDOM safe version of Node.contains.
11
+ */
12
+ export function nodeContains(
13
+ node: Node | Element | null | undefined,
14
+ otherNode: Node | Element | null | undefined,
15
+ ): boolean {
16
+ if (!shadowDOM()) {
17
+ return otherNode && node ? node.contains(otherNode) : false;
18
+ }
19
+
20
+ if (!node || !otherNode) {
21
+ return false;
22
+ }
23
+
24
+ let currentNode: HTMLElement | Node | null | undefined = otherNode;
25
+
26
+ while (currentNode !== null) {
27
+ if (currentNode === node) {
28
+ return true;
29
+ }
30
+
31
+ if (
32
+ (currentNode as HTMLSlotElement).tagName === 'SLOT' &&
33
+ (currentNode as HTMLSlotElement).assignedSlot
34
+ ) {
35
+ // Element is slotted
36
+ currentNode = (currentNode as HTMLSlotElement).assignedSlot!.parentNode;
37
+ } else if (isShadowRoot(currentNode)) {
38
+ // Element is in shadow root
39
+ currentNode = currentNode.host;
40
+ } else {
41
+ currentNode = currentNode.parentNode;
42
+ }
43
+ }
44
+
45
+ return false;
46
+ }
47
+
48
+ /**
49
+ * ShadowDOM safe version of document.activeElement.
50
+ */
51
+ export const getActiveElement = (doc: Document = document): Element | null => {
52
+ if (!shadowDOM()) {
53
+ return doc.activeElement;
54
+ }
55
+ let activeElement: Element | null = doc.activeElement;
56
+
57
+ while (
58
+ activeElement &&
59
+ 'shadowRoot' in activeElement &&
60
+ activeElement.shadowRoot?.activeElement
61
+ ) {
62
+ activeElement = activeElement.shadowRoot.activeElement;
63
+ }
64
+
65
+ return activeElement;
66
+ };
67
+
68
+ /**
69
+ * ShadowDOM safe version of event.target.
70
+ */
71
+ export function getEventTarget<T extends Event>(event: T): EventTarget {
72
+ if (shadowDOM() && event.target instanceof Element && event.target.shadowRoot) {
73
+ if ('composedPath' in event) {
74
+ return (event.composedPath()[0] ?? null) as EventTarget;
75
+ }
76
+ }
77
+ return event.target as EventTarget;
78
+ }
79
+
80
+ /**
81
+ * ShadowDOM safe fast version of node.contains(document.activeElement).
82
+ *
83
+ * @param node
84
+ * @returns
85
+ */
86
+ export function isFocusWithin(node: Element | null | undefined): boolean {
87
+ if (!node) {
88
+ return false;
89
+ }
90
+ // Get the active element within the node's parent shadow root (or the document). Can return null.
91
+ let root = node.getRootNode();
92
+ let ownerWindow = getOwnerWindow(node);
93
+ if (!(root instanceof ownerWindow.Document || root instanceof ownerWindow.ShadowRoot)) {
94
+ return false;
95
+ }
96
+ let activeElement = root.activeElement;
97
+
98
+ // Check if the active element is within this node. These nodes are within the same shadow root.
99
+ return activeElement != null && node.contains(activeElement);
100
+ }
@@ -0,0 +1,303 @@
1
+ // Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/shadowdom/ShadowTreeWalker.ts).
2
+ // https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/ShadowTreeWalker.ts
3
+
4
+ import { nodeContains } from './DOMFunctions';
5
+ import { shadowDOM } from '../../stately/flags';
6
+
7
+ export class ShadowTreeWalker implements TreeWalker {
8
+ public readonly filter: NodeFilter | null;
9
+ public readonly root: Node;
10
+ public readonly whatToShow: number;
11
+
12
+ private _doc: Document;
13
+ private _walkerStack: Array<TreeWalker> = [];
14
+ private _currentNode: Node;
15
+ private _currentSetFor: Set<TreeWalker> = new Set();
16
+
17
+ constructor(doc: Document, root: Node, whatToShow?: number, filter?: NodeFilter | null) {
18
+ this._doc = doc;
19
+ this.root = root;
20
+ this.filter = filter ?? null;
21
+ this.whatToShow = whatToShow ?? NodeFilter.SHOW_ALL;
22
+ this._currentNode = root;
23
+
24
+ this._walkerStack.unshift(doc.createTreeWalker(root, whatToShow, this._acceptNode));
25
+
26
+ const shadowRoot = (root as Element).shadowRoot;
27
+
28
+ if (shadowRoot) {
29
+ const walker = this._doc.createTreeWalker(shadowRoot, this.whatToShow, {
30
+ acceptNode: this._acceptNode,
31
+ });
32
+
33
+ this._walkerStack.unshift(walker);
34
+ }
35
+ }
36
+
37
+ private _acceptNode = (node: Node): number => {
38
+ if (node.nodeType === Node.ELEMENT_NODE) {
39
+ const shadowRoot = (node as Element).shadowRoot;
40
+
41
+ if (shadowRoot) {
42
+ const walker = this._doc.createTreeWalker(shadowRoot, this.whatToShow, {
43
+ acceptNode: this._acceptNode,
44
+ });
45
+
46
+ this._walkerStack.unshift(walker);
47
+
48
+ return NodeFilter.FILTER_ACCEPT;
49
+ } else {
50
+ if (typeof this.filter === 'function') {
51
+ return this.filter(node);
52
+ } else if (this.filter?.acceptNode) {
53
+ return this.filter.acceptNode(node);
54
+ } else if (this.filter === null) {
55
+ return NodeFilter.FILTER_ACCEPT;
56
+ }
57
+ }
58
+ }
59
+
60
+ return NodeFilter.FILTER_SKIP;
61
+ };
62
+
63
+ public get currentNode(): Node {
64
+ return this._currentNode;
65
+ }
66
+
67
+ public set currentNode(node: Node) {
68
+ if (!nodeContains(this.root, node)) {
69
+ throw new Error('Cannot set currentNode to a node that is not contained by the root node.');
70
+ }
71
+
72
+ const walkers: TreeWalker[] = [];
73
+ let curNode: Node | null | undefined = node;
74
+ let currentWalkerCurrentNode = node;
75
+
76
+ this._currentNode = node;
77
+
78
+ while (curNode && curNode !== this.root) {
79
+ if (curNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
80
+ const shadowRoot = curNode as ShadowRoot;
81
+
82
+ const walker = this._doc.createTreeWalker(shadowRoot, this.whatToShow, {
83
+ acceptNode: this._acceptNode,
84
+ });
85
+
86
+ walkers.push(walker);
87
+
88
+ walker.currentNode = currentWalkerCurrentNode;
89
+
90
+ this._currentSetFor.add(walker);
91
+
92
+ curNode = currentWalkerCurrentNode = shadowRoot.host;
93
+ } else {
94
+ curNode = curNode.parentNode;
95
+ }
96
+ }
97
+
98
+ const walker = this._doc.createTreeWalker(this.root, this.whatToShow, {
99
+ acceptNode: this._acceptNode,
100
+ });
101
+
102
+ walkers.push(walker);
103
+
104
+ walker.currentNode = currentWalkerCurrentNode;
105
+
106
+ this._currentSetFor.add(walker);
107
+
108
+ this._walkerStack = walkers;
109
+ }
110
+
111
+ public get doc(): Document {
112
+ return this._doc;
113
+ }
114
+
115
+ public firstChild(): Node | null {
116
+ let currentNode = this.currentNode;
117
+ let newNode = this.nextNode();
118
+ if (!nodeContains(currentNode, newNode)) {
119
+ this.currentNode = currentNode;
120
+ return null;
121
+ }
122
+ if (newNode) {
123
+ this.currentNode = newNode;
124
+ }
125
+ return newNode;
126
+ }
127
+
128
+ public lastChild(): Node | null {
129
+ let walker = this._walkerStack[0];
130
+ let newNode = walker.lastChild();
131
+ if (newNode) {
132
+ this.currentNode = newNode;
133
+ }
134
+ return newNode;
135
+ }
136
+
137
+ public nextNode(): Node | null {
138
+ const nextNode = this._walkerStack[0].nextNode();
139
+
140
+ if (nextNode) {
141
+ const shadowRoot = (nextNode as Element).shadowRoot;
142
+
143
+ if (shadowRoot) {
144
+ let nodeResult: number | undefined;
145
+
146
+ if (typeof this.filter === 'function') {
147
+ nodeResult = this.filter(nextNode);
148
+ } else if (this.filter?.acceptNode) {
149
+ nodeResult = this.filter.acceptNode(nextNode);
150
+ }
151
+
152
+ if (nodeResult === NodeFilter.FILTER_ACCEPT) {
153
+ this.currentNode = nextNode;
154
+ return nextNode;
155
+ }
156
+
157
+ // _acceptNode should have added new walker for this shadow,
158
+ // go in recursively.
159
+ let newNode = this.nextNode();
160
+ if (newNode) {
161
+ this.currentNode = newNode;
162
+ }
163
+ return newNode;
164
+ }
165
+
166
+ if (nextNode) {
167
+ this.currentNode = nextNode;
168
+ }
169
+ return nextNode;
170
+ } else {
171
+ if (this._walkerStack.length > 1) {
172
+ this._walkerStack.shift();
173
+
174
+ let newNode = this.nextNode();
175
+ if (newNode) {
176
+ this.currentNode = newNode;
177
+ }
178
+ return newNode;
179
+ } else {
180
+ return null;
181
+ }
182
+ }
183
+ }
184
+
185
+ public previousNode(): Node | null {
186
+ const currentWalker = this._walkerStack[0];
187
+
188
+ if (currentWalker.currentNode === currentWalker.root) {
189
+ if (this._currentSetFor.has(currentWalker)) {
190
+ this._currentSetFor.delete(currentWalker);
191
+
192
+ if (this._walkerStack.length > 1) {
193
+ this._walkerStack.shift();
194
+ let newNode = this.previousNode();
195
+ if (newNode) {
196
+ this.currentNode = newNode;
197
+ }
198
+ return newNode;
199
+ } else {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ const previousNode = currentWalker.previousNode();
208
+
209
+ if (previousNode) {
210
+ const shadowRoot = (previousNode as Element).shadowRoot;
211
+
212
+ if (shadowRoot) {
213
+ let nodeResult: number | undefined;
214
+
215
+ if (typeof this.filter === 'function') {
216
+ nodeResult = this.filter(previousNode);
217
+ } else if (this.filter?.acceptNode) {
218
+ nodeResult = this.filter.acceptNode(previousNode);
219
+ }
220
+
221
+ if (nodeResult === NodeFilter.FILTER_ACCEPT) {
222
+ if (previousNode) {
223
+ this.currentNode = previousNode;
224
+ }
225
+ return previousNode;
226
+ }
227
+
228
+ // _acceptNode should have added new walker for this shadow,
229
+ // go in recursively.
230
+ let newNode = this.lastChild();
231
+ if (newNode) {
232
+ this.currentNode = newNode;
233
+ }
234
+ return newNode;
235
+ }
236
+
237
+ if (previousNode) {
238
+ this.currentNode = previousNode;
239
+ }
240
+ return previousNode;
241
+ } else {
242
+ if (this._walkerStack.length > 1) {
243
+ this._walkerStack.shift();
244
+
245
+ let newNode = this.previousNode();
246
+ if (newNode) {
247
+ this.currentNode = newNode;
248
+ }
249
+ return newNode;
250
+ } else {
251
+ return null;
252
+ }
253
+ }
254
+ }
255
+
256
+ /**
257
+ * @deprecated
258
+ */
259
+ public nextSibling(): Node | null {
260
+ // if (__DEV__) {
261
+ // throw new Error("Method not implemented.");
262
+ // }
263
+
264
+ return null;
265
+ }
266
+
267
+ /**
268
+ * @deprecated
269
+ */
270
+ public previousSibling(): Node | null {
271
+ // if (__DEV__) {
272
+ // throw new Error("Method not implemented.");
273
+ // }
274
+
275
+ return null;
276
+ }
277
+
278
+ /**
279
+ * @deprecated
280
+ */
281
+ public parentNode(): Node | null {
282
+ // if (__DEV__) {
283
+ // throw new Error("Method not implemented.");
284
+ // }
285
+
286
+ return null;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * ShadowDOM safe version of document.createTreeWalker.
292
+ */
293
+ export function createShadowTreeWalker(
294
+ doc: Document,
295
+ root: Node,
296
+ whatToShow?: number,
297
+ filter?: NodeFilter | null,
298
+ ): TreeWalker {
299
+ if (shadowDOM()) {
300
+ return new ShadowTreeWalker(doc, root, whatToShow, filter);
301
+ }
302
+ return doc.createTreeWalker(root, whatToShow, filter);
303
+ }
@@ -0,0 +1,62 @@
1
+ // Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/useDescription.ts).
2
+
3
+ import type { AriaLabelingProps } from '@react-types/shared';
4
+ import { useState } from 'octane';
5
+
6
+ import { S, splitSlot, subSlot } from '../internal';
7
+ import { useLayoutEffect } from './useLayoutEffect';
8
+
9
+ let descriptionId = 0;
10
+ const descriptionNodes = new Map<string, { refCount: number; element: Element }>();
11
+
12
+ export function useDescription(description?: string): AriaLabelingProps;
13
+ // Slot-threading form: sibling ported hooks pass their derived sub-slot as the trailing arg.
14
+ export function useDescription(
15
+ description: string | undefined,
16
+ slot: symbol | undefined,
17
+ ): AriaLabelingProps;
18
+ export function useDescription(...args: any[]): AriaLabelingProps {
19
+ const [user, slotArg] = splitSlot(args);
20
+ const slot = slotArg ?? S('useDescription');
21
+ const description = user[0] as string | undefined;
22
+
23
+ let [id, setId] = useState<string | undefined>(undefined, subSlot(slot, 'id'));
24
+
25
+ useLayoutEffect(
26
+ () => {
27
+ if (!description) {
28
+ return;
29
+ }
30
+
31
+ let desc = descriptionNodes.get(description);
32
+ if (!desc) {
33
+ let id = `react-aria-description-${descriptionId++}`;
34
+ setId(id);
35
+
36
+ let node = document.createElement('div');
37
+ node.id = id;
38
+ node.style.display = 'none';
39
+ node.textContent = description;
40
+ document.body.appendChild(node);
41
+ desc = { refCount: 0, element: node };
42
+ descriptionNodes.set(description, desc);
43
+ } else {
44
+ setId(desc.element.id);
45
+ }
46
+
47
+ desc.refCount++;
48
+ return () => {
49
+ if (desc && --desc.refCount === 0) {
50
+ desc.element.remove();
51
+ descriptionNodes.delete(description);
52
+ }
53
+ };
54
+ },
55
+ [description],
56
+ subSlot(slot, 'register'),
57
+ );
58
+
59
+ return {
60
+ 'aria-describedby': description ? id : undefined,
61
+ };
62
+ }
@@ -0,0 +1,34 @@
1
+ // Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/useEffectEvent.ts).
2
+ // react-aria's own effect-event (ref synced in the earliest effect phase + a stable
3
+ // wrapper) — ported as-is rather than aliased to octane's built-in `useEffectEvent`,
4
+ // so the sync timing matches upstream exactly. octane always has `useInsertionEffect`,
5
+ // so the React-17 layout-effect fallback collapses away.
6
+ import { useCallback, useInsertionEffect, useRef } from 'octane';
7
+
8
+ import { S, splitSlot, subSlot } from '../internal';
9
+
10
+ export function useEffectEvent<T extends Function>(fn?: T): T;
11
+ // Slot-threading form: sibling ported hooks pass their derived sub-slot as the trailing arg.
12
+ export function useEffectEvent<T extends Function>(fn: T | undefined, slot: symbol | undefined): T;
13
+ export function useEffectEvent(...args: any[]): any {
14
+ const [user, slotArg] = splitSlot(args);
15
+ const slot = slotArg ?? S('useEffectEvent');
16
+ const fn = user[0] as Function | undefined;
17
+
18
+ const ref = useRef<Function | null | undefined>(null, subSlot(slot, 'ref'));
19
+ useInsertionEffect(
20
+ () => {
21
+ ref.current = fn;
22
+ },
23
+ [fn],
24
+ subSlot(slot, 'sync'),
25
+ );
26
+ return useCallback(
27
+ (...callArgs: any[]) => {
28
+ const f = ref.current;
29
+ return f?.(...callArgs);
30
+ },
31
+ [],
32
+ subSlot(slot, 'wrapper'),
33
+ );
34
+ }