@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.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/package.json +60 -0
- package/src/index.ts +52 -0
- package/src/interactions/PressResponder.ts +54 -0
- package/src/interactions/Pressable.ts +37 -0
- package/src/interactions/context.ts +20 -0
- package/src/interactions/createEventHandler.ts +94 -0
- package/src/interactions/focusSafely.ts +40 -0
- package/src/interactions/textSelection.ts +96 -0
- package/src/interactions/useFocus.ts +114 -0
- package/src/interactions/useFocusVisible.ts +456 -0
- package/src/interactions/useFocusWithin.ts +165 -0
- package/src/interactions/useFocusable.ts +205 -0
- package/src/interactions/useHover.ts +268 -0
- package/src/interactions/useInteractOutside.ts +161 -0
- package/src/interactions/useKeyboard.ts +50 -0
- package/src/interactions/useLongPress.ts +159 -0
- package/src/interactions/useMove.ts +281 -0
- package/src/interactions/usePress.ts +1249 -0
- package/src/interactions/useScrollWheel.ts +55 -0
- package/src/interactions/utils.ts +212 -0
- package/src/internal.ts +57 -0
- package/src/ssr/SSRProvider.ts +72 -0
- package/src/stately/flags.ts +21 -0
- package/src/stately/index.ts +7 -0
- package/src/stately/utils/number.ts +70 -0
- package/src/stately/utils/useControlledState.ts +75 -0
- package/src/utils/chain.ts +14 -0
- package/src/utils/constants.ts +5 -0
- package/src/utils/domHelpers.ts +36 -0
- package/src/utils/focusWithoutScrolling.ts +83 -0
- package/src/utils/getNonce.ts +53 -0
- package/src/utils/isElementVisible.ts +66 -0
- package/src/utils/isFocusable.ts +57 -0
- package/src/utils/isScrollable.ts +21 -0
- package/src/utils/isVirtualEvent.ts +47 -0
- package/src/utils/mergeProps.ts +76 -0
- package/src/utils/mergeRefs.ts +48 -0
- package/src/utils/openLink.ts +231 -0
- package/src/utils/platform.ts +74 -0
- package/src/utils/runAfterTransition.ts +114 -0
- package/src/utils/shadowdom/DOMFunctions.ts +100 -0
- package/src/utils/shadowdom/ShadowTreeWalker.ts +303 -0
- package/src/utils/useDescription.ts +62 -0
- package/src/utils/useEffectEvent.ts +34 -0
- package/src/utils/useEvent.ts +55 -0
- package/src/utils/useGlobalListeners.ts +91 -0
- package/src/utils/useId.ts +150 -0
- package/src/utils/useLayoutEffect.ts +7 -0
- package/src/utils/useObjectRef.ts +88 -0
- package/src/utils/useSyncRef.ts +39 -0
- package/src/utils/useValueEffect.ts +81 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/interactions/useScrollWheel.ts).
|
|
2
|
+
// octane adaptations:
|
|
3
|
+
// - The wheel handler receives the native WheelEvent (upstream's untyped param).
|
|
4
|
+
// - Public-hook slot threading (splitSlot/subSlot) per the binding convention.
|
|
5
|
+
import type { RefObject, ScrollEvents } from '@react-types/shared';
|
|
6
|
+
import { useCallback } from 'octane';
|
|
7
|
+
|
|
8
|
+
import { S, splitSlot, subSlot } from '../internal';
|
|
9
|
+
import { useEvent } from '../utils/useEvent';
|
|
10
|
+
|
|
11
|
+
export interface ScrollWheelProps extends ScrollEvents {
|
|
12
|
+
/** Whether the scroll listener should be disabled. */
|
|
13
|
+
isDisabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// scroll wheel needs to be added not passively so it's cancelable, small helper hook to remember that
|
|
17
|
+
// (that means bypassing framework-delegated wheel handling with a direct element listener —
|
|
18
|
+
// which useEvent is. Element-target wheel listeners are cancelable by default; the browser's
|
|
19
|
+
// passive-by-default intervention only covers window/document/body. Upstream likewise passes
|
|
20
|
+
// no explicit `passive: false` — see the pinned source.)
|
|
21
|
+
export function useScrollWheel(props: ScrollWheelProps, ref: RefObject<HTMLElement | null>): void;
|
|
22
|
+
// Slot-threading form: sibling ported hooks pass their derived sub-slot as the trailing arg.
|
|
23
|
+
export function useScrollWheel(
|
|
24
|
+
props: ScrollWheelProps,
|
|
25
|
+
ref: RefObject<HTMLElement | null>,
|
|
26
|
+
slot: symbol | undefined,
|
|
27
|
+
): void;
|
|
28
|
+
export function useScrollWheel(...args: any[]): void {
|
|
29
|
+
const [user, slotArg] = splitSlot(args);
|
|
30
|
+
const slot = slotArg ?? S('useScrollWheel');
|
|
31
|
+
const props = user[0] as ScrollWheelProps;
|
|
32
|
+
const ref = user[1] as RefObject<HTMLElement | null>;
|
|
33
|
+
|
|
34
|
+
let { onScroll, isDisabled } = props;
|
|
35
|
+
let onScrollHandler = useCallback(
|
|
36
|
+
(e: WheelEvent) => {
|
|
37
|
+
// If the ctrlKey is pressed, this is a zoom event, do nothing.
|
|
38
|
+
if (e.ctrlKey) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// stop scrolling the page
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
e.stopPropagation();
|
|
45
|
+
|
|
46
|
+
if (onScroll) {
|
|
47
|
+
onScroll({ deltaX: e.deltaX, deltaY: e.deltaY });
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
[onScroll],
|
|
51
|
+
subSlot(slot, 'handler'),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
useEvent(ref, 'wheel', isDisabled ? undefined : onScrollHandler, subSlot(slot, 'wheel'));
|
|
55
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/interactions/utils.ts).
|
|
2
|
+
// octane adaptation: handlers receive NATIVE events (there is no synthetic layer), so the
|
|
3
|
+
// "synthetic event" here is the native event augmented in place — `createSyntheticEvent`
|
|
4
|
+
// stays for the paths that dispatch/wrap events themselves, and its self-referential
|
|
5
|
+
// `nativeEvent` keeps downstream `.nativeEvent` reads working for both arrival paths.
|
|
6
|
+
import type { FocusableElement } from '@react-types/shared';
|
|
7
|
+
import { useCallback, useRef } from 'octane';
|
|
8
|
+
|
|
9
|
+
import { S, splitSlot, subSlot } from '../internal';
|
|
10
|
+
import { focusWithoutScrolling } from '../utils/focusWithoutScrolling';
|
|
11
|
+
import { getActiveElement, getEventTarget } from '../utils/shadowdom/DOMFunctions';
|
|
12
|
+
import { getOwnerWindow } from '../utils/domHelpers';
|
|
13
|
+
import { isFocusable } from '../utils/isFocusable';
|
|
14
|
+
import { useLayoutEffect } from '../utils/useLayoutEffect';
|
|
15
|
+
|
|
16
|
+
// The augmented-native-event surface react-aria's internals read. On octane the native
|
|
17
|
+
// event IS the event object; these fields are stamped onto it.
|
|
18
|
+
export type SyntheticEventShim<E extends Event = Event> = E & {
|
|
19
|
+
nativeEvent: E;
|
|
20
|
+
isDefaultPrevented(): boolean;
|
|
21
|
+
isPropagationStopped(): boolean;
|
|
22
|
+
persist(): void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Turn a native event into the augmented shape above (upstream: "into a React synthetic
|
|
26
|
+
// event"). Mutates the per-dispatch event object, exactly like upstream.
|
|
27
|
+
export function createSyntheticEvent<E extends Event>(nativeEvent: E): SyntheticEventShim<E> {
|
|
28
|
+
let event = nativeEvent as SyntheticEventShim<E>;
|
|
29
|
+
event.nativeEvent = nativeEvent;
|
|
30
|
+
event.isDefaultPrevented = () => event.defaultPrevented;
|
|
31
|
+
// cancelBubble is technically deprecated in the spec, but still supported in all browsers.
|
|
32
|
+
event.isPropagationStopped = () => (event as any).cancelBubble;
|
|
33
|
+
event.persist = () => {};
|
|
34
|
+
return event;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function setEventTarget(event: Event, target: Element): void {
|
|
38
|
+
Object.defineProperty(event, 'target', { value: target });
|
|
39
|
+
Object.defineProperty(event, 'currentTarget', { value: target });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useSyntheticBlurEvent(onBlur: (e: FocusEvent) => void): (e: FocusEvent) => void;
|
|
43
|
+
// Slot-threading form: sibling ported hooks pass their derived sub-slot as the trailing arg.
|
|
44
|
+
export function useSyntheticBlurEvent(
|
|
45
|
+
onBlur: (e: FocusEvent) => void,
|
|
46
|
+
slot: symbol | undefined,
|
|
47
|
+
): (e: FocusEvent) => void;
|
|
48
|
+
export function useSyntheticBlurEvent(...args: any[]): (e: FocusEvent) => void {
|
|
49
|
+
const [user, slotArg] = splitSlot(args);
|
|
50
|
+
const slot = slotArg ?? S('useSyntheticBlurEvent');
|
|
51
|
+
const onBlur = user[0] as (e: FocusEvent) => void;
|
|
52
|
+
|
|
53
|
+
let stateRef = useRef(
|
|
54
|
+
{
|
|
55
|
+
isFocused: false,
|
|
56
|
+
observer: null as MutationObserver | null,
|
|
57
|
+
},
|
|
58
|
+
subSlot(slot, 'state'),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Clean up MutationObserver on unmount. See below.
|
|
62
|
+
|
|
63
|
+
useLayoutEffect(
|
|
64
|
+
() => {
|
|
65
|
+
const state = stateRef.current;
|
|
66
|
+
return () => {
|
|
67
|
+
if (state.observer) {
|
|
68
|
+
state.observer.disconnect();
|
|
69
|
+
state.observer = null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
[],
|
|
74
|
+
subSlot(slot, 'teardown'),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// This function is called during a focus event.
|
|
78
|
+
return useCallback(
|
|
79
|
+
(e: FocusEvent) => {
|
|
80
|
+
// Browsers do not fire blur when a focused element becomes disabled. Most fire a
|
|
81
|
+
// native focusout event in this case, except for Firefox. In that case, we use a
|
|
82
|
+
// MutationObserver to watch for the disabled attribute, and dispatch these events
|
|
83
|
+
// ourselves. For browsers that do, focusout fires before the MutationObserver, so
|
|
84
|
+
// onBlur should not fire twice.
|
|
85
|
+
let eventTarget = getEventTarget(e);
|
|
86
|
+
if (
|
|
87
|
+
eventTarget instanceof HTMLButtonElement ||
|
|
88
|
+
eventTarget instanceof HTMLInputElement ||
|
|
89
|
+
eventTarget instanceof HTMLTextAreaElement ||
|
|
90
|
+
eventTarget instanceof HTMLSelectElement
|
|
91
|
+
) {
|
|
92
|
+
stateRef.current.isFocused = true;
|
|
93
|
+
|
|
94
|
+
let target = eventTarget;
|
|
95
|
+
let onBlurHandler: ((e: FocusEvent) => void) | null = (e: FocusEvent) => {
|
|
96
|
+
stateRef.current.isFocused = false;
|
|
97
|
+
|
|
98
|
+
if (target.disabled) {
|
|
99
|
+
// For backward compatibility, dispatch the augmented event shape.
|
|
100
|
+
let event = createSyntheticEvent(e);
|
|
101
|
+
onBlur?.(event);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// We no longer need the MutationObserver once the target is blurred.
|
|
105
|
+
if (stateRef.current.observer) {
|
|
106
|
+
stateRef.current.observer.disconnect();
|
|
107
|
+
stateRef.current.observer = null;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
target.addEventListener('focusout', onBlurHandler as EventListener, { once: true });
|
|
112
|
+
|
|
113
|
+
stateRef.current.observer = new MutationObserver(() => {
|
|
114
|
+
if (stateRef.current.isFocused && target.disabled) {
|
|
115
|
+
stateRef.current.observer?.disconnect();
|
|
116
|
+
let relatedTargetEl = target === getActiveElement() ? null : getActiveElement();
|
|
117
|
+
target.dispatchEvent(new FocusEvent('blur', { relatedTarget: relatedTargetEl }));
|
|
118
|
+
target.dispatchEvent(
|
|
119
|
+
new FocusEvent('focusout', { bubbles: true, relatedTarget: relatedTargetEl }),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
stateRef.current.observer.observe(target, {
|
|
125
|
+
attributes: true,
|
|
126
|
+
attributeFilter: ['disabled'],
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
[onBlur],
|
|
131
|
+
subSlot(slot, 'handler'),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export let ignoreFocusEvent = false;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* This function prevents the next focus event fired on `target`, without using
|
|
139
|
+
* `event.preventDefault()`. It works by waiting for the series of focus events to occur, and
|
|
140
|
+
* reverts focus back to where it was before. It also makes these events mostly non-observable by
|
|
141
|
+
* using a capturing listener on the window and stopping propagation.
|
|
142
|
+
*/
|
|
143
|
+
export function preventFocus(target: FocusableElement | null): (() => void) | undefined {
|
|
144
|
+
// The browser will focus the nearest focusable ancestor of our target.
|
|
145
|
+
while (target && !isFocusable(target, { skipVisibilityCheck: true })) {
|
|
146
|
+
target = target.parentElement as FocusableElement | null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let window = getOwnerWindow(target);
|
|
150
|
+
let activeElement = window.document.activeElement as FocusableElement | null;
|
|
151
|
+
if (!activeElement || activeElement === target) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
ignoreFocusEvent = true;
|
|
156
|
+
let isRefocusing = false;
|
|
157
|
+
let onBlur = (e: FocusEvent) => {
|
|
158
|
+
if (getEventTarget(e) === activeElement || isRefocusing) {
|
|
159
|
+
e.stopImmediatePropagation();
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
let onFocusOut = (e: FocusEvent) => {
|
|
164
|
+
if (getEventTarget(e) === activeElement || isRefocusing) {
|
|
165
|
+
e.stopImmediatePropagation();
|
|
166
|
+
|
|
167
|
+
// If there was no focusable ancestor, we don't expect a focus event.
|
|
168
|
+
// Re-focus the original active element here.
|
|
169
|
+
if (!target && !isRefocusing) {
|
|
170
|
+
isRefocusing = true;
|
|
171
|
+
focusWithoutScrolling(activeElement);
|
|
172
|
+
cleanup();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
let onFocus = (e: FocusEvent) => {
|
|
178
|
+
if (getEventTarget(e) === target || isRefocusing) {
|
|
179
|
+
e.stopImmediatePropagation();
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
let onFocusIn = (e: FocusEvent) => {
|
|
184
|
+
if (getEventTarget(e) === target || isRefocusing) {
|
|
185
|
+
e.stopImmediatePropagation();
|
|
186
|
+
|
|
187
|
+
if (!isRefocusing) {
|
|
188
|
+
isRefocusing = true;
|
|
189
|
+
focusWithoutScrolling(activeElement);
|
|
190
|
+
cleanup();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
window.addEventListener('blur', onBlur, true);
|
|
196
|
+
window.addEventListener('focusout', onFocusOut, true);
|
|
197
|
+
window.addEventListener('focusin', onFocusIn, true);
|
|
198
|
+
window.addEventListener('focus', onFocus, true);
|
|
199
|
+
|
|
200
|
+
let cleanup = () => {
|
|
201
|
+
cancelAnimationFrame(raf);
|
|
202
|
+
window.removeEventListener('blur', onBlur, true);
|
|
203
|
+
window.removeEventListener('focusout', onFocusOut, true);
|
|
204
|
+
window.removeEventListener('focusin', onFocusIn, true);
|
|
205
|
+
window.removeEventListener('focus', onFocus, true);
|
|
206
|
+
ignoreFocusEvent = false;
|
|
207
|
+
isRefocusing = false;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
let raf = requestAnimationFrame(cleanup);
|
|
211
|
+
return cleanup;
|
|
212
|
+
}
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Slot mechanics for @octanejs/aria's plain-`.ts` hooks. octane's compiler
|
|
2
|
+
// injects a per-call-site Symbol slot as the trailing arg of every hook call in a
|
|
3
|
+
// compiled `.tsx`/`.tsrx`. These hook files are published source (consumed from
|
|
4
|
+
// node_modules, where the auto-slotting pass is skipped), so each PUBLIC hook
|
|
5
|
+
// receives the caller's slot as its trailing argument and derives a distinct
|
|
6
|
+
// sub-slot for every base hook it composes. Public hooks keep their react-aria
|
|
7
|
+
// parameter types and absorb the injected slot through a trailing rest param:
|
|
8
|
+
// export function useHover(props: HoverProps, ...args: any[]): HoverResult {
|
|
9
|
+
// const slot = restSlot(args) ?? S('useHover');
|
|
10
|
+
// (Same pattern as the other @octanejs bindings; see packages/floating-ui.)
|
|
11
|
+
|
|
12
|
+
// Derive a stable, distinct sub-slot from a wrapper's slot, namespaced per hook.
|
|
13
|
+
// Memoized: subSlot runs on EVERY hook call every render, and the naive form
|
|
14
|
+
// pays a string concat + global symbol-registry lookup each time. The cache is
|
|
15
|
+
// keyed by the slot symbol itself; the minted value is byte-identical to the
|
|
16
|
+
// uncached Symbol.for result, so identity is preserved across HMR re-evals and
|
|
17
|
+
// the per-package copies of this helper. Key universe is bounded: slots are
|
|
18
|
+
// per-call-site module constants (never minted per render).
|
|
19
|
+
const subSlotCache = new Map<symbol, Map<string, symbol>>();
|
|
20
|
+
export function subSlot(slot: symbol | undefined, tag: string): symbol | undefined {
|
|
21
|
+
if (slot === undefined) return undefined;
|
|
22
|
+
let byTag = subSlotCache.get(slot);
|
|
23
|
+
if (byTag === undefined) subSlotCache.set(slot, (byTag = new Map()));
|
|
24
|
+
let sym = byTag.get(tag);
|
|
25
|
+
if (sym === undefined) byTag.set(tag, (sym = Symbol.for((slot.description ?? '') + ':' + tag)));
|
|
26
|
+
return sym;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Split the compiler-injected trailing slot off a hook's args. Needed when a
|
|
30
|
+
// public hook takes optional user args, so the slot can't be located positionally.
|
|
31
|
+
export function splitSlot(args: any[]): [any[], symbol | undefined] {
|
|
32
|
+
const tail = args[args.length - 1];
|
|
33
|
+
const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
|
|
34
|
+
return [slot !== undefined ? args.slice(0, -1) : args, slot];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The common public-hook form: all REQUIRED user params are declared, so the
|
|
38
|
+
// rest array contains at most the injected slot (plus possibly trailing
|
|
39
|
+
// optional user args — callers pass those before the injected slot, so the
|
|
40
|
+
// symbol is always last).
|
|
41
|
+
export function restSlot(args: any[]): symbol | undefined {
|
|
42
|
+
const tail = args[args.length - 1];
|
|
43
|
+
return typeof tail === 'symbol' ? (tail as symbol) : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A stable per-call-site slot for the binding's plain-`.ts` COMPONENTS (written
|
|
47
|
+
// with createElement, not compiled, so they get no auto-injected slots). A
|
|
48
|
+
// component runs in its OWN per-instance scope (componentSlot), so a globally
|
|
49
|
+
// stable Symbol.for(tag) resolves to a distinct slot per instance. Pass S('tag')
|
|
50
|
+
// as the slot to base hooks; sub-hooks derive their own sub-slots from its
|
|
51
|
+
// description.
|
|
52
|
+
const sCache = new Map<string, symbol>();
|
|
53
|
+
export function S(tag: string): symbol {
|
|
54
|
+
let sym = sCache.get(tag);
|
|
55
|
+
if (sym === undefined) sCache.set(tag, (sym = Symbol.for('@octanejs/aria:' + tag)));
|
|
56
|
+
return sym;
|
|
57
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/ssr/SSRProvider.tsx).
|
|
2
|
+
// octane always has SSR-stable `useId` and `useSyncExternalStore`, so React's legacy
|
|
3
|
+
// (<18) SSRProvider machinery — the context-threaded id counter, the Fiber-keyed
|
|
4
|
+
// StrictMode double-render guard — collapses away exactly like upstream's modern path:
|
|
5
|
+
// `SSRProvider` is a pass-through, `useSSRSafeId` prefixes the framework id, and
|
|
6
|
+
// `useIsSSR` reads the store-snapshot seam (server snapshot → true, client → false).
|
|
7
|
+
import { useId as octaneUseId, useState, useSyncExternalStore } from 'octane';
|
|
8
|
+
|
|
9
|
+
import { S, splitSlot, subSlot } from '../internal';
|
|
10
|
+
|
|
11
|
+
export interface SSRProviderProps {
|
|
12
|
+
/** Your application here. */
|
|
13
|
+
children: any;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Legacy-React compatibility wrapper — a no-op pass-through in octane, matching
|
|
18
|
+
* upstream's behavior on React 18+.
|
|
19
|
+
*/
|
|
20
|
+
export function SSRProvider(props: SSRProviderProps): any {
|
|
21
|
+
return props.children;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// In order to support multiple copies of the library potentially being on the page at
|
|
25
|
+
// once, client-only ids get a random per-module prefix. When the app was server
|
|
26
|
+
// rendered (or in tests, where ids must be deterministic), the plain `react-aria`
|
|
27
|
+
// prefix is used so server and client agree.
|
|
28
|
+
const defaultPrefix = String(Math.round(Math.random() * 10000000000));
|
|
29
|
+
|
|
30
|
+
/** @private */
|
|
31
|
+
export function useSSRSafeId(defaultId?: string): string;
|
|
32
|
+
// Slot-threading form: sibling ported hooks pass their derived sub-slot as the trailing arg.
|
|
33
|
+
export function useSSRSafeId(defaultId: string | undefined, slot: symbol | undefined): string;
|
|
34
|
+
export function useSSRSafeId(...args: any[]): string {
|
|
35
|
+
const [user, slotArg] = splitSlot(args);
|
|
36
|
+
const slot = slotArg ?? S('useSSRSafeId');
|
|
37
|
+
const defaultId = user[0] as string | undefined;
|
|
38
|
+
|
|
39
|
+
const id = octaneUseId(subSlot(slot, 'id'));
|
|
40
|
+
const isSSR = useIsSSR(subSlot(slot, 'ssr'));
|
|
41
|
+
const [didSSR] = useState(isSSR, subSlot(slot, 'didSSR'));
|
|
42
|
+
const prefix =
|
|
43
|
+
didSSR || process.env.NODE_ENV === 'test' ? 'react-aria' : `react-aria${defaultPrefix}`;
|
|
44
|
+
return defaultId || `${prefix}-${id}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getSnapshot(): boolean {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getServerSnapshot(): boolean {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function subscribe(): () => void {
|
|
56
|
+
// noop
|
|
57
|
+
return () => {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Returns whether the component is currently being server side rendered or
|
|
62
|
+
* hydrated on the client. Can be used to delay browser-specific rendering
|
|
63
|
+
* until after hydration.
|
|
64
|
+
*/
|
|
65
|
+
export function useIsSSR(): boolean;
|
|
66
|
+
// Slot-threading form: sibling ported hooks pass their derived sub-slot as the trailing arg.
|
|
67
|
+
export function useIsSSR(slot: symbol | undefined): boolean;
|
|
68
|
+
export function useIsSSR(...args: any[]): boolean {
|
|
69
|
+
const [, slotArg] = splitSlot(args);
|
|
70
|
+
const slot = slotArg ?? S('useIsSSR');
|
|
71
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot, subSlot(slot, 'store'));
|
|
72
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Ported from react-stately (source: .react-spectrum/packages/react-stately/src/flags/flags.ts).
|
|
2
|
+
// Internal feature flags (upstream ships them under `react-stately/private/flags/flags`).
|
|
3
|
+
|
|
4
|
+
let _tableNestedRows = false;
|
|
5
|
+
let _shadowDOM = false;
|
|
6
|
+
|
|
7
|
+
export function enableTableNestedRows(): void {
|
|
8
|
+
_tableNestedRows = true;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function tableNestedRows(): boolean {
|
|
12
|
+
return _tableNestedRows;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function enableShadowDOM(): void {
|
|
16
|
+
_shadowDOM = true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function shadowDOM(): boolean {
|
|
20
|
+
return _shadowDOM;
|
|
21
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @octanejs/aria/stately — the `react-stately` surface, ported onto octane. Exports mirror
|
|
2
|
+
// the upstream monopackage exports (.react-spectrum/packages/react-stately/exports/) and
|
|
3
|
+
// grow area-by-area with the migration plan (docs/aria-migration-plan.md).
|
|
4
|
+
|
|
5
|
+
// utils
|
|
6
|
+
export { useControlledState } from './utils/useControlledState';
|
|
7
|
+
export type { SetStateAction } from './utils/useControlledState';
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Ported from react-stately (source: .react-spectrum/packages/react-stately/src/utils/number.ts).
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Takes a value and forces it to the closest min/max if it's outside. Also forces it to the closest
|
|
5
|
+
* valid step.
|
|
6
|
+
*/
|
|
7
|
+
export function clamp(value: number, min: number = -Infinity, max: number = Infinity): number {
|
|
8
|
+
let newValue = Math.min(Math.max(value, min), max);
|
|
9
|
+
return newValue;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function roundToStepPrecision(value: number, step: number): number {
|
|
13
|
+
let roundedValue = value;
|
|
14
|
+
let precision = 0;
|
|
15
|
+
let stepString = step.toString();
|
|
16
|
+
// Handle negative exponents in exponential notation (e.g., "1e-7" → precision 8)
|
|
17
|
+
let eIndex = stepString.toLowerCase().indexOf('e-');
|
|
18
|
+
if (eIndex > 0) {
|
|
19
|
+
precision = Math.abs(Math.floor(Math.log10(Math.abs(step)))) + eIndex;
|
|
20
|
+
} else {
|
|
21
|
+
let pointIndex = stepString.indexOf('.');
|
|
22
|
+
if (pointIndex >= 0) {
|
|
23
|
+
precision = stepString.length - pointIndex;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (precision > 0) {
|
|
27
|
+
let pow = Math.pow(10, precision);
|
|
28
|
+
roundedValue = Math.round(roundedValue * pow) / pow;
|
|
29
|
+
}
|
|
30
|
+
return roundedValue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function snapValueToStep(
|
|
34
|
+
value: number,
|
|
35
|
+
min: number | undefined,
|
|
36
|
+
max: number | undefined,
|
|
37
|
+
step: number,
|
|
38
|
+
): number {
|
|
39
|
+
min = Number(min);
|
|
40
|
+
max = Number(max);
|
|
41
|
+
let remainder = (value - (isNaN(min) ? 0 : min)) % step;
|
|
42
|
+
let snappedValue = roundToStepPrecision(
|
|
43
|
+
Math.abs(remainder) * 2 >= step
|
|
44
|
+
? value + Math.sign(remainder) * (step - Math.abs(remainder))
|
|
45
|
+
: value - remainder,
|
|
46
|
+
step,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (!isNaN(min)) {
|
|
50
|
+
if (snappedValue < min) {
|
|
51
|
+
snappedValue = min;
|
|
52
|
+
} else if (!isNaN(max) && snappedValue > max) {
|
|
53
|
+
snappedValue = min + Math.floor(roundToStepPrecision((max - min) / step, step)) * step;
|
|
54
|
+
}
|
|
55
|
+
} else if (!isNaN(max) && snappedValue > max) {
|
|
56
|
+
snappedValue = Math.floor(roundToStepPrecision(max / step, step)) * step;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// correct floating point behavior by rounding to step precision
|
|
60
|
+
snappedValue = roundToStepPrecision(snappedValue, step);
|
|
61
|
+
|
|
62
|
+
return snappedValue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Takes a value and rounds off to the number of digits. */
|
|
66
|
+
export function toFixedNumber(value: number, digits: number, base: number = 10): number {
|
|
67
|
+
const pow = Math.pow(base, digits);
|
|
68
|
+
|
|
69
|
+
return Math.round(value * pow) / pow;
|
|
70
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Ported from react-stately (source: .react-spectrum/packages/react-stately/src/utils/useControlledState.ts).
|
|
2
|
+
// octane adaptations: React's SetStateAction type is declared locally; octane always has
|
|
3
|
+
// `useInsertionEffect`, so upstream's React-17 layout-effect fallback collapses away; the
|
|
4
|
+
// dev-only controlled↔uncontrolled switch warning (and the ref that only fed it) is not ported.
|
|
5
|
+
|
|
6
|
+
import { useCallback, useInsertionEffect, useReducer, useState, useRef } from 'octane';
|
|
7
|
+
|
|
8
|
+
import { S, splitSlot, subSlot } from '../../internal';
|
|
9
|
+
|
|
10
|
+
export type SetStateAction<S> = S | ((prevState: S) => S);
|
|
11
|
+
|
|
12
|
+
// Use the earliest effect possible to reset the ref below.
|
|
13
|
+
const useEarlyEffect: typeof useInsertionEffect =
|
|
14
|
+
typeof document !== 'undefined' ? useInsertionEffect : ((() => {}) as any);
|
|
15
|
+
|
|
16
|
+
export function useControlledState<T, C = T>(
|
|
17
|
+
value: Exclude<T, undefined>,
|
|
18
|
+
defaultValue: Exclude<T, undefined> | undefined,
|
|
19
|
+
onChange?: (v: C, ...args: any[]) => void,
|
|
20
|
+
): [T, (value: SetStateAction<T>, ...args: any[]) => void];
|
|
21
|
+
export function useControlledState<T, C = T>(
|
|
22
|
+
value: Exclude<T, undefined> | undefined,
|
|
23
|
+
defaultValue: Exclude<T, undefined>,
|
|
24
|
+
onChange?: (v: C, ...args: any[]) => void,
|
|
25
|
+
): [T, (value: SetStateAction<T>, ...args: any[]) => void];
|
|
26
|
+
export function useControlledState(...args: any[]): any {
|
|
27
|
+
const [user, slotArg] = splitSlot(args);
|
|
28
|
+
const slot = slotArg ?? S('useControlledState');
|
|
29
|
+
const value = user[0];
|
|
30
|
+
const defaultValue = user[1];
|
|
31
|
+
const onChange = user[2] as ((v: any, ...onChangeArgs: any[]) => void) | undefined;
|
|
32
|
+
|
|
33
|
+
// Store the value in both state and a ref. The state value will only be used when uncontrolled.
|
|
34
|
+
// The ref is used to track the most current value, which is passed to the function setState callback.
|
|
35
|
+
let [stateValue, setStateValue] = useState(value || defaultValue, subSlot(slot, 'state'));
|
|
36
|
+
let valueRef = useRef(stateValue, subSlot(slot, 'valueRef'));
|
|
37
|
+
|
|
38
|
+
let isControlled = value !== undefined;
|
|
39
|
+
|
|
40
|
+
// After each render, update the ref to the current value.
|
|
41
|
+
// This ensures that the setState callback argument is reset.
|
|
42
|
+
// Note: the effect should not have any dependencies so that controlled values always reset.
|
|
43
|
+
let currentValue = isControlled ? value : stateValue;
|
|
44
|
+
useEarlyEffect(
|
|
45
|
+
() => {
|
|
46
|
+
valueRef.current = currentValue;
|
|
47
|
+
},
|
|
48
|
+
null,
|
|
49
|
+
subSlot(slot, 'sync'),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
let [, forceUpdate] = useReducer<object, void>(() => ({}), {}, subSlot(slot, 'force'));
|
|
53
|
+
let setValue = useCallback(
|
|
54
|
+
(value: any, ...setterArgs: any[]) => {
|
|
55
|
+
let newValue = typeof value === 'function' ? value(valueRef.current) : value;
|
|
56
|
+
if (!Object.is(valueRef.current, newValue)) {
|
|
57
|
+
// Update the ref so that the next setState callback has the most recent value.
|
|
58
|
+
valueRef.current = newValue;
|
|
59
|
+
|
|
60
|
+
setStateValue(newValue);
|
|
61
|
+
|
|
62
|
+
// Always trigger a re-render, even when controlled, so that the layout effect above runs to reset the value.
|
|
63
|
+
forceUpdate();
|
|
64
|
+
|
|
65
|
+
// Trigger onChange. Note that if setState is called multiple times in a single event,
|
|
66
|
+
// onChange will be called for each one instead of only once.
|
|
67
|
+
onChange?.(newValue, ...setterArgs);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
[onChange],
|
|
71
|
+
subSlot(slot, 'set'),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return [currentValue, setValue];
|
|
75
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/chain.ts).
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calls all functions in the order they were chained with the same arguments.
|
|
5
|
+
*/
|
|
6
|
+
export function chain(...callbacks: any[]): (...args: any[]) => void {
|
|
7
|
+
return (...args: any[]) => {
|
|
8
|
+
for (let callback of callbacks) {
|
|
9
|
+
if (typeof callback === 'function') {
|
|
10
|
+
callback(...args);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/constants.ts).
|
|
2
|
+
|
|
3
|
+
// Custom event names for updating the autocomplete's aria-activedecendant.
|
|
4
|
+
export const CLEAR_FOCUS_EVENT = 'react-aria-clear-focus';
|
|
5
|
+
export const FOCUS_EVENT = 'react-aria-focus';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/domHelpers.ts).
|
|
2
|
+
|
|
3
|
+
export const getOwnerDocument = (el: Element | null | undefined): Document => {
|
|
4
|
+
return el?.ownerDocument ?? document;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const getOwnerWindow = (
|
|
8
|
+
el: (Window & typeof globalThis) | Element | null | undefined,
|
|
9
|
+
): Window & typeof globalThis => {
|
|
10
|
+
if (el && 'window' in el && el.window === el) {
|
|
11
|
+
return el;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const doc = getOwnerDocument(el as Element | null | undefined);
|
|
15
|
+
return doc.defaultView || window;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Type guard that checks if a value is a Node. Verifies the presence and type of the nodeType
|
|
20
|
+
* property.
|
|
21
|
+
*/
|
|
22
|
+
function isNode(value: unknown): value is Node {
|
|
23
|
+
return (
|
|
24
|
+
value !== null &&
|
|
25
|
+
typeof value === 'object' &&
|
|
26
|
+
'nodeType' in value &&
|
|
27
|
+
typeof (value as Node).nodeType === 'number'
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Type guard that checks if a node is a ShadowRoot. Uses nodeType and host property checks to
|
|
32
|
+
* distinguish ShadowRoot from other DocumentFragments.
|
|
33
|
+
*/
|
|
34
|
+
export function isShadowRoot(node: Node | null): node is ShadowRoot {
|
|
35
|
+
return isNode(node) && node.nodeType === Node.DOCUMENT_FRAGMENT_NODE && 'host' in node;
|
|
36
|
+
}
|