@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominic Gannaway
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # @octanejs/aria
2
+
3
+ React Aria for the [octane](https://github.com/octanejs/octane) renderer — a
4
+ faithful port of Adobe's [React Aria](https://react-spectrum.adobe.com/react-aria/)
5
+ (`react-aria`, `react-stately`, and eventually `react-aria-components`) onto
6
+ octane's hooks and native event system.
7
+
8
+ - `@octanejs/aria` — the `react-aria` behavior-hook surface.
9
+ - `@octanejs/aria/stately` — the `react-stately` state-hook surface.
10
+ - `@octanejs/aria/components` — the `react-aria-components` surface (planned).
11
+
12
+ Ported from the pinned `adobe/react-spectrum` checkout at the commit publishing
13
+ `react-aria@3.50.0` / `react-stately@3.48.0` / `react-aria-components@1.19.0`,
14
+ and proven by differential parity: the same fixture runs through
15
+ `@octanejs/aria` and the real React packages, asserting byte-identical DOM.
16
+
17
+ Status, supported surface, and known divergences: `status.json` (rendered into
18
+ `docs/bindings-status.md`). Plan and progress: `docs/aria-migration-plan.md`.
19
+
20
+ ## Notable divergences
21
+
22
+ - Octane has no synthetic `onChange`; text-input DOM wiring uses native
23
+ `onInput` (per keystroke — the same timing React's `onChange` has for text
24
+ inputs). React Aria's public value-level `onChange(value)` callbacks are
25
+ unchanged.
26
+ - `forwardRef` becomes octane's ref-as-prop (React 19 style).
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@octanejs/aria",
3
+ "version": "0.0.1",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "octane": {
10
+ "hookSlots": {
11
+ "manual": [
12
+ "src"
13
+ ]
14
+ }
15
+ },
16
+ "description": "React Aria for the octane renderer — a port of Adobe's react-aria / react-stately / react-aria-components (accessible behavior hooks, state hooks, and components) onto octane's hooks and native event system.",
17
+ "author": {
18
+ "name": "Dominic Gannaway",
19
+ "email": "dg@domgan.com"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/octanejs/octane.git",
27
+ "directory": "packages/aria"
28
+ },
29
+ "main": "src/index.ts",
30
+ "module": "src/index.ts",
31
+ "types": "src/index.ts",
32
+ "files": [
33
+ "src",
34
+ "README.md"
35
+ ],
36
+ "exports": {
37
+ ".": "./src/index.ts",
38
+ "./stately": "./src/stately/index.ts"
39
+ },
40
+ "dependencies": {
41
+ "@react-types/shared": "^3.36.0",
42
+ "clsx": "^2.1.1"
43
+ },
44
+ "peerDependencies": {
45
+ "octane": "0.1.7"
46
+ },
47
+ "devDependencies": {
48
+ "@tsrx/react": "^0.2.37",
49
+ "esbuild": "^0.28.1",
50
+ "react": "^19.2.0",
51
+ "react-aria": "3.50.0",
52
+ "react-dom": "^19.2.0",
53
+ "react-stately": "3.48.0",
54
+ "vitest": "^4.1.9",
55
+ "octane": "0.1.7"
56
+ },
57
+ "scripts": {
58
+ "test": "vitest run"
59
+ }
60
+ }
package/src/index.ts ADDED
@@ -0,0 +1,52 @@
1
+ // @octanejs/aria — the `react-aria` surface, ported onto octane. Exports mirror the
2
+ // upstream monopackage index (.react-spectrum/packages/react-aria/exports/index.ts) and
3
+ // grow area-by-area with the migration plan (docs/aria-migration-plan.md).
4
+
5
+ // interactions
6
+ export { useFocus } from './interactions/useFocus';
7
+ export { useFocusVisible } from './interactions/useFocusVisible';
8
+ export { useFocusWithin } from './interactions/useFocusWithin';
9
+ export { useHover } from './interactions/useHover';
10
+ export { useInteractOutside } from './interactions/useInteractOutside';
11
+ export { useKeyboard } from './interactions/useKeyboard';
12
+ export { useMove } from './interactions/useMove';
13
+ export { usePress } from './interactions/usePress';
14
+ export { useLongPress } from './interactions/useLongPress';
15
+ export { useFocusable, Focusable } from './interactions/useFocusable';
16
+ export { Pressable } from './interactions/Pressable';
17
+
18
+ // utils
19
+ export { chain } from './utils/chain';
20
+ export { mergeProps } from './utils/mergeProps';
21
+ export { mergeRefs } from './utils/mergeRefs';
22
+ export { RouterProvider } from './utils/openLink';
23
+ export { useId } from './utils/useId';
24
+ export { useObjectRef } from './utils/useObjectRef';
25
+
26
+ // ssr
27
+ export { SSRProvider, useIsSSR } from './ssr/SSRProvider';
28
+
29
+ // types — upstream re-exports the React-free event/prop types from @react-types/shared;
30
+ // the event-handler prop types come from the ported modules, where React's synthetic
31
+ // event types became native ones.
32
+ export type { FocusProps, FocusResult } from './interactions/useFocus';
33
+ export type { FocusVisibleProps, FocusVisibleResult } from './interactions/useFocusVisible';
34
+ export type { FocusWithinProps, FocusWithinResult } from './interactions/useFocusWithin';
35
+ export type { HoverProps, HoverResult } from './interactions/useHover';
36
+ export type { InteractOutsideProps } from './interactions/useInteractOutside';
37
+ export type { KeyboardProps, KeyboardResult } from './interactions/useKeyboard';
38
+ export type { LongPressProps, LongPressResult } from './interactions/useLongPress';
39
+ export type { MoveResult } from './interactions/useMove';
40
+ export type { PressHookProps, PressProps, PressResult } from './interactions/usePress';
41
+ export type { PressableProps } from './interactions/Pressable';
42
+ export type { FocusableAria, FocusableOptions, FocusableProps } from './interactions/useFocusable';
43
+ export type {
44
+ MoveEvents,
45
+ PressEvent,
46
+ PressEvents,
47
+ LongPressEvent,
48
+ MoveStartEvent,
49
+ MoveMoveEvent,
50
+ MoveEndEvent,
51
+ } from '@react-types/shared';
52
+ export type { SSRProviderProps } from './ssr/SSRProvider';
@@ -0,0 +1,54 @@
1
+ // Ported from react-aria (source: .react-spectrum/packages/react-aria/src/interactions/PressResponder.tsx).
2
+ // octane adaptations:
3
+ // - React.forwardRef → octane ref-as-prop (the forwarded ref arrives as `props.ref`);
4
+ // JSX → createElement (this is a plain-`.ts` component, so no compiled slots — hooks use
5
+ // the stable S()/subSlot component-slot convention).
6
+ // - React's ReactNode/JSX.Element types → `any` (octane descriptors).
7
+ // - The dev-only "PressResponder was rendered without a pressable child" console.warn
8
+ // effect is not ported (repo policy: no dev-only console warnings); `register()`
9
+ // bookkeeping is unchanged.
10
+ import type { FocusableElement } from '@react-types/shared';
11
+ import { createElement, useContext, useMemo, useRef } from 'octane';
12
+
13
+ import { mergeProps } from '../utils/mergeProps';
14
+ import type { PressProps } from './usePress';
15
+ import { PressResponderContext } from './context';
16
+ import { S, subSlot } from '../internal';
17
+ import { useObjectRef } from '../utils/useObjectRef';
18
+ import { useSyncRef } from '../utils/useSyncRef';
19
+
20
+ // octane adaptation: ref-as-prop replaces React's ForwardedRef parameter.
21
+ type ForwardedRef<T> = ((instance: T | null) => (() => void) | void) | { current: T | null } | null;
22
+
23
+ interface PressResponderProps extends PressProps {
24
+ children: any;
25
+ ref?: ForwardedRef<FocusableElement>;
26
+ }
27
+
28
+ export function PressResponder(allProps: PressResponderProps): any {
29
+ const slot = S('PressResponder');
30
+ let { children, ref, ...props } = allProps;
31
+
32
+ let isRegistered = useRef(false, subSlot(slot, 'registered'));
33
+ let prevContext = useContext(PressResponderContext);
34
+ let context: any = mergeProps(prevContext || {}, {
35
+ ...props,
36
+ register() {
37
+ isRegistered.current = true;
38
+ if (prevContext) {
39
+ prevContext.register();
40
+ }
41
+ },
42
+ });
43
+
44
+ context.ref = useObjectRef(ref || prevContext?.ref, subSlot(slot, 'ref'));
45
+ useSyncRef(prevContext, context.ref, subSlot(slot, 'sync'));
46
+
47
+ return createElement(PressResponderContext.Provider, { value: context, children });
48
+ }
49
+
50
+ export function ClearPressResponder({ children }: { children: any }): any {
51
+ const slot = S('ClearPressResponder');
52
+ let context = useMemo(() => ({ register: () => {} }), [], subSlot(slot, 'context'));
53
+ return createElement(PressResponderContext.Provider, { value: context as any, children });
54
+ }
@@ -0,0 +1,37 @@
1
+ // Ported from react-aria (source: .react-spectrum/packages/react-aria/src/interactions/Pressable.tsx).
2
+ // octane adaptations: `forwardRef` becomes ref-as-prop; `React.Children.only` /
3
+ // `cloneElement` are octane's descriptor-based equivalents, so the child must be an
4
+ // element DESCRIPTOR (prop-position JSX, `createElement`, or a `.map()` result — the
5
+ // same contract as radix `Slot`). The upstream effect body is entirely dev-only
6
+ // console warnings and is not ported (repo policy).
7
+ import type { FocusableElement } from '@react-types/shared';
8
+ import { Children, cloneElement } from 'octane';
9
+
10
+ import { S, subSlot } from '../internal';
11
+ import { mergeProps } from '../utils/mergeProps';
12
+ import { mergeRefs, type MergableRef } from '../utils/mergeRefs';
13
+ import { useObjectRef } from '../utils/useObjectRef';
14
+ import { usePress, type PressProps } from './usePress';
15
+ import { useFocusable } from './useFocusable';
16
+
17
+ export interface PressableProps extends PressProps {
18
+ /** The single element-descriptor child the press behavior is projected onto. */
19
+ children: any;
20
+ ref?: MergableRef<FocusableElement>;
21
+ }
22
+
23
+ export function Pressable(props: PressableProps): any {
24
+ const slot = S('Pressable');
25
+ const { children, ref: forwardedRef, ...rest } = props;
26
+ const ref = useObjectRef<FocusableElement>(forwardedRef, subSlot(slot, 'ref'));
27
+ const { pressProps } = usePress({ ...rest, ref }, subSlot(slot, 'press'));
28
+ const { focusableProps } = useFocusable(rest, ref, subSlot(slot, 'focusable'));
29
+ const child = Children.only(children);
30
+
31
+ const childRef = (child as any).props?.ref;
32
+
33
+ return cloneElement(child, {
34
+ ...mergeProps(pressProps, focusableProps, (child as any).props),
35
+ ref: mergeRefs(childRef, ref),
36
+ });
37
+ }
@@ -0,0 +1,20 @@
1
+ // Ported from react-aria (source: .react-spectrum/packages/react-aria/src/interactions/context.ts).
2
+ // octane adaptations: React.createContext → octane createContext (octane's Context is
3
+ // callable-as-Provider, `.Provider` stays as an identity alias, so consumers are unchanged);
4
+ // React's MutableRefObject type → a local structural alias; `displayName` is stamped through
5
+ // a cast (octane's Context type doesn't declare it, but the runtime reads it for diagnostics).
6
+ import type { FocusableElement } from '@react-types/shared';
7
+ import { createContext, type Context } from 'octane';
8
+
9
+ import type { PressProps } from './usePress';
10
+
11
+ type MutableRefObject<T> = { current: T };
12
+
13
+ interface IPressResponderContext extends PressProps {
14
+ register(): void;
15
+ ref?: MutableRefObject<FocusableElement>;
16
+ }
17
+
18
+ export const PressResponderContext: Context<IPressResponderContext> =
19
+ createContext<IPressResponderContext>({ register: () => {} });
20
+ (PressResponderContext as any).displayName = 'PressResponderContext';
@@ -0,0 +1,94 @@
1
+ // Ported from react-aria (source: .react-spectrum/packages/react-aria/src/interactions/createEventHandler.ts).
2
+ // octane adaptations:
3
+ // - Handlers receive NATIVE events. Upstream builds the wrapped event by SPREADING a React
4
+ // synthetic event (`{...e, overrides}`); spreading a native event copies nothing (its
5
+ // properties are non-enumerable prototype accessors), so the wrapped event is a Proxy over
6
+ // the live native event instead: the override methods win, function-valued properties are
7
+ // bound to the underlying event, everything else forwards.
8
+ // - `BaseEvent` from '@react-types/shared' is typed over React's SyntheticEvent; a local
9
+ // structural alias over the native Event type replaces it.
10
+ // - The dev-only console.error inside `stopPropagation` is not ported (the flag mechanics
11
+ // are preserved exactly).
12
+
13
+ // Event bubbling can be problematic in real-world applications, so the default for React Spectrum components
14
+ // is not to propagate. This can be overridden by calling continuePropagation() on the event.
15
+ export type BaseEvent<T extends Event> = T & {
16
+ /**
17
+ * Use continuePropagation.
18
+ *
19
+ * @deprecated
20
+ */
21
+ stopPropagation(): void;
22
+ continuePropagation(): void;
23
+ };
24
+
25
+ const hasOwn = Object.prototype.hasOwnProperty;
26
+
27
+ /**
28
+ * This function wraps an event handler to make stopPropagation the default, and support
29
+ * continuePropagation instead.
30
+ */
31
+ export function createEventHandler<T extends Event>(
32
+ handler?: (e: BaseEvent<T>) => void,
33
+ ): ((e: T) => void) | undefined {
34
+ if (!handler) {
35
+ return undefined;
36
+ }
37
+
38
+ // NB: the flag deliberately lives on the WRAPPER closure, not per dispatch — after a
39
+ // handler calls continuePropagation(), a later event on the same wrapper instance does
40
+ // not re-arm stop-by-default. That is upstream's exact (and admittedly surprising)
41
+ // contract at the pinned version; the differential KeyLatch fixture pins octane to
42
+ // React's observable behavior across consecutive dispatches, so an upstream semantics
43
+ // change will surface at the next pin bump rather than silently diverging here.
44
+ let shouldStopPropagation = true;
45
+ return (e: T) => {
46
+ const overrides: Record<PropertyKey, any> = {
47
+ preventDefault() {
48
+ e.preventDefault();
49
+ },
50
+ isDefaultPrevented() {
51
+ // On a native event, `defaultPrevented` is the source of truth (upstream reads
52
+ // the synthetic event's isDefaultPrevented()).
53
+ return e.defaultPrevented;
54
+ },
55
+ stopPropagation() {
56
+ if (shouldStopPropagation) {
57
+ // Upstream logs a dev-only console.error here ("stopPropagation is now the
58
+ // default behavior..."); the flag is intentionally left as-is.
59
+ } else {
60
+ shouldStopPropagation = true;
61
+ }
62
+ },
63
+ continuePropagation() {
64
+ shouldStopPropagation = false;
65
+ // nested createEventHandler might have set continue propagation so we should continue
66
+ // propagation on wrappers
67
+ if (typeof (e as any).continuePropagation === 'function') {
68
+ (e as any).continuePropagation();
69
+ }
70
+ },
71
+ isPropagationStopped() {
72
+ return shouldStopPropagation;
73
+ },
74
+ };
75
+
76
+ const event = new Proxy(e, {
77
+ get(target, prop) {
78
+ if (hasOwn.call(overrides, prop)) {
79
+ return overrides[prop as any];
80
+ }
81
+ const value = Reflect.get(target, prop);
82
+ // Native event members are prototype accessors/methods; bind functions to the
83
+ // underlying event so invocation through the Proxy works.
84
+ return typeof value === 'function' ? value.bind(target) : value;
85
+ },
86
+ }) as BaseEvent<T>;
87
+
88
+ handler(event);
89
+
90
+ if (shouldStopPropagation) {
91
+ e.stopPropagation();
92
+ }
93
+ };
94
+ }
@@ -0,0 +1,40 @@
1
+ // Ported from react-aria (source: .react-spectrum/packages/react-aria/src/interactions/focusSafely.ts).
2
+ import type { FocusableElement } from '@react-types/shared';
3
+
4
+ import { focusWithoutScrolling } from '../utils/focusWithoutScrolling';
5
+ import { getActiveElement } from '../utils/shadowdom/DOMFunctions';
6
+ import { getInteractionModality } from './useFocusVisible';
7
+ import { getOwnerDocument } from '../utils/domHelpers';
8
+ import { runAfterTransition } from '../utils/runAfterTransition';
9
+
10
+ /**
11
+ * A utility function that focuses an element while avoiding undesired side effects such
12
+ * as page scrolling and screen reader issues with CSS transitions.
13
+ */
14
+ export function focusSafely(element: FocusableElement): void {
15
+ if (!element.isConnected) {
16
+ return;
17
+ }
18
+
19
+ // If the user is interacting with a virtual cursor, e.g. screen reader, then
20
+ // wait until after any animated transitions that are currently occurring on
21
+ // the page before shifting focus. This avoids issues with VoiceOver on iOS
22
+ // causing the page to scroll when moving focus if the element is transitioning
23
+ // from off the screen.
24
+ const ownerDocument = getOwnerDocument(element);
25
+ if (getInteractionModality() === 'virtual') {
26
+ let lastFocusedElement = getActiveElement(ownerDocument);
27
+ runAfterTransition(() => {
28
+ const activeElement = getActiveElement(ownerDocument);
29
+ // If focus did not move or focus was lost to the body, and the element is still in the document, focus it.
30
+ if (
31
+ (activeElement === lastFocusedElement || activeElement === ownerDocument.body) &&
32
+ element.isConnected
33
+ ) {
34
+ focusWithoutScrolling(element);
35
+ }
36
+ });
37
+ } else {
38
+ focusWithoutScrolling(element);
39
+ }
40
+ }
@@ -0,0 +1,96 @@
1
+ // Ported from react-aria (source: .react-spectrum/packages/react-aria/src/interactions/textSelection.ts).
2
+ // Ported verbatim; the only adaptation is a literal-union annotation on the
3
+ // `userSelect`/`webkitUserSelect` style-property picks (a bare `let` widens to
4
+ // `string`, which this repo's strict indexing rejects).
5
+ import { getOwnerDocument } from '../utils/domHelpers';
6
+
7
+ import { isIOS } from '../utils/platform';
8
+ import { runAfterTransition } from '../utils/runAfterTransition';
9
+
10
+ // Safari on iOS starts selecting text on long press. The only way to avoid this, it seems,
11
+ // is to add user-select: none to the entire page. Adding it to the pressable element prevents
12
+ // that element from being selected, but nearby elements may still receive selection. We add
13
+ // user-select: none on touch start, and remove it again on touch end to prevent this.
14
+ // This must be implemented using global state to avoid race conditions between multiple elements.
15
+
16
+ // There are three possible states due to the delay before removing user-select: none after
17
+ // pointer up. The 'default' state always transitions to the 'disabled' state, which transitions
18
+ // to 'restoring'. The 'restoring' state can either transition back to 'disabled' or 'default'.
19
+
20
+ // For non-iOS devices, we apply user-select: none to the pressed element instead to avoid possible
21
+ // performance issues that arise from applying and removing user-select: none to the entire page
22
+ // (see https://github.com/adobe/react-spectrum/issues/1609).
23
+ type State = 'default' | 'disabled' | 'restoring';
24
+
25
+ // Note that state only matters here for iOS. Non-iOS gets user-select: none applied to the target element
26
+ // rather than at the document level so we just need to apply/remove user-select: none for each pressed element individually
27
+ let state: State = 'default';
28
+ let savedUserSelect = '';
29
+ let modifiedElementMap = new WeakMap<Element, string>();
30
+
31
+ export function disableTextSelection(target?: Element): void {
32
+ if (isIOS()) {
33
+ if (state === 'default') {
34
+ const documentObject = getOwnerDocument(target);
35
+ savedUserSelect = documentObject.documentElement.style.webkitUserSelect;
36
+ documentObject.documentElement.style.webkitUserSelect = 'none';
37
+ }
38
+
39
+ state = 'disabled';
40
+ } else if (target instanceof HTMLElement || target instanceof SVGElement) {
41
+ // If not iOS, store the target's original user-select and change to user-select: none
42
+ // Ignore state since it doesn't apply for non iOS
43
+ let property: 'userSelect' | 'webkitUserSelect' =
44
+ 'userSelect' in target.style ? 'userSelect' : 'webkitUserSelect';
45
+ modifiedElementMap.set(target, target.style[property]);
46
+ target.style[property] = 'none';
47
+ }
48
+ }
49
+
50
+ export function restoreTextSelection(target?: Element): void {
51
+ if (isIOS()) {
52
+ // If the state is already default, there's nothing to do.
53
+ // If it is restoring, then there's no need to queue a second restore.
54
+ if (state !== 'disabled') {
55
+ return;
56
+ }
57
+
58
+ state = 'restoring';
59
+
60
+ // There appears to be a delay on iOS where selection still might occur
61
+ // after pointer up, so wait a bit before removing user-select.
62
+ setTimeout(() => {
63
+ // Wait for any CSS transitions to complete so we don't recompute style
64
+ // for the whole page in the middle of the animation and cause jank.
65
+ runAfterTransition(() => {
66
+ // Avoid race conditions
67
+ if (state === 'restoring') {
68
+ const documentObject = getOwnerDocument(target);
69
+ if (documentObject.documentElement.style.webkitUserSelect === 'none') {
70
+ documentObject.documentElement.style.webkitUserSelect = savedUserSelect || '';
71
+ }
72
+
73
+ savedUserSelect = '';
74
+ state = 'default';
75
+ }
76
+ });
77
+ }, 300);
78
+ } else if (target instanceof HTMLElement || target instanceof SVGElement) {
79
+ // If not iOS, restore the target's original user-select if any
80
+ // Ignore state since it doesn't apply for non iOS
81
+ if (target && modifiedElementMap.has(target)) {
82
+ let targetOldUserSelect = modifiedElementMap.get(target) as string;
83
+ let property: 'userSelect' | 'webkitUserSelect' =
84
+ 'userSelect' in target.style ? 'userSelect' : 'webkitUserSelect';
85
+
86
+ if (target.style[property] === 'none') {
87
+ target.style[property] = targetOldUserSelect;
88
+ }
89
+
90
+ if (target.getAttribute('style') === '') {
91
+ target.removeAttribute('style');
92
+ }
93
+ modifiedElementMap.delete(target);
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,114 @@
1
+ // Ported from react-aria (source: .react-spectrum/packages/react-aria/src/interactions/useFocus.ts).
2
+ // octane adaptations:
3
+ // - Handlers receive NATIVE FocusEvents (React's FocusEvent<Target> type → native FocusEvent),
4
+ // so `FocusEvents` / `DOMAttributes` from '@react-types/shared' (typed over React events)
5
+ // become local structural aliases.
6
+ // - Public-hook slot threading (splitSlot/subSlot) per the binding convention.
7
+
8
+ // Portions of the code in this file are based on code from react.
9
+ // Original licensing for the following can be found in the
10
+ // NOTICE file in the root directory of this source tree.
11
+ // See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
12
+
13
+ import type { FocusableElement } from '@react-types/shared';
14
+ import { useCallback } from 'octane';
15
+
16
+ import { S, splitSlot, subSlot } from '../internal';
17
+ import { getActiveElement, getEventTarget } from '../utils/shadowdom/DOMFunctions';
18
+ import { getOwnerDocument } from '../utils/domHelpers';
19
+ import { useSyntheticBlurEvent } from './utils';
20
+
21
+ // octane adaptation: native-event handler props (upstream: FocusEvents<Target> from
22
+ // '@react-types/shared'). The Target parameter is kept for signature parity.
23
+ export interface FocusEvents<Target = FocusableElement> {
24
+ /** Handler that is called when the element receives focus. */
25
+ onFocus?: (e: FocusEvent) => void;
26
+ /** Handler that is called when the element loses focus. */
27
+ onBlur?: (e: FocusEvent) => void;
28
+ /** Handler that is called when the element's focus status changes. */
29
+ onFocusChange?: (isFocused: boolean) => void;
30
+ }
31
+
32
+ // octane adaptation: minimal structural DOMAttributes (upstream's drags React attribute types).
33
+ export type DOMAttributes<T = FocusableElement> = Record<string, any>;
34
+
35
+ export interface FocusProps<Target = FocusableElement> extends FocusEvents<Target> {
36
+ /** Whether the focus events should be disabled. */
37
+ isDisabled?: boolean;
38
+ }
39
+
40
+ export interface FocusResult<Target = FocusableElement> {
41
+ /** Props to spread onto the target element. */
42
+ focusProps: DOMAttributes<Target>;
43
+ }
44
+
45
+ /**
46
+ * Handles focus events for the immediate target.
47
+ * Focus events on child elements will be ignored.
48
+ */
49
+ export function useFocus<Target extends FocusableElement = FocusableElement>(
50
+ props: FocusProps<Target>,
51
+ ): FocusResult<Target>;
52
+ // Slot-threading form: sibling ported hooks pass their derived sub-slot as the trailing arg.
53
+ export function useFocus<Target extends FocusableElement = FocusableElement>(
54
+ props: FocusProps<Target>,
55
+ slot: symbol | undefined,
56
+ ): FocusResult<Target>;
57
+ export function useFocus(...args: any[]): FocusResult {
58
+ const [user, slotArg] = splitSlot(args);
59
+ const slot = slotArg ?? S('useFocus');
60
+ const props = user[0] as FocusProps;
61
+
62
+ let { isDisabled, onFocus: onFocusProp, onBlur: onBlurProp, onFocusChange } = props;
63
+
64
+ const onBlur: FocusProps['onBlur'] = useCallback(
65
+ (e: FocusEvent) => {
66
+ if (getEventTarget(e) === e.currentTarget) {
67
+ if (onBlurProp) {
68
+ onBlurProp(e);
69
+ }
70
+
71
+ if (onFocusChange) {
72
+ onFocusChange(false);
73
+ }
74
+
75
+ return true;
76
+ }
77
+ },
78
+ [onBlurProp, onFocusChange],
79
+ subSlot(slot, 'blur'),
80
+ );
81
+
82
+ const onSyntheticFocus = useSyntheticBlurEvent(onBlur!, subSlot(slot, 'syntheticBlur'));
83
+
84
+ const onFocus: FocusProps['onFocus'] = useCallback(
85
+ (e: FocusEvent) => {
86
+ // Double check that document.activeElement actually matches e.target in case a previously chained
87
+ // focus handler already moved focus somewhere else.
88
+
89
+ let eventTarget = getEventTarget(e);
90
+ const ownerDocument = getOwnerDocument(eventTarget as Element);
91
+ const activeElement = ownerDocument ? getActiveElement(ownerDocument) : getActiveElement();
92
+ if (eventTarget === e.currentTarget && eventTarget === activeElement) {
93
+ if (onFocusProp) {
94
+ onFocusProp(e);
95
+ }
96
+
97
+ if (onFocusChange) {
98
+ onFocusChange(true);
99
+ }
100
+
101
+ onSyntheticFocus(e);
102
+ }
103
+ },
104
+ [onFocusChange, onFocusProp, onSyntheticFocus],
105
+ subSlot(slot, 'focus'),
106
+ );
107
+
108
+ return {
109
+ focusProps: {
110
+ onFocus: !isDisabled && (onFocusProp || onFocusChange || onBlurProp) ? onFocus : undefined,
111
+ onBlur: !isDisabled && (onBlurProp || onFocusChange) ? onBlur : undefined,
112
+ },
113
+ };
114
+ }