UNPKG

@tanstack/vue-router

Version:

Modern and scalable routing for Vue applications

414 lines (413 loc) • 14.3 kB
import { useRouter } from "./useRouter.js"; import { linkLocationContext } from "./matchContext.js"; import { useIntersectionObserver } from "./utils.js"; import { deepEqual, getUrlScheme, hasKeys, isDangerousProtocol, preloadWarning, removeTrailingSlash } from "@tanstack/router-core"; import * as Vue from "vue"; import { isServer } from "@tanstack/router-core/isServer"; //#region src/link.tsx function useLinkProps(options) { return useLinkPropsImpl(() => options); } function useLinkPropsImpl(getOptions) { const router = useRouter(); let renderFetchedHref; if (!router) { console.warn("useRouter must be used inside a <RouterProvider> component!"); return {}; } if (isServer ?? router.isServer) { const options = getOptions(); if (getUrlScheme(`${options.to}`)) return Vue.shallowRef(getExternalLinkProps(options, router)); const next = router.buildLocation(options); const href = getHref(options, router, next); const isActive = !options.disabled && (href === void 0 || !!getUrlScheme(href)) ? false : getIsActive(router.stores.location.get(), next, options.activeOptions, router); const { resolvedProps, resolvedClass, resolvedStyle } = resolveStyleProps(options, isActive); const result = combineResultProps({ href, options, isActive, resolvedProps, resolvedClass, resolvedStyle }); return Vue.shallowRef(result); } const ref = Vue.ref(null); const isExternal = () => !!getUrlScheme(`${getOptions().to}`); const source = Vue.inject(linkLocationContext); const currentLocation = Vue.computed((prev) => prev?.href === source.value.href ? prev : source.value); const dest = Vue.computed((prev) => { const options = getOptions(); const dest = {}; let same = !!prev; for (const key of NAVIGATION_KEYS) { const value = dest[key] = options[key]; if (Vue.isProxy(value) || key === "mask" && value && Object.values(value).some(Vue.isProxy)) same = !currentLocation.value; same &&= prev[key] === value; } return same ? prev : dest; }); const navigateOptions = () => { const options = dest.value; options._fromLocation = source.value; return options; }; const state = Vue.computed((prev) => { const options = getOptions(); const destOptions = dest.value; destOptions._fromLocation = currentLocation.value; const next = router.buildLocation(destOptions); const href = getHref(options, router, next); const isActive = !options.disabled && (href === void 0 || !!getUrlScheme(href)) ? false : getIsActive(currentLocation.value, next, options.activeOptions, router); return prev && prev[0] === href && prev[1] === isActive ? prev : [href, isActive]; }); const preloadMode = Vue.computed(() => { const options = getOptions(); return !isExternal() && !options.reloadDocument && !options.disabled && (options.preload ?? router.options.defaultPreload); }); const preload = () => { const href = !isExternal() && state.value[0]; return !!href && !getUrlScheme(href) && preloadMode.value; }; const preloadDelay = () => getOptions().preloadDelay ?? router.options.defaultPreloadDelay ?? 0; const doPreload = () => { return router.preloadRoute(navigateOptions()).catch((err) => { console.warn(err); console.warn(preloadWarning); }); }; let pendingPreload; let preloadTimeout; const cancelPreload = () => { clearTimeout(preloadTimeout); preloadTimeout = pendingPreload = void 0; }; const enqueuePreload = (e) => { if (!e) { cancelPreload(); return; } const isIntersecting = e.isIntersecting; const preloadMode = isIntersecting === void 0 ? "intent" : "viewport"; if (preload() !== preloadMode || isIntersecting === false) { if (isIntersecting === false && pendingPreload === "viewport") cancelPreload(); return; } if (!preloadDelay()) { doPreload(); return; } if (!preloadTimeout) { const scheduledHref = state.value[0]; pendingPreload = preloadMode; preloadTimeout = setTimeout(() => { preloadTimeout = pendingPreload = void 0; if (preload() === preloadMode && state.value[0] === scheduledHref) doPreload(); }, preloadDelay()); } }; useIntersectionObserver(ref, enqueuePreload, () => preloadMode.value !== "viewport", () => !!preloadMode.value); Vue.watchEffect(() => { if (preloadMode.value !== "render" || !preload()) return; const nextHref = state.value[0]; if (nextHref && renderFetchedHref !== nextHref) { renderFetchedHref = nextHref; doPreload(); } }); const handleClick = (e) => { const options = getOptions(); const href = !isExternal() && state.value[0]; if (!options.disabled && (!href || getUrlScheme(href))) return; const elementTarget = e.currentTarget?.getAttribute("target"); const effectiveTarget = options.target !== void 0 ? options.target : elementTarget; if (!options.disabled && !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && !e.defaultPrevented && (!effectiveTarget || effectiveTarget === "_self") && e.button === 0) { if (options.reloadDocument) return; e.preventDefault(); enqueuePreload(); router.navigate(navigateOptions()); } }; const handleTouchStart = () => { if (preload() === "intent") doPreload(); }; const handleLeave = () => { if (pendingPreload === "intent") cancelPreload(); }; function composeEventHandlers(getUserHandler, handler) { return (event) => { getUserHandler()?.(event); handler(event); }; } const staticEventHandlers = { onClick: composeEventHandlers(() => getOptions().onClick, handleClick), onBlur: composeEventHandlers(() => getOptions().onBlur, handleLeave), onFocus: composeEventHandlers(() => getOptions().onFocus, enqueuePreload), onMouseenter: composeEventHandlers(() => getLinkEventHandlers(getOptions()).onMouseenter, enqueuePreload), onMouseover: composeEventHandlers(() => getLinkEventHandlers(getOptions()).onMouseover, enqueuePreload), onMouseleave: composeEventHandlers(() => getLinkEventHandlers(getOptions()).onMouseleave, handleLeave), onMouseout: composeEventHandlers(() => getLinkEventHandlers(getOptions()).onMouseout, handleLeave), onTouchstart: composeEventHandlers(() => getLinkEventHandlers(getOptions()).onTouchstart, handleTouchStart) }; return Vue.computed(() => { const options = getOptions(); if (isExternal()) return getExternalLinkProps(options, router, ref, staticEventHandlers); const [href, isActive] = state.value; const { resolvedProps, resolvedClass, resolvedStyle } = resolveStyleProps(options, isActive); return combineResultProps({ href, options, ref, staticEventHandlers, isActive, resolvedProps, resolvedClass, resolvedStyle }); }); } var NAVIGATION_KEYS = [ "to", "replace", "resetScroll", "hashScrollIntoView", "startTransition", "viewTransition", "ignoreBlocker", "params", "search", "hash", "state", "mask", "unsafeRelative", "from", "href" ]; function resolveStyleProps(options, isActive) { const props = (isActive ? options.activeProps : options.inactiveProps) || (isActive ? STATIC_ACTIVE_PROPS : EMPTY_OBJECT); const resolvedProps = (typeof props === "function" ? props() : props) || EMPTY_OBJECT; const baseClass = options.class; const stateClass = resolvedProps.class; const resolvedClass = baseClass ? stateClass ? [baseClass, stateClass] : baseClass : stateClass ? stateClass : void 0; const baseStyle = options.style; const stateStyle = resolvedProps.style; let resolvedStyle; if (baseStyle || stateStyle) { const style = {}; Object.assign(style, baseStyle, stateStyle); if (hasKeys(style)) resolvedStyle = style; } return { resolvedProps, resolvedClass, resolvedStyle }; } var STATIC_ACTIVE_PROPS = { class: "active" }; var EMPTY_OBJECT = {}; function combineResultProps({ href, options, isActive, resolvedProps, resolvedClass, resolvedStyle, ref, staticEventHandlers }) { const disabled = options.disabled || href === void 0; const result = { ...getPropsSafeToSpread(options), ref, ...staticEventHandlers, disabled: options._asChild ? disabled : void 0, target: options.target }; if (resolvedStyle) result.style = resolvedStyle; if (resolvedClass) result.class = resolvedClass; if (disabled) { result.role = "link"; result["aria-disabled"] = true; } if (isActive) { result["data-status"] = "active"; result["aria-current"] = "page"; } for (const key of Object.keys(resolvedProps)) if (key !== "class" && key !== "style") result[key] = resolvedProps[key]; result.href = href; return result; } function getExternalLinkProps(options, router, ref, staticEventHandlers) { const dangerous = isDangerousProtocol(options.to, router.protocolAllowlist); const disabled = options.disabled || dangerous; if (process.env.NODE_ENV !== "production" && dangerous) console.warn(`Blocked Link with dangerous protocol: ${options.to}`); const eventHandlers = getLinkEventHandlers(options); const result = { ...getPropsSafeToSpread(options), ref, href: disabled ? void 0 : options.to, target: options.target, disabled: options._asChild ? disabled : void 0, style: options.style, class: options.class, onClick: staticEventHandlers?.onClick ?? options.onClick, onBlur: staticEventHandlers?.onBlur ?? options.onBlur, onFocus: staticEventHandlers?.onFocus ?? options.onFocus, onMouseenter: staticEventHandlers?.onMouseenter ?? eventHandlers.onMouseenter, onMouseleave: staticEventHandlers?.onMouseleave ?? eventHandlers.onMouseleave, onMouseover: staticEventHandlers?.onMouseover ?? eventHandlers.onMouseover, onMouseout: staticEventHandlers?.onMouseout ?? eventHandlers.onMouseout, onTouchstart: staticEventHandlers?.onTouchstart ?? eventHandlers.onTouchstart }; if (disabled) { result.role = "link"; result["aria-disabled"] = true; } for (const key of Object.keys(result)) if (result[key] === void 0) delete result[key]; return result; } function getLinkEventHandlers(options) { return { onMouseenter: options.onMouseEnter ?? options.onMouseenter, onMouseleave: options.onMouseLeave ?? options.onMouseleave, onMouseover: options.onMouseOver ?? options.onMouseover, onMouseout: options.onMouseOut ?? options.onMouseout, onTouchstart: options.onTouchStart ?? options.onTouchstart }; } var LINK_OPTION_KEYS = /* @__PURE__ */ new Set([ ...NAVIGATION_KEYS, "activeProps", "inactiveProps", "activeOptions", "preload", "preloadDelay", "preloadIntentProximity", "children", "target", "disabled", "style", "class", "onClick", "onBlur", "onFocus", "onMouseEnter", "onMouseenter", "onMouseLeave", "onMouseleave", "onMouseOver", "onMouseover", "onMouseOut", "onMouseout", "onTouchStart", "onTouchstart", "reloadDocument", "_asChild", "additionalProps" ]); var getPropsSafeToSpread = (options) => { const propsSafeToSpread = {}; for (const key in options) if (!LINK_OPTION_KEYS.has(key)) propsSafeToSpread[key] = options[key]; return propsSafeToSpread; }; function getIsActive(loc, nextLoc, activeOptions, router) { const currentPath = removeTrailingSlash(loc.pathname, router.basepath); const nextPath = removeTrailingSlash(nextLoc.pathname, router.basepath); if (activeOptions?.exact ? currentPath !== nextPath : !(currentPath.startsWith(nextPath) && (currentPath.length === nextPath.length || currentPath[nextPath.length] === "/"))) return false; if (activeOptions?.includeSearch ?? true) { if (!deepEqual(loc.search, nextLoc.search, !activeOptions?.exact, activeOptions?.explicitUndefined)) return false; } if (activeOptions?.includeHash) return loc.hash === nextLoc.hash; return true; } function getHref(options, router, nextLocation) { if (options.disabled) return; const location = nextLocation?.maskedLocation ?? nextLocation; const publicHref = location?.publicHref; if (!publicHref) return void 0; const href = location?.external ? publicHref : router.history.createHref(publicHref) || "/"; if ((location?.external || href !== publicHref) && isDangerousProtocol(href, router.protocolAllowlist)) { if (process.env.NODE_ENV !== "production") console.warn(`Blocked Link with dangerous protocol: ${href}`); return; } return href; } function createLink(Comp) { return Vue.defineComponent({ name: "CreatedLink", inheritAttrs: false, setup(_, { attrs, slots }) { return () => Vue.h(LinkImpl, { ...attrs, _asChild: Comp }, slots); } }); } function useLinkPropsSnapshot(props, attrs) { const attrsSnapshot = Vue.shallowRef({ ...attrs }); Vue.onBeforeUpdate(() => { const keys = Object.keys(attrs); const previous = attrsSnapshot.value; if (keys.length !== Object.keys(previous).length || keys.some((key) => !Object.is(attrs[key], previous[key]))) attrsSnapshot.value = { ...attrs }; }); const allProps = Vue.computed(() => ({ ...props, ...attrsSnapshot.value })); return () => allProps.value; } var LinkImpl = Vue.defineComponent({ name: "Link", inheritAttrs: false, props: [ "_asChild", "to", "preload", "preloadDelay", "preloadIntentProximity", "activeProps", "inactiveProps", "activeOptions", "from", "search", "params", "hash", "state", "mask", "reloadDocument", "disabled", "additionalProps", "viewTransition", "resetScroll", "startTransition", "hashScrollIntoView", "replace", "ignoreBlocker", "target" ], setup(props, { attrs, slots }) { const linkPropsSource = useLinkPropsImpl(isServer ?? useRouter()?.isServer ? () => ({ ...props, ...attrs }) : useLinkPropsSnapshot(props, attrs)); return () => { const Component = props._asChild || "a"; const linkProps = Vue.unref(linkPropsSource); const isActive = linkProps["data-status"] === "active"; const slotContent = slots.default ? slots.default({ isActive }) : []; if (Component === "svg") { const svgLinkProps = { ...linkProps }; delete svgLinkProps.class; return Vue.h("svg", {}, [Vue.h("a", svgLinkProps, slotContent)]); } if (typeof Component !== "string") return Vue.h(Component, { ...linkProps, children: slotContent }, slotContent); return Vue.h(Component, { ...linkProps }, slotContent); }; } }); /** * Link component with proper TypeScript generics support */ var Link = LinkImpl; var linkOptions = (options) => { return options; }; //#endregion export { Link, createLink, linkOptions, useLinkProps }; //# sourceMappingURL=link.js.map