@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,83 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/focusWithoutScrolling.ts).
|
|
2
|
+
|
|
3
|
+
import type { FocusableElement } from '@react-types/shared';
|
|
4
|
+
|
|
5
|
+
// This is a polyfill for element.focus({preventScroll: true});
|
|
6
|
+
// Currently necessary for Safari and old Edge:
|
|
7
|
+
// https://caniuse.com/#feat=mdn-api_htmlelement_focus_preventscroll_option
|
|
8
|
+
// See https://bugs.webkit.org/show_bug.cgi?id=178583
|
|
9
|
+
//
|
|
10
|
+
|
|
11
|
+
// Original licensing for the following methods can be found in the
|
|
12
|
+
// NOTICE file in the root directory of this source tree.
|
|
13
|
+
// See https://github.com/calvellido/focus-options-polyfill
|
|
14
|
+
|
|
15
|
+
interface ScrollableElement {
|
|
16
|
+
element: HTMLElement;
|
|
17
|
+
scrollTop: number;
|
|
18
|
+
scrollLeft: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function focusWithoutScrolling(element: FocusableElement): void {
|
|
22
|
+
if (supportsPreventScroll()) {
|
|
23
|
+
element.focus({ preventScroll: true });
|
|
24
|
+
} else {
|
|
25
|
+
let scrollableElements = getScrollableElements(element);
|
|
26
|
+
element.focus();
|
|
27
|
+
restoreScrollPosition(scrollableElements);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let supportsPreventScrollCached: boolean | null = null;
|
|
32
|
+
function supportsPreventScroll() {
|
|
33
|
+
if (supportsPreventScrollCached == null) {
|
|
34
|
+
supportsPreventScrollCached = false;
|
|
35
|
+
try {
|
|
36
|
+
let focusElem = document.createElement('div');
|
|
37
|
+
focusElem.focus({
|
|
38
|
+
get preventScroll() {
|
|
39
|
+
supportsPreventScrollCached = true;
|
|
40
|
+
return true;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
} catch {
|
|
44
|
+
// Ignore
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return supportsPreventScrollCached;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getScrollableElements(element: FocusableElement): ScrollableElement[] {
|
|
52
|
+
let parent = element.parentNode;
|
|
53
|
+
let scrollableElements: ScrollableElement[] = [];
|
|
54
|
+
let rootScrollingElement = document.scrollingElement || document.documentElement;
|
|
55
|
+
|
|
56
|
+
while (parent instanceof HTMLElement && parent !== rootScrollingElement) {
|
|
57
|
+
if (parent.offsetHeight < parent.scrollHeight || parent.offsetWidth < parent.scrollWidth) {
|
|
58
|
+
scrollableElements.push({
|
|
59
|
+
element: parent,
|
|
60
|
+
scrollTop: parent.scrollTop,
|
|
61
|
+
scrollLeft: parent.scrollLeft,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
parent = parent.parentNode;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (rootScrollingElement instanceof HTMLElement) {
|
|
68
|
+
scrollableElements.push({
|
|
69
|
+
element: rootScrollingElement,
|
|
70
|
+
scrollTop: rootScrollingElement.scrollTop,
|
|
71
|
+
scrollLeft: rootScrollingElement.scrollLeft,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return scrollableElements;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function restoreScrollPosition(scrollableElements: ScrollableElement[]) {
|
|
79
|
+
for (let { element, scrollTop, scrollLeft } of scrollableElements) {
|
|
80
|
+
element.scrollTop = scrollTop;
|
|
81
|
+
element.scrollLeft = scrollLeft;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/getNonce.ts).
|
|
2
|
+
// octane adaptation: the `globalThis.__webpack_nonce__` read goes through the NonceWindow cast
|
|
3
|
+
// (TS's `globalThis` has no index signature).
|
|
4
|
+
|
|
5
|
+
import { getOwnerWindow } from './domHelpers';
|
|
6
|
+
|
|
7
|
+
type NonceWindow = Window &
|
|
8
|
+
typeof globalThis & {
|
|
9
|
+
__webpack_nonce__?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function getWebpackNonce(doc?: Document): string | undefined {
|
|
13
|
+
let ownerWindow = doc?.defaultView as NonceWindow | null | undefined;
|
|
14
|
+
return (
|
|
15
|
+
ownerWindow?.__webpack_nonce__ || (globalThis as NonceWindow).__webpack_nonce__ || undefined
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let nonceCache = new WeakMap<Document, string>();
|
|
20
|
+
|
|
21
|
+
/** Reset the cached nonce value. Exported for testing only. */
|
|
22
|
+
export function resetNonceCache(): void {
|
|
23
|
+
nonceCache = new WeakMap();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns the CSP nonce, if configured via a `<meta property="csp-nonce">` tag or
|
|
28
|
+
* `__webpack_nonce__`. This allows dynamically injected `<style>` elements to work with Content
|
|
29
|
+
* Security Policy.
|
|
30
|
+
*/
|
|
31
|
+
export function getNonce(doc?: Document): string | undefined {
|
|
32
|
+
let d = doc ?? (typeof document !== 'undefined' ? document : undefined);
|
|
33
|
+
if (!d) {
|
|
34
|
+
return getWebpackNonce(d);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (nonceCache.has(d)) {
|
|
38
|
+
return nonceCache.get(d);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let meta = d.querySelector('meta[property="csp-nonce"]');
|
|
42
|
+
let nonce =
|
|
43
|
+
(meta &&
|
|
44
|
+
meta instanceof getOwnerWindow(meta).HTMLMetaElement &&
|
|
45
|
+
(meta.nonce || meta.content)) ||
|
|
46
|
+
getWebpackNonce(d) ||
|
|
47
|
+
undefined;
|
|
48
|
+
|
|
49
|
+
if (nonce !== undefined) {
|
|
50
|
+
nonceCache.set(d, nonce);
|
|
51
|
+
}
|
|
52
|
+
return nonce;
|
|
53
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/isElementVisible.ts).
|
|
2
|
+
|
|
3
|
+
import { getOwnerWindow } from './domHelpers';
|
|
4
|
+
|
|
5
|
+
const supportsCheckVisibility =
|
|
6
|
+
typeof Element !== 'undefined' && 'checkVisibility' in Element.prototype;
|
|
7
|
+
|
|
8
|
+
function isStyleVisible(element: Element) {
|
|
9
|
+
const windowObject = getOwnerWindow(element);
|
|
10
|
+
if (
|
|
11
|
+
!(element instanceof windowObject.HTMLElement) &&
|
|
12
|
+
!(element instanceof windowObject.SVGElement)
|
|
13
|
+
) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let { display, visibility } = element.style;
|
|
18
|
+
|
|
19
|
+
let isVisible = display !== 'none' && visibility !== 'hidden' && visibility !== 'collapse';
|
|
20
|
+
|
|
21
|
+
if (isVisible) {
|
|
22
|
+
const { getComputedStyle } = getOwnerWindow(element);
|
|
23
|
+
let { display: computedDisplay, visibility: computedVisibility } = getComputedStyle(element);
|
|
24
|
+
|
|
25
|
+
isVisible =
|
|
26
|
+
computedDisplay !== 'none' &&
|
|
27
|
+
computedVisibility !== 'hidden' &&
|
|
28
|
+
computedVisibility !== 'collapse';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return isVisible;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isAttributeVisible(element: Element, childElement?: Element) {
|
|
35
|
+
return (
|
|
36
|
+
!element.hasAttribute('hidden') &&
|
|
37
|
+
// Ignore HiddenSelect when tree walking.
|
|
38
|
+
!element.hasAttribute('data-react-aria-prevent-focus') &&
|
|
39
|
+
(element.nodeName === 'DETAILS' && childElement && childElement.nodeName !== 'SUMMARY'
|
|
40
|
+
? element.hasAttribute('open')
|
|
41
|
+
: true)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Adapted from https://github.com/testing-library/jest-dom and
|
|
47
|
+
* https://github.com/vuejs/vue-test-utils-next/.
|
|
48
|
+
* Licensed under the MIT License.
|
|
49
|
+
*
|
|
50
|
+
* @param element - Element to evaluate for display or visibility.
|
|
51
|
+
*/
|
|
52
|
+
export function isElementVisible(element: Element, childElement?: Element): boolean {
|
|
53
|
+
if (supportsCheckVisibility) {
|
|
54
|
+
return (
|
|
55
|
+
element.checkVisibility({ visibilityProperty: true }) &&
|
|
56
|
+
!element.closest('[data-react-aria-prevent-focus]')
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
element.nodeName !== '#comment' &&
|
|
62
|
+
isStyleVisible(element) &&
|
|
63
|
+
isAttributeVisible(element, childElement) &&
|
|
64
|
+
(!element.parentElement || isElementVisible(element.parentElement, element))
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/isFocusable.ts).
|
|
2
|
+
|
|
3
|
+
import { getOwnerWindow } from './domHelpers';
|
|
4
|
+
import { isElementVisible } from './isElementVisible';
|
|
5
|
+
|
|
6
|
+
const focusableElements = [
|
|
7
|
+
'input:not([disabled]):not([type=hidden])',
|
|
8
|
+
'select:not([disabled])',
|
|
9
|
+
'textarea:not([disabled])',
|
|
10
|
+
'button:not([disabled])',
|
|
11
|
+
'a[href]',
|
|
12
|
+
'area[href]',
|
|
13
|
+
'summary',
|
|
14
|
+
'iframe',
|
|
15
|
+
'object',
|
|
16
|
+
'embed',
|
|
17
|
+
'audio[controls]',
|
|
18
|
+
'video[controls]',
|
|
19
|
+
'[contenteditable]:not([contenteditable^="false"])',
|
|
20
|
+
'permission',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const FOCUSABLE_ELEMENT_SELECTOR =
|
|
24
|
+
focusableElements.join(':not([hidden]),') + ',[tabindex]:not([disabled]):not([hidden])';
|
|
25
|
+
|
|
26
|
+
focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])');
|
|
27
|
+
const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),');
|
|
28
|
+
|
|
29
|
+
export function isFocusable(
|
|
30
|
+
element: Element,
|
|
31
|
+
options?: { skipVisibilityCheck?: boolean },
|
|
32
|
+
): boolean {
|
|
33
|
+
return (
|
|
34
|
+
element.matches(FOCUSABLE_ELEMENT_SELECTOR) &&
|
|
35
|
+
!isInert(element) &&
|
|
36
|
+
(options?.skipVisibilityCheck || isElementVisible(element))
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isTabbable(element: Element): boolean {
|
|
41
|
+
return (
|
|
42
|
+
element.matches(TABBABLE_ELEMENT_SELECTOR) && isElementVisible(element) && !isInert(element)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isInert(element: Element): boolean {
|
|
47
|
+
let node: Element | null = element;
|
|
48
|
+
while (node != null) {
|
|
49
|
+
if (node instanceof getOwnerWindow(node).HTMLElement && node.inert) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
node = node.parentElement;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/isScrollable.ts).
|
|
2
|
+
|
|
3
|
+
export function isScrollable(node: Element | null, checkForOverflow?: boolean): boolean {
|
|
4
|
+
if (!node) {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
let style = window.getComputedStyle(node);
|
|
8
|
+
let root = document.scrollingElement || document.documentElement;
|
|
9
|
+
let isScrollable = /(auto|scroll)/.test(style.overflow + style.overflowX + style.overflowY);
|
|
10
|
+
|
|
11
|
+
// Root element has `visible` overflow by default, but is scrollable nonetheless.
|
|
12
|
+
if (node === root && style.overflow !== 'hidden') {
|
|
13
|
+
isScrollable = true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (isScrollable && checkForOverflow) {
|
|
17
|
+
isScrollable = node.scrollHeight !== node.clientHeight || node.scrollWidth !== node.clientWidth;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return isScrollable;
|
|
21
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/isVirtualEvent.ts).
|
|
2
|
+
|
|
3
|
+
import { isAndroid } from './platform';
|
|
4
|
+
|
|
5
|
+
// Original licensing for the following method can be found in the
|
|
6
|
+
// NOTICE file in the root directory of this source tree.
|
|
7
|
+
// See https://github.com/facebook/react/blob/3c713d513195a53788b3f8bb4b70279d68b15bcc/packages/react-interactions/events/src/dom/shared/index.js#L74-L87
|
|
8
|
+
|
|
9
|
+
// Keyboards, Assistive Technologies, and element.click() all produce a "virtual"
|
|
10
|
+
// click event. This is a method of inferring such clicks. Every browser except
|
|
11
|
+
// IE 11 only sets a zero value of "detail" for click events that are "virtual".
|
|
12
|
+
// However, IE 11 uses a zero value for all click events. For IE 11 we rely on
|
|
13
|
+
// the quirk that it produces click events that are of type PointerEvent, and
|
|
14
|
+
// where only the "virtual" click lacks a pointerType field.
|
|
15
|
+
|
|
16
|
+
export function isVirtualClick(event: MouseEvent | PointerEvent): boolean {
|
|
17
|
+
// JAWS/NVDA with Firefox.
|
|
18
|
+
if ((event as PointerEvent).pointerType === '' && event.isTrusted) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Android TalkBack's detail value varies depending on the event listener providing the event so we have specific logic here instead
|
|
23
|
+
// If pointerType is defined, event is from a click listener. For events from mousedown listener, detail === 0 is a sufficient check
|
|
24
|
+
// to detect TalkBack virtual clicks.
|
|
25
|
+
if (isAndroid() && (event as PointerEvent).pointerType) {
|
|
26
|
+
return event.type === 'click' && event.buttons === 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return event.detail === 0 && !(event as PointerEvent).pointerType;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isVirtualPointerEvent(event: PointerEvent): boolean {
|
|
33
|
+
// If the pointer size is zero, then we assume it's from a screen reader.
|
|
34
|
+
// Android TalkBack double tap will sometimes return a event with width and height of 1
|
|
35
|
+
// and pointerType === 'mouse' so we need to check for a specific combination of event attributes.
|
|
36
|
+
// Cannot use "event.pressure === 0" as the sole check due to Safari pointer events always returning pressure === 0
|
|
37
|
+
// instead of .5, see https://bugs.webkit.org/show_bug.cgi?id=206216. event.pointerType === 'mouse' is to distingush
|
|
38
|
+
// Talkback double tap from Windows Firefox touch screen press
|
|
39
|
+
return (
|
|
40
|
+
(!isAndroid() && event.width === 0 && event.height === 0) ||
|
|
41
|
+
(event.width === 1 &&
|
|
42
|
+
event.height === 1 &&
|
|
43
|
+
event.pressure === 0 &&
|
|
44
|
+
event.detail === 0 &&
|
|
45
|
+
event.pointerType === 'mouse')
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/mergeProps.ts).
|
|
2
|
+
// Event handlers here are octane's NATIVE delegated `onXxx` props — chaining semantics are
|
|
3
|
+
// identical. `className` composition stays clsx (upstream contract); octane's own
|
|
4
|
+
// class/className normalization then applies at the element, which agrees byte-for-byte
|
|
5
|
+
// for the string results clsx produces.
|
|
6
|
+
import clsx from 'clsx';
|
|
7
|
+
|
|
8
|
+
import { chain } from './chain';
|
|
9
|
+
import { mergeIds } from './useId';
|
|
10
|
+
import { mergeRefs } from './mergeRefs';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
[key: string]: any;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type PropsArg = Props | null | undefined;
|
|
17
|
+
|
|
18
|
+
// taken from: https://stackoverflow.com/questions/51603250/typescript-3-parameter-list-intersection-type/51604379#51604379
|
|
19
|
+
type TupleTypes<T> = { [P in keyof T]: T[P] } extends { [key: number]: infer V }
|
|
20
|
+
? NullToObject<V>
|
|
21
|
+
: never;
|
|
22
|
+
type NullToObject<T> = T extends null | undefined ? {} : T;
|
|
23
|
+
|
|
24
|
+
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void
|
|
25
|
+
? I
|
|
26
|
+
: never;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Merges multiple props objects together. Event handlers are chained,
|
|
30
|
+
* classNames are combined, ids are deduplicated, and refs are merged.
|
|
31
|
+
* For all other props, the last prop object overrides all previous ones.
|
|
32
|
+
*
|
|
33
|
+
* @param args - Multiple sets of props to merge together.
|
|
34
|
+
*/
|
|
35
|
+
export function mergeProps<T extends PropsArg[]>(...args: T): UnionToIntersection<TupleTypes<T>> {
|
|
36
|
+
// Start with a base clone of the first argument. This is a lot faster than starting
|
|
37
|
+
// with an empty object and adding properties as we go.
|
|
38
|
+
let result: Props = { ...args[0] };
|
|
39
|
+
for (let i = 1; i < args.length; i++) {
|
|
40
|
+
let props = args[i];
|
|
41
|
+
for (let key in props) {
|
|
42
|
+
let a = result[key];
|
|
43
|
+
let b = props[key];
|
|
44
|
+
|
|
45
|
+
// Chain events
|
|
46
|
+
if (
|
|
47
|
+
typeof a === 'function' &&
|
|
48
|
+
typeof b === 'function' &&
|
|
49
|
+
// This is a lot faster than a regex.
|
|
50
|
+
key[0] === 'o' &&
|
|
51
|
+
key[1] === 'n' &&
|
|
52
|
+
key.charCodeAt(2) >= /* 'A' */ 65 &&
|
|
53
|
+
key.charCodeAt(2) <= /* 'Z' */ 90
|
|
54
|
+
) {
|
|
55
|
+
result[key] = chain(a, b);
|
|
56
|
+
|
|
57
|
+
// Merge classnames, sometimes classNames are empty string which eval to false, so we just need to do a type check
|
|
58
|
+
} else if (
|
|
59
|
+
(key === 'className' || key === 'UNSAFE_className') &&
|
|
60
|
+
typeof a === 'string' &&
|
|
61
|
+
typeof b === 'string'
|
|
62
|
+
) {
|
|
63
|
+
result[key] = clsx(a, b);
|
|
64
|
+
} else if (key === 'id' && a && b) {
|
|
65
|
+
result.id = mergeIds(a, b);
|
|
66
|
+
} else if (key === 'ref' && a && b) {
|
|
67
|
+
result.ref = mergeRefs(a, b);
|
|
68
|
+
// Override others
|
|
69
|
+
} else {
|
|
70
|
+
result[key] = b !== undefined ? b : a;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return result as UnionToIntersection<TupleTypes<T>>;
|
|
76
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/mergeRefs.ts).
|
|
2
|
+
// React's `Ref` type becomes a local structural type — octane refs are the same shapes
|
|
3
|
+
// (callback ref with optional cleanup, or object ref), passed as ordinary props.
|
|
4
|
+
|
|
5
|
+
export type MergableRef<T> =
|
|
6
|
+
| ((value: T | null) => void | (() => void))
|
|
7
|
+
| { current: T | null }
|
|
8
|
+
| null
|
|
9
|
+
| undefined;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Merges multiple refs into one. Works with either callback or object refs.
|
|
13
|
+
*/
|
|
14
|
+
export function mergeRefs<T>(...refs: Array<MergableRef<T>>): MergableRef<T> {
|
|
15
|
+
if (refs.length === 1 && refs[0]) {
|
|
16
|
+
return refs[0];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (value: T | null) => {
|
|
20
|
+
let hasCleanup = false;
|
|
21
|
+
|
|
22
|
+
const cleanups = refs.map((ref) => {
|
|
23
|
+
const cleanup = setRef(ref, value);
|
|
24
|
+
hasCleanup ||= typeof cleanup == 'function';
|
|
25
|
+
return cleanup;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (hasCleanup) {
|
|
29
|
+
return () => {
|
|
30
|
+
cleanups.forEach((cleanup, i) => {
|
|
31
|
+
if (typeof cleanup === 'function') {
|
|
32
|
+
cleanup();
|
|
33
|
+
} else {
|
|
34
|
+
setRef(refs[i], null);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function setRef<T>(ref: MergableRef<T>, value: T | null): void | (() => void) {
|
|
43
|
+
if (typeof ref === 'function') {
|
|
44
|
+
return ref(value);
|
|
45
|
+
} else if (ref != null) {
|
|
46
|
+
ref.current = value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Ported from react-aria (source: .react-spectrum/packages/react-aria/src/utils/openLink.tsx).
|
|
2
|
+
// octane adaptations: JSX → createElement; native events, so `handleLinkClick` takes a DOM
|
|
3
|
+
// MouseEvent and `e.isDefaultPrevented()` → `e.defaultPrevented`; React's DOMAttributes type
|
|
4
|
+
// (a synthetic-event prop bag) is a local structural stand-in for the returned data-* records.
|
|
5
|
+
|
|
6
|
+
import type { Href, LinkDOMProps, RouterOptions } from '@react-types/shared';
|
|
7
|
+
import { createContext, createElement, useContext, useMemo } from 'octane';
|
|
8
|
+
|
|
9
|
+
import { S, splitSlot } from '../internal';
|
|
10
|
+
import { focusWithoutScrolling } from './focusWithoutScrolling';
|
|
11
|
+
import { isFirefox, isIPad, isMac, isWebKit } from './platform';
|
|
12
|
+
|
|
13
|
+
type DOMAttributes<T = Element> = Record<string, any>;
|
|
14
|
+
|
|
15
|
+
interface Router {
|
|
16
|
+
isNative: boolean;
|
|
17
|
+
open: (
|
|
18
|
+
target: Element,
|
|
19
|
+
modifiers: Modifiers,
|
|
20
|
+
href: Href,
|
|
21
|
+
routerOptions: RouterOptions | undefined,
|
|
22
|
+
) => void;
|
|
23
|
+
useHref: (href: Href) => string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const RouterContext = createContext<Router>({
|
|
27
|
+
isNative: true,
|
|
28
|
+
open: openSyntheticLink,
|
|
29
|
+
useHref: (href) => href,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
interface RouterProviderProps {
|
|
33
|
+
navigate: (path: Href, routerOptions: RouterOptions | undefined) => void;
|
|
34
|
+
useHref?: (href: Href) => string;
|
|
35
|
+
children: any;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A RouterProvider accepts a `navigate` function from a framework or client side router,
|
|
40
|
+
* and provides it to all nested React Aria links to enable client side navigation.
|
|
41
|
+
*/
|
|
42
|
+
export function RouterProvider(props: RouterProviderProps): any {
|
|
43
|
+
let { children, navigate, useHref } = props;
|
|
44
|
+
|
|
45
|
+
let ctx = useMemo(
|
|
46
|
+
() => ({
|
|
47
|
+
isNative: false,
|
|
48
|
+
open: (
|
|
49
|
+
target: Element,
|
|
50
|
+
modifiers: Modifiers,
|
|
51
|
+
href: Href,
|
|
52
|
+
routerOptions: RouterOptions | undefined,
|
|
53
|
+
) => {
|
|
54
|
+
getSyntheticLink(target, (link) => {
|
|
55
|
+
if (shouldClientNavigate(link, modifiers)) {
|
|
56
|
+
navigate(href, routerOptions);
|
|
57
|
+
} else {
|
|
58
|
+
openLink(link, modifiers);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
useHref: useHref || ((href) => href),
|
|
63
|
+
}),
|
|
64
|
+
[navigate, useHref],
|
|
65
|
+
S('RouterProvider:ctx'),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return createElement(RouterContext.Provider, { value: ctx, children });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function useRouter(): Router {
|
|
72
|
+
return useContext(RouterContext);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Modifiers {
|
|
76
|
+
metaKey?: boolean;
|
|
77
|
+
ctrlKey?: boolean;
|
|
78
|
+
altKey?: boolean;
|
|
79
|
+
shiftKey?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function shouldClientNavigate(link: HTMLAnchorElement, modifiers: Modifiers): boolean {
|
|
83
|
+
// Use getAttribute here instead of link.target. Firefox will default link.target to "_parent" when inside an iframe.
|
|
84
|
+
let target = link.getAttribute('target');
|
|
85
|
+
return (
|
|
86
|
+
(!target || target === '_self') &&
|
|
87
|
+
link.origin === location.origin &&
|
|
88
|
+
!link.hasAttribute('download') &&
|
|
89
|
+
!modifiers.metaKey && // open in new tab (mac)
|
|
90
|
+
!modifiers.ctrlKey && // open in new tab (windows)
|
|
91
|
+
!modifiers.altKey && // download
|
|
92
|
+
!modifiers.shiftKey
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function openLink(target: HTMLAnchorElement, modifiers: Modifiers, setOpening = true): void {
|
|
97
|
+
let { metaKey, ctrlKey, altKey, shiftKey } = modifiers;
|
|
98
|
+
|
|
99
|
+
// Firefox does not recognize keyboard events as a user action by default, and the popup blocker
|
|
100
|
+
// will prevent links with target="_blank" from opening. However, it does allow the event if the
|
|
101
|
+
// Command/Control key is held, which opens the link in a background tab. This seems like the best we can do.
|
|
102
|
+
// See https://bugzilla.mozilla.org/show_bug.cgi?id=257870 and https://bugzilla.mozilla.org/show_bug.cgi?id=746640.
|
|
103
|
+
if (isFirefox() && window.event?.type?.startsWith('key') && target.target === '_blank') {
|
|
104
|
+
if (isMac()) {
|
|
105
|
+
metaKey = true;
|
|
106
|
+
} else {
|
|
107
|
+
ctrlKey = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// WebKit does not support firing click events with modifier keys, but does support keyboard events.
|
|
112
|
+
// https://github.com/WebKit/WebKit/blob/c03d0ac6e6db178f90923a0a63080b5ca210d25f/Source/WebCore/html/HTMLAnchorElement.cpp#L184
|
|
113
|
+
let event =
|
|
114
|
+
isWebKit() && isMac() && !isIPad() && process.env.NODE_ENV !== 'test'
|
|
115
|
+
? // @ts-ignore - keyIdentifier is a non-standard property, but it's what webkit expects
|
|
116
|
+
new KeyboardEvent('keydown', { keyIdentifier: 'Enter', metaKey, ctrlKey, altKey, shiftKey })
|
|
117
|
+
: new MouseEvent('click', {
|
|
118
|
+
metaKey,
|
|
119
|
+
ctrlKey,
|
|
120
|
+
altKey,
|
|
121
|
+
shiftKey,
|
|
122
|
+
detail: 1,
|
|
123
|
+
bubbles: true,
|
|
124
|
+
cancelable: true,
|
|
125
|
+
});
|
|
126
|
+
(openLink as any).isOpening = setOpening;
|
|
127
|
+
focusWithoutScrolling(target);
|
|
128
|
+
target.dispatchEvent(event);
|
|
129
|
+
(openLink as any).isOpening = false;
|
|
130
|
+
}
|
|
131
|
+
// https://github.com/parcel-bundler/parcel/issues/8724
|
|
132
|
+
(openLink as any).isOpening = false;
|
|
133
|
+
|
|
134
|
+
function getSyntheticLink(target: Element, open: (link: HTMLAnchorElement) => void) {
|
|
135
|
+
if (target instanceof HTMLAnchorElement) {
|
|
136
|
+
open(target);
|
|
137
|
+
} else if (target.hasAttribute('data-href')) {
|
|
138
|
+
let link = document.createElement('a');
|
|
139
|
+
link.href = target.getAttribute('data-href')!;
|
|
140
|
+
if (target.hasAttribute('data-target')) {
|
|
141
|
+
link.target = target.getAttribute('data-target')!;
|
|
142
|
+
}
|
|
143
|
+
if (target.hasAttribute('data-rel')) {
|
|
144
|
+
link.rel = target.getAttribute('data-rel')!;
|
|
145
|
+
}
|
|
146
|
+
if (target.hasAttribute('data-download')) {
|
|
147
|
+
link.download = target.getAttribute('data-download')!;
|
|
148
|
+
}
|
|
149
|
+
if (target.hasAttribute('data-ping')) {
|
|
150
|
+
link.ping = target.getAttribute('data-ping')!;
|
|
151
|
+
}
|
|
152
|
+
if (target.hasAttribute('data-referrer-policy')) {
|
|
153
|
+
link.referrerPolicy = target.getAttribute('data-referrer-policy')!;
|
|
154
|
+
}
|
|
155
|
+
target.appendChild(link);
|
|
156
|
+
open(link);
|
|
157
|
+
target.removeChild(link);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function openSyntheticLink(target: Element, modifiers: Modifiers) {
|
|
162
|
+
getSyntheticLink(target, (link) => openLink(link, modifiers));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function useSyntheticLinkProps(props: LinkDOMProps): DOMAttributes<HTMLElement>;
|
|
166
|
+
export function useSyntheticLinkProps(...args: any[]): DOMAttributes<HTMLElement> {
|
|
167
|
+
const [user] = splitSlot(args);
|
|
168
|
+
const props = user[0] as LinkDOMProps;
|
|
169
|
+
|
|
170
|
+
let router = useRouter();
|
|
171
|
+
const href = router.useHref(props.href ?? '');
|
|
172
|
+
return {
|
|
173
|
+
'data-href': props.href ? href : undefined,
|
|
174
|
+
'data-target': props.target,
|
|
175
|
+
'data-rel': props.rel,
|
|
176
|
+
'data-download': props.download,
|
|
177
|
+
'data-ping': props.ping,
|
|
178
|
+
'data-referrer-policy': props.referrerPolicy,
|
|
179
|
+
} as DOMAttributes<HTMLElement>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** @deprecated - For backward compatibility. */
|
|
183
|
+
export function getSyntheticLinkProps(props: LinkDOMProps): DOMAttributes<HTMLElement> {
|
|
184
|
+
return {
|
|
185
|
+
'data-href': props.href,
|
|
186
|
+
'data-target': props.target,
|
|
187
|
+
'data-rel': props.rel,
|
|
188
|
+
'data-download': props.download,
|
|
189
|
+
'data-ping': props.ping,
|
|
190
|
+
'data-referrer-policy': props.referrerPolicy,
|
|
191
|
+
} as DOMAttributes<HTMLElement>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function useLinkProps(props?: LinkDOMProps): LinkDOMProps;
|
|
195
|
+
export function useLinkProps(...args: any[]): LinkDOMProps {
|
|
196
|
+
const [user] = splitSlot(args);
|
|
197
|
+
const props = user[0] as LinkDOMProps | undefined;
|
|
198
|
+
|
|
199
|
+
let router = useRouter();
|
|
200
|
+
const href = router.useHref(props?.href ?? '');
|
|
201
|
+
let linkProps: LinkDOMProps = {};
|
|
202
|
+
if (props) {
|
|
203
|
+
for (let key of ['href', 'target', 'rel', 'download', 'ping', 'referrerPolicy']) {
|
|
204
|
+
if (key in props) {
|
|
205
|
+
(linkProps as any)[key] = key === 'href' ? href : (props as any)[key];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return linkProps;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function handleLinkClick(
|
|
213
|
+
e: MouseEvent,
|
|
214
|
+
router: Router,
|
|
215
|
+
href: Href | undefined,
|
|
216
|
+
routerOptions: RouterOptions | undefined,
|
|
217
|
+
): void {
|
|
218
|
+
// If a custom router is provided, prevent default and forward if this link should client navigate.
|
|
219
|
+
if (
|
|
220
|
+
!router.isNative &&
|
|
221
|
+
e.currentTarget instanceof HTMLAnchorElement &&
|
|
222
|
+
e.currentTarget.href &&
|
|
223
|
+
// If props are applied to a router Link component, it may have already prevented default.
|
|
224
|
+
!e.defaultPrevented &&
|
|
225
|
+
shouldClientNavigate(e.currentTarget, e) &&
|
|
226
|
+
href
|
|
227
|
+
) {
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
router.open(e.currentTarget, e, href, routerOptions);
|
|
230
|
+
}
|
|
231
|
+
}
|