Skip to content

Commit

Permalink
Add support for routerOptions and useHref (#5864)
Browse files Browse the repository at this point in the history
* Add support for routerOptions and useHref
  • Loading branch information
devongovett authored Apr 3, 2024
1 parent 0e61098 commit 2318292
Show file tree
Hide file tree
Showing 31 changed files with 462 additions and 91 deletions.
10 changes: 9 additions & 1 deletion examples/next-app/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Parameters<ReturnType<typeof useRouter>['push']>[1]>
}
}

export default function Home() {
let router = useRouter();
return (
<Provider theme={defaultTheme} locale="en">
<Provider theme={defaultTheme} locale="en" router={{navigate: router.push}}>
<DatePicker label="Date" />
</Provider>
)
Expand Down
18 changes: 17 additions & 1 deletion examples/remix/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<html lang="en">
<head>
Expand All @@ -18,7 +28,13 @@ export default function App() {
<Links />
</head>
<body>
<Provider theme={defaultTheme} locale="en">
<Provider
theme={defaultTheme}
locale="en"
router={{
navigate,
useHref
}}>
<Outlet />
</Provider>
<ScrollRestoration />
Expand Down
5 changes: 4 additions & 1 deletion examples/remix/app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -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 [
Expand All @@ -13,6 +13,9 @@ export default function Index() {
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<h1>Welcome to Remix</h1>
<DatePicker label="Date" />
<ActionMenu>
<Item href="/foo" routerOptions={{replace: true}}>Link to foo</Item>
</ActionMenu>
</div>
);
}
3 changes: 3 additions & 0 deletions examples/remix/app/routes/foo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Foo() {
return <h1>Foo</h1>
}
17 changes: 15 additions & 2 deletions examples/rsp-next-ts/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Parameters<NextRouter['push']>[2]>
}
}

function MyApp({ Component, pageProps }: AppProps) {
const [theme, setTheme] = useState<ColorScheme>("light");
Expand All @@ -25,7 +31,14 @@ function MyApp({ Component, pageProps }: AppProps) {
enableTableNestedRows();

return (
<Provider theme={lightTheme} colorScheme={theme} router={{navigate: router.push}} locale="en">
<Provider
theme={lightTheme}
colorScheme={theme}
router={{
navigate: (href, opts) => router.push(href, undefined, opts),
useHref: (href: string) => router.basePath + href
}}
locale="en">
<Grid
areas={["header", "content"]}
columns={["1fr"]}
Expand Down
2 changes: 1 addition & 1 deletion examples/rsp-next-ts/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export default function Home() {
<MenuTrigger>
<ActionButton>Menu Trigger</ActionButton>
<Menu>
<Item href="/foo">Link to /foo</Item>
<Item href="/foo" routerOptions={{scroll: false}}>Link to /foo</Item>
<Item>Cut</Item>
<Item>Copy</Item>
<Item>Paste</Item>
Expand Down
2 changes: 1 addition & 1 deletion packages/@adobe/react-spectrum/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
5 changes: 3 additions & 2 deletions packages/@react-aria/combobox/src/useComboBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -124,7 +124,8 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, 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);
}
}

Expand Down
12 changes: 7 additions & 5 deletions packages/@react-aria/link/src/useLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -60,13 +60,14 @@ export function useLink(props: AriaLinkOptions, ref: RefObject<FocusableElement>
}
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,
Expand All @@ -85,10 +86,11 @@ export function useLink(props: AriaLinkOptions, ref: RefObject<FocusableElement>
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);
}
}
})
Expand Down
7 changes: 4 additions & 3 deletions packages/@react-aria/listbox/src/useOption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -149,13 +149,14 @@ export function useOption<T>(props: AriaOptionProps, state: ListState<T>, 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: {
Expand Down
11 changes: 6 additions & 5 deletions packages/@react-aria/menu/src/useMenuItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -142,7 +142,7 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
}

if (e.target instanceof HTMLAnchorElement) {
router.open(e.target, e);
router.open(e.target, e, item.props.href, item.props.routerOptions as RouterOptions);
}
};

Expand Down Expand Up @@ -269,13 +269,14 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, 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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 4 additions & 2 deletions packages/@react-aria/selection/src/useSelectableItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
};

Expand Down
7 changes: 4 additions & 3 deletions packages/@react-aria/tabs/src/useTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -58,11 +58,12 @@ export function useTab<T>(
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,
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
34 changes: 25 additions & 9 deletions packages/@react-aria/utils/src/openLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Router>({
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
}

Expand All @@ -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 (
<RouterContext.Provider value={ctx}>
Expand Down Expand Up @@ -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
};
}
Loading

1 comment on commit 2318292

@rspbot
Copy link

@rspbot rspbot commented on 2318292 Apr 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.