mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-18 03:00:58 +08:00

This patch includes some necessary updates for `@stencil/vue-output-target@v0.9.0`: - we started to export Stencils helpers as runtime via `@stencil/vue-output-target/runtime` similar to what we did in React - this version requires some updates to Vue and TypeScript as well - adjustments related to that update
607 lines
20 KiB
TypeScript
607 lines
20 KiB
TypeScript
import type { AnimationBuilder } from "@ionic/core/components";
|
|
import {
|
|
LIFECYCLE_DID_ENTER,
|
|
LIFECYCLE_DID_LEAVE,
|
|
LIFECYCLE_WILL_ENTER,
|
|
LIFECYCLE_WILL_LEAVE,
|
|
} from "@ionic/core/components";
|
|
import { defineCustomElement } from "@ionic/core/components/ion-router-outlet.js";
|
|
import {
|
|
h,
|
|
defineComponent,
|
|
ref,
|
|
computed,
|
|
inject,
|
|
provide,
|
|
watch,
|
|
shallowRef,
|
|
onUnmounted,
|
|
} from "vue";
|
|
import type { InjectionKey, Ref } from "vue";
|
|
import { matchedRouteKey, routeLocationKey, useRoute } from "vue-router";
|
|
|
|
import { fireLifecycle, generateId, getConfig } from "../utils";
|
|
|
|
// TODO(FW-2969): types
|
|
|
|
const isViewVisible = (enteringEl: HTMLElement) => {
|
|
return (
|
|
!enteringEl.classList.contains("ion-page-hidden") &&
|
|
!enteringEl.classList.contains("ion-page-invisible")
|
|
);
|
|
};
|
|
|
|
const viewDepthKey: InjectionKey<0> = Symbol(0);
|
|
export const IonRouterOutlet = /*@__PURE__*/ defineComponent({
|
|
name: "IonRouterOutlet",
|
|
setup() {
|
|
defineCustomElement();
|
|
|
|
const injectedRoute = inject(routeLocationKey)!;
|
|
const route = useRoute();
|
|
const depth = inject(viewDepthKey, 0);
|
|
const matchedRouteRef: any = computed(() => route.matched[depth]);
|
|
let previousMatchedRouteRef: Ref | undefined;
|
|
let previousMatchedPath: string | undefined;
|
|
|
|
provide(viewDepthKey, (depth + 1) as 0);
|
|
provide(matchedRouteKey, matchedRouteRef);
|
|
|
|
const ionRouterOutlet = ref();
|
|
const id = generateId("ion-router-outlet");
|
|
|
|
const ionRouter: any = inject("navManager");
|
|
const viewStacks: any = inject("viewStacks");
|
|
|
|
const components = shallowRef([]);
|
|
|
|
let skipTransition = false;
|
|
|
|
// The base url for this router outlet
|
|
let parentOutletPath: string;
|
|
|
|
/**
|
|
* Note: Do not listen for matchedRouteRef by itself here
|
|
* as the callback will not fire for parameterized routes (i.e. /page/:id).
|
|
* So going from /page/1 to /page/2 would not fire this callback if we
|
|
* only listened for changes to matchedRouteRef.
|
|
*/
|
|
watch(
|
|
() => [route, matchedRouteRef.value],
|
|
([currentRoute, currentMatchedRouteRef]) => {
|
|
/**
|
|
* This callback checks whether or not a router outlet
|
|
* needs to respond to a change in the matched route.
|
|
* It handles a few cases:
|
|
* 1. The matched route is undefined. This means that
|
|
* the matched route is not applicable to this outlet.
|
|
* For example, a /settings route is not applicable
|
|
* to a /tabs/... route.
|
|
*
|
|
* Note: When going back to a tabs outlet from a non-tabs outlet,
|
|
* the tabs outlet should NOT attempt a page transition from the
|
|
* previous tab to the active tab. To do this we compare the current
|
|
* route with the previous route. Unfortunately, we cannot rely on the
|
|
* previous value provided by Vue in the watch callback. This is because
|
|
* when coming back to the tabs context, the previous matched route will
|
|
* be undefined (because nothing in the tabs context matches /settings)
|
|
* but the current matched route will be defined and so a transition
|
|
* will always occur.
|
|
*
|
|
* 2. The matched route is defined and is different than
|
|
* the previously matched route. This is the most
|
|
* common case such as when you go from /page1 to /page2.
|
|
*
|
|
* 3. The matched route is the same but the parameters are different.
|
|
* This is a special case for parameterized routes (i.e. /page/:id).
|
|
* When going from /page/1 to /page/2, the matched route object will
|
|
* be the same, but we still need to perform a page transition. To do this
|
|
* we check if the parameters are different (i.e. 1 vs 2). To avoid enumerating
|
|
* all of the keys in the params object, we check the url path to
|
|
* see if they are different after ensuring we are in a parameterized route.
|
|
*/
|
|
if (currentMatchedRouteRef !== undefined) {
|
|
const matchedDifferentRoutes =
|
|
currentMatchedRouteRef !== previousMatchedRouteRef;
|
|
const matchedDifferentParameterRoutes =
|
|
currentRoute.matched[currentRoute.matched.length - 1] ===
|
|
currentMatchedRouteRef &&
|
|
currentRoute.path !== previousMatchedPath;
|
|
|
|
if (matchedDifferentRoutes || matchedDifferentParameterRoutes) {
|
|
setupViewItem(matchedRouteRef);
|
|
}
|
|
}
|
|
|
|
previousMatchedRouteRef = currentMatchedRouteRef;
|
|
previousMatchedPath = currentRoute.path;
|
|
},
|
|
/**
|
|
* Future versions of Vue may default watching nested
|
|
* reactive objects to "deep: false".
|
|
* We explicitly set this watcher to "deep: true" to
|
|
* account for that.
|
|
* https://github.com/vuejs/core/issues/9965#issuecomment-1875067499
|
|
*/
|
|
{ deep: true }
|
|
);
|
|
|
|
const canStart = () => {
|
|
const config = getConfig();
|
|
const swipeEnabled =
|
|
config &&
|
|
config.get("swipeBackEnabled", ionRouterOutlet.value.mode === "ios");
|
|
if (!swipeEnabled) return false;
|
|
|
|
const stack = viewStacks.getViewStack(id);
|
|
if (!stack || stack.length <= 1) return false;
|
|
|
|
/**
|
|
* We only want to outlet of the entering view
|
|
* to respond to this gesture, so check
|
|
* to make sure the view is in the outlet we want.
|
|
*/
|
|
const routeInfo = ionRouter.getLeavingRouteInfo();
|
|
const enteringViewItem = viewStacks.findViewItemByRouteInfo(
|
|
{ pathname: routeInfo.pushedByRoute || "" },
|
|
id
|
|
);
|
|
|
|
return !!enteringViewItem;
|
|
};
|
|
const onStart = async () => {
|
|
const routeInfo = ionRouter.getLeavingRouteInfo();
|
|
const { routerAnimation } = routeInfo;
|
|
const enteringViewItem = viewStacks.findViewItemByRouteInfo(
|
|
{ pathname: routeInfo.pushedByRoute || "" },
|
|
id
|
|
);
|
|
const leavingViewItem = viewStacks.findViewItemByRouteInfo(routeInfo, id);
|
|
|
|
if (leavingViewItem) {
|
|
let animationBuilder = routerAnimation;
|
|
const enteringEl = enteringViewItem.ionPageElement;
|
|
const leavingEl = leavingViewItem.ionPageElement;
|
|
|
|
/**
|
|
* If we are going back from a page that
|
|
* was presented using a custom animation
|
|
* we should default to using that
|
|
* unless the developer explicitly
|
|
* provided another animation.
|
|
*/
|
|
const customAnimation = enteringViewItem.routerAnimation;
|
|
if (animationBuilder === undefined && customAnimation !== undefined) {
|
|
animationBuilder = customAnimation;
|
|
}
|
|
|
|
leavingViewItem.routerAnimation = animationBuilder;
|
|
|
|
await transition(
|
|
enteringEl,
|
|
leavingEl,
|
|
"back",
|
|
ionRouter.canGoBack(2),
|
|
true,
|
|
animationBuilder
|
|
);
|
|
}
|
|
|
|
return Promise.resolve();
|
|
};
|
|
|
|
const onEnd = (shouldContinue: boolean) => {
|
|
if (shouldContinue) {
|
|
skipTransition = true;
|
|
|
|
/**
|
|
* Use the same logic as clicking
|
|
* ion-back-button to determine where
|
|
* to go back to.
|
|
*/
|
|
ionRouter.handleNavigateBack();
|
|
} else {
|
|
/**
|
|
* In the event that the swipe
|
|
* gesture was aborted, we should
|
|
* re-hide the page that was going to enter.
|
|
*/
|
|
const routeInfo = ionRouter.getCurrentRouteInfo();
|
|
const enteringViewItem = viewStacks.findViewItemByRouteInfo(
|
|
{ pathname: routeInfo.pushedByRoute || "" },
|
|
id
|
|
);
|
|
enteringViewItem.ionPageElement.setAttribute("aria-hidden", "true");
|
|
enteringViewItem.ionPageElement.classList.add("ion-page-hidden");
|
|
}
|
|
};
|
|
|
|
watch(ionRouterOutlet, () => {
|
|
ionRouterOutlet.value.swipeHandler = {
|
|
canStart,
|
|
onStart,
|
|
onEnd,
|
|
};
|
|
});
|
|
|
|
const transition = async (
|
|
enteringEl: HTMLElement,
|
|
leavingEl: HTMLElement,
|
|
direction: any,
|
|
showGoBack: boolean,
|
|
progressAnimation: boolean,
|
|
animationBuilder?: AnimationBuilder
|
|
) => {
|
|
if (skipTransition) {
|
|
skipTransition = false;
|
|
return Promise.resolve(false);
|
|
}
|
|
|
|
if (enteringEl === leavingEl) {
|
|
return Promise.resolve(false);
|
|
}
|
|
|
|
enteringEl.classList.add("ion-page-invisible");
|
|
|
|
const hasRootDirection =
|
|
direction === undefined || direction === "root" || direction === "none";
|
|
const result = await ionRouterOutlet.value.commit(enteringEl, leavingEl, {
|
|
/**
|
|
* replace operations result in a direction of none.
|
|
* These typically do not have need animations, so we set
|
|
* the duration to 0. However, if a developer explicitly
|
|
* passes an animationBuilder, we should assume that
|
|
* they want an animation to be played even
|
|
* though it is a replace operation.
|
|
*/
|
|
duration:
|
|
hasRootDirection && animationBuilder === undefined ? 0 : undefined,
|
|
direction,
|
|
showGoBack,
|
|
progressAnimation,
|
|
animationBuilder,
|
|
});
|
|
|
|
return result;
|
|
};
|
|
|
|
const handlePageTransition = async () => {
|
|
const routeInfo = ionRouter.getCurrentRouteInfo();
|
|
const {
|
|
routerDirection,
|
|
routerAction,
|
|
routerAnimation,
|
|
prevRouteLastPathname,
|
|
delta,
|
|
} = routeInfo;
|
|
|
|
const enteringViewItem = viewStacks.findViewItemByRouteInfo(
|
|
routeInfo,
|
|
id
|
|
);
|
|
let leavingViewItem = viewStacks.findLeavingViewItemByRouteInfo(
|
|
routeInfo,
|
|
id
|
|
);
|
|
const enteringEl = enteringViewItem.ionPageElement;
|
|
|
|
/**
|
|
* All views that can be transitioned to must have
|
|
* an `<ion-page>` element for transitions and lifecycle
|
|
* methods to work properly.
|
|
*/
|
|
if (enteringEl === undefined) {
|
|
console.warn(`[@ionic/vue Warning]: The view you are trying to render for path ${routeInfo.pathname} does not have the required <ion-page> component. Transitions and lifecycle methods may not work as expected.
|
|
|
|
See https://ionicframework.com/docs/vue/navigation#ionpage for more information.`);
|
|
}
|
|
if (enteringViewItem === leavingViewItem) return;
|
|
|
|
if (!leavingViewItem && prevRouteLastPathname) {
|
|
leavingViewItem = viewStacks.findViewItemByPathname(
|
|
prevRouteLastPathname,
|
|
id
|
|
);
|
|
}
|
|
|
|
/**
|
|
* If the entering view is already
|
|
* visible, then no transition is needed.
|
|
* This is most common when navigating
|
|
* from a tabs page to a non-tabs page
|
|
* and then back to the tabs page.
|
|
* Even when the tabs context navigated away,
|
|
* the inner tabs page was still active.
|
|
* This also avoids an issue where
|
|
* the previous tabs page is incorrectly
|
|
* unmounted since it would automatically
|
|
* unmount the previous view.
|
|
*
|
|
* This should also only apply to entering and
|
|
* leaving items in the same router outlet (i.e.
|
|
* Tab1 and Tab2), otherwise this will
|
|
* return early for swipe to go back when
|
|
* going from a non-tabs page to a tabs page.
|
|
*/
|
|
if (
|
|
isViewVisible(enteringEl) &&
|
|
leavingViewItem?.ionPageElement !== undefined &&
|
|
!isViewVisible(leavingViewItem.ionPageElement)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
fireLifecycle(
|
|
enteringViewItem.vueComponent,
|
|
enteringViewItem.vueComponentRef,
|
|
LIFECYCLE_WILL_ENTER
|
|
);
|
|
|
|
if (
|
|
leavingViewItem?.ionPageElement &&
|
|
enteringViewItem !== leavingViewItem
|
|
) {
|
|
let animationBuilder = routerAnimation;
|
|
const leavingEl = leavingViewItem.ionPageElement;
|
|
|
|
fireLifecycle(
|
|
leavingViewItem.vueComponent,
|
|
leavingViewItem.vueComponentRef,
|
|
LIFECYCLE_WILL_LEAVE
|
|
);
|
|
|
|
/**
|
|
* If we are going back from a page that
|
|
* was presented using a custom animation
|
|
* we should default to using that
|
|
* unless the developer explicitly
|
|
* provided another animation.
|
|
*/
|
|
const customAnimation = enteringViewItem.routerAnimation;
|
|
if (
|
|
animationBuilder === undefined &&
|
|
routerDirection === "back" &&
|
|
customAnimation !== undefined
|
|
) {
|
|
animationBuilder = customAnimation;
|
|
}
|
|
|
|
leavingViewItem.routerAnimation = animationBuilder;
|
|
|
|
await transition(
|
|
enteringEl,
|
|
leavingEl,
|
|
routerDirection,
|
|
!!routeInfo.pushedByRoute,
|
|
false,
|
|
animationBuilder
|
|
);
|
|
|
|
leavingEl.classList.add("ion-page-hidden");
|
|
leavingEl.setAttribute("aria-hidden", "true");
|
|
|
|
const usingLinearNavigation = viewStacks.size() === 1;
|
|
|
|
if (routerAction === "replace") {
|
|
leavingViewItem.mount = false;
|
|
leavingViewItem.ionPageElement = undefined;
|
|
leavingViewItem.ionRoute = false;
|
|
} else if (
|
|
!(routerAction === "push" && routerDirection === "forward")
|
|
) {
|
|
const shouldLeavingViewBeRemoved =
|
|
routerDirection !== "none" &&
|
|
leavingViewItem &&
|
|
enteringViewItem !== leavingViewItem;
|
|
if (shouldLeavingViewBeRemoved) {
|
|
leavingViewItem.mount = false;
|
|
leavingViewItem.ionPageElement = undefined;
|
|
leavingViewItem.ionRoute = false;
|
|
|
|
/**
|
|
* router.go() expects navigation to be
|
|
* linear. If an app is using multiple stacks then
|
|
* it is not using linear navigation. As a result, router.go()
|
|
* will not give the results that developers are expecting.
|
|
*/
|
|
if (usingLinearNavigation) {
|
|
viewStacks.unmountLeavingViews(id, enteringViewItem, delta);
|
|
}
|
|
}
|
|
} else if (usingLinearNavigation) {
|
|
viewStacks.mountIntermediaryViews(id, leavingViewItem, delta);
|
|
}
|
|
|
|
fireLifecycle(
|
|
leavingViewItem.vueComponent,
|
|
leavingViewItem.vueComponentRef,
|
|
LIFECYCLE_DID_LEAVE
|
|
);
|
|
} else {
|
|
/**
|
|
* If there is no leaving element, just show
|
|
* the entering element. Wrap it in an raf
|
|
* in case ion-content's fullscreen callback
|
|
* is running. Otherwise we'd have a flicker.
|
|
*/
|
|
requestAnimationFrame(() =>
|
|
enteringEl.classList.remove("ion-page-invisible")
|
|
);
|
|
}
|
|
|
|
fireLifecycle(
|
|
enteringViewItem.vueComponent,
|
|
enteringViewItem.vueComponentRef,
|
|
LIFECYCLE_DID_ENTER
|
|
);
|
|
|
|
components.value = viewStacks.getChildrenToRender(id);
|
|
};
|
|
|
|
const setupViewItem = (matchedRouteRef: any) => {
|
|
const firstMatchedRoute = route.matched[0];
|
|
if (!parentOutletPath) {
|
|
parentOutletPath = firstMatchedRoute.path;
|
|
}
|
|
|
|
/**
|
|
* If no matched route, do not do anything in this outlet.
|
|
* If there is a match, but it the first matched path
|
|
* is not the root path for this outlet, then this view
|
|
* change needs to be rendered in a different outlet.
|
|
* We also add an exception for when the matchedRouteRef is
|
|
* equal to the first matched route (i.e. the base router outlet).
|
|
* This logic is mainly to help nested outlets/multi-tab
|
|
* setups work better.
|
|
*/
|
|
if (
|
|
!matchedRouteRef.value ||
|
|
(matchedRouteRef.value !== firstMatchedRoute &&
|
|
firstMatchedRoute.path !== parentOutletPath)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const currentRoute = ionRouter.getCurrentRouteInfo();
|
|
let enteringViewItem = viewStacks.findViewItemByRouteInfo(
|
|
currentRoute,
|
|
id
|
|
);
|
|
|
|
if (!enteringViewItem) {
|
|
enteringViewItem = viewStacks.createViewItem(
|
|
id,
|
|
matchedRouteRef.value.components.default,
|
|
matchedRouteRef.value,
|
|
currentRoute
|
|
);
|
|
viewStacks.add(enteringViewItem);
|
|
}
|
|
|
|
if (!enteringViewItem.mount) {
|
|
enteringViewItem.mount = true;
|
|
enteringViewItem.registerCallback = () => {
|
|
handlePageTransition();
|
|
enteringViewItem.registerCallback = undefined;
|
|
};
|
|
} else {
|
|
handlePageTransition();
|
|
}
|
|
|
|
components.value = viewStacks.getChildrenToRender(id);
|
|
};
|
|
|
|
if (matchedRouteRef.value) {
|
|
setupViewItem(matchedRouteRef);
|
|
}
|
|
|
|
/**
|
|
* Remove stack data for this outlet
|
|
* when outlet is destroyed otherwise
|
|
* we will see cached view data.
|
|
*/
|
|
onUnmounted(() => viewStacks.clear(id));
|
|
|
|
const registerIonPage = (viewItem: any, ionPageEl: HTMLElement) => {
|
|
const oldIonPageEl = viewItem.ionPageElement;
|
|
|
|
viewStacks.registerIonPage(viewItem, ionPageEl);
|
|
|
|
/**
|
|
* If there is a registerCallback,
|
|
* then this component is being registered
|
|
* as a result of a navigation change.
|
|
*/
|
|
if (viewItem.registerCallback) {
|
|
/**
|
|
* Page should be hidden initially
|
|
* to avoid flickering.
|
|
*/
|
|
ionPageEl.classList.add("ion-page-invisible");
|
|
viewItem.registerCallback();
|
|
|
|
/**
|
|
* If there is no registerCallback, then
|
|
* this component is likely being re-registered
|
|
* as a result of a hot module replacement.
|
|
* We need to see if the oldIonPageEl has
|
|
* .ion-page-invisible. If it does not then we
|
|
* need to remove it from the new ionPageEl otherwise
|
|
* the page will be hidden when it is replaced.
|
|
*/
|
|
} else if (
|
|
oldIonPageEl &&
|
|
!oldIonPageEl.classList.contains("ion-page-invisible")
|
|
) {
|
|
ionPageEl.classList.remove("ion-page-invisible");
|
|
}
|
|
};
|
|
return {
|
|
id,
|
|
components,
|
|
injectedRoute,
|
|
ionRouterOutlet,
|
|
registerIonPage,
|
|
};
|
|
},
|
|
render() {
|
|
const { components, registerIonPage, injectedRoute } = this;
|
|
|
|
return h(
|
|
"ion-router-outlet",
|
|
{ ref: "ionRouterOutlet" },
|
|
components &&
|
|
components.map((c: any) => {
|
|
let props = {
|
|
ref: c.vueComponentRef,
|
|
key: c.pathname,
|
|
registerIonPage: (ionPageEl: HTMLElement) =>
|
|
registerIonPage(c, ionPageEl),
|
|
};
|
|
|
|
/**
|
|
* IonRouterOutlet does not support named outlets.
|
|
*/
|
|
const routePropsOption = c.matchedRoute?.props?.default;
|
|
|
|
/**
|
|
* Since IonRouterOutlet renders multiple components,
|
|
* each render will cause all props functions to be
|
|
* called again. As a result, we need to cache the function
|
|
* result and provide it on each render so that the props
|
|
* are not lost when navigating from and back to a page.
|
|
* When a component is destroyed and re-created, the
|
|
* function is called again.
|
|
*/
|
|
const getPropsFunctionResult = () => {
|
|
const cachedPropsResult = c.vueComponentData?.propsFunctionResult;
|
|
if (cachedPropsResult) {
|
|
return cachedPropsResult;
|
|
} else {
|
|
const propsFunctionResult = routePropsOption(injectedRoute);
|
|
c.vueComponentData = {
|
|
...c.vueComponentData,
|
|
propsFunctionResult,
|
|
};
|
|
return propsFunctionResult;
|
|
}
|
|
};
|
|
const routeProps = routePropsOption
|
|
? routePropsOption === true
|
|
? c.params
|
|
: typeof routePropsOption === "function"
|
|
? getPropsFunctionResult()
|
|
: routePropsOption
|
|
: null;
|
|
|
|
props = {
|
|
...props,
|
|
...routeProps,
|
|
};
|
|
|
|
return h(c.vueComponent, props);
|
|
})
|
|
);
|
|
},
|
|
});
|