UNPKG

5.51 kBTypeScriptView Raw
1/*
2 * Copyright 2023 Adobe. All rights reserved.
3 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License. You may obtain a copy
5 * of the License at http://www.apache.org/licenses/LICENSE-2.0
6 *
7 * Unless required by applicable law or agreed to in writing, software distributed under
8 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9 * OF ANY KIND, either express or implied. See the License for the specific language
10 * governing permissions and limitations under the License.
11 */
12
13import {focusWithoutScrolling, isMac, isWebKit} from './index';
14import {isFirefox, isIPad} from './platform';
15import {LinkDOMProps} from '@react-types/shared';
16import React, {createContext, ReactNode, useContext, useMemo} from 'react';
17
18interface Router {
19 isNative: boolean,
20 open: (target: Element, modifiers: Modifiers) => void
21}
22
23const RouterContext = createContext<Router>({
24 isNative: true,
25 open: openSyntheticLink
26});
27
28interface RouterProviderProps {
29 navigate: (path: string) => void,
30 children: ReactNode
31}
32
33/**
34 * A RouterProvider accepts a `navigate` function from a framework or client side router,
35 * and provides it to all nested React Aria links to enable client side navigation.
36 */
37export function RouterProvider(props: RouterProviderProps) {
38 let {children, navigate} = props;
39
40 let ctx = useMemo(() => ({
41 isNative: false,
42 open: (target: Element, modifiers: Modifiers) => {
43 getSyntheticLink(target, link => {
44 if (shouldClientNavigate(link, modifiers)) {
45 navigate(link.pathname + link.search + link.hash);
46 } else {
47 openLink(link, modifiers);
48 }
49 });
50 }
51 }), [navigate]);
52
53 return (
54 <RouterContext.Provider value={ctx}>
55 {children}
56 </RouterContext.Provider>
57 );
58}
59
60export function useRouter(): Router {
61 return useContext(RouterContext);
62}
63
64interface Modifiers {
65 metaKey?: boolean,
66 ctrlKey?: boolean,
67 altKey?: boolean,
68 shiftKey?: boolean
69}
70
71export function shouldClientNavigate(link: HTMLAnchorElement, modifiers: Modifiers) {
72 // Use getAttribute here instead of link.target. Firefox will default link.target to "_parent" when inside an iframe.
73 let target = link.getAttribute('target');
74 return (
75 (!target || target === '_self') &&
76 link.origin === location.origin &&
77 !link.hasAttribute('download') &&
78 !modifiers.metaKey && // open in new tab (mac)
79 !modifiers.ctrlKey && // open in new tab (windows)
80 !modifiers.altKey && // download
81 !modifiers.shiftKey
82 );
83}
84
85export function openLink(target: HTMLAnchorElement, modifiers: Modifiers, setOpening = true) {
86 let {metaKey, ctrlKey, altKey, shiftKey} = modifiers;
87
88 // Firefox does not recognize keyboard events as a user action by default, and the popup blocker
89 // will prevent links with target="_blank" from opening. However, it does allow the event if the
90 // Command/Control key is held, which opens the link in a background tab. This seems like the best we can do.
91 // See https://bugzilla.mozilla.org/show_bug.cgi?id=257870 and https://bugzilla.mozilla.org/show_bug.cgi?id=746640.
92 if (isFirefox() && window.event?.type?.startsWith('key') && target.target === '_blank') {
93 if (isMac()) {
94 metaKey = true;
95 } else {
96 ctrlKey = true;
97 }
98 }
99
100 // WebKit does not support firing click events with modifier keys, but does support keyboard events.
101 // https://github.com/WebKit/WebKit/blob/c03d0ac6e6db178f90923a0a63080b5ca210d25f/Source/WebCore/html/HTMLAnchorElement.cpp#L184
102 let event = isWebKit() && isMac() && !isIPad() && process.env.NODE_ENV !== 'test'
103 // @ts-ignore - keyIdentifier is a non-standard property, but it's what webkit expects
104 ? new KeyboardEvent('keydown', {keyIdentifier: 'Enter', metaKey, ctrlKey, altKey, shiftKey})
105 : new MouseEvent('click', {metaKey, ctrlKey, altKey, shiftKey, bubbles: true, cancelable: true});
106 openLink.isOpening = setOpening;
107 focusWithoutScrolling(target);
108 target.dispatchEvent(event);
109 openLink.isOpening = false;
110}
111
112openLink.isOpening = false;
113
114function getSyntheticLink(target: Element, open: (link: HTMLAnchorElement) => void) {
115 if (target instanceof HTMLAnchorElement) {
116 open(target);
117 } else if (target.hasAttribute('data-href')) {
118 let link = document.createElement('a');
119 link.href = target.getAttribute('data-href');
120 if (target.hasAttribute('data-target')) {
121 link.target = target.getAttribute('data-target');
122 }
123 if (target.hasAttribute('data-rel')) {
124 link.rel = target.getAttribute('data-rel');
125 }
126 if (target.hasAttribute('data-download')) {
127 link.download = target.getAttribute('data-download');
128 }
129 if (target.hasAttribute('data-ping')) {
130 link.ping = target.getAttribute('data-ping');
131 }
132 if (target.hasAttribute('data-referrer-policy')) {
133 link.referrerPolicy = target.getAttribute('data-referrer-policy');
134 }
135 target.appendChild(link);
136 open(link);
137 target.removeChild(link);
138 }
139}
140
141function openSyntheticLink(target: Element, modifiers: Modifiers) {
142 getSyntheticLink(target, link => openLink(link, modifiers));
143}
144
145export function getSyntheticLinkProps(props: LinkDOMProps) {
146 return {
147 'data-href': props.href,
148 'data-target': props.target,
149 'data-rel': props.rel,
150 'data-download': props.download,
151 'data-ping': props.ping,
152 'data-referrer-policy': props.referrerPolicy
153 };
154}