529 lines
14 KiB
Plaintext
529 lines
14 KiB
Plaintext
import * as React from 'react'
|
|
import { flushSync } from 'react-dom'
|
|
import {
|
|
deepEqual,
|
|
exactPathTest,
|
|
functionalUpdate,
|
|
preloadWarning,
|
|
removeTrailingSlash,
|
|
} from '@tanstack/router-core'
|
|
import { useRouterState } from './useRouterState'
|
|
import { useRouter } from './useRouter'
|
|
|
|
import {
|
|
useForwardedRef,
|
|
useIntersectionObserver,
|
|
useLayoutEffect,
|
|
} from './utils'
|
|
|
|
import { useMatches } from './Matches'
|
|
import type {
|
|
AnyRouter,
|
|
Constrain,
|
|
LinkCurrentTargetElement,
|
|
LinkOptions,
|
|
RegisteredRouter,
|
|
RoutePaths,
|
|
} from '@tanstack/router-core'
|
|
import type { ReactNode } from 'react'
|
|
import type {
|
|
ValidateLinkOptions,
|
|
ValidateLinkOptionsArray,
|
|
} from './typePrimitives'
|
|
|
|
export function useLinkProps<
|
|
TRouter extends AnyRouter = RegisteredRouter,
|
|
const TFrom extends string = string,
|
|
const TTo extends string | undefined = undefined,
|
|
const TMaskFrom extends string = TFrom,
|
|
const TMaskTo extends string = '',
|
|
>(
|
|
options: UseLinkPropsOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo>,
|
|
forwardedRef?: React.ForwardedRef<Element>,
|
|
): React.ComponentPropsWithRef<'a'> {
|
|
const router = useRouter()
|
|
const [isTransitioning, setIsTransitioning] = React.useState(false)
|
|
const hasRenderFetched = React.useRef(false)
|
|
const innerRef = useForwardedRef(forwardedRef)
|
|
|
|
const {
|
|
// custom props
|
|
activeProps = () => ({ className: 'active' }),
|
|
inactiveProps = () => ({}),
|
|
activeOptions,
|
|
to,
|
|
preload: userPreload,
|
|
preloadDelay: userPreloadDelay,
|
|
hashScrollIntoView,
|
|
replace,
|
|
startTransition,
|
|
resetScroll,
|
|
viewTransition,
|
|
// element props
|
|
children,
|
|
target,
|
|
disabled,
|
|
style,
|
|
className,
|
|
onClick,
|
|
onFocus,
|
|
onMouseEnter,
|
|
onMouseLeave,
|
|
onTouchStart,
|
|
ignoreBlocker,
|
|
...rest
|
|
} = options
|
|
|
|
const {
|
|
// prevent these from being returned
|
|
params: _params,
|
|
search: _search,
|
|
hash: _hash,
|
|
state: _state,
|
|
mask: _mask,
|
|
reloadDocument: _reloadDocument,
|
|
...propsSafeToSpread
|
|
} = rest
|
|
|
|
// If this link simply reloads the current route,
|
|
// make sure it has a new key so it will trigger a data refresh
|
|
|
|
// If this `to` is a valid external URL, return
|
|
// null for LinkUtils
|
|
|
|
const type: 'internal' | 'external' = React.useMemo(() => {
|
|
try {
|
|
new URL(`${to}`)
|
|
return 'external'
|
|
} catch {}
|
|
return 'internal'
|
|
}, [to])
|
|
|
|
// subscribe to search params to re-build location if it changes
|
|
const currentSearch = useRouterState({
|
|
select: (s) => s.location.search,
|
|
structuralSharing: true as any,
|
|
})
|
|
|
|
// when `from` is not supplied, use the leaf route of the current matches as the `from` location
|
|
// so relative routing works as expected
|
|
const from = useMatches({
|
|
select: (matches) => options.from ?? matches[matches.length - 1]?.fullPath,
|
|
})
|
|
// Use it as the default `from` location
|
|
const _options = React.useMemo(() => ({ ...options, from }), [options, from])
|
|
|
|
const next = React.useMemo(
|
|
() => router.buildLocation(_options as any),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[router, _options, currentSearch],
|
|
)
|
|
|
|
const preload = React.useMemo(() => {
|
|
if (_options.reloadDocument) {
|
|
return false
|
|
}
|
|
return userPreload ?? router.options.defaultPreload
|
|
}, [router.options.defaultPreload, userPreload, _options.reloadDocument])
|
|
const preloadDelay =
|
|
userPreloadDelay ?? router.options.defaultPreloadDelay ?? 0
|
|
|
|
const isActive = useRouterState({
|
|
select: (s) => {
|
|
if (activeOptions?.exact) {
|
|
const testExact = exactPathTest(
|
|
s.location.pathname,
|
|
next.pathname,
|
|
router.basepath,
|
|
)
|
|
if (!testExact) {
|
|
return false
|
|
}
|
|
} else {
|
|
const currentPathSplit = removeTrailingSlash(
|
|
s.location.pathname,
|
|
router.basepath,
|
|
).split('/')
|
|
const nextPathSplit = removeTrailingSlash(
|
|
next.pathname,
|
|
router.basepath,
|
|
).split('/')
|
|
|
|
const pathIsFuzzyEqual = nextPathSplit.every(
|
|
(d, i) => d === currentPathSplit[i],
|
|
)
|
|
if (!pathIsFuzzyEqual) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if (activeOptions?.includeSearch ?? true) {
|
|
const searchTest = deepEqual(s.location.search, next.search, {
|
|
partial: !activeOptions?.exact,
|
|
ignoreUndefined: !activeOptions?.explicitUndefined,
|
|
})
|
|
if (!searchTest) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if (activeOptions?.includeHash) {
|
|
return s.location.hash === next.hash
|
|
}
|
|
return true
|
|
},
|
|
})
|
|
|
|
const doPreload = React.useCallback(() => {
|
|
router.preloadRoute(_options as any).catch((err) => {
|
|
console.warn(err)
|
|
console.warn(preloadWarning)
|
|
})
|
|
}, [_options, router])
|
|
|
|
const preloadViewportIoCallback = React.useCallback(
|
|
(entry: IntersectionObserverEntry | undefined) => {
|
|
if (entry?.isIntersecting) {
|
|
doPreload()
|
|
}
|
|
},
|
|
[doPreload],
|
|
)
|
|
|
|
useIntersectionObserver(
|
|
innerRef,
|
|
preloadViewportIoCallback,
|
|
{ rootMargin: '100px' },
|
|
{ disabled: !!disabled || !(preload === 'viewport') },
|
|
)
|
|
|
|
useLayoutEffect(() => {
|
|
if (hasRenderFetched.current) {
|
|
return
|
|
}
|
|
if (!disabled && preload === 'render') {
|
|
doPreload()
|
|
hasRenderFetched.current = true
|
|
}
|
|
}, [disabled, doPreload, preload])
|
|
|
|
if (type === 'external') {
|
|
return {
|
|
...propsSafeToSpread,
|
|
ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'],
|
|
type,
|
|
href: to,
|
|
...(children && { children }),
|
|
...(target && { target }),
|
|
...(disabled && { disabled }),
|
|
...(style && { style }),
|
|
...(className && { className }),
|
|
...(onClick && { onClick }),
|
|
...(onFocus && { onFocus }),
|
|
...(onMouseEnter && { onMouseEnter }),
|
|
...(onMouseLeave && { onMouseLeave }),
|
|
...(onTouchStart && { onTouchStart }),
|
|
}
|
|
}
|
|
|
|
// The click handler
|
|
const handleClick = (e: MouseEvent) => {
|
|
if (
|
|
!disabled &&
|
|
!isCtrlEvent(e) &&
|
|
!e.defaultPrevented &&
|
|
(!target || target === '_self') &&
|
|
e.button === 0
|
|
) {
|
|
e.preventDefault()
|
|
|
|
flushSync(() => {
|
|
setIsTransitioning(true)
|
|
})
|
|
|
|
const unsub = router.subscribe('onResolved', () => {
|
|
unsub()
|
|
setIsTransitioning(false)
|
|
})
|
|
|
|
// All is well? Navigate!
|
|
// N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing
|
|
return router.navigate({
|
|
..._options,
|
|
replace,
|
|
resetScroll,
|
|
hashScrollIntoView,
|
|
startTransition,
|
|
viewTransition,
|
|
ignoreBlocker,
|
|
} as any)
|
|
}
|
|
}
|
|
|
|
// The click handler
|
|
const handleFocus = (_: MouseEvent) => {
|
|
if (disabled) return
|
|
if (preload) {
|
|
doPreload()
|
|
}
|
|
}
|
|
|
|
const handleTouchStart = handleFocus
|
|
|
|
const handleEnter = (e: MouseEvent) => {
|
|
if (disabled) return
|
|
const eventTarget = (e.target || {}) as LinkCurrentTargetElement
|
|
|
|
if (preload) {
|
|
if (eventTarget.preloadTimeout) {
|
|
return
|
|
}
|
|
|
|
eventTarget.preloadTimeout = setTimeout(() => {
|
|
eventTarget.preloadTimeout = null
|
|
doPreload()
|
|
}, preloadDelay)
|
|
}
|
|
}
|
|
|
|
const handleLeave = (e: MouseEvent) => {
|
|
if (disabled) return
|
|
const eventTarget = (e.target || {}) as LinkCurrentTargetElement
|
|
|
|
if (eventTarget.preloadTimeout) {
|
|
clearTimeout(eventTarget.preloadTimeout)
|
|
eventTarget.preloadTimeout = null
|
|
}
|
|
}
|
|
|
|
const composeHandlers =
|
|
(handlers: Array<undefined | ((e: any) => void)>) =>
|
|
(e: { persist?: () => void; defaultPrevented: boolean }) => {
|
|
e.persist?.()
|
|
handlers.filter(Boolean).forEach((handler) => {
|
|
if (e.defaultPrevented) return
|
|
handler!(e)
|
|
})
|
|
}
|
|
|
|
// Get the active props
|
|
const resolvedActiveProps: React.HTMLAttributes<HTMLAnchorElement> = isActive
|
|
? (functionalUpdate(activeProps as any, {}) ?? {})
|
|
: {}
|
|
|
|
// Get the inactive props
|
|
const resolvedInactiveProps: React.HTMLAttributes<HTMLAnchorElement> =
|
|
isActive ? {} : functionalUpdate(inactiveProps, {})
|
|
|
|
const resolvedClassName = [
|
|
className,
|
|
resolvedActiveProps.className,
|
|
resolvedInactiveProps.className,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
|
|
const resolvedStyle = {
|
|
...style,
|
|
...resolvedActiveProps.style,
|
|
...resolvedInactiveProps.style,
|
|
}
|
|
|
|
return {
|
|
...propsSafeToSpread,
|
|
...resolvedActiveProps,
|
|
...resolvedInactiveProps,
|
|
href: disabled
|
|
? undefined
|
|
: next.maskedLocation
|
|
? router.history.createHref(next.maskedLocation.href)
|
|
: router.history.createHref(next.href),
|
|
ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'],
|
|
onClick: composeHandlers([onClick, handleClick]),
|
|
onFocus: composeHandlers([onFocus, handleFocus]),
|
|
onMouseEnter: composeHandlers([onMouseEnter, handleEnter]),
|
|
onMouseLeave: composeHandlers([onMouseLeave, handleLeave]),
|
|
onTouchStart: composeHandlers([onTouchStart, handleTouchStart]),
|
|
disabled: !!disabled,
|
|
target,
|
|
...(Object.keys(resolvedStyle).length && { style: resolvedStyle }),
|
|
...(resolvedClassName && { className: resolvedClassName }),
|
|
...(disabled && {
|
|
role: 'link',
|
|
'aria-disabled': true,
|
|
}),
|
|
...(isActive && { 'data-status': 'active', 'aria-current': 'page' }),
|
|
...(isTransitioning && { 'data-transitioning': 'transitioning' }),
|
|
}
|
|
}
|
|
|
|
type UseLinkReactProps<TComp> = TComp extends keyof React.JSX.IntrinsicElements
|
|
? React.JSX.IntrinsicElements[TComp]
|
|
: React.PropsWithoutRef<
|
|
TComp extends React.ComponentType<infer TProps> ? TProps : never
|
|
> &
|
|
React.RefAttributes<
|
|
TComp extends
|
|
| React.FC<{ ref: infer TRef }>
|
|
| React.Component<{ ref: infer TRef }>
|
|
? TRef
|
|
: never
|
|
>
|
|
|
|
export type UseLinkPropsOptions<
|
|
TRouter extends AnyRouter = RegisteredRouter,
|
|
TFrom extends RoutePaths<TRouter['routeTree']> | string = string,
|
|
TTo extends string | undefined = '.',
|
|
TMaskFrom extends RoutePaths<TRouter['routeTree']> | string = TFrom,
|
|
TMaskTo extends string = '.',
|
|
> = ActiveLinkOptions<'a', TRouter, TFrom, TTo, TMaskFrom, TMaskTo> &
|
|
UseLinkReactProps<'a'>
|
|
|
|
export type ActiveLinkOptions<
|
|
TComp = 'a',
|
|
TRouter extends AnyRouter = RegisteredRouter,
|
|
TFrom extends string = string,
|
|
TTo extends string | undefined = '.',
|
|
TMaskFrom extends string = TFrom,
|
|
TMaskTo extends string = '.',
|
|
> = LinkOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo> &
|
|
ActiveLinkOptionProps<TComp>
|
|
|
|
type ActiveLinkProps<TComp> = Partial<
|
|
LinkComponentReactProps<TComp> & {
|
|
[key: `data-${string}`]: unknown
|
|
}
|
|
>
|
|
|
|
export interface ActiveLinkOptionProps<TComp = 'a'> {
|
|
/**
|
|
* A function that returns additional props for the `active` state of this link.
|
|
* These props override other props passed to the link (`style`'s are merged, `className`'s are concatenated)
|
|
*/
|
|
activeProps?: ActiveLinkProps<TComp> | (() => ActiveLinkProps<TComp>)
|
|
/**
|
|
* A function that returns additional props for the `inactive` state of this link.
|
|
* These props override other props passed to the link (`style`'s are merged, `className`'s are concatenated)
|
|
*/
|
|
inactiveProps?: ActiveLinkProps<TComp> | (() => ActiveLinkProps<TComp>)
|
|
}
|
|
|
|
export type LinkProps<
|
|
TComp = 'a',
|
|
TRouter extends AnyRouter = RegisteredRouter,
|
|
TFrom extends string = string,
|
|
TTo extends string | undefined = '.',
|
|
TMaskFrom extends string = TFrom,
|
|
TMaskTo extends string = '.',
|
|
> = ActiveLinkOptions<TComp, TRouter, TFrom, TTo, TMaskFrom, TMaskTo> &
|
|
LinkPropsChildren
|
|
|
|
export interface LinkPropsChildren {
|
|
// If a function is passed as a child, it will be given the `isActive` boolean to aid in further styling on the element it returns
|
|
children?:
|
|
| React.ReactNode
|
|
| ((state: {
|
|
isActive: boolean
|
|
isTransitioning: boolean
|
|
}) => React.ReactNode)
|
|
}
|
|
|
|
type LinkComponentReactProps<TComp> = Omit<
|
|
UseLinkReactProps<TComp>,
|
|
keyof CreateLinkProps
|
|
>
|
|
|
|
export type LinkComponentProps<
|
|
TComp = 'a',
|
|
TRouter extends AnyRouter = RegisteredRouter,
|
|
TFrom extends string = string,
|
|
TTo extends string | undefined = '.',
|
|
TMaskFrom extends string = TFrom,
|
|
TMaskTo extends string = '.',
|
|
> = LinkComponentReactProps<TComp> &
|
|
LinkProps<TComp, TRouter, TFrom, TTo, TMaskFrom, TMaskTo>
|
|
|
|
export type CreateLinkProps = LinkProps<
|
|
any,
|
|
any,
|
|
string,
|
|
string,
|
|
string,
|
|
string
|
|
>
|
|
|
|
export type LinkComponent<TComp> = <
|
|
TRouter extends AnyRouter = RegisteredRouter,
|
|
const TFrom extends string = string,
|
|
const TTo extends string | undefined = undefined,
|
|
const TMaskFrom extends string = TFrom,
|
|
const TMaskTo extends string = '',
|
|
>(
|
|
props: LinkComponentProps<TComp, TRouter, TFrom, TTo, TMaskFrom, TMaskTo>,
|
|
) => React.ReactElement
|
|
|
|
export function createLink<const TComp>(
|
|
Comp: Constrain<TComp, any, (props: CreateLinkProps) => ReactNode>,
|
|
): LinkComponent<TComp> {
|
|
return React.forwardRef(function CreatedLink(props, ref) {
|
|
return <Link {...(props as any)} _asChild={Comp} ref={ref} />
|
|
}) as any
|
|
}
|
|
|
|
export const Link: LinkComponent<'a'> = React.forwardRef<Element, any>(
|
|
(props, ref) => {
|
|
const { _asChild, ...rest } = props
|
|
const {
|
|
type: _type,
|
|
ref: innerRef,
|
|
...linkProps
|
|
} = useLinkProps(rest as any, ref)
|
|
|
|
const children =
|
|
typeof rest.children === 'function'
|
|
? rest.children({
|
|
isActive: (linkProps as any)['data-status'] === 'active',
|
|
})
|
|
: rest.children
|
|
|
|
if (typeof _asChild === 'undefined') {
|
|
// the ReturnType of useLinkProps returns the correct type for a <a> element, not a general component that has a disabled prop
|
|
// @ts-expect-error
|
|
delete linkProps.disabled
|
|
}
|
|
|
|
return React.createElement(
|
|
_asChild ? _asChild : 'a',
|
|
{
|
|
...linkProps,
|
|
ref: innerRef,
|
|
},
|
|
children,
|
|
)
|
|
},
|
|
) as any
|
|
|
|
function isCtrlEvent(e: MouseEvent) {
|
|
return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)
|
|
}
|
|
|
|
export type LinkOptionsFnOptions<
|
|
TOptions,
|
|
TComp,
|
|
TRouter extends AnyRouter = RegisteredRouter,
|
|
> =
|
|
TOptions extends ReadonlyArray<any>
|
|
? ValidateLinkOptionsArray<TRouter, TOptions, string, TComp>
|
|
: ValidateLinkOptions<TRouter, TOptions, string, TComp>
|
|
|
|
export type LinkOptionsFn<TComp> = <
|
|
const TOptions,
|
|
TRouter extends AnyRouter = RegisteredRouter,
|
|
>(
|
|
options: LinkOptionsFnOptions<TOptions, TComp, TRouter>,
|
|
) => TOptions
|
|
|
|
export const linkOptions: LinkOptionsFn<'a'> = (options) => {
|
|
return options as any
|
|
}
|