@ionic/core
Version:
Base components for Ionic
220 lines (219 loc) • 8.98 kB
JavaScript
/*!
* (C) Ionic http://ionicframework.com - MIT License
*/
import { Build } from "@stencil/core";
import { findClosestIonContent, getScrollElement, isIonContent } from "../../../utils/content/index";
import { componentOnReady, raf } from "../../../utils/helpers";
import { printIonError, printIonWarning } from "../../../utils/logging/index";
import { ROUTER_INTENT_NONE } from "./constants";
/**
* Activates the passed route chain.
*
* There must be exactly one outlet per route entry in the chain.
*
* The methods calls setRouteId on each of the outlet with the corresponding route entry in the chain.
* setRouteId will create or select the view in the outlet.
*/
export const writeNavState = async (root, chain, direction, index, changed = false, animation) => {
try {
// find next navigation outlet in the DOM
const outlet = searchNavNode(root);
// make sure we can continue interacting the DOM, otherwise abort
if (index >= chain.length || !outlet) {
return changed;
}
await new Promise((resolve) => componentOnReady(outlet, resolve));
const route = chain[index];
const result = await outlet.setRouteId(route.id, route.params, direction, animation);
// if the outlet changed the page, reset navigation to neutral (no direction)
// this means nested outlets will not animate
if (result.changed) {
direction = ROUTER_INTENT_NONE;
changed = true;
}
// recursively set nested outlets
changed = await writeNavState(result.element, chain, direction, index + 1, changed, animation);
// once all nested outlets are visible let's make the parent visible too,
// using markVisible prevents flickering
if (result.markVisible) {
await result.markVisible();
}
return changed;
}
catch (e) {
printIonError('[ion-router] - Exception in writeNavState:', e);
return false;
}
};
/**
* Recursively walks the outlet in the DOM.
*
* The function returns a list of RouteID corresponding to each of the outlet and the last outlet without a RouteID.
*/
export const readNavState = async (root) => {
const ids = [];
let outlet;
let node = root;
while ((outlet = searchNavNode(node))) {
const id = await outlet.getRouteId();
if (id) {
node = id.element;
id.element = undefined;
ids.push(id);
}
else {
break;
}
}
return { ids, outlet };
};
/** Max animation frames `scrollToFragment` polls while waiting for the target to mount. */
const FRAGMENT_POLL_FRAMES = 30;
/** Duration (ms) of the smooth-scroll animation that lands on the fragment target. */
const FRAGMENT_SCROLL_DURATION = 300;
const nextFrame = () => new Promise((resolve) => raf(() => resolve()));
/**
* Returns true when `el` lives inside an active `.ion-page`. `ion-page-hidden`
* marks nav back-stack entries; `tab-hidden` marks inactive `ion-tab` elements.
* Either class on the page's ancestor chain disqualifies it. When no `.ion-page`
* exists in the document at all (non-router pages), the candidate is accepted
* so plain anchors still work.
*/
const isInActivePage = (el) => {
const page = el.closest('.ion-page');
if (page === null) {
return document.querySelector('.ion-page') === null;
}
return page.closest('.ion-page-hidden, .tab-hidden') === null;
};
/**
* Polls across animation frames for an element matching `fragment` that lives
* in the active page. Scoping by "last `.ion-page:not(.ion-page-hidden)`" is
* unreliable: inactive `ion-tab` siblings carry `.ion-page` (gated by
* `.tab-hidden`, not `.ion-page-hidden`) and can be ordered after the leaf.
* Instead, locate candidates globally and walk them from last to first,
* accepting the deepest one whose `.ion-page` ancestor is not hidden. The
* last-to-first order preserves leaf-most preference for nested outlets.
*/
const findFragmentTarget = async (fragment, shouldContinue) => {
// CSS.escape is unavailable on very old WebViews; the fallback path uses
// `getElementById` and drops the legacy `<a name>` branch.
const canEscape = typeof CSS !== 'undefined' && typeof CSS.escape === 'function';
const escaped = canEscape ? CSS.escape(fragment) : null;
for (let i = 0; i < FRAGMENT_POLL_FRAMES; i++) {
if (!shouldContinue())
return null;
let candidates = [];
if (escaped !== null) {
try {
candidates = [...document.querySelectorAll(`#${escaped}, a[name="${escaped}"]`)];
}
catch {
candidates = [...document.querySelectorAll(`#${escaped}`)];
}
}
else {
const byId = document.getElementById(fragment);
if (byId !== null)
candidates = [byId];
}
for (let j = candidates.length - 1; j >= 0; j--) {
if (isInActivePage(candidates[j])) {
return candidates[j];
}
}
await nextFrame();
}
return null;
};
/**
* Scrolls to the element whose id matches `fragment`, falling back to a legacy
* `<a name="...">` target. When the target lives inside an `ion-content`, the
* scroll uses its smooth-animated scroll API; otherwise it falls back to
* `Element.scrollIntoView`.
*
* `shouldContinue` lets callers cancel in-flight scrolls when a newer
* navigation supersedes this one. It is checked between async steps.
*/
export const scrollToFragment = async (fragment, shouldContinue = () => true) => {
if (fragment == null || fragment === '') {
return false;
}
// URL fragments are percent-encoded but element ids are not; decode for
// matching per the HTML spec's indicated-element resolution.
let decoded;
try {
decoded = decodeURIComponent(fragment);
}
catch {
decoded = fragment;
}
const target = await findFragmentTarget(decoded, shouldContinue);
if (!target || !shouldContinue()) {
return false;
}
// Best-effort scroll: swallow exceptions if the page tears down mid-animation.
try {
const contentHost = findClosestIonContent(target);
if (contentHost && isIonContent(contentHost)) {
const content = contentHost;
const scrollEl = await getScrollElement(content);
// Yield one frame so the newly mounted target's layout is stable
// before we measure its rect.
await nextFrame();
if (!shouldContinue())
return false;
const targetRect = target.getBoundingClientRect();
const scrollRect = scrollEl.getBoundingClientRect();
const top = targetRect.top - scrollRect.top + scrollEl.scrollTop;
// Preserve scrollLeft so RTL and horizontally-scrolling pages aren't reset.
await content.scrollToPoint(scrollEl.scrollLeft, top, FRAGMENT_SCROLL_DURATION);
}
else {
target.scrollIntoView({ behavior: 'smooth' });
}
return true;
}
catch (e) {
printIonError('[ion-router] - Exception in scrollToFragment:', e);
return false;
}
};
/** How long the router waits for a navigation outlet before giving up and proceeding. */
const NAV_NODE_TIMEOUT = 500;
export const waitUntilNavNode = () => {
if (searchNavNode(document.body)) {
return Promise.resolve();
}
return new Promise((resolve) => {
/**
* Outlets emit `ionNavWillLoad` as they load, so resolve as soon as one does.
* A timeout backstops the case where the page has no outlet at all: without it
* the router would wait forever and its `componentWillLoad` would never settle,
* which in turn prevents the app from signalling that it has finished loading.
*/
const done = () => {
window.removeEventListener('ionNavWillLoad', done);
clearTimeout(timeout);
if (Build.isDev && !searchNavNode(document.body)) {
printIonWarning('[ion-router] - No outlet found. The router requires an ion-router-outlet or ion-tabs to render routed views. ' +
'Note that as of Ionic 9 ion-nav is no longer a router outlet.');
}
resolve();
};
const timeout = setTimeout(done, NAV_NODE_TIMEOUT);
window.addEventListener('ionNavWillLoad', done, { once: true });
});
};
/** Selector for all the outlets supported by the router. */
const OUTLET_SELECTOR = ':not([no-router]) ion-tabs, :not([no-router]) ion-router-outlet';
const searchNavNode = (root) => {
if (!root) {
return undefined;
}
if (root.matches(OUTLET_SELECTOR)) {
return root;
}
const outlet = root.querySelector(OUTLET_SELECTOR);
return outlet ?? undefined;
};