diff --git a/docs/advanced-guides/custom-links.md b/docs/advanced-guides/custom-links.md index afcaeff015..1e9060cc3b 100644 --- a/docs/advanced-guides/custom-links.md +++ b/docs/advanced-guides/custom-links.md @@ -1,3 +1,54 @@ # Custom Links -TODO +In most cases, the exported `` 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 ``'s rendering logic, you can use a few hooks from `react-router-dom` to build your own: + +```tsx +import { useHref, useLinkClickHandler } from "react-router-dom"; + +const StyledLink = styled("a", { color: "fuschia" }); + +const Link = React.forwardRef( + ({ onClick, replace = false, state, target, to, ...rest }, ref) => { + let href = useHref(to); + let handleClick = useLinkClickHandler(to, { replace, state, target }); + + return ( + { + onClick?.(event); + if (!event.defaultPrevented) { + handleClick(event); + } + }} + ref={ref} + target={target} + /> + ); + } +); +``` + +If you're using `react-router-native`, you can create a custom `` with the `useLinkPressHandler` hook: + +```tsx +import { TouchableHighlight } from "react-native"; +import { useLinkPressHandler } from "react-router-native"; + +function Link({ onPress, replace = false, state, to, ...rest }) { + let handlePress = useLinkPressHandler(to, { replace, state }); + + return ( + { + onPress?.(event); + if (!event.defaultPrevented) { + handlePress(event); + } + }} + /> + ); +} +``` diff --git a/docs/api-reference.md b/docs/api-reference.md index 1325fb2ee3..898a3854c5 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -69,6 +69,8 @@ There are a few low-level APIs that we use internally that may also prove useful - [`useResolvedPath`](#useresolvedpath) - resolves a relative path against the current [location](#location) - [`useHref`](#usehref) - resolves a relative path suitable for use as a `` +- [`useLinkClickHandler`](#uselinkclickhandler) - returns an event handler to for navigation when building a custom `` in `react-router-dom` +- [`useLinkPressHandler`](#uselinkpresshandler) - returns an event handler to for navigation when building a custom `` in `react-router-native` - [`resolvePath`](#resolvepath) - resolves a relative path against a given URL pathname @@ -892,6 +894,101 @@ The `useHref` hook returns a URL that may be used to link to the given `to` loca > component in `react-router-dom` to see how it uses `useHref` internally to > determine its own `href` value. + + +### `useLinkClickHandler` + +
+ Type declaration + +```tsx +declare function useLinkClickHandler< + E extends Element = HTMLAnchorElement, + S extends State = State +>( + to: To, + options?: { + target?: React.HTMLAttributeAnchorTarget; + replace?: boolean; + state?: S; + } +): (event: React.MouseEvent) => void; +``` + +
+ +The `useLinkClickHandler` hook returns a click event handler to for navigation when building a custom `` in `react-router-dom`. + +```tsx +import { useHref, useLinkClickHandler } from "react-router-dom"; + +const StyledLink = styled("a", { color: "fuschia" }); + +const Link = React.forwardRef( + ({ onClick, replace = false, state, target, to, ...rest }, ref) => { + let href = useHref(to); + let handleClick = useLinkClickHandler(to, { replace, state, target }); + + return ( + { + onClick?.(event); + if (!event.defaultPrevented) { + handleClick(event); + } + }} + ref={ref} + target={target} + /> + ); + } +); +``` + + + +### `useLinkPressHandler` + +
+ Type declaration + +```tsx +declare function useLinkPressHandler( + to: To, + options?: { + replace?: boolean; + state?: S; + } +): (event: GestureResponderEvent) => void; +``` + +
+ +The `react-router-native` counterpart to `useLinkClickHandler`, `useLinkPressHandler` returns a press event handler for custom `` navigation. + +```tsx +import { TouchableHighlight } from "react-native"; +import { useLinkPressHandler } from "react-router-native"; + +function Link({ onPress, replace = false, state, to, ...rest }) { + let handlePress = useLinkPressHandler(to, { replace, state }); + + return ( + { + onPress?.(event); + if (!event.defaultPrevented) { + handlePress(event); + } + }} + /> + ); +} +``` + ### `useInRouterContext` diff --git a/packages/react-router-dom/__tests__/useLinkClickHandler-test.tsx b/packages/react-router-dom/__tests__/useLinkClickHandler-test.tsx new file mode 100644 index 0000000000..123b921283 --- /dev/null +++ b/packages/react-router-dom/__tests__/useLinkClickHandler-test.tsx @@ -0,0 +1,223 @@ +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import { act } from "react-dom/test-utils"; +import { + MemoryRouter as Router, + Routes, + Route, + useHref, + useLinkClickHandler +} from "react-router-dom"; +import type { LinkProps } from "react-router-dom"; + +describe("Custom link with useLinkClickHandler", () => { + let node: HTMLDivElement; + + function Link({ to, replace, state, target, ...rest }: LinkProps) { + let href = useHref(to); + let handleClick = useLinkClickHandler(to, { target, replace, state }); + return ( + // eslint-disable-next-line jsx-a11y/anchor-has-content + + ); + } + + beforeEach(() => { + node = document.createElement("div"); + document.body.appendChild(node); + }); + + afterEach(() => { + document.body.removeChild(node); + node = null!; + }); + + it("navigates to the new page", () => { + function Home() { + return ( +
+

Home

+ About +
+ ); + } + + function About() { + return

About

; + } + + act(() => { + ReactDOM.render( + + + } /> + } /> + + , + node + ); + }); + + let anchor = node.querySelector("a"); + expect(anchor).not.toBeNull(); + + act(() => { + anchor?.dispatchEvent( + new MouseEvent("click", { + view: window, + bubbles: true, + cancelable: true + }) + ); + }); + + let h1 = node.querySelector("h1"); + expect(h1).not.toBeNull(); + expect(h1?.textContent).toEqual("About"); + }); + + describe("with a right click", () => { + it("stays on the same page", () => { + function Home() { + return ( +
+

Home

+ About +
+ ); + } + + function About() { + return

About

; + } + + act(() => { + ReactDOM.render( + + + } /> + } /> + + , + node + ); + }); + + let anchor = node.querySelector("a"); + expect(anchor).not.toBeNull(); + + act(() => { + // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button + let RightMouseButton = 2; + anchor?.dispatchEvent( + new MouseEvent("click", { + view: window, + bubbles: true, + cancelable: true, + button: RightMouseButton + }) + ); + }); + + let h1 = node.querySelector("h1"); + expect(h1).not.toBeNull(); + expect(h1?.textContent).toEqual("Home"); + }); + }); + + describe("when the link is supposed to open in a new window", () => { + it("stays on the same page", () => { + function Home() { + return ( +
+

Home

+ + About + +
+ ); + } + + function About() { + return

About

; + } + + act(() => { + ReactDOM.render( + + + } /> + } /> + + , + node + ); + }); + + let anchor = node.querySelector("a"); + expect(anchor).not.toBeNull(); + + act(() => { + anchor?.dispatchEvent( + new MouseEvent("click", { + view: window, + bubbles: true, + cancelable: true + }) + ); + }); + + let h1 = node.querySelector("h1"); + expect(h1).not.toBeNull(); + expect(h1?.textContent).toEqual("Home"); + }); + }); + + describe("when the modifier keys are used", () => { + it("stays on the same page", () => { + function Home() { + return ( +
+

Home

+ About +
+ ); + } + + function About() { + return

About

; + } + + act(() => { + ReactDOM.render( + + + } /> + } /> + + , + node + ); + }); + + let anchor = node.querySelector("a"); + expect(anchor).not.toBeNull(); + + act(() => { + anchor?.dispatchEvent( + new MouseEvent("click", { + view: window, + bubbles: true, + cancelable: true, + // The Ctrl key is pressed + ctrlKey: true + }) + ); + }); + + let h1 = node.querySelector("h1"); + expect(h1).not.toBeNull(); + expect(h1?.textContent).toEqual("Home"); + }); + }); +}); diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index bc05752fe4..c4cc5a7f90 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -195,30 +195,17 @@ export interface LinkProps */ export const Link = React.forwardRef( function LinkWithRef( - { onClick, replace: replaceProp = false, state, target, to, ...rest }, + { onClick, replace = false, state, target, to, ...rest }, ref ) { let href = useHref(to); - let navigate = useNavigate(); - let location = useLocation(); - let path = useResolvedPath(to); - - function handleClick(event: React.MouseEvent) { + let internalOnClick = useLinkClickHandler(to, { replace, state, target }); + function handleClick( + event: React.MouseEvent + ) { if (onClick) onClick(event); - if ( - !event.defaultPrevented && // onClick prevented default - event.button === 0 && // Ignore everything but left clicks - (!target || target === "_self") && // Let browser handle "target=_blank" etc. - !isModifiedEvent(event) // Ignore clicks with modifier keys - ) { - event.preventDefault(); - - // If the URL hasn't changed, a regular
will do a replace instead of - // a push, so do the same here. - let replace = - !!replaceProp || createPath(location) === createPath(path); - - navigate(to, { replace, state }); + if (!event.defaultPrevented) { + internalOnClick(event); } } @@ -333,6 +320,47 @@ export function Prompt({ message, when }: PromptProps) { // HOOKS //////////////////////////////////////////////////////////////////////////////// +/** + * Handles the click behavior for router `` components. This is useful if + * you need to create custom `` compoments with the same click behavior we + * use in our exported ``. + */ +export function useLinkClickHandler< + E extends Element = HTMLAnchorElement, + S extends State = State +>( + to: To, + { + target, + replace: replaceProp, + state + }: { + target?: React.HTMLAttributeAnchorTarget; + replace?: boolean; + state?: S; + } = {} +): (event: React.MouseEvent) => void { + let navigate = useNavigate(); + let location = useLocation(); + let path = useResolvedPath(to); + + return function handleClick(event: React.MouseEvent) { + if ( + event.button === 0 && // Ignore everything but left clicks + (!target || target === "_self") && // Let browser handle "target=_blank" etc. + !isModifiedEvent(event) // Ignore clicks with modifier keys + ) { + event.preventDefault(); + + // If the URL hasn't changed, a regular will do a replace instead of + // a push, so do the same here. + let replace = !!replaceProp || createPath(location) === createPath(path); + + navigate(to, { replace, state }); + } + }; +} + /** * Prevents navigation away from the current page using a window.confirm prompt * with the given message. diff --git a/packages/react-router-native/__tests__/__snapshots__/useLinkPressHandler-test.tsx.snap b/packages/react-router-native/__tests__/__snapshots__/useLinkPressHandler-test.tsx.snap new file mode 100644 index 0000000000..0839595805 --- /dev/null +++ b/packages/react-router-native/__tests__/__snapshots__/useLinkPressHandler-test.tsx.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Custom link with useLinkPressHandler navigates to the new view 1`] = ` + + + Home + + + + About + + + +`; + +exports[`Custom link with useLinkPressHandler navigates to the new view 2`] = ` + + + About + + +`; diff --git a/packages/react-router-native/__tests__/useLinkPressHandler-test.tsx b/packages/react-router-native/__tests__/useLinkPressHandler-test.tsx new file mode 100644 index 0000000000..4697ea8f1d --- /dev/null +++ b/packages/react-router-native/__tests__/useLinkPressHandler-test.tsx @@ -0,0 +1,62 @@ +import * as React from "react"; +import { act, create as createTestRenderer } from "react-test-renderer"; +import { Text, TouchableHighlight, View } from "react-native"; +import { + NativeRouter as Router, + Route, + Routes, + useLinkPressHandler +} from "react-router-native"; +import { press } from "./utils"; +import type { LinkProps } from "react-router-native"; +import type { ReactTestRenderer } from "react-test-renderer"; + +describe("Custom link with useLinkPressHandler", () => { + function Link({ to, replace, state, ...rest }: LinkProps) { + let handlePress = useLinkPressHandler(to, { replace, state }); + return ; + } + it("navigates to the new view", () => { + function Home() { + return ( + + Home + + About + + + ); + } + + function About() { + return ( + + About + + ); + } + + let renderer!: ReactTestRenderer; + act(() => { + renderer = createTestRenderer( + + + } /> + } /> + + + ); + }); + + expect(renderer.toJSON()).toMatchSnapshot(); + + let touchable = renderer.root.findByType(TouchableHighlight); + expect(touchable).not.toBeNull(); + + act(() => { + press(touchable); + }); + + expect(renderer.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/packages/react-router-native/index.tsx b/packages/react-router-native/index.tsx index bd635c564e..5b4c0a2068 100644 --- a/packages/react-router-native/index.tsx +++ b/packages/react-router-native/index.tsx @@ -134,12 +134,11 @@ export function Link({ to, ...rest }: LinkProps) { - let navigate = useNavigate(); - + let internalOnPress = useLinkPressHandler(to, { replace, state }); function handlePress(event: GestureResponderEvent) { if (onPress) onPress(event); if (!event.defaultPrevented) { - navigate(to, { replace, state }); + internalOnPress(event); } } @@ -170,6 +169,27 @@ export function Prompt({ message, when }: PromptProps) { const HardwareBackPressEventType = "hardwareBackPress"; const URLEventType = "url"; +/** + * Handles the press behavior for router `` components. This is useful if + * you need to create custom `` compoments with the same press behavior we + * use in our exported ``. + */ +export function useLinkPressHandler( + to: To, + { + replace, + state + }: { + replace?: boolean; + state?: S; + } = {} +): (event: GestureResponderEvent) => void { + let navigate = useNavigate(); + return function handlePress() { + navigate(to, { replace, state }); + }; +} + /** * Enables support for the hardware back button on Android. */