Skip to content

Commit a5583ba

Browse files
authored
Features: useLinkClickHandler and useLinkPressHandler (#7998)
* Add support for `useLinkClickHandler` and `useLinkPressHandler` hooks * Add tests * Resolves #7598
1 parent 48634a4 commit a5583ba

File tree

7 files changed

+541
-24
lines changed

7 files changed

+541
-24
lines changed

docs/advanced-guides/custom-links.md

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,54 @@
11
# Custom Links
22

3-
TODO
3+
In most cases, the exported `<Link>` component should meet all of your needs as an abstraction of the anchor tag. If you need to return anything other than an anchor element, or override any of `<Link>`'s rendering logic, you can use a few hooks from `react-router-dom` to build your own:
4+
5+
```tsx
6+
import { useHref, useLinkClickHandler } from "react-router-dom";
7+
8+
const StyledLink = styled("a", { color: "fuschia" });
9+
10+
const Link = React.forwardRef(
11+
({ onClick, replace = false, state, target, to, ...rest }, ref) => {
12+
let href = useHref(to);
13+
let handleClick = useLinkClickHandler(to, { replace, state, target });
14+
15+
return (
16+
<StyledLink
17+
{...rest}
18+
href={href}
19+
onClick={event => {
20+
onClick?.(event);
21+
if (!event.defaultPrevented) {
22+
handleClick(event);
23+
}
24+
}}
25+
ref={ref}
26+
target={target}
27+
/>
28+
);
29+
}
30+
);
31+
```
32+
33+
If you're using `react-router-native`, you can create a custom `<Link>` with the `useLinkPressHandler` hook:
34+
35+
```tsx
36+
import { TouchableHighlight } from "react-native";
37+
import { useLinkPressHandler } from "react-router-native";
38+
39+
function Link({ onPress, replace = false, state, to, ...rest }) {
40+
let handlePress = useLinkPressHandler(to, { replace, state });
41+
42+
return (
43+
<TouchableHighlight
44+
{...rest}
45+
onPress={event => {
46+
onPress?.(event);
47+
if (!event.defaultPrevented) {
48+
handlePress(event);
49+
}
50+
}}
51+
/>
52+
);
53+
}
54+
```

docs/api-reference.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ There are a few low-level APIs that we use internally that may also prove useful
6969

7070
- [`useResolvedPath`](#useresolvedpath) - resolves a relative path against the current [location](#location)
7171
- [`useHref`](#usehref) - resolves a relative path suitable for use as a `<a href>`
72+
- [`useLinkClickHandler`](#uselinkclickhandler) - returns an event handler to for navigation when building a custom `<Link>` in `react-router-dom`
73+
- [`useLinkPressHandler`](#uselinkpresshandler) - returns an event handler to for navigation when building a custom `<Link>` in `react-router-native`
7274
- [`resolvePath`](#resolvepath) - resolves a relative path against a given URL pathname
7375

7476
<a name="confirming-navigation"></a>
@@ -892,6 +894,101 @@ The `useHref` hook returns a URL that may be used to link to the given `to` loca
892894
> component in `react-router-dom` to see how it uses `useHref` internally to
893895
> determine its own `href` value.
894896
897+
<a name="uselinkclickhandler"></a>
898+
899+
### `useLinkClickHandler`
900+
901+
<details>
902+
<summary>Type declaration</summary>
903+
904+
```tsx
905+
declare function useLinkClickHandler<
906+
E extends Element = HTMLAnchorElement,
907+
S extends State = State
908+
>(
909+
to: To,
910+
options?: {
911+
target?: React.HTMLAttributeAnchorTarget;
912+
replace?: boolean;
913+
state?: S;
914+
}
915+
): (event: React.MouseEvent<E, MouseEvent>) => void;
916+
```
917+
918+
</details>
919+
920+
The `useLinkClickHandler` hook returns a click event handler to for navigation when building a custom `<Link>` in `react-router-dom`.
921+
922+
```tsx
923+
import { useHref, useLinkClickHandler } from "react-router-dom";
924+
925+
const StyledLink = styled("a", { color: "fuschia" });
926+
927+
const Link = React.forwardRef(
928+
({ onClick, replace = false, state, target, to, ...rest }, ref) => {
929+
let href = useHref(to);
930+
let handleClick = useLinkClickHandler(to, { replace, state, target });
931+
932+
return (
933+
<StyledLink
934+
{...rest}
935+
href={href}
936+
onClick={event => {
937+
onClick?.(event);
938+
if (!event.defaultPrevented) {
939+
handleClick(event);
940+
}
941+
}}
942+
ref={ref}
943+
target={target}
944+
/>
945+
);
946+
}
947+
);
948+
```
949+
950+
<a name="uselinkpresshandler"></a>
951+
952+
### `useLinkPressHandler`
953+
954+
<details>
955+
<summary>Type declaration</summary>
956+
957+
```tsx
958+
declare function useLinkPressHandler<S extends State = State>(
959+
to: To,
960+
options?: {
961+
replace?: boolean;
962+
state?: S;
963+
}
964+
): (event: GestureResponderEvent) => void;
965+
```
966+
967+
</details>
968+
969+
The `react-router-native` counterpart to `useLinkClickHandler`, `useLinkPressHandler` returns a press event handler for custom `<Link>` navigation.
970+
971+
```tsx
972+
import { TouchableHighlight } from "react-native";
973+
import { useLinkPressHandler } from "react-router-native";
974+
975+
function Link({ onPress, replace = false, state, to, ...rest }) {
976+
let handlePress = useLinkPressHandler(to, { replace, state });
977+
978+
return (
979+
<TouchableHighlight
980+
{...rest}
981+
onPress={event => {
982+
onPress?.(event);
983+
if (!event.defaultPrevented) {
984+
handlePress(event);
985+
}
986+
}}
987+
/>
988+
);
989+
}
990+
```
991+
895992
<a name="useinroutercontext"></a>
896993

897994
### `useInRouterContext`
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import * as React from "react";
2+
import * as ReactDOM from "react-dom";
3+
import { act } from "react-dom/test-utils";
4+
import {
5+
MemoryRouter as Router,
6+
Routes,
7+
Route,
8+
useHref,
9+
useLinkClickHandler
10+
} from "react-router-dom";
11+
import type { LinkProps } from "react-router-dom";
12+
13+
describe("Custom link with useLinkClickHandler", () => {
14+
let node: HTMLDivElement;
15+
16+
function Link({ to, replace, state, target, ...rest }: LinkProps) {
17+
let href = useHref(to);
18+
let handleClick = useLinkClickHandler(to, { target, replace, state });
19+
return (
20+
// eslint-disable-next-line jsx-a11y/anchor-has-content
21+
<a {...rest} href={href} onClick={handleClick} target={target} />
22+
);
23+
}
24+
25+
beforeEach(() => {
26+
node = document.createElement("div");
27+
document.body.appendChild(node);
28+
});
29+
30+
afterEach(() => {
31+
document.body.removeChild(node);
32+
node = null!;
33+
});
34+
35+
it("navigates to the new page", () => {
36+
function Home() {
37+
return (
38+
<div>
39+
<h1>Home</h1>
40+
<Link to="../about">About</Link>
41+
</div>
42+
);
43+
}
44+
45+
function About() {
46+
return <h1>About</h1>;
47+
}
48+
49+
act(() => {
50+
ReactDOM.render(
51+
<Router initialEntries={["/home"]}>
52+
<Routes>
53+
<Route path="home" element={<Home />} />
54+
<Route path="about" element={<About />} />
55+
</Routes>
56+
</Router>,
57+
node
58+
);
59+
});
60+
61+
let anchor = node.querySelector("a");
62+
expect(anchor).not.toBeNull();
63+
64+
act(() => {
65+
anchor?.dispatchEvent(
66+
new MouseEvent("click", {
67+
view: window,
68+
bubbles: true,
69+
cancelable: true
70+
})
71+
);
72+
});
73+
74+
let h1 = node.querySelector("h1");
75+
expect(h1).not.toBeNull();
76+
expect(h1?.textContent).toEqual("About");
77+
});
78+
79+
describe("with a right click", () => {
80+
it("stays on the same page", () => {
81+
function Home() {
82+
return (
83+
<div>
84+
<h1>Home</h1>
85+
<Link to="../about">About</Link>
86+
</div>
87+
);
88+
}
89+
90+
function About() {
91+
return <h1>About</h1>;
92+
}
93+
94+
act(() => {
95+
ReactDOM.render(
96+
<Router initialEntries={["/home"]}>
97+
<Routes>
98+
<Route path="home" element={<Home />} />
99+
<Route path="about" element={<About />} />
100+
</Routes>
101+
</Router>,
102+
node
103+
);
104+
});
105+
106+
let anchor = node.querySelector("a");
107+
expect(anchor).not.toBeNull();
108+
109+
act(() => {
110+
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button
111+
let RightMouseButton = 2;
112+
anchor?.dispatchEvent(
113+
new MouseEvent("click", {
114+
view: window,
115+
bubbles: true,
116+
cancelable: true,
117+
button: RightMouseButton
118+
})
119+
);
120+
});
121+
122+
let h1 = node.querySelector("h1");
123+
expect(h1).not.toBeNull();
124+
expect(h1?.textContent).toEqual("Home");
125+
});
126+
});
127+
128+
describe("when the link is supposed to open in a new window", () => {
129+
it("stays on the same page", () => {
130+
function Home() {
131+
return (
132+
<div>
133+
<h1>Home</h1>
134+
<Link to="../about" target="_blank">
135+
About
136+
</Link>
137+
</div>
138+
);
139+
}
140+
141+
function About() {
142+
return <h1>About</h1>;
143+
}
144+
145+
act(() => {
146+
ReactDOM.render(
147+
<Router initialEntries={["/home"]}>
148+
<Routes>
149+
<Route path="home" element={<Home />} />
150+
<Route path="about" element={<About />} />
151+
</Routes>
152+
</Router>,
153+
node
154+
);
155+
});
156+
157+
let anchor = node.querySelector("a");
158+
expect(anchor).not.toBeNull();
159+
160+
act(() => {
161+
anchor?.dispatchEvent(
162+
new MouseEvent("click", {
163+
view: window,
164+
bubbles: true,
165+
cancelable: true
166+
})
167+
);
168+
});
169+
170+
let h1 = node.querySelector("h1");
171+
expect(h1).not.toBeNull();
172+
expect(h1?.textContent).toEqual("Home");
173+
});
174+
});
175+
176+
describe("when the modifier keys are used", () => {
177+
it("stays on the same page", () => {
178+
function Home() {
179+
return (
180+
<div>
181+
<h1>Home</h1>
182+
<Link to="../about">About</Link>
183+
</div>
184+
);
185+
}
186+
187+
function About() {
188+
return <h1>About</h1>;
189+
}
190+
191+
act(() => {
192+
ReactDOM.render(
193+
<Router initialEntries={["/home"]}>
194+
<Routes>
195+
<Route path="home" element={<Home />} />
196+
<Route path="about" element={<About />} />
197+
</Routes>
198+
</Router>,
199+
node
200+
);
201+
});
202+
203+
let anchor = node.querySelector("a");
204+
expect(anchor).not.toBeNull();
205+
206+
act(() => {
207+
anchor?.dispatchEvent(
208+
new MouseEvent("click", {
209+
view: window,
210+
bubbles: true,
211+
cancelable: true,
212+
// The Ctrl key is pressed
213+
ctrlKey: true
214+
})
215+
);
216+
});
217+
218+
let h1 = node.querySelector("h1");
219+
expect(h1).not.toBeNull();
220+
expect(h1?.textContent).toEqual("Home");
221+
});
222+
});
223+
});

0 commit comments

Comments
 (0)