From 23182926f918b70199e01d735a09d73438c944be Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 2 Apr 2024 20:06:35 -0600 Subject: [PATCH] Add support for routerOptions and useHref (#5864) * Add support for routerOptions and useHref --- examples/next-app/app/page.tsx | 10 +- examples/remix/app/root.tsx | 18 +- examples/remix/app/routes/_index.tsx | 5 +- examples/remix/app/routes/foo.tsx | 3 + examples/rsp-next-ts/pages/_app.tsx | 17 +- examples/rsp-next-ts/pages/index.tsx | 2 +- packages/@adobe/react-spectrum/src/index.ts | 2 +- .../@react-aria/combobox/src/useComboBox.ts | 5 +- packages/@react-aria/link/src/useLink.ts | 12 +- packages/@react-aria/listbox/src/useOption.ts | 7 +- packages/@react-aria/menu/src/useMenuItem.ts | 11 +- .../selection/src/useSelectableCollection.ts | 3 +- .../selection/src/useSelectableItem.ts | 6 +- packages/@react-aria/tabs/src/useTab.ts | 7 +- packages/@react-aria/utils/src/index.ts | 2 +- packages/@react-aria/utils/src/openLink.tsx | 34 +++- .../breadcrumbs/test/Breadcrumbs.test.js | 19 ++- .../combobox/test/ComboBox.test.js | 8 +- .../@react-spectrum/link/test/Link.test.js | 10 +- .../list/test/ListView.test.js | 4 +- .../listbox/test/ListBox.test.js | 8 +- .../picker/test/Picker.test.js | 11 +- .../@react-spectrum/table/test/Table.test.js | 4 +- .../selection/src/SelectionManager.ts | 4 + .../@react-stately/selection/src/types.ts | 4 +- packages/@react-types/provider/src/index.d.ts | 5 +- packages/@react-types/shared/src/dom.d.ts | 15 +- .../dev/docs/pages/react-aria/routing.mdx | 148 +++++++++++++++-- .../dev/docs/pages/react-spectrum/routing.mdx | 157 ++++++++++++++++-- packages/react-aria-components/src/index.ts | 2 +- .../react-aria-components/test/Link.test.js | 10 +- 31 files changed, 462 insertions(+), 91 deletions(-) create mode 100644 examples/remix/app/routes/foo.tsx diff --git a/examples/next-app/app/page.tsx b/examples/next-app/app/page.tsx index 0c938b6cddf..368ec781f6b 100644 --- a/examples/next-app/app/page.tsx +++ b/examples/next-app/app/page.tsx @@ -1,10 +1,18 @@ "use client"; import {Provider, defaultTheme, DatePicker} from '@adobe/react-spectrum'; +import {useRouter} from 'next/navigation'; + +declare module '@adobe/react-spectrum' { + interface RouterConfig { + routerOptions: NonNullable['push']>[1]> + } +} export default function Home() { + let router = useRouter(); return ( - + ) diff --git a/examples/remix/app/root.tsx b/examples/remix/app/root.tsx index 41996727450..700916766a7 100644 --- a/examples/remix/app/root.tsx +++ b/examples/remix/app/root.tsx @@ -5,10 +5,20 @@ import { Outlet, Scripts, ScrollRestoration, + useNavigate, + useHref } from '@remix-run/react'; +import type {NavigateOptions} from 'react-router-dom'; import {Provider, defaultTheme} from '@adobe/react-spectrum'; +declare module '@adobe/react-spectrum' { + interface RouterConfig { + routerOptions: NavigateOptions + } +} + export default function App() { + let navigate = useNavigate(); return ( @@ -18,7 +28,13 @@ export default function App() { - + diff --git a/examples/remix/app/routes/_index.tsx b/examples/remix/app/routes/_index.tsx index f2b48df1f4c..9e7812157f4 100644 --- a/examples/remix/app/routes/_index.tsx +++ b/examples/remix/app/routes/_index.tsx @@ -1,5 +1,5 @@ import type { MetaFunction } from "@remix-run/node"; -import {DatePicker} from '@adobe/react-spectrum'; +import {ActionMenu, DatePicker, Item} from '@adobe/react-spectrum'; export const meta: MetaFunction = () => { return [ @@ -13,6 +13,9 @@ export default function Index() {

Welcome to Remix

+ + Link to foo +
); } diff --git a/examples/remix/app/routes/foo.tsx b/examples/remix/app/routes/foo.tsx new file mode 100644 index 00000000000..278b227d594 --- /dev/null +++ b/examples/remix/app/routes/foo.tsx @@ -0,0 +1,3 @@ +export default function Foo() { + return

Foo

+} diff --git a/examples/rsp-next-ts/pages/_app.tsx b/examples/rsp-next-ts/pages/_app.tsx index 4ddcbd27ff8..cc476a3c364 100644 --- a/examples/rsp-next-ts/pages/_app.tsx +++ b/examples/rsp-next-ts/pages/_app.tsx @@ -14,7 +14,13 @@ import Moon from "@spectrum-icons/workflow/Moon"; import Light from "@spectrum-icons/workflow/Light"; import { ToastContainer } from "@react-spectrum/toast"; import {enableTableNestedRows} from '@react-stately/flags'; -import {useRouter} from 'next/router'; +import {useRouter, type NextRouter} from 'next/router'; + +declare module '@adobe/react-spectrum' { + interface RouterConfig { + routerOptions: NonNullable[2]> + } +} function MyApp({ Component, pageProps }: AppProps) { const [theme, setTheme] = useState("light"); @@ -25,7 +31,14 @@ function MyApp({ Component, pageProps }: AppProps) { enableTableNestedRows(); return ( - + router.push(href, undefined, opts), + useHref: (href: string) => router.basePath + href + }} + locale="en"> Menu Trigger - Link to /foo + Link to /foo Cut Copy Paste diff --git a/packages/@adobe/react-spectrum/src/index.ts b/packages/@adobe/react-spectrum/src/index.ts index eccaceaeea7..7354aa695e0 100644 --- a/packages/@adobe/react-spectrum/src/index.ts +++ b/packages/@adobe/react-spectrum/src/index.ts @@ -115,4 +115,4 @@ export type {VisuallyHiddenAria, VisuallyHiddenProps} from '@react-aria/visually export type {DateFormatter, DateFormatterOptions, Filter, FormatMessage, Locale, LocalizedStrings} from '@react-aria/i18n'; export type {SSRProviderProps} from '@react-aria/ssr'; export type {DirectoryDropItem, DragAndDropHooks, DragAndDropOptions, DraggableCollectionEndEvent, DraggableCollectionMoveEvent, DraggableCollectionStartEvent, DragPreviewRenderer, DragTypes, DropItem, DropOperation, DroppableCollectionDropEvent, DroppableCollectionEnterEvent, DroppableCollectionExitEvent, DroppableCollectionInsertDropEvent, DroppableCollectionMoveEvent, DroppableCollectionOnItemDropEvent, DroppableCollectionReorderEvent, DroppableCollectionRootDropEvent, DropPosition, DropTarget, FileDropItem, ItemDropTarget, RootDropTarget, TextDropItem} from '@react-spectrum/dnd'; -export type {Key, Selection, ItemProps, SectionProps} from '@react-types/shared'; +export type {Key, Selection, ItemProps, SectionProps, RouterConfig} from '@react-types/shared'; diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index 39372cca5fe..b977b816c4c 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -15,7 +15,7 @@ import {AriaButtonProps} from '@react-types/button'; import {AriaComboBoxProps} from '@react-types/combobox'; import {ariaHideOutside} from '@react-aria/overlays'; import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox'; -import {BaseEvent, DOMAttributes, KeyboardDelegate, PressEvent, ValidationResult} from '@react-types/shared'; +import {BaseEvent, DOMAttributes, KeyboardDelegate, PressEvent, RouterOptions, ValidationResult} from '@react-types/shared'; import {chain, isAppleDevice, mergeProps, useLabels, useRouter} from '@react-aria/utils'; import {ComboBoxState} from '@react-stately/combobox'; import {FocusEvent, InputHTMLAttributes, KeyboardEvent, RefObject, TouchEvent, useEffect, useMemo, useRef} from 'react'; @@ -124,7 +124,8 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta if (e.key === 'Enter') { let item = listBoxRef.current.querySelector(`[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]`); if (item instanceof HTMLAnchorElement) { - router.open(item, e); + let collectionItem = state.collection.getItem(state.selectionManager.focusedKey); + router.open(item, e, collectionItem.props.href, collectionItem.props.routerOptions as RouterOptions); } } diff --git a/packages/@react-aria/link/src/useLink.ts b/packages/@react-aria/link/src/useLink.ts index 15fb18ba2a4..d4299b2c508 100644 --- a/packages/@react-aria/link/src/useLink.ts +++ b/packages/@react-aria/link/src/useLink.ts @@ -12,7 +12,7 @@ import {AriaLinkProps} from '@react-types/link'; import {DOMAttributes, FocusableElement} from '@react-types/shared'; -import {filterDOMProps, mergeProps, shouldClientNavigate, useRouter} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, shouldClientNavigate, useLinkProps, useRouter} from '@react-aria/utils'; import React, {RefObject} from 'react'; import {useFocusable} from '@react-aria/focus'; import {usePress} from '@react-aria/interactions'; @@ -60,13 +60,14 @@ export function useLink(props: AriaLinkOptions, ref: RefObject } let {focusableProps} = useFocusable(props, ref); let {pressProps, isPressed} = usePress({onPress, onPressStart, onPressEnd, isDisabled, ref}); - let domProps = filterDOMProps(otherProps, {labelable: true, isLink: elementType === 'a'}); + let domProps = filterDOMProps(otherProps, {labelable: true}); let interactionHandlers = mergeProps(focusableProps, pressProps); let router = useRouter(); + let routerLinkProps = useLinkProps(props); return { isPressed, // Used to indicate press state for visual - linkProps: mergeProps(domProps, { + linkProps: mergeProps(domProps, routerLinkProps, { ...interactionHandlers, ...linkProps, 'aria-disabled': isDisabled || undefined, @@ -85,10 +86,11 @@ export function useLink(props: AriaLinkOptions, ref: RefObject e.currentTarget.href && // If props are applied to a router Link component, it may have already prevented default. !e.isDefaultPrevented() && - shouldClientNavigate(e.currentTarget, e) + shouldClientNavigate(e.currentTarget, e) && + props.href ) { e.preventDefault(); - router.open(e.currentTarget, e); + router.open(e.currentTarget, e, props.href, props.routerOptions); } } }) diff --git a/packages/@react-aria/listbox/src/useOption.ts b/packages/@react-aria/listbox/src/useOption.ts index 611853fd8ec..b4429e0efe1 100644 --- a/packages/@react-aria/listbox/src/useOption.ts +++ b/packages/@react-aria/listbox/src/useOption.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {chain, filterDOMProps, isMac, isWebKit, mergeProps, useSlotId} from '@react-aria/utils'; +import {chain, filterDOMProps, isMac, isWebKit, mergeProps, useLinkProps, useSlotId} from '@react-aria/utils'; import {DOMAttributes, FocusableElement, Key} from '@react-types/shared'; import {getItemCount} from '@react-stately/collections'; import {getItemId, listData} from './utils'; @@ -149,13 +149,14 @@ export function useOption(props: AriaOptionProps, state: ListState, ref: R } }); - let domProps = filterDOMProps(item?.props, {isLink: !!item?.props?.href}); + let domProps = filterDOMProps(item?.props); delete domProps.id; + let linkProps = useLinkProps(item?.props); return { optionProps: { ...optionProps, - ...mergeProps(domProps, itemProps, hoverProps), + ...mergeProps(domProps, itemProps, hoverProps, linkProps), id: getItemId(state, key) }, labelProps: { diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index a4ec0c068c8..5833f5e951c 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -10,8 +10,8 @@ * governing permissions and limitations under the License. */ -import {DOMAttributes, DOMProps, FocusableElement, FocusEvents, HoverEvents, Key, KeyboardEvents, PressEvent, PressEvents} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useRouter, useSlotId} from '@react-aria/utils'; +import {DOMAttributes, DOMProps, FocusableElement, FocusEvents, HoverEvents, Key, KeyboardEvents, PressEvent, PressEvents, RouterOptions} from '@react-types/shared'; +import {filterDOMProps, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils'; import {getItemCount} from '@react-stately/collections'; import {isFocusVisible, useFocus, useHover, useKeyboard, usePress} from '@react-aria/interactions'; import {menuData} from './useMenu'; @@ -142,7 +142,7 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re } if (e.target instanceof HTMLAnchorElement) { - router.open(e.target, e); + router.open(e.target, e, item.props.href, item.props.routerOptions as RouterOptions); } }; @@ -269,13 +269,14 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re }); let {focusProps} = useFocus({onBlur, onFocus, onFocusChange}); - let domProps = filterDOMProps(item.props, {isLink: !!item?.props?.href}); + let domProps = filterDOMProps(item.props); delete domProps.id; + let linkProps = useLinkProps(item.props); return { menuItemProps: { ...ariaProps, - ...mergeProps(domProps, isTrigger ? {onFocus: itemProps.onFocus} : itemProps, pressProps, hoverProps, keyboardProps, focusProps), + ...mergeProps(domProps, linkProps, isTrigger ? {onFocus: itemProps.onFocus} : itemProps, pressProps, hoverProps, keyboardProps, focusProps), tabIndex: itemProps.tabIndex != null ? -1 : undefined }, labelProps: { diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 38c363cd544..1996b7e58b5 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -141,7 +141,8 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions }); let item = scrollRef.current.querySelector(`[data-key="${CSS.escape(key.toString())}"]`); - router.open(item, e); + let itemProps = manager.getItemProps(key); + router.open(item, e, itemProps.href, itemProps.routerOptions); return; } diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index cb722929ec5..747016018ec 100644 --- a/packages/@react-aria/selection/src/useSelectableItem.ts +++ b/packages/@react-aria/selection/src/useSelectableItem.ts @@ -131,7 +131,8 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte if (manager.isLink(key)) { if (linkBehavior === 'selection') { - router.open(ref.current, e); + let itemProps = manager.getItemProps(key); + router.open(ref.current, e, itemProps.href, itemProps.routerOptions); // Always set selected keys back to what they were so that select and combobox close. manager.setSelectedKeys(manager.selectedKeys); return; @@ -218,7 +219,8 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte } if (hasLinkAction) { - router.open(ref.current, e); + let itemProps = manager.getItemProps(key); + router.open(ref.current, e, itemProps.href, itemProps.routerOptions); } }; diff --git a/packages/@react-aria/tabs/src/useTab.ts b/packages/@react-aria/tabs/src/useTab.ts index 67a25e138f4..18f198b2996 100644 --- a/packages/@react-aria/tabs/src/useTab.ts +++ b/packages/@react-aria/tabs/src/useTab.ts @@ -12,7 +12,7 @@ import {AriaTabProps} from '@react-types/tabs'; import {DOMAttributes, FocusableElement} from '@react-types/shared'; -import {filterDOMProps, mergeProps} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, useLinkProps} from '@react-aria/utils'; import {generateId} from './utils'; import {RefObject} from 'react'; import {TabListState} from '@react-stately/tabs'; @@ -58,11 +58,12 @@ export function useTab( let {tabIndex} = itemProps; let item = state.collection.getItem(key); - let domProps = filterDOMProps(item?.props, {isLink: !!item?.props?.href, labelable: true}); + let domProps = filterDOMProps(item?.props, {labelable: true}); delete domProps.id; + let linkProps = useLinkProps(item?.props); return { - tabProps: mergeProps(domProps, itemProps, { + tabProps: mergeProps(domProps, linkProps, itemProps, { id: tabId, 'aria-selected': isSelected, 'aria-disabled': isDisabled || undefined, diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index 0f9f03377df..534b8c5d531 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -17,7 +17,7 @@ export {mergeRefs} from './mergeRefs'; export {filterDOMProps} from './filterDOMProps'; export {focusWithoutScrolling} from './focusWithoutScrolling'; export {getOffset} from './getOffset'; -export {openLink, getSyntheticLinkProps, RouterProvider, shouldClientNavigate, useRouter} from './openLink'; +export {openLink, getSyntheticLinkProps, RouterProvider, shouldClientNavigate, useRouter, useLinkProps} from './openLink'; export {runAfterTransition} from './runAfterTransition'; export {useDrag1D} from './useDrag1D'; export {useGlobalListeners} from './useGlobalListeners'; diff --git a/packages/@react-aria/utils/src/openLink.tsx b/packages/@react-aria/utils/src/openLink.tsx index fc6fce16222..1a9e3718a3a 100644 --- a/packages/@react-aria/utils/src/openLink.tsx +++ b/packages/@react-aria/utils/src/openLink.tsx @@ -11,22 +11,25 @@ */ import {focusWithoutScrolling, isMac, isWebKit} from './index'; +import {Href, LinkDOMProps, RouterOptions} from '@react-types/shared'; import {isFirefox, isIPad} from './platform'; -import {LinkDOMProps} from '@react-types/shared'; import React, {createContext, ReactNode, useContext, useMemo} from 'react'; interface Router { isNative: boolean, - open: (target: Element, modifiers: Modifiers) => void + open: (target: Element, modifiers: Modifiers, href: Href, routerOptions: RouterOptions | undefined) => void, + useHref: (href: Href) => string } const RouterContext = createContext({ isNative: true, - open: openSyntheticLink + open: openSyntheticLink, + useHref: (href) => href }); interface RouterProviderProps { - navigate: (path: string) => void, + navigate: (path: Href, routerOptions: RouterOptions | undefined) => void, + useHref?: (href: Href) => string, children: ReactNode } @@ -35,20 +38,21 @@ interface RouterProviderProps { * and provides it to all nested React Aria links to enable client side navigation. */ export function RouterProvider(props: RouterProviderProps) { - let {children, navigate} = props; + let {children, navigate, useHref} = props; let ctx = useMemo(() => ({ isNative: false, - open: (target: Element, modifiers: Modifiers) => { + open: (target: Element, modifiers: Modifiers, href: Href, routerOptions: RouterOptions | undefined) => { getSyntheticLink(target, link => { if (shouldClientNavigate(link, modifiers)) { - navigate(link.pathname + link.search + link.hash); + navigate(href, routerOptions); } else { openLink(link, modifiers); } }); - } - }), [navigate]); + }, + useHref: useHref || ((href) => href) + }), [navigate, useHref]); return ( @@ -152,3 +156,15 @@ export function getSyntheticLinkProps(props: LinkDOMProps) { 'data-referrer-policy': props.referrerPolicy }; } + +export function useLinkProps(props: LinkDOMProps) { + let router = useRouter(); + return { + href: props?.href ? router.useHref(props?.href) : undefined, + target: props?.target, + rel: props?.rel, + download: props?.download, + ping: props?.ping, + referrerPolicy: props?.referrerPolicy + }; +} diff --git a/packages/@react-spectrum/breadcrumbs/test/Breadcrumbs.test.js b/packages/@react-spectrum/breadcrumbs/test/Breadcrumbs.test.js index 505bfc68ccf..f3a3a891d54 100644 --- a/packages/@react-spectrum/breadcrumbs/test/Breadcrumbs.test.js +++ b/packages/@react-spectrum/breadcrumbs/test/Breadcrumbs.test.js @@ -443,21 +443,23 @@ describe('Breadcrumbs', function () { it('should support RouterProvider', () => { let navigate = jest.fn(); + let useHref = href => '/base' + href; let {getByRole, getAllByRole} = render( - + - Example.com - Foo - Bar - Baz - Qux + Example.com + Foo + Bar + Baz + Qux ); let links = getAllByRole('link'); + expect(links[0]).toHaveAttribute('href', '/base/foo/bar'); triggerPress(links[0]); - expect(navigate).toHaveBeenCalledWith('/foo/bar'); + expect(navigate).toHaveBeenCalledWith('/foo/bar', {foo: 'bar'}); navigate.mockReset(); let menuButton = getByRole('button'); @@ -466,7 +468,8 @@ describe('Breadcrumbs', function () { let menu = getByRole('menu'); let items = within(menu).getAllByRole('menuitemradio'); + expect(items[1]).toHaveAttribute('href', '/base/foo'); triggerPress(items[1]); - expect(navigate).toHaveBeenCalledWith('/foo'); + expect(navigate).toHaveBeenCalledWith('/foo', {foo: 'foo'}); }); }); diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index 20024056908..401a9f3b923 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -5941,10 +5941,11 @@ describe('ComboBox', function () { it('supports RouterProvider', async () => { let navigate = jest.fn(); + let useHref = href => href.startsWith('http') ? href : '/base' + href; let tree = render( - + - One + One Two @@ -5959,13 +5960,14 @@ describe('ComboBox', function () { let listbox = tree.getByRole('listbox'); let items = within(listbox).getAllByRole('option'); + expect(items[0]).toHaveAttribute('href', '/base/one'); triggerPress(items[0]); act(() => { jest.runAllTimers(); }); - expect(navigate).toHaveBeenCalledWith('/one'); + expect(navigate).toHaveBeenCalledWith('/one', {foo: 'bar'}); expect(combobox).toHaveValue(''); expect(listbox).not.toBeInTheDocument(); diff --git a/packages/@react-spectrum/link/test/Link.test.js b/packages/@react-spectrum/link/test/Link.test.js index ebac60ec731..43a85a3e841 100644 --- a/packages/@react-spectrum/link/test/Link.test.js +++ b/packages/@react-spectrum/link/test/Link.test.js @@ -149,9 +149,15 @@ describe('Link', function () { it('supports RouterProvider', () => { let navigate = jest.fn(); - let {getByRole} = render(Click me); + let useHref = href => '/base' + href; + let {getByRole} = render( + + Click me + + ); let link = getByRole('link'); + expect(link).toHaveAttribute('href', '/base/foo'); triggerPress(link); - expect(navigate).toHaveBeenCalledWith('/foo'); + expect(navigate).toHaveBeenCalledWith('/foo', {foo: 'bar'}); }); }); diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index f4dec565860..37aa01b27a3 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -1633,7 +1633,7 @@ describe('ListView', function () { let {getAllByRole} = render( - One + One Two @@ -1641,7 +1641,7 @@ describe('ListView', function () { let items = getAllByRole('row'); trigger(items[0]); - expect(navigate).toHaveBeenCalledWith('/one'); + expect(navigate).toHaveBeenCalledWith('/one', {foo: 'bar'}); navigate.mockReset(); let onClick = mockClickDefault(); diff --git a/packages/@react-spectrum/listbox/test/ListBox.test.js b/packages/@react-spectrum/listbox/test/ListBox.test.js index efe3b1f1f06..e2387e415c0 100644 --- a/packages/@react-spectrum/listbox/test/ListBox.test.js +++ b/packages/@react-spectrum/listbox/test/ListBox.test.js @@ -1014,18 +1014,20 @@ describe('ListBox', function () { it('works with RouterProvider', async () => { let navigate = jest.fn(); + let useHref = href => href.startsWith('http') ? href : '/base' + href; let {getAllByRole} = render( - + - One + One Two ); let items = getAllByRole('option'); + expect(items[0]).toHaveAttribute('href', '/base/one'); trigger(items[0]); - expect(navigate).toHaveBeenCalledWith('/one'); + expect(navigate).toHaveBeenCalledWith('/one', {foo: 'bar'}); navigate.mockReset(); let onClick = mockClickDefault(); diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index 8735a20a210..8b83b3a16e4 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -2509,10 +2509,11 @@ describe('Picker', function () { it('works with RouterProvider', async () => { let navigate = jest.fn(); + let useHref = href => '/base' + href; let tree = render( - + - One + One Two @@ -2526,8 +2527,12 @@ describe('Picker', function () { let listbox = tree.getByRole('listbox'); let items = within(listbox).getAllByRole('option'); + expect(items[0]).toHaveAttribute('href', '/base/one'); + let onClick = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('click', onClick); triggerPress(items[0]); - expect(navigate).toHaveBeenCalledWith('/one'); + expect(navigate).toHaveBeenCalledWith('/one', {foo: 'bar'}); + window.removeEventListener('click', onClick); }); }); }); diff --git a/packages/@react-spectrum/table/test/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js index d6e5c34904c..a45b23fd21b 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -4824,7 +4824,7 @@ export let tableTests = () => { Baz - + Foo 1 Bar 1 Baz 1 @@ -4841,7 +4841,7 @@ export let tableTests = () => { let items = getAllByRole('row').slice(1); await trigger(items[0]); - expect(navigate).toHaveBeenCalledWith('/one'); + expect(navigate).toHaveBeenCalledWith('/one', {foo: 'bar'}); navigate.mockReset(); let onClick = mockClickDefault(); diff --git a/packages/@react-stately/selection/src/SelectionManager.ts b/packages/@react-stately/selection/src/SelectionManager.ts index 3514bd61651..3e3755d35e0 100644 --- a/packages/@react-stately/selection/src/SelectionManager.ts +++ b/packages/@react-stately/selection/src/SelectionManager.ts @@ -494,4 +494,8 @@ export class SelectionManager implements MultipleSelectionManager { isLink(key: Key) { return !!this.collection.getItem(key)?.props?.href; } + + getItemProps(key: Key) { + return this.collection.getItem(key)?.props; + } } diff --git a/packages/@react-stately/selection/src/types.ts b/packages/@react-stately/selection/src/types.ts index 5852bd9bef7..c116cb07384 100644 --- a/packages/@react-stately/selection/src/types.ts +++ b/packages/@react-stately/selection/src/types.ts @@ -105,5 +105,7 @@ export interface MultipleSelectionManager extends FocusState { /** Sets the selection behavior for the collection. */ setSelectionBehavior(selectionBehavior: SelectionBehavior): void, /** Returns whether the given key is a hyperlink. */ - isLink(key: Key): boolean + isLink(key: Key): boolean, + /** Returns the props for the given item. */ + getItemProps(key: Key): any } diff --git a/packages/@react-types/provider/src/index.d.ts b/packages/@react-types/provider/src/index.d.ts index f028da706fb..9e54d05c218 100644 --- a/packages/@react-types/provider/src/index.d.ts +++ b/packages/@react-types/provider/src/index.d.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {DOMProps, StyleProps, ValidationState} from '@react-types/shared'; +import {DOMProps, Href, RouterOptions, StyleProps, ValidationState} from '@react-types/shared'; import {ReactNode} from 'react'; export type ColorScheme = 'light' | 'dark'; @@ -57,7 +57,8 @@ interface ContextProps { } interface Router { - navigate: (path: string) => void + navigate: (path: string, routerOptions: RouterOptions | undefined) => void, + useHref?: (href: Href) => string } export interface ProviderProps extends ContextProps, DOMProps, StyleProps { diff --git a/packages/@react-types/shared/src/dom.d.ts b/packages/@react-types/shared/src/dom.d.ts index 8375e99bbf7..88fea2c1c13 100644 --- a/packages/@react-types/shared/src/dom.d.ts +++ b/packages/@react-types/shared/src/dom.d.ts @@ -169,10 +169,19 @@ export interface TextInputDOMProps extends DOMProps, InputDOMProps, TextInputDOM inputMode?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search' } +/** + * This type allows configuring link props with router options and type-safe URLs via TS module augmentation. + * By default, this is an empty type. Extend with `href` and `routerOptions` properties to configure your router. + */ +export interface RouterConfig {} + +export type Href = RouterConfig extends {href: infer H} ? H : string; +export type RouterOptions = RouterConfig extends {routerOptions: infer O} ? O : never; + // Make sure to update filterDOMProps.ts when updating this. export interface LinkDOMProps { /** A URL to link to. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#href). */ - href?: string, + href?: Href, /** Hints at the human language of the linked URL. See[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#hreflang). */ hrefLang?: string, /** The target window for the link. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target). */ @@ -184,7 +193,9 @@ export interface LinkDOMProps { /** A space-separated list of URLs to ping when the link is followed. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#ping). */ ping?: string, /** How much of the referrer to send when following the link. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#referrerpolicy). */ - referrerPolicy?: HTMLAttributeReferrerPolicy + referrerPolicy?: HTMLAttributeReferrerPolicy, + /** Options for the configured client side router. */ + routerOptions?: RouterOptions } /** Any focusable element, including both HTML and SVG elements. */ diff --git a/packages/dev/docs/pages/react-aria/routing.mdx b/packages/dev/docs/pages/react-aria/routing.mdx index 6d4a8cbe01c..7c53075555c 100644 --- a/packages/dev/docs/pages/react-aria/routing.mdx +++ b/packages/dev/docs/pages/react-aria/routing.mdx @@ -34,16 +34,17 @@ Note that external links to different origins will not trigger client side routi ## RouterProvider -The `RouterProvider` component accepts a single prop: `navigate`. This should be set to a function received from your router for performing a client side navigation programmatically. The following example shows the general pattern. Framework-specific examples are shown below. +The `RouterProvider` component accepts two props: `navigate` and `useHref`. `navigate` should be set to a function received from your router for performing a client side navigation programmatically. `useHref` is an optional prop that converts a router-specific href to a native HTML href, e.g. prepending a base path. The following example shows the general pattern. Framework-specific examples are shown below. ```tsx import {RouterProvider} from 'react-aria-components'; +import {useNavigate, useHref} from 'your-router'; function App() { - let navigate = useNavigateFromYourRouter(); + let navigate = useNavigate(); return ( - + {/* ... */} ); @@ -52,19 +53,45 @@ function App() { Note: if you are using React Aria hooks rather than components, you can import `RouterProvider` from `react-aria` instead. +### Router options + +All React Aria link components accept a `routerOptions` prop, which is an object that is passed through to the client side router's `navigate` function as the second argument. This can be used to control any router-specific behaviors, such as scrolling, replacing instead of pushing to the history, etc. + +```tsx +{/* ...*/} +``` + +When using TypeScript, you can configure the `RouterConfig` type globally so that all link components have auto complete and type safety using a type provided by your router. + +```tsx +import type {RouterOptions} from 'your-router'; + +declare module 'react-aria-components' { + interface RouterConfig { + routerOptions: RouterOptions + } +} +``` + ### React Router -The [useNavigate](https://reactrouter.com/en/main/hooks/use-navigate) hook from `react-router-dom` returns a `navigate` function you can pass to `RouterProvider`. Ensure that the component that calls `useNavigate` and renders `RouterProvider` is inside the router component (e.g. `BrowserRouter`) so that it has access to React Router's internal context. The React Router `` element should also be defined inside React Aria's `` so that links inside the rendered routes have access to the router. +The [useNavigate](https://reactrouter.com/en/main/hooks/use-navigate) hook from `react-router-dom` returns a `navigate` function you can pass to `RouterProvider`. The [useHref](https://reactrouter.com/en/main/hooks/use-href) hook can also be provided if you're using React Router's `basename` option. Ensure that the component that calls `useNavigate` and renders `RouterProvider` is inside the router component (e.g. `BrowserRouter`) so that it has access to React Router's internal context. The React Router `` element should also be defined inside React Aria's `` so that links inside the rendered routes have access to the router. ```tsx -import {BrowserRouter, useNavigate} from 'react-router-dom'; +import {BrowserRouter, useNavigate, useHref, type NavigateOptions} from 'react-router-dom'; import {RouterProvider} from 'react-aria-components'; +declare module 'react-aria-components' { + interface RouterConfig { + routerOptions: NavigateOptions + } +} + function App() { let navigate = useNavigate(); return ( - + {/* Your app here... */} } /> @@ -92,6 +119,12 @@ The [useRouter](https://nextjs.org/docs/app/api-reference/functions/use-router) import {useRouter} from 'next/navigation'; import {RouterProvider} from 'react-aria-components'; +declare module 'react-aria-components' { + interface RouterConfig { + routerOptions: NonNullable['push']>[1]> + } +} + export function ClientProviders({children}) { let router = useRouter(); @@ -120,6 +153,37 @@ export default function RootLayout({children}) { } ``` +If you are using the Next.js [basePath](https://nextjs.org/docs/app/api-reference/next-config-js/basePath) setting, you'll need to configure an environment variable to access it. Then, provide a custom `useHref` function to prepend it to the href for all links. + +```tsx +// next.config.js +const basePath = '...'; +const nextConfig = { + basePath, + env: { + BASE_PATH: basePath + } +}; +``` + +```tsx +// app/provider.tsx +// ... + +export function ClientProviders({children}) { + let router = useRouter(); + /*- begin highlight -*/ + let useHref = (href: string) => process.env.BASE_PATH + href; + /*- end highlight -*/ + + return ( + + {children} + + ); +} +``` + #### Pages router The [useRouter](https://nextjs.org/docs/pages/api-reference/functions/use-router) hook from `next/router` returns a router object that can be used to perform navigation. `RouterProvider` should be rendered at the root of each page that includes React Aria links, or in `pages/_app.tsx` to add it to all pages. @@ -127,14 +191,42 @@ The [useRouter](https://nextjs.org/docs/pages/api-reference/functions/use-router ```tsx // pages/_app.tsx import type { AppProps } from 'next/app'; -import {useRouter} from 'next/router'; +import {useRouter, type NextRouter} from 'next/router'; import {RouterProvider} from 'react-aria-components'; +declare module 'react-aria-components' { + interface RouterConfig { + routerOptions: NonNullable[2]> + } +} + export default function MyApp({Component, pageProps}: AppProps) { let router = useRouter(); return ( - + router.push(href, undefined, opts)}> + + + ); +} +``` + +When using the [basePath](https://nextjs.org/docs/app/api-reference/next-config-js/basePath) configuration option, provide a `useHref` prop to `RouterProvider` to prepend it to links automatically. + +```tsx +// pages/_app.tsx +// ... + +export default function MyApp({Component, pageProps}: AppProps) { + let router = useRouter(); + + return ( + router.push(href, undefined, opts)} + /*- begin highlight -*/ + useHref={(href: string) => router.basePath + href} + /*- end highlight -*/ + > ); @@ -143,13 +235,20 @@ export default function MyApp({Component, pageProps}: AppProps) { ### Remix -Remix uses React Router under the hood, so the same [useNavigate](https://reactrouter.com/en/main/hooks/use-navigate) hook described above also works in Remix apps. `RouterProvider` should be rendered at the root of each page that includes React Aria links, or in `app/root.tsx` to add it to all pages. See the [Remix docs](https://remix.run/docs/en/main/file-conventions/root) for more details. +Remix uses React Router under the hood, so the same [useNavigate](https://reactrouter.com/en/main/hooks/use-navigate) and [useHref](https://reactrouter.com/en/main/hooks/use-href) hooks described above also work in Remix apps. `RouterProvider` should be rendered at the root of each page that includes React Aria links, or in `app/root.tsx` to add it to all pages. See the [Remix docs](https://remix.run/docs/en/main/file-conventions/root) for more details. ```tsx // app/root.tsx -import {useNavigate, Outlet} from '@remix-run/react'; +import {useNavigate, useHref, Outlet} from '@remix-run/react'; +import type {NavigateOptions} from 'react-router-dom'; import {RouterProvider} from 'react-aria-components'; +declare module 'react-aria-components' { + interface RouterConfig { + routerOptions: NavigateOptions + } +} + export default function App() { let navigate = useNavigate(); @@ -159,7 +258,7 @@ export default function App() { {/* ... */} - + {/* ... */} @@ -168,3 +267,30 @@ export default function App() { ); } ``` + +### TanStack Router + +To use [TanStack Router](https://tanstack.com/router) with React Aria, render React Aria's `RouterProvider` inside your root route. Use `router.navigate` in the `navigate` prop, and `router.buildLocation` in the `useHref` prop. You can also configure TypeScript to get autocomplete for the `href` prop by declaring the `RouterConfig` type using the types provided by TanStack Router. + +```tsx +import {useRouter, type RegisteredRouter, type NavigateOptions, type ToOptions} from '@tanstack/react-router'; +import {RouterProvider} from 'react-aria-components'; + +declare module 'react-aria-components' { + interface RouterConfig { + href: ToPathOption; + routerOptions: Omit + } +} + +function RootRoute() { + let router = useRouter(); + return ( + router.navigate({to, ...options})} + useHref={to => router.buildLocation(to).href}> + {/* ...*/} + + ); +} +``` diff --git a/packages/dev/docs/pages/react-spectrum/routing.mdx b/packages/dev/docs/pages/react-spectrum/routing.mdx index 953da4bf00f..b5a5939d792 100644 --- a/packages/dev/docs/pages/react-spectrum/routing.mdx +++ b/packages/dev/docs/pages/react-spectrum/routing.mdx @@ -32,35 +32,62 @@ Note that external links to different origins will not trigger client side routi ## Provider setup -The `Provider` component accepts a `router` prop, which can be set to a router object. This should include a `navigate` function received from your router for performing a client side navigation programmatically. The following example shows the general pattern. Framework-specific examples are shown below. +The `Provider` component accepts a `router` prop, which can be set to a router object. This should include a `navigate` function received from your router for performing a client side navigation programmatically. It can optionally also include a `useHref` function that converts a router-specific href to a native HTML href, e.g. prepending a base path. The following example shows the general pattern. Framework-specific examples are shown below. ```tsx import {Provider, defaultTheme} from '@adobe/react-spectrum'; +import {useNavigate, useHref} from 'your-router'; function App() { - let navigate = useNavigateFromYourRouter(); + let navigate = useNavigate(); return ( - + {/* ... */} ); } ``` +### Router options + +All React Spectrum link components accept a `routerOptions` prop, which is an object that is passed through to the client side router's `navigate` function as the second argument. This can be used to control any router-specific behaviors, such as scrolling, replacing instead of pushing to the history, etc. + +```tsx +{/* ...*/} +``` + +When using TypeScript, you can configure the `RouterConfig` type globally so that all link components have auto complete and type safety using a type provided by your router. + +```tsx +import type {RouterOptions} from 'your-router'; + +declare module '@adobe/react-spectrum' { + interface RouterConfig { + routerOptions: RouterOptions + } +} +``` + ### React Router -The [useNavigate](https://reactrouter.com/en/main/hooks/use-navigate) hook from `react-router-dom` returns a `navigate` function you can pass to `Provider`. Ensure that the component that calls `useNavigate` and renders `Provider` is inside the router component (e.g. `BrowserRouter`) so that it has access to React Router's internal context. The React Router `` element should also be defined inside React Spectrum's `` so that links inside the rendered routes have access to the router. +The [useNavigate](https://reactrouter.com/en/main/hooks/use-navigate) hook from `react-router-dom` returns a `navigate` function you can pass to `Provider`. The [useHref](https://reactrouter.com/en/main/hooks/use-href) hook can also be provided if you're using React Router's `basename` option. Ensure that the component that calls `useNavigate` and renders `Provider` is inside the router component (e.g. `BrowserRouter`) so that it has access to React Router's internal context. The React Router `` element should also be defined inside React Spectrum's `` so that links inside the rendered routes have access to the router. ```tsx -import {BrowserRouter, useNavigate} from 'react-router-dom'; +import {BrowserRouter, useNavigate, useHref, type NavigateOptions} from 'react-router-dom'; import {Provider, defaultTheme} from '@adobe/react-spectrum'; +declare module '@adobe/react-spectrum' { + interface RouterConfig { + routerOptions: NavigateOptions + } +} + function App() { let navigate = useNavigate(); return ( - + {/* Your app here... */} } /> @@ -88,6 +115,12 @@ The [useRouter](https://nextjs.org/docs/app/api-reference/functions/use-router) import {useRouter} from 'next/navigation'; import {Provider, defaultTheme} from '@adobe/react-spectrum'; +declare module '@adobe/react-spectrum' { + interface RouterConfig { + routerOptions: NonNullable['push']>[1]> + } +} + export function ClientProviders({children}) { let router = useRouter(); @@ -116,6 +149,37 @@ export default function RootLayout({children}) { } ``` +If you are using the Next.js [basePath](https://nextjs.org/docs/app/api-reference/next-config-js/basePath) setting, you'll need to configure an environment variable to access it. Then, provide a custom `useHref` function to prepend it to the href for all links. + +```tsx +// next.config.js +const basePath = '...'; +const nextConfig = { + basePath, + env: { + BASE_PATH: basePath + } +}; +``` + +```tsx +// app/provider.tsx +// ... + +export function ClientProviders({children}) { + let router = useRouter(); + /*- begin highlight -*/ + let useHref = (href: string) => process.env.BASE_PATH + href; + /*- end highlight -*/ + + return ( + + {children} + + ); +} +``` + #### Pages router The [useRouter](https://nextjs.org/docs/pages/api-reference/functions/use-router) hook from `next/router` returns a router object that can be used to perform navigation. `Provider` should be rendered at the root of each page that includes React Spectrum components, or in `pages/_app.tsx` to add it to all pages. @@ -123,14 +187,48 @@ The [useRouter](https://nextjs.org/docs/pages/api-reference/functions/use-router ```tsx // pages/_app.tsx import type {AppProps} from 'next/app'; -import {useRouter} from 'next/router'; +import {useRouter, type NextRouter} from 'next/router'; import {Provider, defaultTheme} from '@adobe/react-spectrum'; +declare module '@adobe/react-spectrum' { + interface RouterConfig { + routerOptions: NonNullable[2]> + } +} + export default function MyApp({Component, pageProps}: AppProps) { let router = useRouter(); return ( - + router.push(href, undefined, opts), + }}> + + + ); +} +``` + +When using the [basePath](https://nextjs.org/docs/app/api-reference/next-config-js/basePath) configuration option, provide a `useHref` option to the `router` passed to `Provider` to prepend it to links automatically. + +```tsx +// pages/_app.tsx +// ... + +export default function MyApp({Component, pageProps}: AppProps) { + let router = useRouter(); + + return ( + router.push(href, undefined, opts), + /*- begin highlight -*/ + useHref: (href: string) => router.basePath + href + /*- end highlight -*/ + }}> ); @@ -139,13 +237,20 @@ export default function MyApp({Component, pageProps}: AppProps) { ### Remix -Remix uses React Router under the hood, so the same [useNavigate](https://reactrouter.com/en/main/hooks/use-navigate) hook described above also works in Remix apps. `Provider` should be rendered at the root of each page that includes React Spectrum components, or in `app/root.tsx` to add it to all pages. See the [Remix docs](https://remix.run/docs/en/main/file-conventions/root) for more details. +Remix uses React Router under the hood, so the same [useNavigate](https://reactrouter.com/en/main/hooks/use-navigate) and [useHref](https://reactrouter.com/en/main/hooks/use-href) hooks described above also work in Remix apps. `Provider` should be rendered at the root of each page that includes React Spectrum components, or in `app/root.tsx` to add it to all pages. See the [Remix docs](https://remix.run/docs/en/main/file-conventions/root) for more details. ```tsx // app/root.tsx -import {useNavigate, Outlet} from '@remix-run/react'; +import {useNavigate, useHref, Outlet} from '@remix-run/react'; +import type {NavigateOptions} from 'react-router-dom'; import {Provider, defaultTheme} from '@adobe/react-spectrum'; +declare module '@adobe/react-spectrum' { + interface RouterConfig { + routerOptions: NavigateOptions + } +} + export default function App() { let navigate = useNavigate(); @@ -155,7 +260,7 @@ export default function App() { {/* ... */} - + {/* ... */} @@ -164,3 +269,33 @@ export default function App() { ); } ``` + +### TanStack Router + +To use [TanStack Router](https://tanstack.com/router) with React Spectrum, render React Spectrum's `Provider` inside your root route. Use `router.navigate` in the `navigate` prop, and `router.buildLocation` in the `useHref` prop. You can also configure TypeScript to get autocomplete for the `href` prop by declaring the `RouterConfig` type using the types provided by TanStack Router. + +```tsx +import {useRouter, type RegisteredRouter, type NavigateOptions, type ToOptions} from '@tanstack/react-router'; +import {Provider, defaultTheme} from '@adobe/react-spectrum'; + +declare module '@adobe/react-spectrum' { + interface RouterConfig { + href: ToPathOption; + routerOptions: Omit + } +} + +function RootRoute() { + let router = useRouter(); + return ( + router.navigate({to, ...options}), + useHref: to => router.buildLocation(to).href + }}> + {/* ...*/} + + ); +} +``` diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index fc54a5aacc1..26683d85130 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -114,4 +114,4 @@ export type {ContextValue, SlotProps} from './utils'; export type {DateValue, DateRange, TimeValue} from 'react-aria'; export type {DirectoryDropItem, DraggableCollectionEndEvent, DraggableCollectionMoveEvent, DraggableCollectionStartEvent, DragPreviewRenderer, DragTypes, DropItem, DropOperation, DroppableCollectionDropEvent, DroppableCollectionEnterEvent, DroppableCollectionExitEvent, DroppableCollectionInsertDropEvent, DroppableCollectionMoveEvent, DroppableCollectionOnItemDropEvent, DroppableCollectionReorderEvent, DroppableCollectionRootDropEvent, DropPosition, DropTarget, FileDropItem, ItemDropTarget, RootDropTarget, TextDropItem, PressEvent} from 'react-aria'; export type {Key, Selection, SortDescriptor, SortDirection, SelectionMode} from 'react-stately'; -export type {ValidationResult} from '@react-types/shared'; +export type {ValidationResult, RouterConfig} from '@react-types/shared'; diff --git a/packages/react-aria-components/test/Link.test.js b/packages/react-aria-components/test/Link.test.js index dd5c69dd15d..f934d84de27 100644 --- a/packages/react-aria-components/test/Link.test.js +++ b/packages/react-aria-components/test/Link.test.js @@ -136,9 +136,15 @@ describe('Link', () => { it('should work with RouterProvider', async () => { let navigate = jest.fn(); - let {getByRole} = render(Test); + let useHref = href => '/base' + href; + let {getByRole} = render( + + Test + + ); let link = getByRole('link'); + expect(link).toHaveAttribute('href', '/base/foo'); await user.click(link); - expect(navigate).toHaveBeenCalledWith('/foo'); + expect(navigate).toHaveBeenCalledWith('/foo', {foo: 'bar'}); }); });