Skip to content

Commit

Permalink
fix: update link component to allow modifier+click to properly work, …
Browse files Browse the repository at this point in the history
…and write tests (#534)
  • Loading branch information
jacobhq authored Feb 9, 2025
1 parent 0f72506 commit 253f35d
Show file tree
Hide file tree
Showing 3 changed files with 149 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ export default function MdxLink(props: MdxLinkProps): JSX.Element {
<Anchor
component={Link}
{...props}
target="_blank"
variant="transparent"
display="inline"
style={{ fontWeight: 400 }}
Expand Down
128 changes: 128 additions & 0 deletions packages/tuono-router/src/components/Link.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, fireEvent, screen } from '@testing-library/react'

import Link from './Link'

const pushMock = vi.fn()
const preloadMock = vi.fn()

vi.mock('../hooks/useRouter', () => ({
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(<Link href="/test">Test Link</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(<Link href="/test">Test Link</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(<Link href="#section">Anchor Link</Link>)
const link = getByRole('link')

fireEvent.click(link)
expect(pushMock).not.toHaveBeenCalled()
})

it('preloads route when in viewport and preload is true', () => {
render(
<Link href="/test" preload={true}>
Test Link
</Link>,
)

intersectionObserverCallback?.(true)
expect(preloadMock).toHaveBeenCalled()
})

it('does not preload route when preload is false', () => {
render(
<Link href="/test" preload={false}>
Test Link
</Link>,
)

intersectionObserverCallback?.(true)
expect(preloadMock).not.toHaveBeenCalled()
})

it('does not call router.push when clicked with a modifier key', () => {
const { getByRole } = render(<Link href="/test">Test Link</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(
<Link href="/test" onClick={onClickMock}>
Test Link
</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(
<Link href="/test" onClick={onClickMock}>
Test Link
</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()
})
})
24 changes: 21 additions & 3 deletions packages/tuono-router/src/components/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ interface TuonoLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
scroll?: boolean
}

function isEventModifierKeyActiveAndTargetDifferentFromSelf(
event: React.MouseEvent<HTMLAnchorElement>,
): 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 {
Expand All @@ -42,14 +55,19 @@ export default function Link(
const handleTransition: React.MouseEventHandler<HTMLAnchorElement> = (
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 })
}

Expand Down

0 comments on commit 253f35d

Please sign in to comment.