diff --git a/.changeset/link-target-blank.md b/.changeset/link-target-blank.md new file mode 100644 index 0000000000..25c0b4f448 --- /dev/null +++ b/.changeset/link-target-blank.md @@ -0,0 +1,5 @@ +--- +"@frontify/fondue-components": patch +--- + +fix(Link): respect `target="_blank"` and modifier clicks instead of always navigating through the router diff --git a/packages/components/src/components/Link/Link.stories.tsx b/packages/components/src/components/Link/Link.stories.tsx index 89f49d0bf1..45d2d19509 100644 --- a/packages/components/src/components/Link/Link.stories.tsx +++ b/packages/components/src/components/Link/Link.stories.tsx @@ -110,6 +110,22 @@ export const AllVariants: Story = { }, }; +export const Targets: Story = { + render: () => ( + alert(`Navigate to: ${path}`)} useHref={(path: string) => path}> +
+ Default: navigates through the router + + target="_self": navigates through the router + + + target="_blank": opens a new tab + +
+
+ ), +}; + export const Truncated: Story = { args: { children: 'This is a very long link text that should be truncated if it overflows the container.', diff --git a/packages/components/src/components/Link/Link.tsx b/packages/components/src/components/Link/Link.tsx index 624211d880..c4fb14414c 100644 --- a/packages/components/src/components/Link/Link.tsx +++ b/packages/components/src/components/Link/Link.tsx @@ -116,6 +116,7 @@ export const Link = forwardRef( onContainer, onPress, disabled = false, + target, ...props }, ref, @@ -130,13 +131,24 @@ export const Link = forwardRef( return; } onPress?.(event); + if (event.defaultPrevented) { + return; + } - if (href) { + const shouldUseClientSideNavigation = !target || target === '_self'; + if ( + href && + shouldUseClientSideNavigation && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + event.button === 0 + ) { event.preventDefault(); navigate(href); } }, - [href, navigate, onPress, disabled], + [href, navigate, onPress, disabled, target], ); const computedColor = disabled @@ -160,6 +172,7 @@ export const Link = forwardRef( .join(' ')} ref={ref} href={disabled ? undefined : resolvedHref} + target={target} onClick={handleClick} aria-disabled={disabled || undefined} tabIndex={disabled ? -1 : undefined} diff --git a/packages/components/src/components/Link/__tests__/Link.spec.tsx b/packages/components/src/components/Link/__tests__/Link.spec.tsx index 2714491610..87e8f57c71 100644 --- a/packages/components/src/components/Link/__tests__/Link.spec.tsx +++ b/packages/components/src/components/Link/__tests__/Link.spec.tsx @@ -1,7 +1,7 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; import { RouterProvider } from '../../RouterProvider/RouterProvider'; import { Link } from '../Link'; @@ -20,4 +20,47 @@ describe('Link Component', () => { const link = screen.getByText('Link Text'); expect(link).toHaveAttribute('href', '/resolved/foo'); }); + + it('should navigate client-side on a plain click', () => { + const navigate = vi.fn(); + + render( + path}> + Link Text + , + ); + + fireEvent.click(screen.getByText('Link Text')); + expect(navigate).toHaveBeenCalledWith('/foo'); + }); + + it('should let the browser handle links with target="_blank"', () => { + const navigate = vi.fn(); + + render( + path}> + + Link Text + + , + ); + + const link = screen.getByText('Link Text'); + expect(link).toHaveAttribute('target', '_blank'); + expect(fireEvent.click(link)).toBe(true); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('should let the browser handle modifier clicks', () => { + const navigate = vi.fn(); + + render( + path}> + Link Text + , + ); + + expect(fireEvent.click(screen.getByText('Link Text'), { metaKey: true })).toBe(true); + expect(navigate).not.toHaveBeenCalled(); + }); });