@layr/browser-navigator
Version:
Provides a navigation system for a Layr app running in a browser
201 lines • 8.51 kB
JavaScript
import { Navigator, normalizeURL, stringifyURL } from '@layr/navigator';
import { SECOND } from '@layr/utilities';
import debounce from 'lodash/debounce';
import { possiblyAsync } from 'possibly-async';
/**
* *Inherits from [`Navigator`](https://layrjs.com/docs/v2/reference/navigator).*
*
* A [`Navigator`](https://layrjs.com/docs/v2/reference/navigator) relying on the browser's [History API](https://developer.mozilla.org/en-US/docs/Web/API/History) to determine the current [route](https://layrjs.com/docs/v2/reference/route).
*
* #### Usage
*
* If you are using [React](https://reactjs.org/), the easiest way to set up a `BrowserNavigator` in your app is to use the [`BrowserNavigatorView`](https://layrjs.com/docs/v2/reference/react-integration#browser-navigator-view-react-component) React component that is provided by the `@layr/react-integration` package.
*
* See an example of use in the [`BrowserNavigatorView`](https://layrjs.com/docs/v2/reference/react-integration#browser-navigator-view-react-component) React component.
*/
export class BrowserNavigator extends Navigator {
/**
* Creates a [`BrowserNavigator`](https://layrjs.com/docs/v2/reference/browser-navigator).
*
* @returns The [`BrowserNavigator`](https://layrjs.com/docs/v2/reference/browser-navigator) instance that was created.
*
* @category Creation
*/
constructor(options = {}) {
super(options);
this.afterConstruct();
}
mount() {
// --- 'popstate' event ---
this._popStateHandler = () => {
if (!this._ignorePopStateHandler) {
return possiblyAsync(!this.getIsBlocked() || this.confirmNavigation(), (canNavigate) => {
const cancelNavigation = (error) => {
return possiblyAsync(this._go(this.getInternalHistoryIndex() - this.getHistoryIndex()), () => {
this.setInternalHistoryIndex(this.getHistoryIndex());
if (error) {
throw error;
}
});
};
if (!canNavigate) {
return cancelNavigation();
}
return possiblyAsync(this.callBeforeNavigate(), () => {
this.setInternalHistoryIndex(this.getHistoryIndex());
this.callObservers();
}, (error) => {
return cancelNavigation(error);
});
});
}
};
this._ignorePopStateHandler = false;
window.addEventListener('popstate', this._popStateHandler);
// --- 'beforeunload' event ---
this._beforeUnloadHandler = (event) => {
if (this.getIsBlocked() || this.getBeforeNavigate() !== undefined) {
// Navigation is blocked or a `beforeNavigate` hook is set
event.preventDefault(); // Not supported in all browsers
// The following message will not be be displayed in modern browsers,
// but it is required to set it so Chrome can prevent navigation
event.returnValue =
'Are you sure you want to leave this page? Changes you made may not be saved.';
return event.returnValue;
}
};
window.addEventListener('beforeunload', this._beforeUnloadHandler, { capture: true });
// --- 'mouseup' event ---
this._mouseUpHandler = (event) => {
// Detect Ctrl+Click (Windows/Linux), Cmd+Click (Mac), Shift+Click (Popup) or Middle-Click
if (event.ctrlKey || event.metaKey || event.shiftKey || event.button === 1) {
this._openNewWindowUntil = Date.now() + 3 * SECOND;
this._openNewWindowPopup = event.shiftKey;
}
else {
this._openNewWindowUntil = undefined;
this._openNewWindowPopup = undefined;
}
};
window.addEventListener('mouseup', this._mouseUpHandler, { capture: true });
// --- 'layrNavigatorNavigate' event ---
this._navigateHandler = (event) => {
this.navigate(event.detail.url);
};
document.body.addEventListener('layrNavigatorNavigate', this._navigateHandler);
// --- Hash navigation fix ---
this._scrollToHash = debounce(() => {
const element = document.getElementById(this._expectedHash);
if (element !== null) {
element.scrollIntoView({ block: 'start', behavior: 'smooth' });
this._expectedHash = undefined;
}
}, 50);
this._mutationObserver = new MutationObserver(() => {
if (this._expectedHash !== undefined) {
this._scrollToHash();
}
});
this._mutationObserver.observe(document.body, {
attributes: true,
characterData: true,
childList: true,
subtree: true
});
this._fixScrollPosition();
}
unmount() {
window.removeEventListener('popstate', this._popStateHandler);
window.removeEventListener('beforeunload', this._beforeUnloadHandler, { capture: true });
window.removeEventListener('mouseup', this._mouseUpHandler, { capture: true });
document.body.removeEventListener('layrNavigatorNavigate', this._navigateHandler);
this._mutationObserver.disconnect();
}
// === Component Registration ===
/**
* See the methods that are inherited from the [`Navigator`](https://layrjs.com/docs/v2/reference/navigator#component-registration) class.
*
* @category Component Registration
*/
// === Routes ===
/**
* See the methods that are inherited from the [`Navigator`](https://layrjs.com/docs/v2/reference/navigator#routes) class.
*
* @category Routes
*/
// === Current Location ===
/**
* See the methods that are inherited from the [`Navigator`](https://layrjs.com/docs/v2/reference/navigator#current-location) class.
*
* @category Current Location
*/
_getCurrentURL() {
return normalizeURL(window.location.href);
}
// === Navigation ===
/**
* See the methods that are inherited from the [`Navigator`](https://layrjs.com/docs/v2/reference/navigator#navigation) class.
*
* @category Navigation
*/
_navigate(url) {
if (!this._possiblyOpenNewWindow(url)) {
window.history.pushState({ index: this._getHistoryIndex() + 1 }, '', stringifyURL(url));
this._fixScrollPosition();
}
}
_redirect(url) {
if (!this._possiblyOpenNewWindow(url)) {
window.history.replaceState({ index: this._getHistoryIndex() }, '', stringifyURL(url));
this._fixScrollPosition();
}
}
shouldOpenNewWindow() {
return this._openNewWindowUntil !== undefined && Date.now() < this._openNewWindowUntil;
}
_possiblyOpenNewWindow(url) {
if (this.shouldOpenNewWindow()) {
const isPopup = this._openNewWindowPopup === true;
this._openNewWindowUntil = undefined;
this._openNewWindowPopup = undefined;
// Try to open the URL in a new window
if (window.open(stringifyURL(url), '_blank', isPopup ? 'popup' : undefined)) {
return true;
}
}
return false;
}
_fixScrollPosition() {
window.scrollTo(0, 0);
const hash = this.getCurrentHash();
if (hash !== undefined) {
this._expectedHash = hash;
this._scrollToHash();
}
}
_reload(url) {
if (url !== undefined) {
window.location.replace(stringifyURL(url));
}
else {
window.location.reload();
}
}
_go(delta) {
return new Promise((resolve) => {
this._ignorePopStateHandler = true;
window.addEventListener('popstate', () => {
this._ignorePopStateHandler = false;
resolve();
}, { once: true });
window.history.go(delta);
});
}
_getHistoryLength() {
return window.history.length;
}
_getHistoryIndex() {
return (window.history.state?.index ?? 0);
}
}
//# sourceMappingURL=browser-navigator.js.map