From 253f35dbbb4a2affd40d75688ebef88b7ed2dced Mon Sep 17 00:00:00 2001 From: Jacob Marshall Date: Sun, 9 Feb 2025 17:50:45 +0000 Subject: [PATCH] fix: update link component to allow modifier+click to properly work, and write tests (#534) --- .../MdxProvider/MdxLink/MdxLink.tsx | 1 - .../tuono-router/src/components/Link.spec.tsx | 128 ++++++++++++++++++ packages/tuono-router/src/components/Link.tsx | 24 +++- 3 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 packages/tuono-router/src/components/Link.spec.tsx diff --git a/apps/documentation/src/components/MdxProvider/MdxLink/MdxLink.tsx b/apps/documentation/src/components/MdxProvider/MdxLink/MdxLink.tsx index 0dc95eff..1fca9d13 100644 --- a/apps/documentation/src/components/MdxProvider/MdxLink/MdxLink.tsx +++ b/apps/documentation/src/components/MdxProvider/MdxLink/MdxLink.tsx @@ -34,7 +34,6 @@ export default function MdxLink(props: MdxLinkProps): JSX.Element { ({ + useRouter: (): { push: typeof pushMock } => ({ push: pushMock }), +})) + +vi.mock('../hooks/useRoute', () => ({ + useRoute: (): { component: { preload: typeof preloadMock } } => ({ + component: { preload: preloadMock }, + }), +})) + +let intersectionObserverCallback: ((inView: boolean) => void) | undefined + +vi.mock('react-intersection-observer', () => ({ + useInView: (options: { + onChange: (inView: boolean) => void + }): { + ref: () => void + } => { + intersectionObserverCallback = options.onChange + return { ref: vi.fn() } + }, +})) + +describe('Link Component', () => { + beforeEach(() => { + pushMock.mockReset() + preloadMock.mockReset() + intersectionObserverCallback = undefined + }) + + it('renders with correct href and text', () => { + render(Test Link) + const link = screen.getByRole('link', { name: 'Test Link' }) + + expect(link.getAttribute('href')).toBe('/test') + }) + + it('calls router.push on normal click', () => { + const { getByRole } = render(Test Link) + const link = getByRole('link') + + fireEvent.click(link) + expect(pushMock).toHaveBeenCalledWith('/test', { scroll: true }) + }) + + it('does not navigate if href starts with "#"', () => { + const { getByRole } = render(Anchor Link) + const link = getByRole('link') + + fireEvent.click(link) + expect(pushMock).not.toHaveBeenCalled() + }) + + it('preloads route when in viewport and preload is true', () => { + render( + + Test Link + , + ) + + intersectionObserverCallback?.(true) + expect(preloadMock).toHaveBeenCalled() + }) + + it('does not preload route when preload is false', () => { + render( + + Test Link + , + ) + + intersectionObserverCallback?.(true) + expect(preloadMock).not.toHaveBeenCalled() + }) + + it('does not call router.push when clicked with a modifier key', () => { + const { getByRole } = render(Test Link) + const link = getByRole('link') + + fireEvent.click(link, { ctrlKey: true }) + fireEvent.click(link, { metaKey: true }) + fireEvent.click(link, { shiftKey: true }) + fireEvent.click(link, { altKey: true }) + + expect(pushMock).not.toHaveBeenCalled() + }) + + it('calls onClick handler when clicked', () => { + const onClickMock = vi.fn() + const { getByRole } = render( + + Test Link + , + ) + const link = getByRole('link') + + fireEvent.click(link) + + expect(onClickMock).toHaveBeenCalledTimes(1) + expect(pushMock).toHaveBeenCalledWith('/test', { scroll: true }) + }) + + it('calls onClick but does not navigate when clicked with a modifier key', () => { + const onClickMock = vi.fn() + const { getByRole } = render( + + Test Link + , + ) + const link = getByRole('link') + + fireEvent.click(link, { ctrlKey: true }) + fireEvent.click(link, { metaKey: true }) + fireEvent.click(link, { shiftKey: true }) + fireEvent.click(link, { altKey: true }) + + expect(onClickMock).toHaveBeenCalledTimes(4) + expect(pushMock).not.toHaveBeenCalled() + }) +}) diff --git a/packages/tuono-router/src/components/Link.tsx b/packages/tuono-router/src/components/Link.tsx index a6e62c03..a5890b1c 100644 --- a/packages/tuono-router/src/components/Link.tsx +++ b/packages/tuono-router/src/components/Link.tsx @@ -18,6 +18,19 @@ interface TuonoLinkProps extends React.AnchorHTMLAttributes { scroll?: boolean } +function isEventModifierKeyActiveAndTargetDifferentFromSelf( + event: React.MouseEvent, +): boolean { + const target = event.currentTarget.getAttribute('target') + return ( + (target && target !== '_self') || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey // triggers resource download + ) +} + export default function Link( componentProps: TuonoLinkProps, ): React.JSX.Element { @@ -42,14 +55,19 @@ export default function Link( const handleTransition: React.MouseEventHandler = ( event, ) => { - event.preventDefault() onClick?.(event) - if (href?.startsWith('#')) { - window.location.hash = href + if ( + href?.startsWith('#') || + // If the user is pressing a modifier key or using the target attribute, + // we fall back to default behaviour of `a` tag + isEventModifierKeyActiveAndTargetDifferentFromSelf(event) + ) { return } + event.preventDefault() + router.push(href || '', { scroll }) }