hy-push-state
Version:
Turn web sites into web apps
934 lines (797 loc) • 31.8 kB
JavaScript
// # src / mixin / index.js
// Copyright (c) 2017 Florian Klampfer <https://qwtel.com/>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// ## Overview
// This component is written in [RxJS] and reading its code requires some basic understanding
// of how RxJS works. It may also serve as an example of how to use RxJS.
//
// Other than RxJS, you should be familiar with [ES6 Mixin][esmixins],
// which is a clever way of using the ES6 class syntax to achieve inheritance-based mixins.
// The mixin in the main export of this file.
// ## Table of Contents
// {:.no_toc}
// * Table of Contents
// {:toc}
// ## Imports
// Including the patches for ES6+ functions, but
// there is a -lite version of the component that comes without these.
import 'core-js/fn/array/for-each';
import 'core-js/fn/array/from';
import 'core-js/fn/function/bind';
import 'core-js/fn/object/assign';
// Importing the hy-compontent base libary,
// which helps with making multiple versions of the component (Vanilla JS, WebComponent, etc...).
import { componentMixin, COMPONENT_FEATURE_TESTS } from 'hy-component/src/component';
import { sSetup, sSetupDOM, sFire } from 'hy-component/src/symbols';
import { Observable, Subject } from 'rxjs';
// Importing the subset of RxJS functions that we are going to use.
import { defer } from 'rxjs/observable/defer';
import { from } from 'rxjs/observable/from';
import { fromEvent } from 'rxjs/observable/fromEvent';
import { merge } from 'rxjs/observable/merge';
import { never } from 'rxjs/observable/never';
import { of } from 'rxjs/observable/of';
import { timer } from 'rxjs/observable/timer';
import { ajax } from 'rxjs/observable/dom/ajax';
import {
catchError,
tap,
concatMap,
distinctUntilChanged,
filter,
map,
mapTo,
observeOn,
partition,
pairwise,
share,
startWith,
switchMap,
take,
takeUntil,
withLatestFrom,
zip,
} from 'rxjs/operators';
import { animationFrame } from 'rxjs/scheduler/animationFrame';
import { array, bool, number, regex, string } from 'attr-types';
import { Set } from 'qd-set';
// Partial polyfill of the URL class. Only provides the most basic funtionality of `URL`,
// but sufficient for this compoennt.
import { URL } from '../url';
// Some of our own helper functions from [src / common.js](../common.md).
import {
fragmentFromString,
getScrollTop,
getScrollHeight,
isExternal,
isHash,
matchesAncestors,
} from '../common';
// ## Constants
// A set of [Modernizr] tests that are required to run this component.
// These are the bare-minimum requirements, more ad-hoc features tests for optional behavior
// is part of the code below.
export const MIXIN_FEATURE_TESTS = new Set([
...COMPONENT_FEATURE_TESTS,
'documentfragment',
'eventlistener',
'history',
'promises',
'queryselector',
'requestanimationframe',
]);
// We export the setup symbols,
// so that mixin users don't have to import them from hy-compnent separately.
export { sSetup, sSetupDOM };
// These are some 'types' that we use throught the component.
// Going with strings here instead of classes + instanceof / dynamic dispatch for simplicity.
export const INIT = 'init';
export const HINT = 'hint';
export const PUSH = 'push';
export const POP = 'pop';
// If Symbol isn't supported, just use underscore naming convention for private properties.
// We don't need advanced features of Symbol.
const Symbol = global.Symbol || (x => `_${x}`);
// We use `Symbol`s for all internal variables, to avoid naming conflicts when using the mixin.
const sAnimPromise = Symbol('animPromise');
const sReload$ = Symbol('reloadObservable');
// For convenience....
const assign = Object.assign.bind(Object);
// Patching the document fragment's `getElementById` function, which is
// not implemented in all browsers, even some modern ones.
DocumentFragment.prototype.getElementById = DocumentFragment.prototype.getElementById ||
function getElementById(id) { return this.querySelector(`#${id}`); };
// ## Functions
// What you will notice about the following helper functions is that many make reference to `this`.
// This is because they are invoked with the `::` operator, binding `this` to the component,
// effectively turning them into (private) methods. Since the final export is a mixin,
// we want to avoid potentially conflicting names as much as possible.
// ### Observable extensions
// #### Unsubscribe when
// This operator unsubscribes from the source observable when `pauser$` emits a truthy value,
// and re-subscribes when it emits a falsy value.
const unsubscribeWhen = pauser$ => (source) => {
if (process.env.DEBUG && !pauser$) throw Error();
return pauser$.pipe(switchMap(paused => (paused ? never() : source)));
};
// #### Custom subscribe
// A custom subscribe function that will `recover` from an error and log it to the console.
// This is a line of last defense to make sure the entire pipeline/page doesn't crash.
// TODO: maybe just let it crash s.t. the page reloads on the next click on a link!?
/*
function subscribe(ne, er, co) {
let res = this;
if (process.env.DEBUG) res = this.pipe(tap({ error: e => console.error(e) }));
return res
.pipe(catchError((e, c) => c))
.subscribe(ne, er, co);
}
*/
// ### Event filters
function shouldLoadAnchor(anchor, hrefRegex) {
return anchor && anchor.target === ''
&& (!hrefRegex || anchor.href.search(hrefRegex) !== -1);
}
function isPushEvent({ metaKey, ctrlKey, currentTarget }) {
return !metaKey && !ctrlKey
&& shouldLoadAnchor(currentTarget, this._hrefRegex)
&& !isExternal(currentTarget);
}
function isHintEvent({ currentTarget }) {
return shouldLoadAnchor(currentTarget, this._hrefRegex)
&& !isExternal(currentTarget)
&& !isHash(currentTarget);
}
// ### Managing scroll positions
// The following functions deal with managing the scroll position of the site.
// Returns an identifier to mark frames on the history stack.
function histId() {
return this.el.id || this.constructor.componentName;
}
// Given a hash, find the element of the same id on the page, and scroll it into view.
// If no hash is provided, scroll to the top instead.
function scrollHashIntoView(hash) {
if (hash) {
const el = document.getElementById(hash.substr(1));
if (el) el.scrollIntoView();
else if (process.env.DEBUG) console.warn(`Can't find element with id ${hash}`);
} else window.scroll(window.pageXOffset, 0);
}
// Takes the current history state, and restores the scroll position.
function restoreScrollPostion() {
const id = histId.call(this); // TODO
const state = (window.history.state && window.history.state[id]) || {};
if (state.scrollTop != null) {
document.body.style.minHeight = state.scrollHeight;
window.scroll(window.pageXOffset, state.scrollTop);
/* document.body.style.minHeight = ''; */
} else if (state.hash) {
scrollHashIntoView(window.location.hash);
}
}
// TODO
function manageScrollPostion({ type, url: { hash } }) {
if (type === PUSH) {
scrollHashIntoView(hash);
}
if (type === POP && this.scrollRestoration) {
restoreScrollPostion.call(this);
}
}
function saveScrollPosition(state) {
const id = histId.call(this);
return assign(state, {
[id]: assign(state[id] || {}, {
scrollTop: getScrollTop(),
scrollHeight: getScrollHeight(),
}),
});
}
function updateHistoryState({ type, replace, url: { href, hash } }) {
if (type === PUSH || type === INIT) {
const id = histId.call(this);
const method = replace || href === window.location.href ? 'replaceState' : 'pushState';
window.history[method]({ [id]: { hash: !!hash } }, '', href);
}
}
function updateHistoryStateHash({ type, url }) {
const { hash, href } = url;
if (type === PUSH) {
const id = histId.call(this);
const currState = assign(window.history.state, {
[id]: assign(window.history.state[id], { hash: true }),
});
const nextState = {
[id]: { hash: true },
};
window.history.replaceState(currState, document.title, window.location.href);
window.history.pushState(nextState, document.title, href);
}
scrollHashIntoView(hash);
}
function saveScrollHistoryState() {
const state = saveScrollPosition.call(this, window.history.state || {});
window.history.replaceState(state, document.title, window.location);
}
// ### Fetching
function hrefToAjax({ url }) {
return {
method: 'GET',
responseType: 'text',
url,
};
}
// The `ajax` method will throw when it encoutners an a 400+ status code,
// however these are still valid responses from the server, that can be shown using this component.
// This assumes error pages have the same HTML strcuture as the other pages though.
function recoverIfResponse(context, error) {
const { status, xhr } = error;
// If we have a response, recover with it and continue with the pipeline.
if (xhr && xhr.response && status > 400) {
return of(assign(context, { response: xhr.response }));
}
// If we don't have a response, this is an acutal error that should be dealt with.
return of(assign(context, { error }));
}
// This function returns the request that matches a given URL.
// The way the pipeline is set up, it is either the current `prefetch` value,
// or the next value on the prefetch observable.
// TODO: rename
function getFetch$({ url: { href } }, latestPrefetch, prefetch$) {
return href === latestPrefetch.url.href && latestPrefetch.error == null ?
of(latestPrefetch) :
prefetch$.pipe(take(1));
}
// TODO: rename
function getResponse(prefetch$, [context, latestPrefetch]) {
return getFetch$(context, latestPrefetch, prefetch$).pipe(
map(fetch => assign(fetch, context)),
zip(this[sAnimPromise], x => x),
);
}
// ### Experimental script feature
// TODO
// This function removes all script tags (as query'ed by `_scriptSelector`) from the response.
function tempRemoveScriptTags(replaceEls) {
const scripts = [];
replaceEls.forEach(docfrag =>
Array.from(docfrag.querySelectorAll(this._scriptSelector)).forEach((script) => {
const pair = [script, script.previousSibling];
script.parentNode.removeChild(script);
scripts.push(pair);
}));
return scripts;
}
// Attempts to (synchronously) insert a `script` tag into the DOM, *before* a given `ref` element.
function insertScript([script, ref]) {
// Temporarily overwrite `document.wirte` to simulate its behavior during the initial load.
// This only works because scripts are inserted one-at-a-time (via `concatMap`).
const originalWrite = document.write;
document.write = (...args) => {
const temp = document.createElement('div');
temp.innerHTML = args.join();
Array.from(temp.childNodes).forEach((node) => {
ref.parentNode.insertBefore(node, ref.nextSibling);
});
};
// If the script tag needs to fetch its source code, we insert it into the DOM,
// but we return an observable that only completes once the script has fired its `load` event.
return script.src !== '' ?
Observable.create((observer) => {
script.addEventListener('load', (x) => {
document.write = originalWrite;
observer.complete(x);
});
script.addEventListener('error', (x) => {
document.write = originalWrite;
observer.error(x);
});
ref.parentNode.insertBefore(script, ref.nextSibling);
}) :
// Otherwise we insert it into the DOM and reset the `document.write` function.
of({}).pipe(tap(() => {
ref.parentNode.insertBefore(script, ref.nextSibling);
document.write = originalWrite;
}));
}
// Takes a list of `script`--`ref` pairs, and inserts them into the DOM one-by-one.
function reinsertScriptTags(context) {
const { scripts } = context;
return from(scripts).pipe(
concatMap(insertScript),
catchError((error) => { throw assign(context, { error }); }),
)
.toPromise()
.then(() => context);
}
// ### Content replacement
// TODO
function getTitle(fragment) {
return (fragment.querySelector('title') || {}).textContent;
}
function getReplaceElements(fragment) {
if (this.replaceIds.length > 0) {
return this.replaceIds.map(id => fragment.getElementById(id));
} else {
let replaceEl;
if (this.el.id) {
replaceEl = fragment.getElementById(this.el.id);
} else {
const index = Array.from(document.getElementsByTagName(this.el.tagName)).indexOf(this.el);
replaceEl = fragment.querySelectorAll(this.el.tagName)[index];
}
return [replaceEl];
}
}
function responseToContent(context) {
const { response } = context;
const fragment = fragmentFromString(response);
const title = getTitle.call(this, fragment);
const replaceEls = getReplaceElements.call(this, fragment);
if (replaceEls.some(x => x == null)) {
throw assign(context, { relaceElMissing: true });
}
const scripts = this._scriptSelector ? tempRemoveScriptTags.call(this, replaceEls) : [];
return assign(context, { title, replaceEls, scripts });
}
function replaceContentByIds(elements) {
this.replaceIds
.map(id => document.getElementById(id))
.forEach((oldElement, i) => {
oldElement.parentNode.replaceChild(elements[i], oldElement);
});
}
function replaceContentWholesale([el]) {
this.el.innerHTML = el.innerHTML;
}
function replaceContent(replaceEls) {
if (this.replaceIds.length > 0) {
replaceContentByIds.call(this, replaceEls);
} else {
replaceContentWholesale.call(this, replaceEls);
}
}
function updateDOM(context) {
try {
const { title, replaceEls, type } = context;
document.title = title;
if (type === PUSH) {
window.history.replaceState(window.history.state, title, window.location);
}
replaceContent.call(this, replaceEls);
} catch (error) {
throw assign(context, { error });
}
}
// ### Event functions
// These functions are called at various points along the observable pipeline to fire events,
// and cause other side effects.
// #### On start
function onStart(context) {
// By default, hy-push-state will wait at least `duration` ms before replacing the content,
// so that animations have enough time to finish.
// The behavior is encoded with a promise that resolves after `duration` ms.
this[sAnimPromise] = timer(this.duration);
// The `waitUntil` function lets users of this component override the animation promise.
// This allows for event-based code execution, rather than timing-based, which prevents hiccups
// and glitches when, for example, painting takes longer than expected.
const waitUntil = (promise) => {
if (process.env.DEBUG && !(promise instanceof Promise || promise instanceof Observable)) {
console.warn('waitUntil expects a Promise as first argument.');
}
this[sAnimPromise] = promise;
};
this[sFire]('start', { detail: assign(context, { waitUntil }) });
}
// Example usage of `waitUntil`:
//
// ```js
// hyPushStateEl.addEventListener('hy-push-state-start', ({ detail }) => {
// const animPromise = new Promise((resolve) => {
// const anim = myContent.animate(...);
// anim.addEventListener('finish', resolve);
// });
// detail.waitUntil(animPromise);
// });
// ```
// {:style="font-style:italic"}
// #### Error callbacks
// This function handles errors while trying to insert the new content into the document.
// If the retrieved documened doesn't contain the ids we are looking for
// we can't insert the content dynamically, so we tell the browser to open the link directly.
function onDOMError(context) {
const { relaceElMissing, url } = context;
// Ideally you should prevent this situation by adding the
// `no-push-state` CSS class
// on links to documents that don't match the expected document layout.
// This only serves as a fallback.
if (relaceElMissing) {
if (process.env.DEBUG) {
const ids = this.replaceIds.concat(this.el.id || []).map(x => `#${x}`).join(', ');
console.warn(`Couldn't find one or more ids of '${ids}' in the document at '${window.location}'. Opening the link directly.`);
}
// To open the link directly, we first pop one entry off the browser history.
// We have to do this because (some) browsers won't handle the back button correctly otherwise.
// We then wait for a short time and change the document's location.
// TODO: If we didn't call `pushState` optimistically we wouldn't have to do this.
window.history.back();
setTimeout(() => { document.location.href = url; }, 100);
// If it's a different error, throw the generic `error` event.
} else {
if (process.env.DEBUG) console.error(context);
this[sFire]('error', { detail: context });
}
}
// If there is a network error during (pre-) fetching, fire `networkerror` event.
function onNetworkError(context) {
if (process.env.DEBUG) console.error(context);
this[sFire]('networkerror', { detail: context });
}
// When using the experimental script feature,
// fire `scripterror` event if something goes wrong during script insertion.
function onError(context) {
if (process.env.DEBUG) console.error(context);
this[sFire]('error', { detail: context });
}
// #### Others
// These event callbacks simply fire an event and pass the context as `detail`.
function onReady(context) {
this[sFire]('ready', { detail: context });
}
function onAfter(context) {
this[sFire]('after', { detail: context });
}
function onProgress(context) {
this[sFire]('progress', { detail: context });
}
function onLoad(context) {
this[sFire]('load', { detail: context });
}
// A compare function for contexts, used in combination with `distinctUntilChanged`.
// We use `cacheNr` as it is a convenient (hacky) way of circumventing
// `distinctUntilChanged` when retrying requests.
let cacheNr = 1;
function compareContext(p, q) {
return p.url.href === q.url.href
&& p.error === q.error
&& p.cacheNr === q.cacheNr;
}
// Determines if a pair of context's constitutes a hash change (vs. a page chagne)
// We take as a hash change when the pathname of the URLs is the same,
// and the `hash` isn't empty.
function isHashChange([
{ url: { pathname: prevPathname } },
{ url: { pathname, hash }, type },
]) {
return pathname === prevPathname
&& (type === POP || (type === PUSH && hash !== ''));
}
// ### Setup observable
// This functions sets up the core observable pipeline of this component.
function setupObservables() {
// For now, we take for granted that we have a stream of all `PUSH` events (loading a new page by
// clicking on a link) and `HINT` events (probable click on a link) which are `pushSubject` and
// `hintSubject` respectively.
const pushSubject = new Subject();
const hintSubject = new Subject();
// Emits a value each time the `reload` method is called on this component.
this[sReload$] = new Subject();
// This is used to reference deferred observaables.
const ref = {};
// TODO
const push$ = pushSubject.pipe(
filter(isPushEvent.bind(this)),
map(event => ({
type: PUSH,
url: new URL(event.currentTarget.href),
anchor: event.currentTarget,
event,
cacheNr,
})),
tap(({ event }) => {
event.preventDefault();
saveScrollHistoryState.call(this);
}),
);
// In additon to `HINT` and `PUSH` events, there's also `POP` events, which are caused by
// modifying the browser history, e.g. clicking the back button, etc.
const pop$ = fromEvent(window, 'popstate').pipe(
filter(() => window.history.state && window.history.state[histId.call(this)]),
map(event => ({
type: POP,
url: new URL(window.location),
event,
cacheNr,
})),
);
// TODO
const [hash$, page$] = merge(push$, pop$, this[sReload$]).pipe(
startWith({ url: new URL(window.location) }),
pairwise(),
share(),
partition(isHashChange),
)
.map(obs$ => obs$.pipe(map(([, x]) => x), share()));
// We don't want to prefetch (i.e. use bandwidth) for a _probabilistic_ page load,
// while a _definitive_ page load is going on => `pauser$` stream.
// Needs to be deferred b/c of "cyclical" dependency.
const pauser$ = defer(() =>
// A page change event means we want to pause prefetching, while
// a response event means we want to resume prefetching.
merge(page$.pipe(mapTo(true)), ref.fetch$.pipe(mapTo(false)))).pipe(
// Start with `false`, i.e. we want to prefetch
startWith(false),
share(),
);
// TODO
const hint$ = hintSubject.pipe(
unsubscribeWhen(pauser$),
filter(isHintEvent.bind(this)),
map(event => ({
type: HINT,
url: new URL(event.currentTarget.href),
anchor: event.currentTarget,
event,
cacheNr,
})),
);
// The stream of (pre-)fetch events.
// Includes definitive page change events do deal with unexpected page changes.
const prefetch$ = merge(hint$, page$).pipe(
// Don't abort a request if the user "jiggles" over a link
distinctUntilChanged(compareContext),
switchMap(context =>
ajax(hrefToAjax(context)).pipe(
map(({ response }) => assign(context, { response })),
catchError(error => recoverIfResponse.call(this, context, error)),
)),
// Start with some value so `withLatestFrom` below doesn't "block"
startWith({ url: {} }),
share(),
);
// TODO
ref.fetch$ = page$.pipe(
tap(updateHistoryState.bind(this)),
tap(onStart.bind(this)),
withLatestFrom(prefetch$),
switchMap(getResponse.bind(this, prefetch$)),
share(),
);
// TODO
const [fetchOk$, fetchError$] = ref.fetch$.pipe(partition(({ error }) => !error));
// TODO
let main$ = fetchOk$.pipe(
map(responseToContent.bind(this)),
observeOn(animationFrame),
tap(onReady.bind(this)),
tap(updateDOM.bind(this)),
tap(onAfter.bind(this)),
tap(manageScrollPostion.bind(this)),
tap({ error: e => onDOMError.call(this, e) }),
catchError((e, c) => c),
);
// If the experimental script feature is enabled,
// scripts tags have been stripped from the content,
// and this is where we insert them again.
if (this._scriptSelector) {
main$ = main$.pipe(
switchMap(reinsertScriptTags.bind(this)),
tap({ error: e => onError.call(this, e) }),
catchError((e, c) => c),
);
}
// #### Subscriptions
// Subscribe to main and hash observables.
main$.subscribe(onLoad.bind(this));
hash$.subscribe(updateHistoryStateHash.bind(this));
// Subscribe to the fetch error branch.
fetchError$.subscribe(onNetworkError.bind(this));
// Fire `progress` event when fetching takes longer than expected.
page$.pipe(switchMap(context =>
defer(() => this[sAnimPromise]).pipe(
takeUntil(ref.fetch$),
mapTo(context),
)))
.subscribe(onProgress.bind(this));
// #### Keeping track of links
// We use a `MutationObserver` to keep track of all the links inside the component,
// and put events on the `pushSubject` and `hintSubject` observables,
// but first we need to check if `MutationObserver` is available.
if ('MutationObserver' in window && 'Set' in window) {
// An observable of mutations. The `MutationObserver` will put mutations onto it.
const mutation$ = new Subject();
// A `Set` of `Element`s.
// We use this to keep track of which links already have their event listeners registered.
// TODO: can we guarantee that we won't find the same link twice?
const links = new Set();
// Binding `next` functions to their `Subject`s,
// so that we can pass them as callbacks directly. This is just for convenience.
const mutationNext = mutation$.next.bind(mutation$);
const pushNext = pushSubject.next.bind(pushSubject);
const hintNext = hintSubject.next.bind(hintSubject);
// We don't use `Observable.fromEvent` here to avoid creating too many observables.
// Registering an unknown number of event listeners is somewhat debatable,
// but we certainly don't want to make it wrose.
// (The number could be brought down by using an `IntersectionObserver` in the future.
// Also note that typically there will be an animation playing while this is happening,
// so the effects are not easily noticed).
// In any case, `MutationObserver` and `Set` help us keep track of which links are children
// of this component, so that we can reliably add and remove the event listeners.
// The function to be called for every added node:
const addListeners = (addedNode) => {
if (addedNode instanceof Element) {
Array.from(addedNode.querySelectorAll(this.linkSelector)).forEach((link) => {
if (!links.has(link)) {
links.add(link);
link.addEventListener('click', pushNext);
link.addEventListener('mouseenter', hintNext, { passive: true });
link.addEventListener('touchstart', hintNext, { passive: true });
link.addEventListener('focus', hintNext, { passive: true });
}
});
}
};
// Next, The function to be called for every removed node.
// Usually the elments will be removed from the document altogher
// when they are removed from this component,
// but since we can't be sure, we remove the event listeners anyway.
const removeListeners = (removedNode) => {
if (removedNode instanceof Element) {
Array.from(removedNode.querySelectorAll(this.linkSelector)).forEach((link) => {
links.delete(link);
link.removeEventListener('click', pushNext);
link.removeEventListener('mouseenter', hintNext, { passive: true });
link.removeEventListener('touchstart', hintNext, { passive: true });
link.removeEventListener('focus', hintNext, { passive: true });
});
}
};
// The mutation observer callback simply puts all mutations on the `mutation$` observable.
const observer = new MutationObserver(mutations => Array.from(mutations).forEach(mutationNext));
// For every mutation, we remove the event listeners of elements that go out of the component
// (if any), and add event listeners for all elements that make it into the compnent (if any).
mutation$.pipe(unsubscribeWhen(pauser$))
.subscribe(({ addedNodes, removedNodes }) => {
Array.from(removedNodes).forEach(removeListeners.bind(this));
Array.from(addedNodes).forEach(addListeners.bind(this));
});
// We're interested in nodes entering and leaving the entire subtree of this component,
// but not attribute changes:
observer.observe(this.el, { childList: true, subtree: true });
// The mutation observer does not pick up the links that are already on the page,
// so we add them manually here, once.
addListeners.call(this, this.el);
// If we don't have `MutationObserver` and `Set`, we just register a `click` event listener
// on the entire component, and check if a click occurred on one of our links.
// Note that we can't reliably generate hints this way, so we don't.
} else {
this.el.addEventListener('click', (event) => {
const anchor = matchesAncestors.call(event.target, this.linkSelector);
if (anchor && anchor.href) {
event.currentTarget = anchor; // eslint-disable-line no-param-reassign
pushSubject.next(event);
}
});
}
}
// ## Push state mixin
export function pushStateMixin(C) {
// TODO: see ES6 mixins...
return class extends componentMixin(C) {
// The name of the component (required by hy-component)
static get componentName() { return 'hy-push-state'; }
// ### Setup
// Overriding the setup function.
[sSetup](el, props) {
super[sSetup](el, props);
// Setting up scroll restoration
if ('scrollRestoration' in window.history && this.scrollRestoration) {
window.history.scrollRestoration = 'manual';
}
// If restore the last scroll position, if any.
restoreScrollPostion.call(this);
// Remember the current scroll position (for F5/reloads).
window.addEventListener('beforeunload', saveScrollHistoryState.bind(this));
// Finally, calling the [setup observables function](#setup-observables) function.
setupObservables.call(this);
// Setting the initial `history.state`.
const url = new URL(window.location);
updateHistoryState.call(this, { type: INIT, replace: true, url });
// After all this is done, we can fire the one-time `init` event...
this[sFire]('init');
// ...and our custom `load` event, which gets fired on every page change.
// We provide similar data as subsequent `load` events,
// however we can't provide an `anchor` or `event`,
// since this `load` event wasn't caused by a user interaction.
onLoad.call(this, {
type: INIT,
title: getTitle.call(this, document),
replaceEls: getReplaceElements.call(this, document),
url,
cacheNr,
});
// Allow function chaining.
return this;
}
[sSetupDOM](el) { return el; }
// ### Options
// The default values (and types) of the configuration options (required by hy-component)
// See [Options](../../options.md) for usage information.
static get defaults() {
return {
replaceIds: [],
linkSelector: 'a[href]:not(.no-push-state)',
scrollRestoration: false,
duration: 0,
_hrefRegex: null,
_scriptSelector: null,
/* prefetch: true, */
/* repeatDelay: 500, */
};
}
static get types() {
return {
replaceIds: array,
linkSelector: string,
scrollRestoration: bool,
duration: number,
_hrefRegex: regex,
_scriptSelector: string,
/* prefetch: bool, */
/* repeatDelay: number, */
};
}
// Modifying options of this component doesn't have side effects (so far).
static get sideEffects() {
return {};
}
// ### Methods
// Public methods of this component. See [Methods](../../methods.md) for more.
assign(url) {
this[sReload$].next({
type: PUSH,
url: new URL(url, window.location),
cacheNr: ++cacheNr, // eslint-disable-line no-plusplus
});
}
reload() {
this[sReload$].next({
type: PUSH,
url: new URL(window.location.href),
cacheNr: ++cacheNr, // eslint-disable-line no-plusplus
replace: true,
});
}
replace(url) {
this[sReload$].next({
type: PUSH,
url: new URL(url, window.location),
cacheNr: ++cacheNr, // eslint-disable-line no-plusplus
replace: true,
});
}
};
}
// This concludes the implementation of push-state mixin.
// You can now check out
//
// * [vanilla / index.js](../vanilla/index.md)
// * [jquery / index.js](../jquery/index.md)
// * [webcomponent / index.js](../webcomponent/index.md)
//
// to see how it is used.
// [rxjs]: https://github.com/ReactiveX/rxjs
// [esmixins]: http://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/
// [modernizr]: https://modernizr.com/