From 2f6be546d2a182e8e4f45839549a86bfda97a3a0 Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Thu, 7 Nov 2024 15:26:47 +0100 Subject: [PATCH] feat: client side routing --- src/components/Button/Button.tsx | 67 ++++--- src/components/Link/Link.tsx | 23 ++- src/components/Menu/MenuItem.tsx | 42 +++-- src/components/Toc/TocItem/TocItem.tsx | 5 +- src/components/Toc/types.ts | 5 +- .../lab/Breadcrumbs/BreadcrumbItem.tsx | 25 +-- .../lab/Breadcrumbs/Breadcrumbs.tsx | 24 ++- src/components/lab/Breadcrumbs/README.md | 87 +-------- .../__tests__/Breadcrumbs.test.tsx | 37 ++-- src/components/lab/Breadcrumbs/utils.ts | 21 --- src/components/lab/router/README.md | 171 ++++++++++++++++++ .../lab/router/__stories__/Docs.mdx | 27 +++ .../__stories__/RouterProvider.stories.tsx | 49 +++++ src/components/lab/router/router.tsx | 117 ++++++++++++ src/unstable.ts | 3 + 15 files changed, 506 insertions(+), 197 deletions(-) create mode 100644 src/components/lab/router/README.md create mode 100644 src/components/lab/router/__stories__/Docs.mdx create mode 100644 src/components/lab/router/__stories__/RouterProvider.stories.tsx create mode 100644 src/components/lab/router/router.tsx diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 6ffa7adef4..57ed24f7bb 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -2,7 +2,8 @@ import React from 'react'; -import type {DOMProps, QAProps} from '../types'; +import {useLinkProps} from '../lab/router/router'; +import type {DOMProps, Href, QAProps, RouterOptions} from '../types'; import {block} from '../utils/cn'; import {isIcon, isSvg} from '../utils/common'; import {eventBroker} from '../utils/event-broker'; @@ -49,7 +50,7 @@ export interface ButtonProps extends DOMProps, QAProps { id?: string; type?: 'button' | 'submit' | 'reset'; component?: React.ElementType; - href?: string; + href?: Href; target?: string; rel?: string; extraProps?: @@ -62,6 +63,7 @@ export interface ButtonProps extends DOMProps, QAProps { onBlur?: React.FocusEventHandler; /** Button content. You can mix button text with `` component */ children?: React.ReactNode; + routerOptions?: RouterOptions; } const b = block('button'); @@ -79,9 +81,6 @@ const ButtonWithHandlers = React.forwardRef(function B tabIndex, type = 'button', component, - href, - target, - rel, extraProps, onClick, onMouseEnter, @@ -93,6 +92,7 @@ const ButtonWithHandlers = React.forwardRef(function B style, className, qa, + ...props }, ref, ) { @@ -137,37 +137,60 @@ const ButtonWithHandlers = React.forwardRef(function B 'data-qa': qa, }; - if (typeof href === 'string' || component) { - const linkProps = { - href, - target, - rel: target === '_blank' && !rel ? 'noopener noreferrer' : rel, - }; + const linkProps = useLinkProps({ + ...extraProps, + ...props, + onClick: (e) => { + if (disabled) { + e.preventDefault(); + return; + } + + if (typeof onClick === 'function') { + onClick(e); + } + }, + }); + + if (component) { return React.createElement( - component || 'a', + component, { ...extraProps, ...commonProps, - ...(component ? {} : linkProps), - ref: ref as React.Ref, + ref, 'aria-disabled': disabled || loading, }, prepareChildren(children), ); - } else { + } + + if (props.href) { return ( - + ); } + + return ( + + ); }); ButtonWithHandlers.displayName = 'Button'; diff --git a/src/components/Link/Link.tsx b/src/components/Link/Link.tsx index 3b693cae3c..0d03faa743 100644 --- a/src/components/Link/Link.tsx +++ b/src/components/Link/Link.tsx @@ -2,7 +2,8 @@ import React from 'react'; -import type {DOMProps, QAProps} from '../types'; +import {useLinkProps} from '../lab/router/router'; +import type {DOMProps, Href, QAProps, RouterOptions} from '../types'; import {block} from '../utils/cn'; import {eventBroker} from '../utils/event-broker'; @@ -15,7 +16,7 @@ export interface LinkProps extends DOMProps, QAProps { visitable?: boolean; underline?: boolean; title?: string; - href: string; + href: Href; target?: string; rel?: string; id?: string; @@ -24,6 +25,7 @@ export interface LinkProps extends DOMProps, QAProps { onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; extraProps?: React.AnchorHTMLAttributes; + routerOptions?: RouterOptions; } const b = block('link'); @@ -46,18 +48,22 @@ export const Link = React.forwardRef(function Link style, className, qa, + routerOptions, }, ref, ) { - const handleClickCapture = React.useCallback((event: React.SyntheticEvent) => { + const handleClickCapture = (event: React.SyntheticEvent) => { eventBroker.publish({ componentId: 'Link', eventId: 'click', domEvent: event, }); - }, []); + }; const commonProps = { + href, + target, + rel, title, onClick, onClickCapture: handleClickCapture, @@ -69,10 +75,13 @@ export const Link = React.forwardRef(function Link 'data-qa': qa, }; - const relProp = target === '_blank' && !rel ? 'noopener noreferrer' : rel; - return ( - + {children} ); diff --git a/src/components/Menu/MenuItem.tsx b/src/components/Menu/MenuItem.tsx index 340d545d82..1b58ff0645 100644 --- a/src/components/Menu/MenuItem.tsx +++ b/src/components/Menu/MenuItem.tsx @@ -3,7 +3,8 @@ import React from 'react'; import {useActionHandlers} from '../../hooks'; -import type {DOMProps, QAProps} from '../types'; +import {useLinkProps} from '../lab/router/router'; +import type {DOMProps, Href, QAProps, RouterOptions} from '../types'; import {block} from '../utils/cn'; import {eventBroker} from '../utils/event-broker'; @@ -18,7 +19,7 @@ export interface MenuItemProps extends DOMProps, QAProps { disabled?: boolean; active?: boolean; selected?: boolean; - href?: string; + href?: Href; target?: string; rel?: string; onClick?: React.MouseEventHandler; @@ -27,6 +28,7 @@ export interface MenuItemProps extends DOMProps, QAProps { | React.HTMLAttributes | React.AnchorHTMLAttributes; children?: React.ReactNode; + routerOptions?: RouterOptions; } export const MenuItem = React.forwardRef(function MenuItem( @@ -38,9 +40,6 @@ export const MenuItem = React.forwardRef(function Me disabled, active, selected, - href, - target, - rel, onClick, style, className, @@ -48,10 +47,24 @@ export const MenuItem = React.forwardRef(function Me extraProps, children, qa, + ...props }, ref, ) { - const {onKeyDown} = useActionHandlers(onClick); + const handleClick = (e: React.MouseEvent) => { + if (disabled) { + e.preventDefault(); + return; + } + + if (typeof onClick === 'function') { + onClick(e); + } + }; + + const linkProps = useLinkProps({...extraProps, ...props, onClick: handleClick}); + + const {onKeyDown} = useActionHandlers(linkProps.onClick); const handleClickCapture = React.useCallback((event: React.SyntheticEvent) => { eventBroker.publish({ @@ -63,18 +76,24 @@ export const MenuItem = React.forwardRef(function Me const defaultProps = { role: 'menuitem', - onKeyDown: onClick && !disabled ? onKeyDown : undefined, + onKeyDown, }; const commonProps = { + ...linkProps, title, - onClick: disabled ? undefined : onClick, onClickCapture: disabled ? undefined : handleClickCapture, style, tabIndex: disabled ? -1 : 0, className: b( 'item', - {disabled, active, selected, theme, interactive: Boolean(onClick) || Boolean(href)}, + { + disabled, + active, + selected, + theme, + interactive: Boolean(onClick) || Boolean(props.href), + }, className, ), 'data-qa': qa, @@ -96,15 +115,12 @@ export const MenuItem = React.forwardRef(function Me ]; let item; - if (href) { + if (props.href) { item = ( )} {...commonProps} - href={href} - target={target} - rel={rel} > {content} diff --git a/src/components/Toc/TocItem/TocItem.tsx b/src/components/Toc/TocItem/TocItem.tsx index d67c0cf3df..0864e13700 100644 --- a/src/components/Toc/TocItem/TocItem.tsx +++ b/src/components/Toc/TocItem/TocItem.tsx @@ -3,6 +3,7 @@ import React from 'react'; import {useActionHandlers} from '../../../hooks'; +import {useLinkProps} from '../../lab/router/router'; import {block} from '../../utils/cn'; import type {TocItem as TocItemType} from '../types'; @@ -29,6 +30,8 @@ export const TocItem = (props: TocItemProps) => { const {onKeyDown} = useActionHandlers(handleClick); + const linkProps = useLinkProps({...props, onClick: handleClick}); + const item = href === undefined ? (
{ {content}
) : ( - + {content} ); diff --git a/src/components/Toc/types.ts b/src/components/Toc/types.ts index f214b8fe17..309385d920 100644 --- a/src/components/Toc/types.ts +++ b/src/components/Toc/types.ts @@ -1,6 +1,9 @@ +import type {Href, RouterOptions} from '../types'; + export interface TocItem { value?: string; content?: React.ReactNode; - href?: string; + href?: Href; items?: TocItem[]; + routerOptions?: RouterOptions; } diff --git a/src/components/lab/Breadcrumbs/BreadcrumbItem.tsx b/src/components/lab/Breadcrumbs/BreadcrumbItem.tsx index e7c32c5f24..81ab7a6f96 100644 --- a/src/components/lab/Breadcrumbs/BreadcrumbItem.tsx +++ b/src/components/lab/Breadcrumbs/BreadcrumbItem.tsx @@ -2,18 +2,17 @@ import React from 'react'; -import type {Href, RouterOptions} from '../../types'; import {filterDOMProps} from '../../utils/filterDOMProps'; +import {useLinkProps} from '../router/router'; import type {BreadcrumbsItemProps} from './Breadcrumbs'; -import {b, shouldClientNavigate} from './utils'; +import {b} from './utils'; interface BreadcrumbProps extends BreadcrumbsItemProps { onAction?: () => void; current?: boolean; itemType?: 'link' | 'menu'; disabled?: boolean; - navigate?: (href: Href, routerOptions: RouterOptions | undefined) => void; } export function BreadcrumbItem(props: BreadcrumbProps) { const Element = props.href ? 'a' : 'span'; @@ -33,14 +32,6 @@ export function BreadcrumbItem(props: BreadcrumbProps) { if (typeof props.onAction === 'function') { props.onAction(); } - - const target = event.currentTarget; - if (typeof props.navigate === 'function' && target instanceof HTMLAnchorElement) { - if (props.href && !event.isDefaultPrevented() && shouldClientNavigate(target, event)) { - event.preventDefault(); - props.navigate(props.href, props.routerOptions); - } - } }; const isDisabled = props.disabled || props.current; @@ -49,14 +40,10 @@ export function BreadcrumbItem(props: BreadcrumbProps) { onClick: handleAction, 'aria-disabled': isDisabled ? true : undefined, }; + + const linkDomProps = useLinkProps({...props, onClick: handleAction}); if (Element === 'a') { - linkProps.href = props.href; - linkProps.hrefLang = props.hrefLang; - linkProps.target = props.target; - linkProps.rel = props.target === '_blank' && !props.rel ? 'noopener noreferrer' : props.rel; - linkProps.download = props.download; - linkProps.ping = props.ping; - linkProps.referrerPolicy = props.referrerPolicy; + linkProps = {...linkProps, ...linkDomProps}; } else { linkProps.role = 'link'; linkProps.tabIndex = isDisabled ? undefined : 0; @@ -68,7 +55,7 @@ export function BreadcrumbItem(props: BreadcrumbProps) { } if (props.current) { - linkProps['aria-current'] = 'page'; + linkProps['aria-current'] = props['aria-current'] ?? 'page'; } if (props.itemType === 'menu') { diff --git a/src/components/lab/Breadcrumbs/Breadcrumbs.tsx b/src/components/lab/Breadcrumbs/Breadcrumbs.tsx index 8cd6ef2120..343dbb3272 100644 --- a/src/components/lab/Breadcrumbs/Breadcrumbs.tsx +++ b/src/components/lab/Breadcrumbs/Breadcrumbs.tsx @@ -8,11 +8,12 @@ import {DropdownMenu} from '../../DropdownMenu'; import type {PopupPlacement} from '../../Popup'; import type {AriaLabelingProps, DOMProps, Href, Key, QAProps, RouterOptions} from '../../types'; import {filterDOMProps} from '../../utils/filterDOMProps'; +import {useRouter} from '../router/router'; import {BreadcrumbItem} from './BreadcrumbItem'; import {BreadcrumbsSeparator} from './BreadcrumbsSeparator'; import i18n from './i18n'; -import {b, shouldClientNavigate} from './utils'; +import {b} from './utils'; import './Breadcrumbs.scss'; @@ -27,6 +28,7 @@ export interface BreadcrumbsItemProps { ping?: string; referrerPolicy?: React.HTMLAttributeReferrerPolicy; 'aria-label'?: string; + 'aria-current'?: React.AriaAttributes['aria-current']; routerOptions?: RouterOptions; } @@ -42,7 +44,6 @@ export interface BreadcrumbsProps extends DOMProps, AriaLabelingProps, QAProps { popupStyle?: 'staircase'; popupPlacement?: PopupPlacement; children: React.ReactElement | React.ReactElement[]; - navigate?: (href: Href, routerOptions: RouterOptions | undefined) => void; disabled?: boolean; onAction?: (key: Key) => void; } @@ -153,7 +154,7 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs( } }); - const {navigate} = props; + const {openLink} = useRouter(); let contents = items; if (items.length > visibleItemsCount) { contents = []; @@ -183,13 +184,17 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs( // TODO: move this logic to DropdownMenu const target = event.currentTarget; - if ( - typeof navigate === 'function' && - target instanceof HTMLAnchorElement - ) { - if (el.props.href && shouldClientNavigate(target, event)) { + if (target instanceof HTMLAnchorElement) { + if ( + el.props.href && + openLink( + target, + event, + el.props.href, + el.props.routerOptions, + ) + ) { event.preventDefault(); - navigate(el.props.href, el.props.routerOptions); } } }, @@ -239,7 +244,6 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs( current={isCurrent} disabled={props.disabled} onAction={handleAction} - navigate={navigate} > {child.props.children} diff --git a/src/components/lab/Breadcrumbs/README.md b/src/components/lab/Breadcrumbs/README.md index b3e3683234..f994676053 100644 --- a/src/components/lab/Breadcrumbs/README.md +++ b/src/components/lab/Breadcrumbs/README.md @@ -336,92 +336,7 @@ LANDING_BLOCK--> ### Integration with routers -`Breadcrumbs` component accepts navigate function received from your router for performing a client side navigation programmatically. -The following example shows the general pattern. - -```jsx -function Header() { - const navigate = useNavigateFromYourRouter(); - - return ( -
- {/*...*/} -
- ); -} -``` - -#### React Router v5 - -```jsx -import {useHistory} from 'react-router-dom'; -import {Breadcrumbs} from '@gravity-ui/uikit'; - -function Header() { - const history = useHistory(); - - return ( -
- {/*...*/} -
- ); -} -``` - -#### React Router v6 - -```jsx -import {useNavigate} from 'react-router-dom'; -import {Breadcrumbs} from '@gravity-ui/uikit'; - -function Header() { - const navigate = useNavigate(); - - return ( -
- {/*...*/} -
- ); -} -``` - -#### Next.js - -`App router` - -```jsx -'use client'; - -import {useRouter} from 'next/navigation'; -import {Breadcrumbs} from '@gravity-ui/uikit'; - -function Header() { - const router = useRouter(); - - return ( -
- {/*...*/} -
- ); -} -``` - -`Pages router` - -```jsx -import {useRouter} from 'next/router'; -import {Breadcrumbs} from '@gravity-ui/uikit'; - -function Header() { - const router = useRouter(); - - return ( -
- {/*...*/} -
- ); -} -``` +`Breadcrumbs` supports integration with routers via `RouterProvider` component. ### Landmarks diff --git a/src/components/lab/Breadcrumbs/__tests__/Breadcrumbs.test.tsx b/src/components/lab/Breadcrumbs/__tests__/Breadcrumbs.test.tsx index f44ec07f73..82bf604db8 100644 --- a/src/components/lab/Breadcrumbs/__tests__/Breadcrumbs.test.tsx +++ b/src/components/lab/Breadcrumbs/__tests__/Breadcrumbs.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import {userEvent} from '@testing-library/user-event'; import {render, screen, within} from '../../../../../test-utils/utils'; +import {RouterProvider} from '../../router/router'; import {Breadcrumbs} from '../Breadcrumbs'; beforeEach(() => { @@ -248,23 +249,25 @@ it('should support RouterProvider', async () => { */ const navigate = jest.fn(); render( - - - Example.com - - - Foo - - - Bar - - - Baz - - - Qux - - , + + + + Example.com + + + Foo + + + Bar + + + Baz + + + Qux + + + , ); const links = screen.getAllByRole('link'); diff --git a/src/components/lab/Breadcrumbs/utils.ts b/src/components/lab/Breadcrumbs/utils.ts index c1ee9352d2..34facfd140 100644 --- a/src/components/lab/Breadcrumbs/utils.ts +++ b/src/components/lab/Breadcrumbs/utils.ts @@ -1,24 +1,3 @@ import {block} from '../../utils/cn'; -interface Modifiers { - metaKey?: boolean; - ctrlKey?: boolean; - altKey?: boolean; - shiftKey?: boolean; -} -export function shouldClientNavigate(link: HTMLAnchorElement, modifiers: Modifiers) { - // Use getAttribute here instead of link.target. Firefox will default link.target to "_parent" when inside an iframe. - const target = link.getAttribute('target'); - return ( - link.href && - (!target || target === '_self') && - link.origin === location.origin && - !link.hasAttribute('download') && - !modifiers.metaKey && // open in new tab (mac) - !modifiers.ctrlKey && // open in new tab (windows) - !modifiers.altKey && // download - !modifiers.shiftKey - ); -} - export const b = block('breadcrumbs2'); diff --git a/src/components/lab/router/README.md b/src/components/lab/router/README.md new file mode 100644 index 0000000000..565651421f --- /dev/null +++ b/src/components/lab/router/README.md @@ -0,0 +1,171 @@ + + +# Client side routing + + + +```tsx +import {unstable_RouterProvider as RouterProvider} from '@gravity-ui/uikit/unstable'; +``` + +## Provider setup + +`RouterProvider` component accepts navigate function received from your router for performing a client side navigation. +The following example shows the general pattern. + +```jsx +import {useNavigation, useHref} from 'your-router'; +import {Link} from '@gravity-ui/uikit'; +import {unstable_RouterProvider as RouterProvider} from '@gravity-ui/uikit/unstable'; + +import type {NavigateOptions, ToOptions} from 'your-router'; + +declare module '@gravity-ui/uikit' { + interface RouterConfig { + href: ToOptions; + routerOptions: NavigateOptions; + } +} + +function Header() { + const navigate = useNavigation(); + + return ( + + + Post 1 (local link) + + Gravity UI (external link) + + ); +} +``` + + + + + + + + + +### React Router v5 + +```jsx +import {useHistory} from 'react-router-dom'; +import {unstable_RouterProvider as RouterProvider} from '@gravity-ui/uikit/unstable'; + +function App() { + const history = useHistory(); + + return {/*...*/}; +} +``` + +### React Router v6 + +```jsx +import {useNavigate, useHref} from 'react-router-dom'; +import {unstable_RouterProvider as RouterProvider} from '@gravity-ui/uikit/unstable'; + +import type {NavigateOptions} from 'react-router-dom'; + +declare module '@gravity-ui/uikit' { + interface RouterConfig { + routerOptions: NavigateOptions + } +} + +function App() { + const navigate = useNavigate(); + + return ( + {/*...*/} + ); +} +``` + +### Next.js + +`App router` + +```jsx +'use client'; + +import {useRouter} from 'next/navigation'; +import {unstable_RouterProvider as RouterProvider} from '@gravity-ui/uikit/unstable'; + +declare module '@gravity-ui/uikit' { + interface RouterConfig { + routerOptions: NonNullable< + Parameters['push']>[1] + > + } +} + +function App() { + const router = useRouter(); + + return ( + {/*...*/} + ); +} +``` + +`Pages router` + +```jsx +import {useRouter} from 'next/router'; +import { + unstable_Breadcrumbs as Breadcrumbs, + unstable_RouterProvider as RouterProvider +} from '@gravity-ui/uikit/unstable'; + +import type {NextRouter} from 'next/router'; + +declare module '@gravity-ui/uikit' { + interface RouterConfig { + routerOptions: NonNullable[2]> + } +} + +function App() { + const router = useRouter(); + + return ( + router.push(href, undefined, opts)}>{/*...*/} + ); +} +``` + +### TanStack Router + +```jsx +import {useRouter} from '@tanstack/react-router'; +import {unstable_RouterProvider as RouterProvider} from '@gravity-ui/uikit/unstable'; + +import type {NavigateOptions, ToOptions} from '@tanstack/react-router'; + +declare module '@gravity-ui/uikit' { + interface RouterConfig { + href: ToOptions; + routerOptions: Omit; + } +} + +function App() { + const router = useRouter(); + + return ( + router.navigate({...to, ...opts})} + useHref={(to) => router.buildLocation(to).href} + > + {/*...*/} + + ); +} +``` diff --git a/src/components/lab/router/__stories__/Docs.mdx b/src/components/lab/router/__stories__/Docs.mdx new file mode 100644 index 0000000000..94aad91547 --- /dev/null +++ b/src/components/lab/router/__stories__/Docs.mdx @@ -0,0 +1,27 @@ +import { + Meta, + Markdown, + Canvas, + AnchorMdx, + CodeOrSourceMdx, + HeadersMdx, +} from '@storybook/addon-docs'; +import * as Stories from './RouterProvider.stories'; +import Readme from '../README.md?raw'; + +export const RouterProviderExample = () => ; + + + + + {Readme} + diff --git a/src/components/lab/router/__stories__/RouterProvider.stories.tsx b/src/components/lab/router/__stories__/RouterProvider.stories.tsx new file mode 100644 index 0000000000..fbad1618a8 --- /dev/null +++ b/src/components/lab/router/__stories__/RouterProvider.stories.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import type {Meta, StoryObj} from '@storybook/react'; + +import {Link} from '../../../Link'; +import {Flex} from '../../../layout'; +import type {Href, RouterOptions} from '../../../types'; +import {RouterProvider} from '../router'; + +const meta: Meta = { + title: 'Lab/RouterProvider', + component: RouterProvider, +}; + +export default meta; + +type Story = StoryObj; + +export const Default = { + render: () => { + /* + * declare module '@gravity-ui/uikit' { + * interface RouterConfig { + * href: {to: string; params: Record} | string; + * routerOptions: {replace?: boolean}; + * } + * } + */ + type HrefType = {to: string; params: Record} | string; + return ( + console.log('Navigate to: ', {href, opts})} + useHref={(href: HrefType) => + typeof href === 'string' ? href : href.to.replace('$pastId', href.params.pastId) + } + > + + + Post 1 (local link) + + Gravity UI (external link) + + + ); + }, +} satisfies Story; diff --git a/src/components/lab/router/router.tsx b/src/components/lab/router/router.tsx new file mode 100644 index 0000000000..76b26d51d5 --- /dev/null +++ b/src/components/lab/router/router.tsx @@ -0,0 +1,117 @@ +import React from 'react'; + +import type {Href, RouterOptions} from '../../types'; + +interface RouterProps { + openLink: ( + link: HTMLAnchorElement, + modifiers: Modifiers, + href: Href, + routerOptions: RouterOptions | undefined, + ) => boolean; + useHref: (href: Href) => string; +} + +const routerContext = React.createContext({ + openLink: () => false, + useHref: (href) => href, +}); + +export interface RouterProviderProps { + navigate: (href: Href, routerOptions: RouterOptions | undefined) => void; + useHref?: (href: Href) => string; + children: React.ReactNode; +} + +export function RouterProvider({navigate, useHref, children}: RouterProviderProps) { + const value: RouterProps = React.useMemo( + () => ({ + openLink: (link, modifiers, href, routerOptions) => { + if (shouldClientNavigate(link, modifiers)) { + navigate(href, routerOptions); + return true; + } + return false; + }, + useHref: useHref || ((href: Href) => href), + }), + [navigate, useHref], + ); + + return {children}; +} + +export function useRouter() { + return React.useContext(routerContext); +} + +interface Modifiers { + metaKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; +} + +export function shouldClientNavigate(link: HTMLAnchorElement, modifiers: Modifiers) { + // Use getAttribute here instead of link.target. Firefox will default link.target to "_parent" when inside an iframe. + const target = link.getAttribute('target'); + return ( + link.href && + (!target || target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + !modifiers.metaKey && // open in new tab (mac) + !modifiers.ctrlKey && // open in new tab (windows) + !modifiers.altKey && // download + !modifiers.shiftKey + ); +} + +interface LinkProps { + /** A URL to link to. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#href). */ + 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). */ + target?: React.HTMLAttributeAnchorTarget; + /** The relationship between the linked resource and the current page. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel). */ + rel?: string; + /** Causes the browser to download the linked URL. A string may be provided to suggest a file name. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#download). */ + download?: boolean | string; + /** 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?: React.HTMLAttributeReferrerPolicy; + /** Options for the configured client side router. */ + routerOptions?: RouterOptions; + /** Handler that is called when the press is released over the target. */ + onClick?: (e: React.MouseEvent) => void; +} + +export function useLinkProps(props: LinkProps) { + const {useHref, openLink} = useRouter(); + const href = useHref(props.href ?? ''); + return { + href: props.href ? href : undefined, + hrefLang: props.hrefLang, + target: props.target, + rel: props.target === '_blank' && !props.rel ? 'noopener noreferrer' : props.rel, + download: props.download, + ping: props.ping, + referrerPolicy: props.referrerPolicy, + onClick: (e: React.MouseEvent) => { + if (typeof props.onClick === 'function') { + props.onClick(e); + } + + if ( + props.href && + !e.defaultPrevented && + e.currentTarget instanceof HTMLAnchorElement && + openLink(e.currentTarget, e, props.href, props.routerOptions) + ) { + e.preventDefault(); + } + }, + }; +} diff --git a/src/unstable.ts b/src/unstable.ts index 872d5416e3..3004971b92 100644 --- a/src/unstable.ts +++ b/src/unstable.ts @@ -42,3 +42,6 @@ export type { export {NumberInput as unstable_NumberInput} from './components/lab/NumberInput'; export type {NumberInputProps as unstable_NumberInputProps} from './components/lab/NumberInput'; + +export {RouterProvider as unstable_RouterProvider} from './components/lab/router/router'; +export type {RouterProviderProps as unstable_RouterProviderProps} from './components/lab/router/router';