@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,205 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/interactions/useFocusable.tsx).
|
|
2
|
+
// octane adaptations:
|
|
3
|
+
// - React's `forwardRef` becomes octane's ref-as-prop: `FocusableProvider` and `Focusable`
|
|
4
|
+
// read the forwarded ref from props.
|
|
5
|
+
// - Components are built with `createElement`; `Focusable` re-projects its child with
|
|
6
|
+
// octane's `cloneElement`/`isValidElement`/`Children` (they operate on element
|
|
7
|
+
// DESCRIPTORS — prop-position JSX / createElement results). Non-element children render
|
|
8
|
+
// unchanged (upstream's dev-warning path, without the warning).
|
|
9
|
+
// - The composed child ref is memoized (upstream builds `mergeRefs(...)` fresh per render);
|
|
10
|
+
// a fresh identity would make octane detach/re-attach the child's ref every render (see
|
|
11
|
+
// the radix Slot note on ref churn).
|
|
12
|
+
// - Upstream's dev-only console warning effect (ref/focusable/ARIA-role checks) is not ported.
|
|
13
|
+
// - `FocusableProps` / `DOMAttributes` from '@react-types/shared' drag React event types;
|
|
14
|
+
// local structural equivalents (built on the ported FocusEvents/KeyboardEvents) replace them.
|
|
15
|
+
import type { FocusableDOMProps, FocusableElement, RefObject } from '@react-types/shared';
|
|
16
|
+
import {
|
|
17
|
+
Children,
|
|
18
|
+
cloneElement,
|
|
19
|
+
createContext,
|
|
20
|
+
createElement,
|
|
21
|
+
isValidElement,
|
|
22
|
+
useContext,
|
|
23
|
+
useEffect,
|
|
24
|
+
useMemo,
|
|
25
|
+
useRef,
|
|
26
|
+
} from 'octane';
|
|
27
|
+
|
|
28
|
+
import { S, splitSlot, subSlot } from '../internal';
|
|
29
|
+
import { focusSafely } from './focusSafely';
|
|
30
|
+
import { mergeProps } from '../utils/mergeProps';
|
|
31
|
+
import { mergeRefs, type MergableRef } from '../utils/mergeRefs';
|
|
32
|
+
import { useFocus, type FocusEvents } from './useFocus';
|
|
33
|
+
import { useKeyboard, type KeyboardEvents } from './useKeyboard';
|
|
34
|
+
import { useObjectRef } from '../utils/useObjectRef';
|
|
35
|
+
import { useSyncRef } from '../utils/useSyncRef';
|
|
36
|
+
|
|
37
|
+
// octane adaptation: minimal structural DOMAttributes (upstream's drags React attribute types).
|
|
38
|
+
export type DOMAttributes = Record<string, any>;
|
|
39
|
+
|
|
40
|
+
// octane adaptation: local structural FocusableProps (upstream's is typed over React events).
|
|
41
|
+
export interface FocusableProps<Target = FocusableElement>
|
|
42
|
+
extends FocusEvents<Target>, KeyboardEvents {
|
|
43
|
+
/** Whether the element should receive focus on render. */
|
|
44
|
+
autoFocus?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type MutableRefObject<T> = { current: T };
|
|
48
|
+
|
|
49
|
+
export interface FocusableOptions<T = FocusableElement>
|
|
50
|
+
extends FocusableProps<T>, FocusableDOMProps {
|
|
51
|
+
/** Whether focus should be disabled. */
|
|
52
|
+
isDisabled?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface FocusableProviderProps extends DOMAttributes {
|
|
56
|
+
/** The child element to provide DOM props to. */
|
|
57
|
+
children?: any;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface FocusableContextValue extends FocusableProviderProps {
|
|
61
|
+
ref?: MutableRefObject<FocusableElement | null>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Exported for collections, which forwards this context.
|
|
65
|
+
/** @private */
|
|
66
|
+
export let FocusableContext = createContext<FocusableContextValue | null>(null);
|
|
67
|
+
|
|
68
|
+
function useFocusableContext(
|
|
69
|
+
ref: RefObject<FocusableElement | null>,
|
|
70
|
+
slot: symbol | undefined,
|
|
71
|
+
): FocusableContextValue {
|
|
72
|
+
let context = useContext(FocusableContext) || {};
|
|
73
|
+
useSyncRef(context, ref, subSlot(slot, 'sync'));
|
|
74
|
+
|
|
75
|
+
let { ref: _, ...otherProps } = context;
|
|
76
|
+
return otherProps;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Provides DOM props to the nearest focusable child.
|
|
81
|
+
*/
|
|
82
|
+
export function FocusableProvider(
|
|
83
|
+
props: FocusableProviderProps & { ref?: MergableRef<FocusableElement> },
|
|
84
|
+
): any {
|
|
85
|
+
const slot = S('FocusableProvider');
|
|
86
|
+
let { children, ref, ...otherProps } = props;
|
|
87
|
+
let objRef = useObjectRef<FocusableElement>(ref, subSlot(slot, 'objRef'));
|
|
88
|
+
let context = {
|
|
89
|
+
...otherProps,
|
|
90
|
+
ref: objRef,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return createElement(FocusableContext.Provider, { value: context, children });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface FocusableAria {
|
|
97
|
+
/** Props for the focusable element. */
|
|
98
|
+
focusableProps: DOMAttributes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Used to make an element focusable and capable of auto focus.
|
|
103
|
+
*/
|
|
104
|
+
export function useFocusable<T extends FocusableElement = FocusableElement>(
|
|
105
|
+
props: FocusableOptions<T>,
|
|
106
|
+
domRef: RefObject<FocusableElement | null>,
|
|
107
|
+
): FocusableAria;
|
|
108
|
+
// Slot-threading form: sibling ported hooks pass their derived sub-slot as the trailing arg.
|
|
109
|
+
export function useFocusable<T extends FocusableElement = FocusableElement>(
|
|
110
|
+
props: FocusableOptions<T>,
|
|
111
|
+
domRef: RefObject<FocusableElement | null>,
|
|
112
|
+
slot: symbol | undefined,
|
|
113
|
+
): FocusableAria;
|
|
114
|
+
export function useFocusable(...args: any[]): FocusableAria {
|
|
115
|
+
const [user, slotArg] = splitSlot(args);
|
|
116
|
+
const slot = slotArg ?? S('useFocusable');
|
|
117
|
+
const props = user[0] as FocusableOptions;
|
|
118
|
+
const domRef = user[1] as RefObject<FocusableElement | null>;
|
|
119
|
+
|
|
120
|
+
let { focusProps } = useFocus(props, subSlot(slot, 'focus'));
|
|
121
|
+
let { keyboardProps } = useKeyboard(props, subSlot(slot, 'keyboard'));
|
|
122
|
+
let interactions = mergeProps(focusProps, keyboardProps);
|
|
123
|
+
let domProps = useFocusableContext(domRef, subSlot(slot, 'context'));
|
|
124
|
+
let interactionProps = props.isDisabled ? {} : domProps;
|
|
125
|
+
let autoFocusRef = useRef(props.autoFocus, subSlot(slot, 'autoFocus'));
|
|
126
|
+
|
|
127
|
+
useEffect(
|
|
128
|
+
() => {
|
|
129
|
+
if (autoFocusRef.current && domRef.current) {
|
|
130
|
+
focusSafely(domRef.current);
|
|
131
|
+
}
|
|
132
|
+
autoFocusRef.current = false;
|
|
133
|
+
},
|
|
134
|
+
[domRef],
|
|
135
|
+
subSlot(slot, 'autoFocusEffect'),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Always set a tabIndex so that Safari allows focusing native buttons and inputs.
|
|
139
|
+
let tabIndex: number | undefined = props.excludeFromTabOrder ? -1 : 0;
|
|
140
|
+
if (props.isDisabled) {
|
|
141
|
+
tabIndex = undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
focusableProps: mergeProps(
|
|
146
|
+
{
|
|
147
|
+
...interactions,
|
|
148
|
+
tabIndex,
|
|
149
|
+
},
|
|
150
|
+
interactionProps,
|
|
151
|
+
),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface FocusableComponentProps extends FocusableOptions {
|
|
156
|
+
children: any;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function Focusable(
|
|
160
|
+
props: FocusableComponentProps & { ref?: MergableRef<FocusableElement> },
|
|
161
|
+
): any {
|
|
162
|
+
const slot = S('Focusable');
|
|
163
|
+
let { children, ref: forwardedRef, ...otherProps } = props;
|
|
164
|
+
let ref = useObjectRef<FocusableElement>(forwardedRef, subSlot(slot, 'objRef'));
|
|
165
|
+
let { focusableProps } = useFocusable(otherProps, ref, subSlot(slot, 'focusable'));
|
|
166
|
+
|
|
167
|
+
// octane adaptation: upstream's dev-only console warning effect (Element ref /
|
|
168
|
+
// isFocusable / interactive-ARIA-role checks) is not ported.
|
|
169
|
+
|
|
170
|
+
// Resolve the single element child. octane children arrive as descriptors from
|
|
171
|
+
// prop-position JSX / createElement; a single-element array unwraps (octane
|
|
172
|
+
// convention — see radix Slot).
|
|
173
|
+
let target: any = children;
|
|
174
|
+
if (Array.isArray(children)) {
|
|
175
|
+
const arr = Children.toArray(children);
|
|
176
|
+
if (arr.length === 1) {
|
|
177
|
+
target = arr[0];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// octane adaptation: with a non-element child (e.g. text, or a compiled
|
|
182
|
+
// children-position render function), render children unchanged — the octane
|
|
183
|
+
// equivalent of upstream's warning path, without the warning.
|
|
184
|
+
if (!isValidElement(target)) {
|
|
185
|
+
return children;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let child: any = Children.only(target);
|
|
189
|
+
|
|
190
|
+
// octane is ref-as-prop (React 19 shape), so the child's own ref lives on its props.
|
|
191
|
+
let childRef = child.props?.ref;
|
|
192
|
+
|
|
193
|
+
// octane adaptation: memoize the composed ref so its identity is stable across
|
|
194
|
+
// renders (upstream rebuilds it every render; see the header note).
|
|
195
|
+
let mergedRef = useMemo(
|
|
196
|
+
() => mergeRefs(childRef, ref),
|
|
197
|
+
[childRef, ref],
|
|
198
|
+
subSlot(slot, 'mergedRef'),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
return cloneElement(child, {
|
|
202
|
+
...mergeProps(focusableProps, child.props ?? {}),
|
|
203
|
+
ref: mergedRef,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/interactions/useHover.ts).
|
|
2
|
+
// octane adaptations:
|
|
3
|
+
// - `hoverProps` are octane NATIVE delegated event props (`onPointerEnter`/`onPointerLeave`
|
|
4
|
+
// receive native events; octane's enter/leave delegation is target-only, matching the
|
|
5
|
+
// platform). `DOMAttributes` from '@react-types/shared' becomes a local structural alias.
|
|
6
|
+
// - `e.currentTarget` is EventTarget-typed on native events; casts to Element where
|
|
7
|
+
// upstream relied on React's element-typed currentTarget.
|
|
8
|
+
// - Public-hook slot threading (splitSlot/subSlot) per the binding convention; the
|
|
9
|
+
// explicit useMemo/useEffect dep arrays are preserved exactly.
|
|
10
|
+
|
|
11
|
+
// Portions of the code in this file are based on code from react.
|
|
12
|
+
// Original licensing for the following can be found in the
|
|
13
|
+
// NOTICE file in the root directory of this source tree.
|
|
14
|
+
// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
|
|
15
|
+
|
|
16
|
+
import type { HoverEvents } from '@react-types/shared';
|
|
17
|
+
import { useEffect, useMemo, useRef, useState } from 'octane';
|
|
18
|
+
|
|
19
|
+
import { S, splitSlot, subSlot } from '../internal';
|
|
20
|
+
import { getEventTarget, nodeContains } from '../utils/shadowdom/DOMFunctions';
|
|
21
|
+
import { getOwnerDocument } from '../utils/domHelpers';
|
|
22
|
+
import { useGlobalListeners } from '../utils/useGlobalListeners';
|
|
23
|
+
|
|
24
|
+
// octane adaptation: minimal structural DOMAttributes (upstream's drags React attribute types).
|
|
25
|
+
export type DOMAttributes = Record<string, any>;
|
|
26
|
+
|
|
27
|
+
export interface HoverProps extends HoverEvents {
|
|
28
|
+
/** Whether the hover events should be disabled. */
|
|
29
|
+
isDisabled?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface HoverResult {
|
|
33
|
+
/** Props to spread on the target element. */
|
|
34
|
+
hoverProps: DOMAttributes;
|
|
35
|
+
isHovered: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// iOS fires onPointerEnter twice: once with pointerType="touch" and again with pointerType="mouse".
|
|
39
|
+
// We want to ignore these emulated events so they do not trigger hover behavior.
|
|
40
|
+
// See https://bugs.webkit.org/show_bug.cgi?id=214609.
|
|
41
|
+
let globalIgnoreEmulatedMouseEvents = false;
|
|
42
|
+
let hoverCount = 0;
|
|
43
|
+
|
|
44
|
+
function setGlobalIgnoreEmulatedMouseEvents() {
|
|
45
|
+
globalIgnoreEmulatedMouseEvents = true;
|
|
46
|
+
|
|
47
|
+
// Clear globalIgnoreEmulatedMouseEvents after a short timeout. iOS fires onPointerEnter
|
|
48
|
+
// with pointerType="mouse" immediately after onPointerUp and before onFocus. On other
|
|
49
|
+
// devices that don't have this quirk, we don't want to ignore a mouse hover sometime in
|
|
50
|
+
// the distant future because a user previously touched the element.
|
|
51
|
+
setTimeout(() => {
|
|
52
|
+
globalIgnoreEmulatedMouseEvents = false;
|
|
53
|
+
}, 500);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function handleGlobalPointerEvent(e: PointerEvent) {
|
|
57
|
+
if (e.pointerType === 'touch') {
|
|
58
|
+
setGlobalIgnoreEmulatedMouseEvents();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function setupGlobalTouchEvents(): (() => void) | undefined {
|
|
63
|
+
let ownerDocument = getOwnerDocument(null);
|
|
64
|
+
if (typeof ownerDocument === 'undefined') {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (hoverCount === 0) {
|
|
69
|
+
if (typeof PointerEvent !== 'undefined') {
|
|
70
|
+
ownerDocument.addEventListener('pointerup', handleGlobalPointerEvent);
|
|
71
|
+
} else if (process.env.NODE_ENV === 'test') {
|
|
72
|
+
ownerDocument.addEventListener('touchend', setGlobalIgnoreEmulatedMouseEvents);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
hoverCount++;
|
|
77
|
+
return () => {
|
|
78
|
+
hoverCount--;
|
|
79
|
+
if (hoverCount > 0) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (typeof PointerEvent !== 'undefined') {
|
|
84
|
+
ownerDocument.removeEventListener('pointerup', handleGlobalPointerEvent);
|
|
85
|
+
} else if (process.env.NODE_ENV === 'test') {
|
|
86
|
+
ownerDocument.removeEventListener('touchend', setGlobalIgnoreEmulatedMouseEvents);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Handles pointer hover interactions for an element. Normalizes behavior
|
|
93
|
+
* across browsers and platforms, and ignores emulated mouse events on touch devices.
|
|
94
|
+
*/
|
|
95
|
+
export function useHover(props: HoverProps): HoverResult;
|
|
96
|
+
// Slot-threading form: sibling ported hooks pass their derived sub-slot as the trailing arg.
|
|
97
|
+
export function useHover(props: HoverProps, slot: symbol | undefined): HoverResult;
|
|
98
|
+
export function useHover(...args: any[]): HoverResult {
|
|
99
|
+
const [user, slotArg] = splitSlot(args);
|
|
100
|
+
const slot = slotArg ?? S('useHover');
|
|
101
|
+
const props = user[0] as HoverProps;
|
|
102
|
+
|
|
103
|
+
let { onHoverStart, onHoverChange, onHoverEnd, isDisabled } = props;
|
|
104
|
+
|
|
105
|
+
let [isHovered, setHovered] = useState(false, subSlot(slot, 'hovered'));
|
|
106
|
+
let state = useRef(
|
|
107
|
+
{
|
|
108
|
+
isHovered: false,
|
|
109
|
+
ignoreEmulatedMouseEvents: false,
|
|
110
|
+
pointerType: '',
|
|
111
|
+
target: null as HTMLElement | null,
|
|
112
|
+
},
|
|
113
|
+
subSlot(slot, 'state'),
|
|
114
|
+
).current;
|
|
115
|
+
|
|
116
|
+
useEffect(setupGlobalTouchEvents, [], subSlot(slot, 'globalTouch'));
|
|
117
|
+
let { addGlobalListener, removeAllGlobalListeners } = useGlobalListeners(subSlot(slot, 'global'));
|
|
118
|
+
|
|
119
|
+
let { hoverProps, triggerHoverEnd } = useMemo(
|
|
120
|
+
() => {
|
|
121
|
+
let triggerHoverStart = (event: any, pointerType: any) => {
|
|
122
|
+
state.pointerType = pointerType;
|
|
123
|
+
if (
|
|
124
|
+
isDisabled ||
|
|
125
|
+
pointerType === 'touch' ||
|
|
126
|
+
state.isHovered ||
|
|
127
|
+
!nodeContains(event.currentTarget as Element, getEventTarget(event) as Element)
|
|
128
|
+
) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
state.isHovered = true;
|
|
133
|
+
let target = event.currentTarget;
|
|
134
|
+
state.target = target;
|
|
135
|
+
|
|
136
|
+
// When an element that is hovered over is removed, no pointerleave event is fired by the browser,
|
|
137
|
+
// even though the originally hovered target may have shrunk in size so it is no longer hovered.
|
|
138
|
+
// However, a pointerover event will be fired on the new target the mouse is over.
|
|
139
|
+
// In Chrome this happens immediately. In Safari and Firefox, it happens upon moving the mouse one pixel.
|
|
140
|
+
addGlobalListener(
|
|
141
|
+
getOwnerDocument(getEventTarget(event) as Element),
|
|
142
|
+
'pointerover',
|
|
143
|
+
(e: PointerEvent) => {
|
|
144
|
+
if (
|
|
145
|
+
state.isHovered &&
|
|
146
|
+
state.target &&
|
|
147
|
+
!nodeContains(state.target, getEventTarget(e) as Element)
|
|
148
|
+
) {
|
|
149
|
+
triggerHoverEnd(e, e.pointerType);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
{ capture: true },
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (onHoverStart) {
|
|
156
|
+
onHoverStart({
|
|
157
|
+
type: 'hoverstart',
|
|
158
|
+
target,
|
|
159
|
+
pointerType,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (onHoverChange) {
|
|
164
|
+
onHoverChange(true);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
setHovered(true);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
let triggerHoverEnd = (event: any, pointerType: any) => {
|
|
171
|
+
let target = state.target;
|
|
172
|
+
state.pointerType = '';
|
|
173
|
+
state.target = null;
|
|
174
|
+
|
|
175
|
+
if (pointerType === 'touch' || !state.isHovered || !target) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
state.isHovered = false;
|
|
180
|
+
removeAllGlobalListeners();
|
|
181
|
+
|
|
182
|
+
if (onHoverEnd) {
|
|
183
|
+
onHoverEnd({
|
|
184
|
+
type: 'hoverend',
|
|
185
|
+
target,
|
|
186
|
+
pointerType,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (onHoverChange) {
|
|
191
|
+
onHoverChange(false);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setHovered(false);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
let hoverProps: DOMAttributes = {};
|
|
198
|
+
|
|
199
|
+
if (typeof PointerEvent !== 'undefined') {
|
|
200
|
+
hoverProps.onPointerEnter = (e: PointerEvent) => {
|
|
201
|
+
if (globalIgnoreEmulatedMouseEvents && e.pointerType === 'mouse') {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
triggerHoverStart(e, e.pointerType);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
hoverProps.onPointerLeave = (e: PointerEvent) => {
|
|
209
|
+
if (
|
|
210
|
+
!isDisabled &&
|
|
211
|
+
nodeContains(e.currentTarget as Element, getEventTarget(e) as Element)
|
|
212
|
+
) {
|
|
213
|
+
triggerHoverEnd(e, e.pointerType);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
} else if (process.env.NODE_ENV === 'test') {
|
|
217
|
+
hoverProps.onTouchStart = () => {
|
|
218
|
+
state.ignoreEmulatedMouseEvents = true;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
hoverProps.onMouseEnter = (e: MouseEvent) => {
|
|
222
|
+
if (!state.ignoreEmulatedMouseEvents && !globalIgnoreEmulatedMouseEvents) {
|
|
223
|
+
triggerHoverStart(e, 'mouse');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
state.ignoreEmulatedMouseEvents = false;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
hoverProps.onMouseLeave = (e: MouseEvent) => {
|
|
230
|
+
if (
|
|
231
|
+
!isDisabled &&
|
|
232
|
+
nodeContains(e.currentTarget as Element, getEventTarget(e) as Element)
|
|
233
|
+
) {
|
|
234
|
+
triggerHoverEnd(e, 'mouse');
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
return { hoverProps, triggerHoverEnd };
|
|
239
|
+
},
|
|
240
|
+
[
|
|
241
|
+
onHoverStart,
|
|
242
|
+
onHoverChange,
|
|
243
|
+
onHoverEnd,
|
|
244
|
+
isDisabled,
|
|
245
|
+
state,
|
|
246
|
+
addGlobalListener,
|
|
247
|
+
removeAllGlobalListeners,
|
|
248
|
+
],
|
|
249
|
+
subSlot(slot, 'props'),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
useEffect(
|
|
253
|
+
() => {
|
|
254
|
+
// Call the triggerHoverEnd as soon as isDisabled changes to true
|
|
255
|
+
// Safe to call triggerHoverEnd, it will early return if we aren't currently hovering
|
|
256
|
+
if (isDisabled) {
|
|
257
|
+
triggerHoverEnd({ currentTarget: state.target }, state.pointerType);
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
[isDisabled],
|
|
261
|
+
subSlot(slot, 'disabled'),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
hoverProps,
|
|
266
|
+
isHovered,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/interactions/useInteractOutside.ts).
|
|
2
|
+
// octane adaptations:
|
|
3
|
+
// - Public-hook slot threading (splitSlot/subSlot) per the binding convention; the
|
|
4
|
+
// explicit `[ref, isDisabled]` effect deps are preserved exactly.
|
|
5
|
+
// - Upstream's untyped handler/validator params are typed loosely (native events).
|
|
6
|
+
|
|
7
|
+
// Portions of the code in this file are based on code from react.
|
|
8
|
+
// Original licensing for the following can be found in the
|
|
9
|
+
// NOTICE file in the root directory of this source tree.
|
|
10
|
+
// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
|
|
11
|
+
|
|
12
|
+
import type { RefObject } from '@react-types/shared';
|
|
13
|
+
import { useEffect, useRef } from 'octane';
|
|
14
|
+
|
|
15
|
+
import { S, splitSlot, subSlot } from '../internal';
|
|
16
|
+
import { getEventTarget, nodeContains } from '../utils/shadowdom/DOMFunctions';
|
|
17
|
+
import { getOwnerDocument } from '../utils/domHelpers';
|
|
18
|
+
import { useEffectEvent } from '../utils/useEffectEvent';
|
|
19
|
+
|
|
20
|
+
export interface InteractOutsideProps {
|
|
21
|
+
ref: RefObject<Element | null>;
|
|
22
|
+
onInteractOutside?: (e: PointerEvent) => void;
|
|
23
|
+
onInteractOutsideStart?: (e: PointerEvent) => void;
|
|
24
|
+
/** Whether the interact outside events should be disabled. */
|
|
25
|
+
isDisabled?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Example, used in components like Dialogs and Popovers so they can close
|
|
30
|
+
* when a user clicks outside them.
|
|
31
|
+
*/
|
|
32
|
+
export function useInteractOutside(props: InteractOutsideProps): void;
|
|
33
|
+
// Slot-threading form: sibling ported hooks pass their derived sub-slot as the trailing arg.
|
|
34
|
+
export function useInteractOutside(props: InteractOutsideProps, slot: symbol | undefined): void;
|
|
35
|
+
export function useInteractOutside(...args: any[]): void {
|
|
36
|
+
const [user, slotArg] = splitSlot(args);
|
|
37
|
+
const slot = slotArg ?? S('useInteractOutside');
|
|
38
|
+
const props = user[0] as InteractOutsideProps;
|
|
39
|
+
|
|
40
|
+
let { ref, onInteractOutside, isDisabled, onInteractOutsideStart } = props;
|
|
41
|
+
let stateRef = useRef(
|
|
42
|
+
{
|
|
43
|
+
isPointerDown: false,
|
|
44
|
+
ignoreEmulatedMouseEvents: false,
|
|
45
|
+
},
|
|
46
|
+
subSlot(slot, 'state'),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
let onPointerDown = useEffectEvent(
|
|
50
|
+
(e: any) => {
|
|
51
|
+
if (onInteractOutside && isValidEvent(e, ref)) {
|
|
52
|
+
if (onInteractOutsideStart) {
|
|
53
|
+
onInteractOutsideStart(e);
|
|
54
|
+
}
|
|
55
|
+
stateRef.current.isPointerDown = true;
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
subSlot(slot, 'pointerDown'),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
let triggerInteractOutside = useEffectEvent(
|
|
62
|
+
(e: PointerEvent) => {
|
|
63
|
+
if (onInteractOutside) {
|
|
64
|
+
onInteractOutside(e);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
subSlot(slot, 'trigger'),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
useEffect(
|
|
71
|
+
() => {
|
|
72
|
+
let state = stateRef.current;
|
|
73
|
+
if (isDisabled) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const element = ref.current;
|
|
78
|
+
const documentObject = getOwnerDocument(element);
|
|
79
|
+
|
|
80
|
+
// Use pointer events if available. Otherwise, fall back to mouse and touch events.
|
|
81
|
+
if (typeof PointerEvent !== 'undefined') {
|
|
82
|
+
let onClick = (e: any) => {
|
|
83
|
+
if (state.isPointerDown && isValidEvent(e, ref)) {
|
|
84
|
+
triggerInteractOutside(e);
|
|
85
|
+
}
|
|
86
|
+
state.isPointerDown = false;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// changing these to capture phase fixed combobox
|
|
90
|
+
// Use click instead of pointerup to avoid Android Chrome issue
|
|
91
|
+
// https://issues.chromium.org/issues/40732224
|
|
92
|
+
documentObject.addEventListener('pointerdown', onPointerDown, true);
|
|
93
|
+
documentObject.addEventListener('click', onClick, true);
|
|
94
|
+
|
|
95
|
+
return () => {
|
|
96
|
+
documentObject.removeEventListener('pointerdown', onPointerDown, true);
|
|
97
|
+
documentObject.removeEventListener('click', onClick, true);
|
|
98
|
+
};
|
|
99
|
+
} else if (process.env.NODE_ENV === 'test') {
|
|
100
|
+
let onMouseUp = (e: any) => {
|
|
101
|
+
if (state.ignoreEmulatedMouseEvents) {
|
|
102
|
+
state.ignoreEmulatedMouseEvents = false;
|
|
103
|
+
} else if (state.isPointerDown && isValidEvent(e, ref)) {
|
|
104
|
+
triggerInteractOutside(e);
|
|
105
|
+
}
|
|
106
|
+
state.isPointerDown = false;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
let onTouchEnd = (e: any) => {
|
|
110
|
+
state.ignoreEmulatedMouseEvents = true;
|
|
111
|
+
if (state.isPointerDown && isValidEvent(e, ref)) {
|
|
112
|
+
triggerInteractOutside(e);
|
|
113
|
+
}
|
|
114
|
+
state.isPointerDown = false;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
documentObject.addEventListener('mousedown', onPointerDown, true);
|
|
118
|
+
documentObject.addEventListener('mouseup', onMouseUp, true);
|
|
119
|
+
documentObject.addEventListener('touchstart', onPointerDown, true);
|
|
120
|
+
documentObject.addEventListener('touchend', onTouchEnd, true);
|
|
121
|
+
|
|
122
|
+
return () => {
|
|
123
|
+
documentObject.removeEventListener('mousedown', onPointerDown, true);
|
|
124
|
+
documentObject.removeEventListener('mouseup', onMouseUp, true);
|
|
125
|
+
documentObject.removeEventListener('touchstart', onPointerDown, true);
|
|
126
|
+
documentObject.removeEventListener('touchend', onTouchEnd, true);
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
[ref, isDisabled],
|
|
131
|
+
subSlot(slot, 'listen'),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isValidEvent(event: any, ref: RefObject<Element | null>): boolean {
|
|
136
|
+
if (event.button > 0) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
let target = getEventTarget(event) as Element;
|
|
140
|
+
if (target) {
|
|
141
|
+
// if the event target is no longer in the document, ignore
|
|
142
|
+
const ownerDocument = target.ownerDocument;
|
|
143
|
+
if (!ownerDocument || !nodeContains(ownerDocument.documentElement, target)) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
// If the target is within a top layer element (e.g. toasts), ignore.
|
|
147
|
+
if (target.closest('[data-react-aria-top-layer]')) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!ref.current) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// When the event source is inside a Shadow DOM, event.target is just the shadow root.
|
|
157
|
+
// Using event.composedPath instead means we can get the actual element inside the shadow root.
|
|
158
|
+
// This only works if the shadow root is open, there is no way to detect if it is closed.
|
|
159
|
+
// If the event composed path contains the ref, interaction is inside.
|
|
160
|
+
return !event.composedPath().includes(ref.current);
|
|
161
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/interactions/useKeyboard.ts).
|
|
2
|
+
// octane adaptations:
|
|
3
|
+
// - `KeyboardEvents` / `DOMAttributes` from '@react-types/shared' are typed over React's
|
|
4
|
+
// synthetic events; local structural aliases over native KeyboardEvent replace them.
|
|
5
|
+
// - Public-hook slot threading (splitSlot) per the binding convention; no octane base hooks
|
|
6
|
+
// are composed here, so the slot is absorbed and unused.
|
|
7
|
+
import type { BaseEvent } from './createEventHandler';
|
|
8
|
+
import { createEventHandler } from './createEventHandler';
|
|
9
|
+
import { splitSlot } from '../internal';
|
|
10
|
+
|
|
11
|
+
// octane adaptation: native-event handler props (upstream: KeyboardEvents from '@react-types/shared').
|
|
12
|
+
export interface KeyboardEvents {
|
|
13
|
+
/** Handler that is called when a key is pressed. */
|
|
14
|
+
onKeyDown?: (e: BaseEvent<KeyboardEvent>) => void;
|
|
15
|
+
/** Handler that is called when a key is released. */
|
|
16
|
+
onKeyUp?: (e: BaseEvent<KeyboardEvent>) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// octane adaptation: minimal structural DOMAttributes (upstream's drags React attribute types).
|
|
20
|
+
export type DOMAttributes = Record<string, any>;
|
|
21
|
+
|
|
22
|
+
export interface KeyboardProps extends KeyboardEvents {
|
|
23
|
+
/** Whether the keyboard events should be disabled. */
|
|
24
|
+
isDisabled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface KeyboardResult {
|
|
28
|
+
/** Props to spread onto the target element. */
|
|
29
|
+
keyboardProps: DOMAttributes;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Handles keyboard interactions for a focusable element.
|
|
34
|
+
*/
|
|
35
|
+
export function useKeyboard(props: KeyboardProps): KeyboardResult;
|
|
36
|
+
// Slot-threading form: sibling ported hooks pass their derived sub-slot as the trailing arg.
|
|
37
|
+
export function useKeyboard(props: KeyboardProps, slot: symbol | undefined): KeyboardResult;
|
|
38
|
+
export function useKeyboard(...args: any[]): KeyboardResult {
|
|
39
|
+
const [user] = splitSlot(args);
|
|
40
|
+
const props = user[0] as KeyboardProps;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
keyboardProps: props.isDisabled
|
|
44
|
+
? {}
|
|
45
|
+
: {
|
|
46
|
+
onKeyDown: createEventHandler(props.onKeyDown),
|
|
47
|
+
onKeyUp: createEventHandler(props.onKeyUp),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|