Skip to content

Commit

Permalink
refactor(components)!: handle external links with useHref wrapper (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
Niznikr authored Aug 5, 2024
1 parent 764e059 commit 2169c64
Show file tree
Hide file tree
Showing 14 changed files with 70 additions and 141 deletions.
5 changes: 5 additions & 0 deletions .changeset/selfish-coins-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@launchpad-ui/components": minor
---

Handle external links with `useHref` wrapper
8 changes: 6 additions & 2 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,7 +17,11 @@ import '../packages/tokens/dist/themes.css';

const RouterProvider = ({ children }: { children: ReactNode }) => {
const navigate = useNavigate();
return <AriaRouterProvider navigate={navigate}>{children}</AriaRouterProvider>;
return (
<AriaRouterProvider navigate={navigate} useHref={useHref}>
{children}
</AriaRouterProvider>
);
};

export const parameters: Parameters = {
Expand Down
26 changes: 17 additions & 9 deletions packages/components/__tests__/Link.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<Link href="/test">Link</Link>
</MemoryRouter>,
<RouterProvider navigate={navigate} useHref={useHref}>
<MemoryRouter>
<Link href="/test">Link</Link>
</MemoryRouter>
</RouterProvider>,
);
expect(screen.getByRole('link')).toBeVisible();
expect(screen.getByRole('link')).toHaveAttribute('href', '/test');
});
});

describe('ExternalLink', () => {
it('renders', () => {
render(<ExternalLink href="https://www.test.com">Link</ExternalLink>);
it('renders external links', () => {
const navigate = vi.fn();
render(
<RouterProvider navigate={navigate} useHref={useHref}>
<MemoryRouter>
<Link href="https://www.test.com">Link</Link>
</MemoryRouter>
</RouterProvider>,
);
expect(screen.getByRole('link')).toBeVisible();
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://www.test.com');
});
Expand Down
9 changes: 1 addition & 8 deletions packages/components/__tests__/LinkButton.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -34,10 +34,3 @@ describe('LinkButton', () => {
expect(spy).toHaveBeenCalledTimes(1);
});
});

describe('ExternalLinkButton', () => {
it('renders', () => {
render(<ExternalLinkButton href="https://www.test.com">LinkButton</ExternalLinkButton>);
expect(screen.getByRole('link')).toBeVisible();
});
});
9 changes: 1 addition & 8 deletions packages/components/__tests__/LinkIconButton.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -15,10 +15,3 @@ describe('LinkIconButton', () => {
expect(screen.getByRole('img', { hidden: true })).toBeVisible();
});
});

describe('ExternalLinkIconButton', () => {
it('renders', () => {
render(<ExternalLinkIconButton icon="add" aria-label="create" href="https://www.test.com" />);
expect(screen.getByRole('link')).toBeVisible();
});
});
3 changes: 2 additions & 1 deletion packages/components/global.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
40 changes: 4 additions & 36 deletions packages/components/src/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,20 +23,12 @@ const link = cva(styles.base, {
},
});

interface BaseLinkProps extends AriaLinkProps, VariantProps<typeof link>, DOMProps {}

interface LinkProps extends Omit<BaseLinkProps, 'href'> {
href?: To;
}

interface ExternalLinkProps extends Omit<BaseLinkProps, 'routerOptions'> {}
interface LinkProps extends AriaLinkProps, VariantProps<typeof link>, DOMProps {}

const _Link = (
{ variant = 'default', href, ...props }: LinkProps,
ref: ForwardedRef<HTMLAnchorElement>,
) => {
// @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 (
Expand All @@ -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 && <Icon name="slash" className={styles.separator} />}
</>
Expand All @@ -64,27 +54,5 @@ const _Link = (
*/
const Link = forwardRef(_Link);

const _ExternalLink = (
{ variant = 'default', ...props }: ExternalLinkProps,
ref: ForwardedRef<HTMLAnchorElement>,
) => {
return (
<AriaLink
{...props}
ref={ref}
className={composeRenderProps(props.className, (className, renderProps) =>
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 };
32 changes: 4 additions & 28 deletions packages/components/src/LinkButton.tsx
Original file line number Diff line number Diff line change
@@ -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<LinkProps, 'variant'>, ButtonVariants {}
interface ExternalLinkButtonProps extends Omit<ExternalLinkProps, 'variant'>, ButtonVariants {}

const _LinkButton = (
{ size = 'medium', variant = 'default', ...props }: LinkButtonProps,
Expand All @@ -34,28 +33,5 @@ const _LinkButton = (
*/
const LinkButton = forwardRef(_LinkButton);

const _ExternalLinkButton = (
{ size = 'medium', variant = 'default', ...props }: ExternalLinkButtonProps,
ref: ForwardedRef<HTMLAnchorElement>,
) => {
return (
<ExternalLink
{...props}
ref={ref}
className={composeRenderProps(props.className, (className, renderProps) =>
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 };
37 changes: 4 additions & 33 deletions packages/components/src/LinkIconButton.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<LinkProps, 'variant' | 'children' | 'aria-label'>,
IconButtonBaseProps {}

interface ExternalLinkIconButtonProps
extends Omit<ExternalLinkProps, 'variant' | 'children' | 'aria-label'>,
IconButtonBaseProps {}

const _LinkIconButton = (
{ size = 'medium', variant = 'default', icon, ...props }: LinkIconButtonProps,
ref: ForwardedRef<HTMLAnchorElement>,
Expand All @@ -44,30 +40,5 @@ const _LinkIconButton = (
*/
const LinkIconButton = forwardRef(_LinkIconButton);

const _ExternalLinkIconButton = (
{ size = 'medium', variant = 'default', icon, ...props }: ExternalLinkIconButtonProps,
ref: ForwardedRef<HTMLAnchorElement>,
) => {
return (
<ExternalLink
{...props}
ref={ref}
className={composeRenderProps(props.className, (className, renderProps) =>
cx(button({ ...renderProps, size, variant, className }), iconButton({ size })),
)}
variant={null}
>
<Icon name={icon} size="small" aria-hidden />
</ExternalLink>
);
};

/**
* 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 };
13 changes: 7 additions & 6 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -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';
17 changes: 16 additions & 1 deletion packages/components/src/utils.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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 };
4 changes: 1 addition & 3 deletions packages/components/stories/Link.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Link> = {
component: Link,
// @ts-ignore
subcomponents: { ExternalLink },
title: 'Components/Navigation/Link',
parameters: {
status: {
Expand Down
4 changes: 1 addition & 3 deletions packages/components/stories/LinkButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react';

import { ExternalLinkButton, LinkButton } from '../src';
import { LinkButton } from '../src';

const meta: Meta<typeof LinkButton> = {
component: LinkButton,
// @ts-ignore
subcomponents: { ExternalLinkButton },
title: 'Components/Navigation/LinkButton',
parameters: {
status: {
Expand Down
4 changes: 1 addition & 3 deletions packages/components/stories/LinkIconButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react';

import { ExternalLinkIconButton, LinkIconButton } from '../src';
import { LinkIconButton } from '../src';

const meta: Meta<typeof LinkIconButton> = {
component: LinkIconButton,
// @ts-ignore
subcomponents: { ExternalLinkIconButton },
title: 'Components/Navigation/LinkIconButton',
parameters: {
status: {
Expand Down

0 comments on commit 2169c64

Please sign in to comment.