UNPKG

@tanstack/vue-router

Version:

Modern and scalable routing for Vue applications

375 lines (374 loc) 14.8 kB
import { useRouter } from "./useRouter.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"; import { useSelector } from "@tanstack/vue-store"; //#region src/link.tsx var timeoutMap = /* @__PURE__ */ new WeakMap(); 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 Vue.computed(() => ({})); } const ref = Vue.ref(null); if (isServer ?? router.isServer) { const options = getOptions(); if (getUrlScheme(`${options.to}`)) return Vue.ref(getExternalLinkProps(options, router, ref)); 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.ref(result); } const isExternal = Vue.computed(() => !!getUrlScheme(`${getOptions().to}`)); const currentLocation = isExternal.value ? Vue.shallowRef(router.stores.location.get()) : useSelector(router.stores.location, (l) => l, { compare: (prev, next) => prev.href === next.href }); if (isExternal.value) Vue.watchEffect((onCleanup) => { if (isExternal.value) return; const store = router.stores.location; currentLocation.value = store.get(); const subscription = store.subscribe((location) => { if (currentLocation.value.href !== location.href) currentLocation.value = location; }); onCleanup(() => subscription.unsubscribe()); }); const next = Vue.computed(() => { const options = getOptions(); const opts = { _fromLocation: currentLocation.value, ...options }; return router.buildLocation(opts); }); const href = Vue.computed(() => { return getHref(getOptions(), router, next.value); }); const preload = Vue.computed(() => { const options = getOptions(); if (isExternal.value || !options.disabled && (href.value === void 0 || !!getUrlScheme(href.value)) || options.reloadDocument || options.disabled) return false; return options.preload ?? router.options.defaultPreload; }); const preloadDelay = Vue.computed(() => getOptions().preloadDelay ?? router.options.defaultPreloadDelay ?? 0); const isActive = Vue.computed(() => { const options = getOptions(); if (isExternal.value || !options.disabled && (href.value === void 0 || !!getUrlScheme(href.value))) return false; return getIsActive(currentLocation.value, next.value, options.activeOptions, router); }); const doPreload = () => { const options = getOptions(); return router.preloadRoute(options).catch((err) => { console.warn(err); console.warn(preloadWarning); }); }; let pendingPreload; const enqueuePreload = (e) => { if (!e) { clearTimeout(timeoutMap.get(ref)); timeoutMap.delete(ref); pendingPreload = void 0; return; } const isIntersecting = e.isIntersecting; const preloadMode = isIntersecting === void 0 ? "intent" : "viewport"; if (preload.value !== preloadMode || isIntersecting === false) { if (isIntersecting === false && pendingPreload === "viewport") { clearTimeout(timeoutMap.get(ref)); timeoutMap.delete(ref); pendingPreload = void 0; } return; } if (!preloadDelay.value) { doPreload(); return; } if (!timeoutMap.has(ref)) { const scheduledHref = next.value.href; pendingPreload = preloadMode; timeoutMap.set(ref, setTimeout(() => { timeoutMap.delete(ref); pendingPreload = void 0; if (preload.value === preloadMode && next.value.href === scheduledHref) doPreload(); }, preloadDelay.value)); } }; useIntersectionObserver(ref, enqueuePreload, () => preload.value !== "viewport"); Vue.watchEffect(() => { if (preload.value !== "render") return; const nextHref = next.value.href; if (nextHref && renderFetchedHref !== nextHref) { renderFetchedHref = nextHref; doPreload(); } }); const handleClick = (e) => { const options = getOptions(); if (isExternal.value || !options.disabled && (href.value === void 0 || !!getUrlScheme(href.value))) 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(); router.navigate({ ...options, replace: options.replace, resetScroll: options.resetScroll, hashScrollIntoView: options.hashScrollIntoView, startTransition: options.startTransition, viewTransition: options.viewTransition, ignoreBlocker: options.ignoreBlocker }); } }; const handleTouchStart = () => { if (preload.value === "intent") doPreload(); }; const handleLeave = () => { if (pendingPreload === "intent") { clearTimeout(timeoutMap.get(ref)); timeoutMap.delete(ref); pendingPreload = void 0; } }; function composeEventHandlers(getUserHandler, handler) { return (event) => { getUserHandler()?.(event); handler(event); }; } const resolvedStyleProps = Vue.computed(() => { return resolveStyleProps(getOptions(), isActive.value); }); 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.value) return getExternalLinkProps(options, router, ref, staticEventHandlers); const { resolvedProps, resolvedClass, resolvedStyle } = resolvedStyleProps.value; return combineResultProps({ href: href.value, options, ref, staticEventHandlers, isActive: isActive.value, resolvedProps, resolvedClass, resolvedStyle }); }); } 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 getPropsSafeToSpread = (options) => { const { activeProps: _activeProps, inactiveProps: _inactiveProps, activeOptions: _activeOptions, to: _to, preload: _preload, preloadDelay: _preloadDelay, preloadIntentProximity: _preloadIntentProximity, hashScrollIntoView: _hashScrollIntoView, replace: _replace, startTransition: _startTransition, resetScroll: _resetScroll, viewTransition: _viewTransition, children: _children, target: _target, disabled: _disabled, style: _style, class: _class, onClick: _onClick, onBlur: _onBlur, onFocus: _onFocus, onMouseEnter: _onMouseEnter, onMouseenter: _onMouseenter, onMouseLeave: _onMouseLeave, onMouseleave: _onMouseleave, onMouseOver: _onMouseOver, onMouseover: _onMouseover, onMouseOut: _onMouseOut, onMouseout: _onMouseout, onTouchStart: _onTouchStart, onTouchstart: _onTouchstart, ignoreBlocker: _ignoreBlocker, params: _params, search: _search, hash: _hash, state: _state, mask: _mask, reloadDocument: _reloadDocument, unsafeRelative: _unsafeRelative, _asChild: __asChild, from: _from, additionalProps: _additionalProps, ...propsSafeToSpread } = options; 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, { partial: !activeOptions?.exact, ignoreUndefined: !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); } }); } 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 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 })); const linkPropsSource = useLinkPropsImpl(() => allProps.value); 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