smoothr
Version:
Smoothr: Smooth Router - A custom React router that leverages the Web Animations API and CSS animations.
742 lines (654 loc) • 27.4 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('react'), require('path-to-regexp')) :
typeof define === 'function' && define.amd ? define(['exports', 'react', 'path-to-regexp'], factory) :
(factory((global.Smoothr = {}),global.React,global.pathToRegexp));
}(this, (function (exports,React,pathToRegexp) { 'use strict';
var React__default = 'default' in React ? React['default'] : React;
pathToRegexp = pathToRegexp && pathToRegexp.hasOwnProperty('default') ? pathToRegexp['default'] : pathToRegexp;
var SmoothRContext = React.createContext();
var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) {
return typeof obj;
} : function (obj) {
return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj;
};
var classCallCheck = function (instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
};
var _extends = Object.assign || function (target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
}
}
return target;
};
var inherits = function (subClass, superClass) {
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function, not " + typeof superClass);
}
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true
}
});
if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
};
var objectWithoutProperties = function (obj, keys) {
var target = {};
for (var i in obj) {
if (keys.indexOf(i) >= 0) continue;
if (!Object.prototype.hasOwnProperty.call(obj, i)) continue;
target[i] = obj[i];
}
return target;
};
var possibleConstructorReturn = function (self, call) {
if (!self) {
throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
}
return call && (typeof call === "object" || typeof call === "function") ? call : self;
};
function extractPathVars(pathWithVars, extractingPath) {
var pathKeys = [];
var pathAsRegexp = pathToRegexp(pathWithVars, pathKeys);
pathAsRegexp.exec(extractingPath);
return pathKeys.reduce(function (acc, key, i) {
var _babelHelpers$extends;
return _extends((_babelHelpers$extends = {}, _babelHelpers$extends[key.name] = pathAsRegexp.exec(extractingPath)[i + 1], _babelHelpers$extends), acc);
}, {});
}
function getLSArray(key) {
var val = JSON.parse(localStorage.getItem(key));
return !val ? [] : [].concat(val).filter(function (k) {
return k;
});
}
function pushLSArray(key, val) {
var force = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
var lsArray = getLSArray(key);
if (typeof val === 'string' && (force || lsArray.indexOf(val) === -1)) {
lsArray.push(val);
}
// Arbitrary max amount of keys
if (lsArray.length >= 100) {
lsArray = [];
}
localStorage.setItem(key, JSON.stringify(lsArray));
}
// Local Storage Keys USED
var LS_KEYS = Object.freeze({
VISITED_URL_LIST: 'SmoothrVisitedUrls',
VISITED_ROUTE_LIST: 'SmoothrVisitedRoutes'
});
var Smoothr = function (_Component) {
inherits(Smoothr, _Component);
function Smoothr(props) {
classCallCheck(this, Smoothr);
// Set some class properties
var _this = possibleConstructorReturn(this, _Component.call(this, props));
_this.deriveCurrentRoute = function () {
var suffix = window.location.href.split('/');
suffix = '/' + suffix.slice(3, suffix.length).join('/');
var preppedUrl = suffix.split(_this.originPath).join('') || '/';
if (preppedUrl + '#' === _this.originPath) {
preppedUrl = '/';
}
return preppedUrl;
};
_this.setRouteConsts = function (routeConsts, notFoundPath) {
function merge(a, b, prop) {
var reduced = a.filter(function (aitem) {
return !b.find(function (bitem) {
return aitem[prop] === bitem[prop];
});
});
return reduced.concat(b);
}
// Add any routes to the routeConsts that aren't already there
_this.setState(function (state) {
var newStateObj = { routeConsts: merge(state.routeConsts, routeConsts) };
if (_this.defaultNotFoundPath) {
_this.defaultNotFoundPath = false;
newStateObj.notFoundPath = notFoundPath;
}
return newStateObj;
});
};
_this.setRouteGroupVar = function (groupHash, k, v) {
if (!_this.routeVars[groupHash]) {
_this.routeVars[groupHash] = {};
}
_this.routeVars[groupHash][k] = v;
};
_this.handlePopState = function (e) {
if (e.state) {
var backNavigation = e.state.pageNavigated < _this.state.pageNavigated;
_this.handleRouteChange(e.state.url, backNavigation);
}
};
_this.handleHashChange = function () {
var incomingUrl = _this.deriveCurrentRoute();
_this.handleRouteChange(incomingUrl);
};
_this.resolveRoutes = function (incomingUrl) {
// Remove query string and hash from new url
var cleanNewUrl = incomingUrl.replace(/\?(.*)|\#(.*)/, '');
var queryStringHash = incomingUrl.split(cleanNewUrl).join('');
// Handle 404
var incomingRoute = void 0,
outgoingRoute = void 0;
var newUrlIsFound = false;
_this.state.routeConsts.forEach(function (routeObj) {
// If it matches the current URL
if (RegExp(routeObj.pathRegexp).test(_this.state.currentUrl)) {
outgoingRoute = routeObj.path;
}
// If it matches the new URL
if (RegExp(routeObj.pathRegexp).test(cleanNewUrl)) {
// Handle pathResolving
if (routeObj.pathResolve) {
var keyValObj = extractPathVars(routeObj.path, cleanNewUrl);
var resolvedUrl = routeObj.pathResolve(keyValObj);
if (typeof resolvedUrl === 'string' && RegExp(routeObj.pathRegexp).test(resolvedUrl)) {
cleanNewUrl = resolvedUrl;
incomingUrl = '' + resolvedUrl + queryStringHash;
incomingRoute = routeObj.path;
newUrlIsFound = true;
}
} else {
incomingRoute = routeObj.path;
newUrlIsFound = true;
}
}
});
if (!newUrlIsFound) {
incomingUrl = _this.state.notFoundPath;
incomingRoute = _this.state.notFoundPath;
}
// Handle if the previous url was the 404
if (_this.state.currentUrl === _this.state.notFoundPath) {
outgoingRoute = _this.state.notFoundPath;
}
var outgoingUrl = !newUrlIsFound ? _this.state.notFoundPath : _this.state.currentUrl;
return {
incomingRoute: incomingRoute,
incomingUrl: incomingUrl,
outgoingRoute: outgoingRoute,
outgoingUrl: outgoingUrl
};
};
_this.handleRouteChange = function (newRoute) {
var backNavigation = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
var linkNavigation = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
var _this$resolveRoutes = _this.resolveRoutes(newRoute),
outgoingUrl = _this$resolveRoutes.outgoingUrl,
incomingUrl = _this$resolveRoutes.incomingUrl,
outgoingRoute = _this$resolveRoutes.outgoingRoute,
incomingRoute = _this$resolveRoutes.incomingRoute;
// All incoming routes and URLs are set
var newStateObj = {};
if (linkNavigation) {
// Change url in browser
window.history.pushState({
url: incomingUrl,
pageNavigated: _this.state.pageNavigated + 1
}, '', '' + _this.originPath + incomingUrl);
// Push visited links to state and local storage
if (_this.state.visitedUrls.indexOf(outgoingUrl) === -1) {
newStateObj.visitedUrls = [].concat(_this.state.visitedUrls, [outgoingUrl]);
pushLSArray(LS_KEYS.VISITED_URL_LIST, outgoingUrl, true);
}
if (_this.state.visitedRoutes.indexOf(outgoingRoute) === -1) {
newStateObj.visitedRoutes = [].concat(_this.state.visitedRoutes, [outgoingRoute]);
pushLSArray(LS_KEYS.VISITED_ROUTE_LIST, outgoingRoute, true);
}
}
// Handle initial pageload without animating
if (_this.initialPageload) {
_this.initialPageload = false;
window.history.replaceState({ url: incomingUrl, pageNavigated: _this.state.pageNavigated }, '', '' + _this.originPath + incomingUrl);
_this.setState(_extends({}, newStateObj, {
currentUrl: incomingUrl
}));
} else {
// Kick off the animation
new Promise(function (resolve) {
if (_this.props.beforeAnimation) {
_this.props.beforeAnimation({
outgoingUrl: outgoingUrl,
incomingUrl: incomingUrl,
outgoingRoute: outgoingRoute,
incomingRoute: incomingRoute,
backNavigation: backNavigation
});
}
resolve();
}).then(function () {
if (_this.props.onAnimationStart) {
_this.props.onAnimationStart({
initialPageload: false
});
}
// Execute the animation in state
var interrupted = false;
_this.setState(function (state) {
var pageNavigated = backNavigation ? state.pageNavigated - 1 : state.pageNavigated + 1;
if (state.newUrl) {
// Interupted animation. End animation.
interrupted = true;
clearTimeout(_this.animationTimeout);
_this.domInAnimation.cancel();
_this.domOutAnimation.cancel();
return _extends({}, newStateObj, {
newUrl: null,
currentUrl: incomingUrl,
pageNavigated: pageNavigated,
backNavigation: false
});
}
// Start animation
return _extends({}, newStateObj, {
newUrl: incomingUrl,
pageNavigated: pageNavigated,
backNavigation: backNavigation
});
}, function () {
if (interrupted) {
_this.domInAnimation.cancel = function () {};
_this.domOutAnimation.cancel = function () {};
if (_this.props.onAnimationEnd) {
_this.props.onAnimationEnd();
}
}
});
});
}
};
_this.initialPageload = true;
_this.defaultNotFoundPath = true;
_this.originPath = '';
if (props.originPath && props.originPath !== '/') {
_this.originPath = props.originPath;
}
_this.routeVars = {};
// DOM Animation class properties
_this.domInAnimation = {};
_this.domInAnimation.cancel = function () {};
_this.domOutAnimation = {};
_this.domOutAnimation.cancel = function () {};
_this.state = {
currentUrl: _this.deriveCurrentRoute(),
newUrl: null,
pageNavigated: 1,
backNavigation: false,
routeConsts: [],
notFoundPath: '/notfound',
visitedUrls: getLSArray(LS_KEYS.VISITED_URL_LIST),
visitedRoutes: getLSArray(LS_KEYS.VISITED_ROUTE_LIST)
};
return _this;
}
Smoothr.prototype.componentDidMount = function componentDidMount() {
var _this2 = this;
// Run the initial page load after setting all routeGroupVars
setTimeout(function () {
_this2.handleRouteChange(_this2.state.currentUrl);
}, 0);
window.addEventListener('popstate', this.handlePopState);
window.addEventListener('hashchange', this.handleHashChange);
};
Smoothr.prototype.componentWillUnmount = function componentWillUnmount() {
window.removeEventListener('popstate', this.handlePopState);
window.removeEventListener('hashchange', this.handleHashChange);
};
Smoothr.prototype.componentDidUpdate = function componentDidUpdate() {
var _this3 = this;
var endRouteChange = function () {
_this3.setState(function (state) {
// This prevents redundant setting of state
if (state.newUrl) {
return {
newUrl: null,
currentUrl: state.newUrl,
backNavigation: false
};
}
return null;
}, function () {
_this3.domInAnimation.cancel = function () {};
_this3.domOutAnimation.cancel = function () {};
if (_this3.props.onAnimationEnd) {
_this3.props.onAnimationEnd();
}
});
};
// If an animation just started, execute animation in the DOM
if (this.state.newUrl) {
// Clear out any timeout that may have been interrupted
clearTimeout(this.animationTimeout);
this.domInAnimation.cancel();
this.domOutAnimation.cancel();
// Execute the route animations for each route group
Object.keys(this.routeVars).forEach(function (routeHash) {
var routeGroup = _this3.routeVars[routeHash];
var inAnimation = routeGroup.animationIn;
var outAnimation = routeGroup.animationOut;
var opts = routeGroup.animationOpts;
// Determine if we use reverse animations
if (_this3.state.backNavigation) {
inAnimation = routeGroup.reverseAnimationIn || inAnimation;
outAnimation = routeGroup.reverseAnimationOut || outAnimation;
opts = routeGroup.reverseAnimationOpts || opts;
}
// Force the opts to be an object
if (typeof opts === 'number') {
opts = { duration: opts };
} else if ((typeof opts === 'undefined' ? 'undefined' : _typeof(opts)) === 'object') {
opts.fill = 'forwards';
} else if (opts) {
throw 'Smoothr Error: animationOpts/reverseAnimationOps prop must be an object or integer';
} else {
opts = { duration: 0 };
}
// Error if using Infinity
if (opts.iterations && opts.iterations === Infinity) {
throw 'Smoothr Error: Cannot use `iterations: Infinity` as animation options';
}
var duration = (opts.delay || 0) + opts.duration * (opts.iterations || 1) + (opts.endDelay || 0);
var cssAnimationUsed = false;
// IN ANIMATIONS
if (routeGroup.newPageRef) {
if (typeof inAnimation !== 'string') {
// In animation object
_this3.domInAnimation = routeGroup.newPageRef.animate(inAnimation, opts);
_this3.domInAnimation.onfinish = endRouteChange;
_this3.domInAnimation.oncancel = endRouteChange;
_this3.domInAnimation.play();
} else {
// In animation className
cssAnimationUsed = true;
}
}
// OUT ANIMATIONS
if (routeGroup.currentPageRef) {
// Out animation object
if (typeof outAnimation !== 'string' && routeGroup.currentPageRef) {
_this3.domOutAnimation = routeGroup.currentPageRef.animate(outAnimation, opts);
_this3.domOutAnimation.onfinish = endRouteChange;
_this3.domOutAnimation.oncancel = endRouteChange;
_this3.domOutAnimation.play();
} else {
// Out animation className
cssAnimationUsed = true;
}
}
if (cssAnimationUsed) {
_this3.animationTimeout = setTimeout(endRouteChange, duration);
}
});
}
};
Smoothr.prototype.render = function render() {
return React__default.createElement(
SmoothRContext.Provider,
{
value: {
originPath: this.originPath,
state: this.state,
handleRouteChange: this.handleRouteChange,
setRouteConsts: this.setRouteConsts,
setRouteGroupVar: this.setRouteGroupVar
}
},
this.props.children
);
};
return Smoothr;
}(React.Component);
function generateHash() {
var crypto = window.crypto || window.msCrypto;
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, function (c) {
return (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16);
});
}
function assignUserSetProps(attributes, props) {
Object.keys(attributes).forEach(function (propName) {
if (['component', 'path', 'animationIn', 'animationOut', 'animationOpts', 'children'].indexOf(propName) === -1) {
var _Object$assign;
Object.assign(props, (_Object$assign = {}, _Object$assign[propName] = attributes[propName], _Object$assign));
}
});
}
function assignPathProps(pathRegexp, pathKeys, path, props) {
var r = pathRegexp.exec(path);
Object.assign(props, pathKeys.reduce(function (acc, key, i) {
var _babelHelpers$extends;
return _extends((_babelHelpers$extends = {}, _babelHelpers$extends[key.name] = r[i + 1], _babelHelpers$extends), acc);
}, {}));
}
function SmoothRoutes(props) {
return React__default.createElement(
SmoothRContext.Consumer,
null,
function (context) {
return React__default.createElement(SmoothRoutesRender, _extends({}, props, { context: context }));
}
);
}
var SmoothRoutesRender = function (_Component) {
inherits(SmoothRoutesRender, _Component);
function SmoothRoutesRender(props) {
classCallCheck(this, SmoothRoutesRender);
// Generate a route group hash, for group variables
var _this = possibleConstructorReturn(this, _Component.call(this, props));
_this.groupHash = generateHash();
// Set all of the paths for the route group, as well as the 404 path
var notFoundPath = '/notfound';
var routeConsts = [];
_this.props.children.forEach(function (c) {
if (c.props.notFound && c.props.path.indexOf(':') === -1) {
notFoundPath = c.props.path;
return;
} else if (c.props.notFound) {
throw 'Smoothr Error: The `path` on the <Route /> with the `notFound` attribute cannot have any URL variables.';
}
var path = c.props.path.replace(/\?(.*)|\#(.*)/, '');
var routeObj = {
path: path,
pathRegexp: pathToRegexp(path)
};
// Optionally add the pathResolve prop
if (c.props.path.indexOf(':') !== -1 && c.props.pathResolve) {
routeObj.pathResolve = c.props.pathResolve;
}
routeConsts.push(routeObj);
});
_this.props.context.setRouteConsts(routeConsts, notFoundPath);
return _this;
}
SmoothRoutesRender.prototype.render = function render() {
var _this2 = this;
var _props = this.props,
context = _props.context,
animationIn = _props.animationIn,
animationOut = _props.animationOut,
animationOpts = _props.animationOpts,
reverseAnimationIn = _props.reverseAnimationIn,
reverseAnimationOut = _props.reverseAnimationOut,
reverseAnimationOpts = _props.reverseAnimationOpts;
var _context$state = context.state,
newUrl = _context$state.newUrl,
currentUrl = _context$state.currentUrl;
var NewPageComponent = function () {
return null;
};
var CurrentPageComponent = function () {
return null;
};
var NotFoundPageComponent = function () {
return null;
};
var newPageClass = null;
var newPageProps = {};
var newPageKey = null;
var currentPageClass = null;
var currentPageProps = {};
var currentPageFound = false;
var currentPageKey = null;
this.props.children.forEach(function (route) {
var pathKeys = [];
var pathAsRegexp = pathToRegexp(route.props.path.replace(/\?(.*)|\#(.*)/, ''), pathKeys);
// Set the animation, use the <Route> prop, and the <SmoothRoutes> prop if not set.
var usedAnimationIn = route.props.animationIn || animationIn;
var usedAnimationOut = route.props.animationOut || animationOut;
var usedAnimationOpts = route.props.animationOpts || animationOpts;
var usedReverseAnimationIn = route.props.reverseAnimationIn || reverseAnimationIn;
var usedReverseAnimationOut = route.props.reverseAnimationOut || reverseAnimationOut;
var usedReverseAnimationOpts = route.props.reverseAnimationOpts || reverseAnimationOpts;
// For each applicable <Route>, do the following...
if (newUrl && RegExp(pathAsRegexp).test(newUrl)) {
assignUserSetProps(route.props, newPageProps);
assignPathProps(pathAsRegexp, pathKeys, newUrl, newPageProps);
NewPageComponent = route.props.component;
newPageKey = _this2.groupHash + '-' + newUrl;
context.setRouteGroupVar(_this2.groupHash, 'animationIn', usedAnimationIn);
context.setRouteGroupVar(_this2.groupHash, 'animationOut', usedAnimationOut);
context.setRouteGroupVar(_this2.groupHash, 'animationOpts', usedAnimationOpts);
context.setRouteGroupVar(_this2.groupHash, 'reverseAnimationIn', usedReverseAnimationIn);
context.setRouteGroupVar(_this2.groupHash, 'reverseAnimationOut', usedReverseAnimationOut);
context.setRouteGroupVar(_this2.groupHash, 'reverseAnimationOpts', usedReverseAnimationOpts);
if (typeof route.props.animationIn === 'string' || typeof _this2.props.animationIn === 'string') {
newPageClass = !context.state.backNavigation ? usedAnimationIn : usedReverseAnimationIn;
}
if (typeof route.props.animationOut === 'string' || typeof _this2.props.animationOut === 'string') {
currentPageClass = !context.state.backNavigation ? usedAnimationOut : usedReverseAnimationOut;
}
}
if (RegExp(pathAsRegexp).test(currentUrl)) {
assignUserSetProps(route.props, currentPageProps);
assignPathProps(pathAsRegexp, pathKeys, currentUrl, currentPageProps);
currentPageFound = true;
CurrentPageComponent = route.props.component;
currentPageKey = _this2.groupHash + '-' + currentUrl;
}
if (route.props.notFound) {
NotFoundPageComponent = route.props.component;
}
});
if (!currentPageFound) {
CurrentPageComponent = NotFoundPageComponent;
}
var containerStyles = {
height: '100%',
width: '100%',
position: 'absolute',
animationFillMode: 'forwards'
};
return newUrl ? React__default.createElement(
'div',
{ style: containerStyles },
React__default.createElement(
'div',
{
style: containerStyles,
className: newPageClass,
ref: function ref(el) {
return context.setRouteGroupVar(_this2.groupHash, 'newPageRef', el);
}
},
React__default.createElement(NewPageComponent, _extends({}, newPageProps, { key: newPageKey }))
),
React__default.createElement(
'div',
{
style: containerStyles,
className: currentPageClass,
ref: function ref(el) {
return context.setRouteGroupVar(_this2.groupHash, 'currentPageRef', el);
}
},
React__default.createElement(CurrentPageComponent, _extends({}, currentPageProps, { key: currentPageKey }))
)
) : React__default.createElement(CurrentPageComponent, _extends({}, currentPageProps, { key: currentPageKey }));
};
return SmoothRoutesRender;
}(React.Component);
function Route() {
return null;
}
function Link(props) {
return React__default.createElement(
SmoothRContext.Consumer,
null,
function (context) {
var href = props.href,
fuzzyCurrent = props.fuzzyCurrent,
fuzzyVisited = props.fuzzyVisited,
onClick = props.onClick,
children = props.children,
otherProps = objectWithoutProperties(props, ['href', 'fuzzyCurrent', 'fuzzyVisited', 'onClick', 'children']);
var originPath = context.originPath;
var _context$state = context.state,
newUrl = _context$state.newUrl,
currentUrl = _context$state.currentUrl,
visitedRoutes = _context$state.visitedRoutes,
visitedUrls = _context$state.visitedUrls,
routeConsts = _context$state.routeConsts;
var isCurrentRoute = newUrl ? href === newUrl : href === currentUrl;
if (fuzzyCurrent) {
// If the current url and the href of this link match the same route, it's a fuzzy current
isCurrentRoute = routeConsts.some(function (route) {
var r = RegExp(route.pathRegexp);
if (newUrl) {
return r.test(newUrl) && r.test(href);
}
return r.test(currentUrl) && r.test(href);
});
}
function handleNavigation(e) {
e.preventDefault();
if (!props.disabled && !isCurrentRoute) {
context.handleRouteChange(href, false, true);
if (onClick) {
onClick();
}
}
}
// Determine if this link has been visited
var visited = false;
if (fuzzyVisited) {
visited = visitedRoutes.some(function (vp) {
var pathAsRegexp = pathToRegexp(vp);
return RegExp(pathAsRegexp).test(href);
});
} else {
visited = visitedUrls.indexOf(href) !== -1;
}
return React__default.createElement(
'a',
_extends({
href: '' + originPath + href,
onClick: handleNavigation,
'data-smoothr-current-link': isCurrentRoute,
'data-smoothr-visited-link': visited
}, otherProps),
children
);
}
);
}
exports.Smoothr = Smoothr;
exports.SmoothRoutes = SmoothRoutes;
exports.Route = Route;
exports.Link = Link;
Object.defineProperty(exports, '__esModule', { value: true });
})));
//# sourceMappingURL=smoothr.umd.js.map