Source: navigation.js

/*!
 * Module Navigation
 */

/**
 * @namespace Navigation
 */

const MODULE_NAME = 'Navigation';



//###[ IMPORTS ]########################################################################################################

import {warn} from './logging.js';
import {hasValue, orDefault, isPlainObject, isArray, isWindow, isFunction, assert} from './basic.js';
import {createNode} from './elements.js';
import {browserSupportsHistoryManipulation} from './context.js';
import {urlHref} from './urls.js';



//###[ DATA ]###########################################################################################################

export const HISTORY = {
	current : {
		state : null,
		title : '',
		...getHostAndPathname()
	},
	popState : {
		listening : false,
		callbacks : [],
		handler(e){
			const historyNew = {
				state : e.state,
				title : e.title,
				...getHostAndPathname()
			};

			HISTORY.popState.callbacks.forEach(cb => {
				cb.stateful(e, historyNew);
			});

			HISTORY.current = historyNew;
		}
	}
};



//###[ HELPERS ]########################################################################################################

function getHostAndPathname(){
	const hostAndPathname = {
		host : undefined,
		pathname : undefined
	};

	try {
		hostAndPathname.host = window.location.host;
		hostAndPathname.pathname = window.location.pathname;
	} catch(ex){
		hostAndPathname.host = undefined;
		hostAndPathname.pathname = undefined;
	}

	return hostAndPathname;
}



//###[ EXPORTS ]########################################################################################################

/**
 * @namespace Navigation:redirect
 */

/**
 * Everything you need to do basic navigation without history API.
 *
 * Provide a URL to navigate to or leave the URL out, to use the current full URL. See `urlHref` for details.
 *
 * Add GET-parameters (adding to those already present in the URL), define an anchor (or automatically get the one
 * defined in the URL), set a target to define a window to navigate to (or open a new one) and even
 * define POST-parameters to navigate while providing POST-data.
 *
 * Provided params have to be a flat plain object, with ordinal values or arrays of ordinal values on the first level.
 * Everything else will be stringified and url-encoded as is. Usually, parameters defined here add to present
 * parameters in the URL. To force-override present values, declare the param name with a "!" prefix
 * (`{'!presentparam' : 'new'}`).
 *
 * If you define POST-params to navigate to a URL providing POST-data we internally build a custom form element,
 * with type "post", filled with hidden fields adding the form data, which we submit to navigate to the action, which
 * contains our url. Even the target carries over.
 *
 * If you define a target window and therefore open a new tab/window this function adds "noopener,noreferrer"
 * automatically if the origins do not match to increase security. If you need the opener, have a look at
 * "openWindow", which gives you more manual control in that regard.
 *
 * If you define a target and open an external URL, repeated calls to the same target will open multiple windows
 * due to the security settings.
 *
 * @param {?String|URL} [url=null] - the location to load, if null current location is reloaded/used
 * @param {?Object} [params=null] - plain object of GET-parameters to add to the url
 * @param {?String} [anchor=null] - anchor/hash to set for called url, has precedence over URL hash
 * @param {?String} [target=null] - name of the window to perform the redirect to/in, use "_blank" to open a new window/tab
 * @param {?Object} [postParams=null] - plain object of postParameters to send with the redirect, solved with a hidden form
 * @param {?Boolean} [markListParams=false] - if true, params with more than one value will be marked with "[]" preceding the param name
 * @throws error if url is not usable
 *
 * @memberof Navigation:redirect
 * @alias redirect
 * @see Urls.urlHref
 * @example
 * redirect('https://test.com', {search : 'kittens', order : 'asc'}, 'fluffykittens');
 * redirect(null, {order : 'desc'});
 */
export function redirect(url=null, params=null, anchor=null, target=null, postParams=null, markListParams=false){
	url = urlHref(url, params, anchor, markListParams);
	target = orDefault(target, null, 'str');
	postParams = isPlainObject(postParams) ? postParams : null;


	if( hasValue(postParams) ){
		const formAttributes = {method : 'post', action : url, 'data-ajax' : 'false'};
		if( hasValue(target) ){
			formAttributes.target = target;
		}

		const redirectForm = createNode('form', formAttributes);
		for( let paramName in postParams ){
			if( isArray(postParams[paramName]) ){
				postParams[paramName].forEach(val => {
					redirectForm.appendChild(createNode(
						'input',
						{type : 'hidden', name : `${paramName}[]`, value : `${val}`}
					));
				});
			} else {
				redirectForm.appendChild(createNode(
					'input',
					{type : 'hidden', name : paramName, value : `${postParams[paramName]}`}
				));
			}
		}

		document.body.appendChild(redirectForm);
		redirectForm.submit();
		document.body.removeChild(redirectForm);
	} else if( hasValue(target) ){
		const parsedUrl = new URL(url);

		if( parsedUrl.origin !== window.location.origin ){
			// we have to jump through hoops here, since adding security features to window.open
			// forces popup windows in some browsers and although we can set opener via the created
			// window, we cannot reliably set the referrer that way
			const eLink = document.createElement('a');
			eLink.href = url;
			eLink.target = target;
			eLink.rel = 'noopener noreferrer';
			document.body.appendChild(eLink);
			eLink.click();
			eLink.parentNode.removeChild(eLink);
		} else {
			window.open(url, target);
		}
	} else {
		window.location.assign(url);
	}
}



/**
 * @namespace Navigation:openTab
 */

/**
 * Opens a sub-window for the current window as _blank, which should result in a new tab in most browsers.
 *
 * This method is just a shortcut for "redirect" with a set target and reasonable parameters.
 *
 * By using "redirect", this method also automatically takes care of adding "noopener,noreferrer" to external
 * links, which are determined by not having the same origin as the current location. For more manual control
 * over such parameters, have a look at "openWindow" instead.
 *
 * @param {?String} [url] - the location to load, if null current location is reloaded/used
 * @param {?Object} [params=null] - plain object of GET-parameters to add to the url, adds to existing ones in the URL and overwrites existing ones with same name
 * @param {?String} [anchor=null] - anchor/hash to set for called url, has precedence over URL hash
 * @param {?Object} [postParams=null] - plain object of postParameters to send with the redirect, solved with a hidden form
 *
 * @memberof Navigation:openTab
 * @alias openTab
 * @see redirect
 * @example
 * openTab('/misc/faq.html');
 */
export function openTab(url, params=null, anchor=null, postParams=null){
	redirect(url, params, anchor, '_blank', postParams);
}



/**
 * @namespace Navigation:openWindow
 */

/**
 * Opens a sub-window for the current window or another defined parent window.
 * Be aware that most browsers open new windows as a tab by default, have a look at the "tryAsPopup"-parameter
 * if you need to open a new standalone window and your configuration results in new tabs instead.
 *
 * For window options (in this implementation, we consider "name" to be an option as well), see:
 * https://developer.mozilla.org/en-US/docs/Web/API/Window/open#window_features
 *
 * Keep in mind to set "noopener" and/or "noreferrer" for external URLs in options, to improve security and privacy.
 * Hint for older MS browsers: if you set these security options, these will most likely open the URL in a popup
 * window. If you want to circumvent this, you'll have to drop the "noreferrer" and settle for "noopener", by
 * setting opener to null on the returned window like this: `openWindow('url').opener = null;`
 *
 * @param {?String|URL} [url=null] - the URL to load in the new window, if nullish, the current URL is used
 * @param {?Object} [options=null] - parameters for the new window according to the definitions of window.open & "name" for the window name
 * @param {?Window} [parentWindow=null] - parent window for the new window, current if not defined
 * @param {?Boolean} [tryAsPopup=false] - defines if it should be tried to force a real new window instead of a tab
 * @throws error if url is not usable
 * @returns {Window} the newly opened window/tab
 *
 * @memberof Navigation:openWindow
 * @alias openWindow
 * @see Urls.urlHref
 * @example
 * openWindow('/img/gallery.html');
 * openWindow('http://www.kittens.com', {name : 'kitten_popup'}, parent);
 */
export function openWindow(url=null, options=null, parentWindow=null, tryAsPopup=false){
	url = urlHref(url);
	options = isPlainObject(options) ? options : null;
	parentWindow = isWindow(parentWindow) ? parentWindow : window;
	tryAsPopup = orDefault(tryAsPopup, false, 'bool');

	let	windowName = '';
	const optionArray = [];

	if( hasValue(options) ){
		for( let prop in options ){
			if( prop === 'name' ){
				windowName = options[prop];
			}

			if( (prop !== 'name') || tryAsPopup ){
				if( [true, 1, 'yes'].includes(options[prop]) ){
					optionArray.push(`${prop}`);
				} else {
					optionArray.push(`${prop}=${options[prop]}`);
				}
			}
		}
	}

	return parentWindow.open(url, windowName, optionArray.join(','));
}



/**
 * @namespace Navigation:reload
 */

/**
 * Reloads the current window-location. Differentiates between cached and cache-refreshing reload.
 * Hint: the forcedReload param in window.location.reload is deprecated and not supported anymore in all browsers,
 * so, in order to do a cache busting reload we have to use a trick, by using a POST-reload, since POST never
 * gets cached. If, for some reason, you cannot POST to a URL, I also provided a second, less effective fallback,
 * using "replace".
 *
 * Hint: depending on your browser a cached reload may keep the current scrolling position in the document, while
 * the uncached variants won't
 *
 * @param {?Boolean} [cached=true] - should we use the cache on reload?
 * @param {?Boolean} [postUsable=true] - if set to false, we try to replace URL instead of POSTing to it
 *
 * @memberof Navigation:reload
 * @alias reload
 * @example
 * // with cache
 * reload();
 * // without cache via POST
 * reload(false);
 * // without cache via "replace"
 * reload(false, false);
 */
export function reload(cached=true, postUsable=true){
	cached = orDefault(cached, true, 'bool');
	postUsable = orDefault(postUsable, true, 'bool');

	if( !cached && postUsable ){
		const form = document.createElement('form');
		form.method = 'post';
		form.action = window.location.href;
		document.body.appendChild(form);
		form.submit();
		document.body.removeChild(form);
	} else if( !cached && !postUsable ){
		window.location.replace(window.location.href);
	} else {
		window.location.reload();
	}
}



/**
 * @namespace Navigation:changeCurrentUrl
 */

/**
 * Changes the current URL by using the history API (this means, we can only change to a path on the same origin).
 * Be aware that this replaces the current URL in the history _without_ any normal navigation or reload.
 * This method only works if the history API is supported by the browser, otherwise no navigation will occur
 * (but a warning will be shown in console).
 * For more details on the history API see:
 * https://developer.mozilla.org/en-US/docs/Web/API/History
 *
 * @param {?String|URL} [url=null] - a url to change the current address to on the same origin, will use current URL if nullish
 * @param {?Boolean} [usePushState=false] - push new state instead of replacing current
 * @param {?*} [state=null] - a serializable object to append to the history state (gets retrieved on popState-event)
 * @param {?String} [title=null] - a name/title for the new state (as of yet, only Safari uses this, other browser will return undefined)
 * @throws error if state is not serializable by browser
 *
 * @memberof Navigation:changeCurrentUrl
 * @alias changeCurrentUrl
 * @see onHistoryChange
 * @see Urls.urlHref
 * @example
 * changeCurrentUrl('/article/important-stuff', false, {id : 666});
 */
export function changeCurrentUrl(url=null, usePushState=false, state=null, title=null){
	url = urlHref(url);
	usePushState = orDefault(usePushState, false, 'bool');
	title = orDefault(title, '', 'str');

	if ( browserSupportsHistoryManipulation() ) {
		if( usePushState ){
			window.history.pushState(state, title, url);
		} else {
			window.history.replaceState(state, title, url);
		}

		HISTORY.current = {
			state,
			title,
			host : window.location.host,
			path : window.location.pathname
		};
	} else {
		warn(`${MODULE_NAME}:changeCurrentUrl | this browser does not support history api, skipping`);
	}
}



/**
 * @namespace Navigation:onHistoryChange
 */

/**
 * Registers an onpopstate event if history API is available (does nothing and warns if not available).
 * Takes a callback, which is provided with states as plain objects like: {state, title, host, path}.
 * Hint: do not rely on title, since that property may only be supported by browsers like Safari,
 * serialize everything important into state and use title as orientation only.
 *
 * In case of a regular binding all callbacks get the current state, so the state that is being changed to, but
 * if you set "usePreviousState" to true and prior navigation was done with "changeCurrentUrl", all callbacks
 * get two states: "from" and "to". With this you can define rules an behaviour depending on the state you are
 * coming from. Keep in mind: this only works if you use "changeCurrentUrl" for navigation in tandem with this method.
 *
 * @param {Function} callback - function to execute on popstate
 * @param {?Boolean} [clearOld=false] - defines if old handlers should be removed before setting new one
 * @param {?Boolean} [usePreviousState=false] - defines if callbacks should be provided with previous state as well (in that case, changeCurrentUrl must have been used for prior navigation)
 * @throws error if callback is no function
 *
 * @memberof Navigation:onHistoryChange
 * @alias onHistoryChange
 * @see changeCurrentUrl
 * @see offHistoryChange
 * @example
 * onHistoryChange(function(){ alert('Hey, don\'t do this!'); }, true);
 */
export function onHistoryChange(callback, clearOld=false, usePreviousState=false){
	const __methodName__ = 'onHistoryChange';

	clearOld = orDefault(clearOld, false, 'bool');
	usePreviousState = orDefault(usePreviousState, false, 'bool');

	assert(isFunction(callback), `${MODULE_NAME}:${__methodName__} | callback is no function`);

	if ( browserSupportsHistoryManipulation() ) {
		if( clearOld ){
			HISTORY.popState.callbacks = [];
		}

		const statefulCallback = function(e, historyNew){
			if( usePreviousState ){
				callback(HISTORY.current, historyNew);
			} else {
				callback(historyNew);
			}
		};

		HISTORY.popState.callbacks.push({
			original : callback,
			stateful : statefulCallback
		});

		if( !HISTORY.popState.listening ){
			HISTORY.popState.listening = true;

			window.addEventListener('popstate',	HISTORY.popState.handler);
		}
	} else {
		warn(`${MODULE_NAME}:${__methodName__} | this browser does not support history api, skipping`);
	}
}



/**
 * @namespace Navigation:offHistoryChange
 */

/**
 * Removes registered history change handlers, that have been created with "onHistoryChange".
 * If a callback is provided, that callback is removed from callbacks, if the function is called
 * without parameters all callbacks are removed and the event listener for the callbacks is removed.
 *
 * @param {?Function} [callback=true] - reference to the callback to be removed, if missing all callbacks are removed
 * @throws error if callback is no function
 * @return {Boolean} true if callback(s) are/were removed, false if nothing was done
 *
 * @memberof Navigation:offHistoryChange
 * @alias offHistoryChange
 * @see changeCurrentUrl
 * @see onHistoryChange
 * @example
 * offHistoryChange(thatOneCallback);
 * offHistoryChange();
 */
export function offHistoryChange(callback=null){
	const __methodName__ = 'offHistoryChange';

	if( hasValue(callback) ){
		assert(isFunction(callback), `${MODULE_NAME}:${__methodName__} | callback is not a function`);

		const oldCallbackCount = HISTORY.popState.callbacks.length;
		HISTORY.popState.callbacks = HISTORY.popState.callbacks.reduce((cbs, cb) => {
			if( cb.original !== callback ){
				cbs.push(cb);
			}

			return cbs;
		}, []);
		const newCallbackCount = HISTORY.popState.callbacks.length;

		if( newCallbackCount === 0 ){
			window.removeEventListener('popstate', HISTORY.popState.handler);
			HISTORY.popState.listening = false;
		}

		return oldCallbackCount > newCallbackCount;
	} else {
		HISTORY.popState.callbacks = [];
		window.removeEventListener('popstate', HISTORY.popState.handler);
		HISTORY.popState.listening = false;

		return true;
	}
}