Created
August 26, 2025 09:32
-
-
Save artalar/4622e3b804f1d7dc94e0dc0df4cb93a9 to your computer and use it in GitHub Desktop.
Link component for @reatom/react
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { RouteAtom } from '@reatom/core' | |
import React from 'react' | |
import { reatomComponent, useWrap } from './internal' | |
// Polymorphic component types | |
type AsProp<C extends React.ElementType> = { | |
as?: C | |
} | |
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P) | |
type PolymorphicComponentProp< | |
C extends React.ElementType, | |
Props = {}, | |
> = React.PropsWithChildren<Props & AsProp<C>> & | |
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>> | |
type PolymorphicRef<C extends React.ElementType> = | |
React.ComponentPropsWithRef<C>['ref'] | |
type PolymorphicComponentPropWithRef< | |
C extends React.ElementType, | |
Props, | |
> = PolymorphicComponentProp<C, Props> & { ref?: PolymorphicRef<C> } | |
// Link specific props | |
type LinkOwnProps<Params> = { | |
route: { | |
go: (params: Params) => any | |
} | |
replace?: boolean | |
prefetch?: boolean | 'intent' | 'render' | 'viewport' | |
scroll?: boolean | |
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void | |
} & ({} extends Params ? { params?: Params } : { params: Params }) | |
type LinkProps< | |
C extends React.ElementType = 'a', | |
Params = any, | |
> = PolymorphicComponentPropWithRef<C, LinkOwnProps<Params>> | |
const isModifiedEvent = (event: React.MouseEvent) => { | |
return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) | |
} | |
const shouldNavigate = (event: React.MouseEvent<HTMLAnchorElement>) => { | |
const target = event.currentTarget.target | |
return ( | |
event.button === 0 && // left click | |
(!target || target === '_self') && // no target or same window | |
!isModifiedEvent(event) // no modifier keys | |
) | |
} | |
export const Link = reatomComponent( | |
<C extends React.ElementType = 'a', Params = any>({ | |
as, | |
route, | |
params, | |
replace = false, | |
prefetch, | |
scroll = true, | |
onClick, | |
...props | |
}: LinkProps<C, Params>) => { | |
const Component = as || 'a' | |
// Generate href from route | |
const href = React.useMemo(() => { | |
try { | |
return route.path(params as Params) | |
} catch (error) { | |
console.error('Failed to generate route path:', error) | |
return '#' | |
} | |
}, [route, params]) | |
// Wrap navigation action | |
const navigate = useWrap(() => { | |
route.go(params as Params, replace) | |
if (scroll && typeof window !== 'undefined') { | |
// Defer scrolling to next tick to ensure navigation completes | |
setTimeout(() => window.scrollTo(0, 0), 0) | |
} | |
}, 'Link.navigate') | |
// Handle click events | |
const handleClick = useWrap( | |
(event: React.MouseEvent<HTMLAnchorElement>) => { | |
// Call user's onClick if provided | |
onClick?.(event) | |
// If default prevented by user, don't navigate | |
if (event.defaultPrevented) { | |
return | |
} | |
// Check if we should handle navigation | |
if (shouldNavigate(event)) { | |
event.preventDefault() | |
navigate() | |
} | |
}, | |
'Link.handleClick', | |
) | |
// Handle prefetching | |
React.useEffect(() => { | |
if (prefetch === true || prefetch === 'render') { | |
// Prefetch on render | |
route.loader() | |
} | |
}, [route, prefetch]) | |
const handleMouseEnter = useWrap(() => { | |
if (prefetch === 'intent') { | |
route.loader() | |
} | |
}, 'Link.prefetchOnIntent') | |
// Set up viewport observer for prefetch | |
const elementRef = React.useRef<HTMLElement>(null) | |
React.useEffect(() => { | |
if ( | |
prefetch !== 'viewport' || | |
typeof IntersectionObserver === 'undefined' | |
) { | |
return | |
} | |
const observer = new IntersectionObserver( | |
(entries) => { | |
entries.forEach((entry) => { | |
if (entry.isIntersecting) { | |
route.loader() | |
observer.disconnect() | |
} | |
}) | |
}, | |
{ rootMargin: '50px' }, | |
) | |
if (elementRef.current) { | |
observer.observe(elementRef.current) | |
} | |
return () => observer.disconnect() | |
}, [route, prefetch]) | |
// Merge refs if user provided one | |
const ref = React.useMemo(() => { | |
if (!props.ref && prefetch !== 'viewport') { | |
return undefined | |
} | |
return (element: HTMLElement | null) => { | |
// @ts-ignore | |
elementRef.current = element | |
// Handle user ref - in React 19, ref is just a regular prop | |
if (props.ref) { | |
if (typeof props.ref === 'function') { | |
props.ref(element) | |
} else if (props.ref && typeof props.ref === 'object') { | |
// Check if it's a mutable ref by trying to write to it | |
try { | |
props.ref.current = element | |
} catch { | |
// If it throws, it's probably a readonly ref, ignore it | |
} | |
} | |
} | |
} | |
}, [props.ref, prefetch]) | |
// Build final props | |
const componentProps = { | |
...props, | |
href, | |
onClick: handleClick, | |
onMouseEnter: | |
prefetch === 'intent' ? handleMouseEnter : props.onMouseEnter, | |
ref, | |
} | |
return <Component {...componentProps} /> | |
}, | |
'Link', | |
) | |
// Type helper for inferring params from route | |
export type LinkPropsForRoute< | |
Route extends RouteAtom<any, any, any, any, any, any>, | |
C extends React.ElementType = 'a', | |
> = | |
Route extends RouteAtom<any, any, any, any, infer Params, any> | |
? LinkProps<C, Params> | |
: never |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
В handleMouseEnter разве не нужно вызвать переданый props.onMouseEnter?