UNPKG

smoothr

Version:

Smoothr: Smooth Router - A custom React router that leverages the Web Animations API and CSS animations.

728 lines (643 loc) 25.4 kB
import React, { createContext, Component } from 'react'; import pathToRegexp from 'path-to-regexp'; var SmoothRContext = 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.createElement( SmoothRContext.Provider, { value: { originPath: this.originPath, state: this.state, handleRouteChange: this.handleRouteChange, setRouteConsts: this.setRouteConsts, setRouteGroupVar: this.setRouteGroupVar } }, this.props.children ); }; return Smoothr; }(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.createElement( SmoothRContext.Consumer, null, function (context) { return React.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.createElement( 'div', { style: containerStyles }, React.createElement( 'div', { style: containerStyles, className: newPageClass, ref: function ref(el) { return context.setRouteGroupVar(_this2.groupHash, 'newPageRef', el); } }, React.createElement(NewPageComponent, _extends({}, newPageProps, { key: newPageKey })) ), React.createElement( 'div', { style: containerStyles, className: currentPageClass, ref: function ref(el) { return context.setRouteGroupVar(_this2.groupHash, 'currentPageRef', el); } }, React.createElement(CurrentPageComponent, _extends({}, currentPageProps, { key: currentPageKey })) ) ) : React.createElement(CurrentPageComponent, _extends({}, currentPageProps, { key: currentPageKey })); }; return SmoothRoutesRender; }(Component); function Route() { return null; } function Link(props) { return React.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.createElement( 'a', _extends({ href: '' + originPath + href, onClick: handleNavigation, 'data-smoothr-current-link': isCurrentRoute, 'data-smoothr-visited-link': visited }, otherProps), children ); } ); } export { Smoothr, SmoothRoutes, Route, Link };