From 2169c646754d66f0a34299af841754b259c3cfec Mon Sep 17 00:00:00 2001 From: Robert Niznik Date: Mon, 5 Aug 2024 11:09:44 -0400 Subject: [PATCH] refactor(components)!: handle external links with `useHref` wrapper (#1361) --- .changeset/selfish-coins-applaud.md | 5 +++ .storybook/preview.tsx | 8 +++- packages/components/__tests__/Link.spec.tsx | 26 +++++++----- .../components/__tests__/LinkButton.spec.tsx | 9 +---- .../__tests__/LinkIconButton.spec.tsx | 9 +---- packages/components/global.d.ts | 3 +- packages/components/src/Link.tsx | 40 ++----------------- packages/components/src/LinkButton.tsx | 32 ++------------- packages/components/src/LinkIconButton.tsx | 37 ++--------------- packages/components/src/index.ts | 13 +++--- packages/components/src/utils.tsx | 17 +++++++- packages/components/stories/Link.stories.tsx | 4 +- .../components/stories/LinkButton.stories.tsx | 4 +- .../stories/LinkIconButton.stories.tsx | 4 +- 14 files changed, 70 insertions(+), 141 deletions(-) create mode 100644 .changeset/selfish-coins-applaud.md diff --git a/.changeset/selfish-coins-applaud.md b/.changeset/selfish-coins-applaud.md new file mode 100644 index 000000000..d5769a9de --- /dev/null +++ b/.changeset/selfish-coins-applaud.md @@ -0,0 +1,5 @@ +--- +"@launchpad-ui/components": minor +--- + +Handle external links with `useHref` wrapper diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index f05d54e8c..8ec919332 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -3,7 +3,7 @@ import type { DecoratorFunction, GlobalTypes, Parameters } from '@storybook/type import type { ReactNode } from 'react'; import { Box } from '@launchpad-ui/box'; -import { RouterProvider as AriaRouterProvider } from '@launchpad-ui/components'; +import { RouterProvider as AriaRouterProvider, useHref } from '@launchpad-ui/components'; import { withThemeByDataAttribute } from '@storybook/addon-themes'; import { themes } from '@storybook/theming'; import { BrowserRouter, useNavigate } from 'react-router-dom'; @@ -17,7 +17,11 @@ import '../packages/tokens/dist/themes.css'; const RouterProvider = ({ children }: { children: ReactNode }) => { const navigate = useNavigate(); - return {children}; + return ( + + {children} + + ); }; export const parameters: Parameters = { diff --git a/packages/components/__tests__/Link.spec.tsx b/packages/components/__tests__/Link.spec.tsx index 995559b91..43dbfbcec 100644 --- a/packages/components/__tests__/Link.spec.tsx +++ b/packages/components/__tests__/Link.spec.tsx @@ -1,24 +1,32 @@ import { MemoryRouter } from 'react-router-dom'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { render, screen } from '../../../test/utils'; -import { ExternalLink, Link } from '../src'; +import { Link, RouterProvider, useHref } from '../src'; describe('Link', () => { it('renders', () => { + const navigate = vi.fn(); render( - - Link - , + + + Link + + , ); expect(screen.getByRole('link')).toBeVisible(); expect(screen.getByRole('link')).toHaveAttribute('href', '/test'); }); -}); -describe('ExternalLink', () => { - it('renders', () => { - render(Link); + it('renders external links', () => { + const navigate = vi.fn(); + render( + + + Link + + , + ); expect(screen.getByRole('link')).toBeVisible(); expect(screen.getByRole('link')).toHaveAttribute('href', 'https://www.test.com'); }); diff --git a/packages/components/__tests__/LinkButton.spec.tsx b/packages/components/__tests__/LinkButton.spec.tsx index 3dd07a831..aa137fde1 100644 --- a/packages/components/__tests__/LinkButton.spec.tsx +++ b/packages/components/__tests__/LinkButton.spec.tsx @@ -3,7 +3,7 @@ import { MemoryRouter } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; import { render, screen, userEvent } from '../../../test/utils'; -import { ExternalLinkButton, LinkButton } from '../src'; +import { LinkButton } from '../src'; describe('LinkButton', () => { it('renders', () => { @@ -34,10 +34,3 @@ describe('LinkButton', () => { expect(spy).toHaveBeenCalledTimes(1); }); }); - -describe('ExternalLinkButton', () => { - it('renders', () => { - render(LinkButton); - expect(screen.getByRole('link')).toBeVisible(); - }); -}); diff --git a/packages/components/__tests__/LinkIconButton.spec.tsx b/packages/components/__tests__/LinkIconButton.spec.tsx index d26f29315..1a3095718 100644 --- a/packages/components/__tests__/LinkIconButton.spec.tsx +++ b/packages/components/__tests__/LinkIconButton.spec.tsx @@ -2,7 +2,7 @@ import { MemoryRouter } from 'react-router-dom'; import { describe, expect, it } from 'vitest'; import { render, screen } from '../../../test/utils'; -import { ExternalLinkIconButton, LinkIconButton } from '../src'; +import { LinkIconButton } from '../src'; describe('LinkIconButton', () => { it('renders', () => { @@ -15,10 +15,3 @@ describe('LinkIconButton', () => { expect(screen.getByRole('img', { hidden: true })).toBeVisible(); }); }); - -describe('ExternalLinkIconButton', () => { - it('renders', () => { - render(); - expect(screen.getByRole('link')).toBeVisible(); - }); -}); diff --git a/packages/components/global.d.ts b/packages/components/global.d.ts index 78b0c7e54..55770d105 100644 --- a/packages/components/global.d.ts +++ b/packages/components/global.d.ts @@ -1,7 +1,8 @@ -import type { NavigateOptions } from 'react-router-dom'; +import type { NavigateOptions, To } from 'react-router-dom'; declare module 'react-aria-components' { interface RouterConfig { + href: To; routerOptions: NavigateOptions; } } diff --git a/packages/components/src/Link.tsx b/packages/components/src/Link.tsx index 222c03f11..549456f80 100644 --- a/packages/components/src/Link.tsx +++ b/packages/components/src/Link.tsx @@ -2,13 +2,11 @@ import type { DOMProps } from '@react-types/shared'; import type { VariantProps } from 'class-variance-authority'; import type { ForwardedRef } from 'react'; import type { LinkProps as AriaLinkProps } from 'react-aria-components'; -import type { To } from 'react-router-dom'; import { Icon } from '@launchpad-ui/icons'; import { cva } from 'class-variance-authority'; import { forwardRef } from 'react'; import { Link as AriaLink, composeRenderProps, useSlottedContext } from 'react-aria-components'; -import { useHref } from 'react-router-dom'; import { LinkContext } from './Breadcrumbs'; import styles from './styles/Link.module.css'; @@ -25,20 +23,12 @@ const link = cva(styles.base, { }, }); -interface BaseLinkProps extends AriaLinkProps, VariantProps, DOMProps {} - -interface LinkProps extends Omit { - href?: To; -} - -interface ExternalLinkProps extends Omit {} +interface LinkProps extends AriaLinkProps, VariantProps, DOMProps {} const _Link = ( { variant = 'default', href, ...props }: LinkProps, ref: ForwardedRef, ) => { - // @ts-expect-error href can be undefined https://react-spectrum.adobe.com/react-aria/Link.html#javascript-handled-links - const routerHref = useHref(href); const linkProps = useSlottedContext(LinkContext); return ( @@ -50,7 +40,7 @@ const _Link = ( className={composeRenderProps(props.className, (className, renderProps) => link({ ...renderProps, variant: linkProps?.variant ?? variant, className }), )} - href={href ? routerHref : undefined} + href={href} /> {href && linkProps && } @@ -64,27 +54,5 @@ const _Link = ( */ const Link = forwardRef(_Link); -const _ExternalLink = ( - { variant = 'default', ...props }: ExternalLinkProps, - ref: ForwardedRef, -) => { - return ( - - link({ ...renderProps, variant, className }), - )} - /> - ); -}; - -/** - * A link allows a user to navigate to another page or resource within a web page or application. - * - * https://react-spectrum.adobe.com/react-aria/Link.html - */ -const ExternalLink = forwardRef(_ExternalLink); - -export { ExternalLink, Link }; -export type { ExternalLinkProps, LinkProps }; +export { Link }; +export type { LinkProps }; diff --git a/packages/components/src/LinkButton.tsx b/packages/components/src/LinkButton.tsx index 3ae05fea6..4a5f1b9a8 100644 --- a/packages/components/src/LinkButton.tsx +++ b/packages/components/src/LinkButton.tsx @@ -1,15 +1,14 @@ import type { ForwardedRef } from 'react'; import type { ButtonVariants } from './Button'; -import type { ExternalLinkProps, LinkProps } from './Link'; +import type { LinkProps } from './Link'; import { forwardRef } from 'react'; import { composeRenderProps } from 'react-aria-components'; import { button } from './Button'; -import { ExternalLink, Link } from './Link'; +import { Link } from './Link'; interface LinkButtonProps extends Omit, ButtonVariants {} -interface ExternalLinkButtonProps extends Omit, ButtonVariants {} const _LinkButton = ( { size = 'medium', variant = 'default', ...props }: LinkButtonProps, @@ -34,28 +33,5 @@ const _LinkButton = ( */ const LinkButton = forwardRef(_LinkButton); -const _ExternalLinkButton = ( - { size = 'medium', variant = 'default', ...props }: ExternalLinkButtonProps, - ref: ForwardedRef, -) => { - return ( - - button({ ...renderProps, size, variant, className }), - )} - variant={null} - /> - ); -}; - -/** - * A link allows a user to navigate to another page or resource within a web page or application. - * - * https://react-spectrum.adobe.com/react-aria/Link.html - */ -const ExternalLinkButton = forwardRef(_ExternalLinkButton); - -export { ExternalLinkButton, LinkButton }; -export type { ExternalLinkButtonProps, LinkButtonProps }; +export { LinkButton }; +export type { LinkButtonProps }; diff --git a/packages/components/src/LinkIconButton.tsx b/packages/components/src/LinkIconButton.tsx index 17841871e..7b227f350 100644 --- a/packages/components/src/LinkIconButton.tsx +++ b/packages/components/src/LinkIconButton.tsx @@ -1,6 +1,6 @@ import type { ForwardedRef } from 'react'; import type { IconButtonBaseProps } from './IconButton'; -import type { ExternalLinkProps, LinkProps } from './Link'; +import type { LinkProps } from './Link'; import { Icon } from '@launchpad-ui/icons'; import { cx } from 'class-variance-authority'; @@ -9,16 +9,12 @@ import { composeRenderProps } from 'react-aria-components'; import { button } from './Button'; import { iconButton } from './IconButton'; -import { ExternalLink, Link } from './Link'; +import { Link } from './Link'; interface LinkIconButtonProps extends Omit, IconButtonBaseProps {} -interface ExternalLinkIconButtonProps - extends Omit, - IconButtonBaseProps {} - const _LinkIconButton = ( { size = 'medium', variant = 'default', icon, ...props }: LinkIconButtonProps, ref: ForwardedRef, @@ -44,30 +40,5 @@ const _LinkIconButton = ( */ const LinkIconButton = forwardRef(_LinkIconButton); -const _ExternalLinkIconButton = ( - { size = 'medium', variant = 'default', icon, ...props }: ExternalLinkIconButtonProps, - ref: ForwardedRef, -) => { - return ( - - cx(button({ ...renderProps, size, variant, className }), iconButton({ size })), - )} - variant={null} - > - - - ); -}; - -/** - * A link allows a user to navigate to another page or resource within a web page or application. - * - * https://react-spectrum.adobe.com/react-aria/Link.html - */ -const ExternalLinkIconButton = forwardRef(_ExternalLinkIconButton); - -export { ExternalLinkIconButton, LinkIconButton }; -export type { ExternalLinkIconButtonProps, LinkIconButtonProps }; +export { LinkIconButton }; +export type { LinkIconButtonProps }; diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 743864d0d..78850b530 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -28,9 +28,9 @@ export type { HeadingProps } from './Heading'; export type { InputProps } from './Input'; export type { IconButtonProps } from './IconButton'; export type { LabelProps } from './Label'; -export type { ExternalLinkProps, LinkProps } from './Link'; -export type { ExternalLinkButtonProps, LinkButtonProps } from './LinkButton'; -export type { ExternalLinkIconButtonProps, LinkIconButtonProps } from './LinkIconButton'; +export type { LinkProps } from './Link'; +export type { LinkButtonProps } from './LinkButton'; +export type { LinkIconButtonProps } from './LinkIconButton'; export type { ListBoxProps, ListBoxItemProps } from './ListBox'; export type { MenuProps, MenuItemProps, MenuTriggerProps, SubmenuTriggerProps } from './Menu'; export type { ModalProps, ModalOverlayProps } from './Modal'; @@ -88,9 +88,9 @@ export { Input } from './Input'; export { IconButton } from './IconButton'; export { Keyboard } from './Keyboard'; export { Label } from './Label'; -export { ExternalLink, Link } from './Link'; -export { ExternalLinkButton, LinkButton } from './LinkButton'; -export { ExternalLinkIconButton, LinkIconButton } from './LinkIconButton'; +export { Link } from './Link'; +export { LinkButton } from './LinkButton'; +export { LinkIconButton } from './LinkIconButton'; export { ListBox, ListBoxItem } from './ListBox'; export { Menu, MenuItem, MenuTrigger, SubmenuTrigger } from './Menu'; export { Modal, ModalOverlay } from './Modal'; @@ -117,3 +117,4 @@ export { SnackbarContainer, SnackbarQueue, ToastContainer, ToastQueue } from './ export { ToggleButton } from './ToggleButton'; export { ToggleIconButton } from './ToggleIconButton'; export { Tooltip, TooltipTrigger } from './Tooltip'; +export { useHref, useMedia } from './utils'; diff --git a/packages/components/src/utils.tsx b/packages/components/src/utils.tsx index 8487ef0e7..d62f80500 100644 --- a/packages/components/src/utils.tsx +++ b/packages/components/src/utils.tsx @@ -1,4 +1,7 @@ +import type { Href } from '@react-types/shared'; + import { useEffect, useState } from 'react'; +import { useHref as useRouterHref } from 'react-router-dom'; const useMedia = (media: string) => { const [isActive, setIsActive] = useState(false); @@ -25,4 +28,16 @@ const useMedia = (media: string) => { return isActive; }; -export { useMedia }; +const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; + +// https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/index.tsx#L957-L962 +const useHref = (href: Href) => { + let absoluteHref: string | undefined; + if (typeof href === 'string' && ABSOLUTE_URL_REGEX.test(href)) { + absoluteHref = href; + } + const routerHref = useRouterHref(href); + return absoluteHref || routerHref; +}; + +export { useHref, useMedia }; diff --git a/packages/components/stories/Link.stories.tsx b/packages/components/stories/Link.stories.tsx index 75e900270..b44b5103f 100644 --- a/packages/components/stories/Link.stories.tsx +++ b/packages/components/stories/Link.stories.tsx @@ -3,12 +3,10 @@ import type { Meta, StoryObj } from '@storybook/react'; import { vars } from '@launchpad-ui/vars'; import { fireEvent, userEvent, within } from '@storybook/test'; -import { ExternalLink, Link } from '../src'; +import { Link } from '../src'; const meta: Meta = { component: Link, - // @ts-ignore - subcomponents: { ExternalLink }, title: 'Components/Navigation/Link', parameters: { status: { diff --git a/packages/components/stories/LinkButton.stories.tsx b/packages/components/stories/LinkButton.stories.tsx index fbca37699..0da90fbf8 100644 --- a/packages/components/stories/LinkButton.stories.tsx +++ b/packages/components/stories/LinkButton.stories.tsx @@ -1,11 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { ExternalLinkButton, LinkButton } from '../src'; +import { LinkButton } from '../src'; const meta: Meta = { component: LinkButton, - // @ts-ignore - subcomponents: { ExternalLinkButton }, title: 'Components/Navigation/LinkButton', parameters: { status: { diff --git a/packages/components/stories/LinkIconButton.stories.tsx b/packages/components/stories/LinkIconButton.stories.tsx index 3b10625e0..1ad0b94c8 100644 --- a/packages/components/stories/LinkIconButton.stories.tsx +++ b/packages/components/stories/LinkIconButton.stories.tsx @@ -1,11 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { ExternalLinkIconButton, LinkIconButton } from '../src'; +import { LinkIconButton } from '../src'; const meta: Meta = { component: LinkIconButton, - // @ts-ignore - subcomponents: { ExternalLinkIconButton }, title: 'Components/Navigation/LinkIconButton', parameters: { status: {