@tanstack/vue-router
Version:
Modern and scalable routing for Vue applications
414 lines (413 loc) • 14.3 kB
JavaScript
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