Skip to content

Instantly share code, notes, and snippets.

@akc42
Created September 11, 2018 12:30
Show Gist options
  • Save akc42/46edb621544753295df8551feb9cc874 to your computer and use it in GitHub Desktop.
Save akc42/46edb621544753295df8551feb9cc874 to your computer and use it in GitHub Desktop.
Page management
export default function domHost(self) {
let parent = self.parentNode;
while(parent && parent.nodeType !== 11) {
parent = parent.parentNode; //work up the hierarchy
}
return parent ? parent.host : self;
}
import domHost from '../modules/host.js';
/**
* @polymer
* @mixinClass
* @mixinFunction
*/
export const PasPageManager = (superClass) => class extends superClass {
static get properties() {
return {
page: {
type: String,
observer: '_pageChanged',
readOnly: true
},
route: {
type: Object
},
subRoute: {
type: Object
},
pageTitle: {
type: String,
value: '',
notify: true
}
};
}
static get observers() {
return [
'_routePageChanged(subRoute.active, subRoute.params.page)',
'_routeInactive(route.active)'
];
}
ready() {
super.ready();
this.addEventListener('pas-close', e => this._closeRequest(e));
this.addEventListener('pas-closed', e => this._closeResponse(e));
}
_closeRequest(e) {
var element;
e.stopPropagation();
if (e.composedPath()[0] === this) { //we don'tneed to cater for event retargetting
//this event was meant for us, so lets see if we are at the home page or not
if (this.page !== this.homePage()) {
/* not at home page, so we should tell our subordinate (who
is currently selected) that there has been a close request
*/
element = this.shadowRoot.querySelector('[name=' + this.page + ']');
if (element) {
element.dispatchEvent(new CustomEvent('pas-close', {bubbles: true, composed: true}));
} else {
//eslint-disable-next-line no-console
console.warn('didn\'t find the element with page name = %s', this.page);
window.dispatchEvent(new CustomEvent('route-updated',{
detail: {
segment: this.route.segment,
path: '/'
}
}));
}
} else {
/* we are at home page, either tell our host that we have therefore closed
or if we are trying to do an explicit reroute then do if via abstract */
if (!this.closeReroute() && domHost(this)) {
domHost(this).dispatchEvent(new CustomEvent('pas-closed', {bubbles: true, composed: true}));
}
}
} else {
/*
* we have informed our child of the close request but he hasn't responded,
* so we just switch to the home page (at our level)
*
* In order to do that, we take advantage of the fact that we know that the
* url for out home page doesn't contain the value of the correct "page" in
* the url, and therefore we can emulate what pas-route does without the complexity
* that it uses
*/
window.dispatchEvent(new CustomEvent('route-updated',{
detail: {
segment: this.route.segment,
path: '/'
}
}));
}
}
_closeResponse(e) {
/*
* we have been told that our child was already at home, so is
* closed, we need to switch to the home page (at our level)
*/
e.stopPropagation();
//see comment above to see why this works
window.dispatchEvent(new CustomEvent('route-updated',{
detail: {
segment: this.route.segment,
path: '/'
}
}));
}
_routeInactive(active) {
if (active === undefined) return;
if (!active) this.aboutToClose();
}
_routePageChanged(active, page) {
if (active !== undefined && page !== undefined) {
this._setPage((active && page) ? page : this.homePage());
if (active && this.constructor.titles !== undefined) {
const title = this.constructor.titles[this.page];
if (title !== undefined) this.pageTitle = title;
}
}
}
_pageChanged(page) {
if (this.subRoute && this.subRoute.active) { //only load anything if we have a route
const element = this.shadowRoot.querySelector('[name=' + page + ']');
if (element !== undefined) {
// load page import on demand.
performance.mark('start_page_' + page);
this.loadPage(page);
}
}
}
// abstract
aboutToClose() {}
closeReroute() {return false;}
homePage() {return 'home';}
loadPage() {}
};
let routeCallback = null;
let dwellTime = 2000;
let lastChangedAt;
let route;
export function connectUrl(callback) {
routeCallback = callback;
window.addEventListener('hashchange',urlChanged);
window.addEventListener('popstate', urlChanged);
window.addEventListener('location-changed',urlChanged);
window.addEventListener('route-updated', routeUpdated);
Promise.resolve().then(() => {
urlChanged();
lastChangedAt = window.performance.now() - (dwellTime - 200);
});
}
export function disconnectUrl() {
routeCallback = null;
window.removeEventListener('hashchange',urlChanged);
window.removeEventListener('popstate', urlChanged);
window.removeEventListener('location-changed',urlChanged);
window.removeEventListener('route-updated', routeUpdated);
}
function urlChanged() {
const path = window.decodeURIComponent(window.location.pathname);
const query = decodeParams(window.location.search.substring(1));
if (route && route.path === path && route.query === query) return;
lastChangedAt = window.performance.now();
route = {
path: path,
segment: 0,
params: {},
query: query,
active: true
};
if (routeCallback) routeCallback(route);
}
function routeUpdated(e) {
let newPath = route.path;
if(e.detail.path !== undefined) {
if (Number.isInteger(e.detail.segment)) {
let segments = route.path.split('/');
if (segments[0] === '') segments.shift(); //loose leeding
if(segments.length > e.detail.segment) segments.length = e.detail.segment; //truncate to just before path
if (e.detail.path.length > 1) {
const newPaths = e.detail.path.split('/');
if (newPaths[0] === '') newPaths.shift(); //ignore blank if first char of path is '/'
segments = segments.concat(newPaths);
}
newPath = '/' + segments.join('/');
//lose trailing slash if not just a single '/'
if (newPath.slice(-1) === '/' && newPath.length > 1) newPath = newPath.slice(0,-1);
} else {
throw new Error('Invalid segment info in route-updated event');
}
}
let query = Object.assign({}, route.query);
if (e.detail.query !== undefined) {
query = e.detail.query;
}
let newUrl = window.encodeURI(newPath).replace(/#/g, '%23').replace(/\?/g, '%3F');
if (Object.keys(query).length > 0) {
newUrl += '?' + encodeParams(query)
.replace(/%3F/g, '?')
.replace(/%2F/g, '/')
.replace(/'/g, '%27')
.replace(/#/g, '%23')
;
}
newUrl += window.location.hash;
// Tidy up if base tag in header
newUrl = new URL(newUrl, window.location.protocol + '//' + window.location.host).href;
if (newUrl !== window.location.href) { //has it changed?
let now = window.performance.now();
if (lastChangedAt + dwellTime > now) {
window.history.replaceState({}, '', newUrl);
} else {
window.history.pushState({}, '', newUrl);
}
urlChanged();
}
}
function encodeParams(params) {
const encodedParams = [];
for (let key in params) {
const value = params[key];
if (value === '') {
encodedParams.push(encodeURIComponent(key));
} else {
encodedParams.push(
encodeURIComponent(key) + '=' +
encodeURIComponent(value.toString()));
}
}
return encodedParams.join('&');
}
function decodeParams(paramString) {
var params = {};
// Work around a bug in decodeURIComponent where + is not
// converted to spaces:
paramString = (paramString || '').replace(/\+/g, '%20');
var paramList = paramString.split('&');
for (var i = 0; i < paramList.length; i++) {
var param = paramList[i].split('=');
if (param[0]) {
let value;
try {
value = decodeURIComponent(param[1]);
if (value === 'true') {
value = true;
} else if (value === 'false') {
value = false;
} else if (/^-?\d+$/.test(value)) {
value = parseInt(value,10);
}
} catch (e) {
value = '';
}
params[decodeURIComponent(param[0])] = value;
}
}
return params;
}
export default class Route {
constructor(match = '', ifmatched = '') {
//set default values
this.preroute = {active: false, segment: 0, path: '', params: {}, query: {}};
if (ifmatched.length > 0) {
this.matcher = ifmatched.split(':');
if (this.matcher.length !== 2) throw new Error('pas-route: Invalid ifmatched String');
} else {
this.matcher = [];
}
this.match = match;
//this is our output
this._route = {active: false, segment: 0, path: '', params: {}, query: {}};
this.sentRouteChanged = false;
}
routeChange(preroute) {
this.preroute = preroute; //remomber it
if (preroute !== undefined && preroute.active && preroute.path.length > 0 &&
this._ifMatches(preroute.params) ) {
if (this.match.length > 0) {
let completed = false;
if (this.match.slice(-1) === '/') {
completed = true; //Special meaning
if (this.match.length > 1) {
this.match = this.match.slice(0,-1);
}
}
const matchedPieces = this.match.split('/');
if (matchedPieces[0] === '') matchedPieces.shift(); //not interested in blank front
const urlPieces = preroute.path.split('/');
if (urlPieces.length < 2 || urlPieces[0] !== '') {
//something is wrong with path as it should have started with a '/'
this._route.active = false;
throw new Error('Route: Invalid path (should start with /) in route');
}
urlPieces.shift();
let j = urlPieces.length;
const newRoute = {
segment: preroute.segment + matchedPieces.length,
params: {},
active: true,
query: preroute.query
};
for(let i = 0; i < matchedPieces.length; i++) {
if (j <= 0) {
return this._clearOutActive();
}
let segment = urlPieces.shift();
j--;
if (matchedPieces[i].length !== 0) {
if (matchedPieces[i].substring(0,1) === ':') {
const key = matchedPieces[i].substring(1);
if (key.length > 0) {
if (/^-?\d+$/.test(segment)) {
segment = parseInt(segment,10);
}
newRoute.params[key] = segment;
} else {
throw new Error('Route: Match pattern missing parameter name');
}
} else if (matchedPieces[i] !== segment) {
return this._clearOutActive();
}
} else if (segment.length > 0 ){
return this._clearOutActive();
}
}
if (completed || preroute.path === '/') {
newRoute.path = '';
} else if (j == 0) {
newRoute.path = '/';
} else {
newRoute.path = '/' + urlPieces.join('/');
}
if (!this._route.active ||
JSON.stringify(this._route.params) !== JSON.stringify(newRoute.params) ||
JSON.stringify(this._route.query) !== JSON.stringify(newRoute.query) ||
this._route.path !== newRoute.path || this._route.segment !== newRoute.segment) {
this._route = newRoute;
this.sentRouteChanged = true;
}
} else {
throw new Error('Route: Match String Required');
}
} else {
this._clearOutActive();
}
return this._route;
}
/*
* set new paramters provided route is active
*/
set params(value) {
if (this._route.active) {
let match = this.match;
if (match.slice(-1) === '/' && match.length > 1) match = this.match.slice(0,-1);
const matchedPieces = match.split('/');
if (matchedPieces[0] === '') matchedPieces.shift(); //not interested in blank front
let urlPieces = this.preroute.path.split('/');
urlPieces.shift(); //loose blank front
let changeMade = false;
for (let i = 0; i < matchedPieces.length; i++) {
if (urlPieces.length < i) urlPieces.push(''); //ensure there is a url segment for this match
if (matchedPieces[i].length !== 0) {
if (matchedPieces[i].substring(0,1) === ':') {
const key = matchedPieces[i].substring(1);
if (value[key] !== undefined) {
if (Number.isInteger(value[key])) {
if (urlPieces[i] !== value[key].toString()) {
urlPieces[i] = value[key].toString();
changeMade = true;
}
} else if (typeof value[key] === 'string') {
if (value[key].length > 0) {
if (urlPieces[i] !== value[key]) {
urlPieces[i] = value[key];
changeMade = true;
}
} else {
//terminate url here
urlPieces.length = i;
changeMade = true;
break;
}
} else if (value[key] === null) {
//terminate url here
urlPieces.length = i;
changeMade = true;
break;
} else {
throw new Error('Route: Invalid params.' + key + ' provided (should be a String or an Integer)');
}
}
}
}
}
if (changeMade) window.dispatchEvent(new CustomEvent('route-updated',{
detail: {
segment: this.preroute.segment,
path: '/' + urlPieces.join('/')
}
}));
}
}
/*
* Set a new query value provided route is active
*/
set query(value) {
if (this._route.active && JSON.stringify(this._route.query) !== JSON.stringify(value)) {
window.dispatchEvent(new CustomEvent('route-updated',{
detail: {
query: value
}
}));
}
}
/*
* We can set or break the connection between a pre-route and its route
*/
set connection(value) {
if (this.preroute.active) {
if (this._route.active) {
if (value) return; //can't set a matched route active
//just reset to a url
window.dispatchEvent(new CustomEvent('route-updated',{
detail: {
segment: this.preroute.segment,
path: '/'
}
}));
} else {
if (value) {
let match = this.match;
if (match.slice(-1) === '/' && match.length > 1) match = this.match.slice(0,-1);
const matchedPieces = match.split('/');
if (matchedPieces[0] === '') matchedPieces.shift(); //not interested in blank front
if (matchedPieces.length < 1) return;
if (matchedPieces.every(piece => piece.length > 0 && piece.indexOf(':') < 0)) {
window.dispatchEvent(new CustomEvent('route-updated',{
detail: {
segment: this.preroute.segment,
path: '/' + matchedPieces.join('/')
}
}));
}
}
}
}
}
_ifMatches (params) {
if (this.matcher.length === 0) return true; //Empty String always matches
return (params[this.matcher[0]] !== undefined && params[this.matcher[0]] === this.matcher[1]);
}
_clearOutActive () {
if (this._route === undefined) return;
if (this._route.active || !this.sentRouteChanged) {
this._route = Object.assign({}, this._route, {active:false});
this.sentRouteChanged = true;
}
return this._route;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment