Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for routerOptions and useHref #5864

Merged
merged 7 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
};
}
Comment on lines +160 to +170
Copy link
Member

Choose a reason for hiding this comment

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

minor nit but it almost feels like this useRouter call should be in filterDOMProps instead of being a separate function if only to guard against cases where both filterDOMProps and useLinkProps are called and the props from useLinkProps need to be merged after the ones from filterDOMProps. Is there something I'm missing here as to why this is a separate function?

Copy link
Member Author

Choose a reason for hiding this comment

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

because filterDOMProps isn't a hook so it cannot call other hooks... :/

Copy link
Member

Choose a reason for hiding this comment

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

ahhh, right ugh

Loading