@app-elements/router
Version:
The best router.
509 lines (427 loc) • 11.5 kB
JavaScript
import React, { createContext, useContext, useRef, useEffect, useState } from 'react';
const has = Object.prototype.hasOwnProperty;
/**
* Decode a URI encoded string.
*
* @param {String} input The URI encoded string.
* @returns {String} The decoded string.
* @api private
*/
const decode = input => decodeURIComponent(input.replace(/\+/g, ' '));
/**
* Simple query string parser.
*
* @param {String} query The query string that needs to be parsed.
* @returns {Object}
* @api public
*/
function parse(query) {
const parser = /([^=?&]+)=?([^&]*)/g;
const result = {};
let part;
while ((part = parser.exec(query)) != null) {
const key = decode(part[1]); //
// Prevent overriding of existing properties. This ensures that build-in
// methods like `toString` or __proto__ are not overriden by malicious
// querystrings.
//
if (key in result) continue;
result[key] = decode(part[2]);
}
return result;
}
/**
* Transform an object into a query string.
*
* @param {Object} obj Object that should be transformed.
* @param {String} prefix Optional prefix.
* @returns {String}
* @api public
*/
function stringify(obj, prefix = '') {
const pairs = []; //
// Optionally prefix with a '?' if needed
//
if (typeof prefix !== 'string') prefix = '?';
for (const key in obj) {
if (has.call(obj, key)) {
pairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(obj[key]));
}
}
return pairs.length ? prefix + pairs.join('&') : '';
}
const hasProp = Object.prototype.hasOwnProperty;
const segmentize = url => {
return url.replace(/(^\/+|\/+$)/g, '').split('/');
};
function updateQuery(queries) {
const existingParams = parse(window.location.search);
return window.location.pathname + `?${stringify({ ...existingParams,
...queries
})}`;
} // route matching logic, taken from preact-router
const exec = (url, route) => {
const reg = /(?:\?([^#]*))?(#.*)?$/;
const c = url.match(reg);
const matches = {};
let ret;
if (c && c[1]) {
const p = c[1].split('&');
for (let i = 0; i < p.length; i++) {
const r = p[i].split('=');
matches[decodeURIComponent(r[0])] = decodeURIComponent(r.slice(1).join('='));
}
}
url = segmentize(url.replace(reg, ''));
route = segmentize(route || '');
const max = Math.max(url.length, route.length);
for (let i = 0; i < max; i++) {
if (route[i] && route[i].charAt(0) === ':') {
const param = route[i].replace(/(^:|[+*?]+$)/g, '');
const flags = (route[i].match(/[+*?]+$/) || {})[0] || '';
const plus = ~flags.indexOf('+');
const star = ~flags.indexOf('*');
const val = url[i] || '';
if (!val && !star && (flags.indexOf('?') < 0 || plus)) {
ret = false;
break;
}
matches[param] = decodeURIComponent(val);
if (plus || star) {
matches[param] = url.slice(i).map(decodeURIComponent).join('/');
break;
}
} else if (route[i] !== url[i]) {
ret = false;
break;
}
}
if (ret === false) return false;
return matches;
};
function getRouteComponent(routes, currentPath) {
for (const route in routes) {
if (hasProp.call(routes[route], 'routes')) {
const shouldRender = Object.values(routes[route].routes).some(({
path
}) => path && exec(currentPath, path));
if (shouldRender) {
const App = routes[route].component;
return [App, null];
}
} else {
const routeArgs = exec(currentPath, routes[route].path);
if (routeArgs) {
const newRoute = {
name: route,
path: routes[route].path,
args: { ...(routes[route].args || {}),
...routeArgs
}
};
const Component = routes[route].component;
return [Component, newRoute];
}
}
}
}
const getAllRoutes = routes => Object.keys(routes || {}).reduce((acc, r) => hasProp.call(routes[r], 'routes') ? { ...acc,
...getAllRoutes(routes[r].routes)
} : { ...acc,
[r]: routes[r]
}, {});
const getHref = ({
rule,
args,
queries,
hash
}) => {
const replaced = Object.keys(args).reduce((acc, k) => acc.replace(`:${k}`, args[k]), rule.path);
const hasQueries = Object.keys(queries).length > 0;
const hashStr = hash != null ? `#${hash}` : '';
return `${replaced}${!hasQueries ? '' : '?' + stringify(queries)}${hashStr}`;
};
const Context = createContext('Router');
let allRoutes;
let skipScroll;
function useRouterState() {
const [path, setPath] = useState(null);
const [route, setRoute] = useState(null);
const routeTo = newPath => {
if (newPath !== path) {
window.history.pushState(null, null, newPath);
setPath(newPath);
}
};
const setPathMaybe = newPath => {
if (path == null || path !== newPath) {
setPath(newPath);
}
};
const setRouteMaybe = newRoute => {
if (route == null || route.name !== newRoute.name) {
setRoute(newRoute);
}
};
return {
path,
setPath: setPathMaybe,
routeTo,
route,
setRoute: setRouteMaybe
};
}
function useRouter() {
const value = useContext(Context);
if (value == null) {
throw new Error('Component must be wrapped with <RouteProvider>');
}
return value;
}
function useScrollToTop() {
const backRef = useRef(false);
const nodeRef = useRef();
const pathRef = useRef();
const {
path
} = useRouter();
useEffect(() => {
const onPop = () => backRef.current = true;
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const isBack = backRef && backRef.current;
if (!isBack && path !== pathRef.current) {
if (path !== skipScroll) {
if (nodeRef && nodeRef.current) {
nodeRef.current.scrollIntoView();
} else {
window.scrollTo(0, 0);
}
} else {
skipScroll = null;
}
pathRef.current = path;
} else if (isBack) {
backRef.current = false;
}
return nodeRef;
}
function Link({
to,
name,
className,
activeClass,
args = {},
queries = {},
hash,
noScroll,
children,
...props
}) {
if (allRoutes == null) {
throw new Error('<Link /> must be child of <RouteProvider />');
}
to = to || name;
const rule = allRoutes[to];
if (!rule) {
console.error('No route found for name: ' + to);
return null;
}
const href = getHref({
rule,
args,
queries,
hash
});
return /*#__PURE__*/React.createElement(Context.Consumer, null, ({
path,
routeTo
}) => {
const isActive = path === href;
const onClick = ev => {
ev.preventDefault();
if (noScroll) {
skipScroll = href;
}
routeTo(href);
};
return /*#__PURE__*/React.createElement("a", Object.assign({
href: href,
onClick: onClick,
className: `${className || ''} ${isActive ? activeClass : ''}`.trim()
}, props), children);
});
}
function RouteTo({
to,
name,
url,
args = {},
queries = {},
hash
}) {
let href;
to = to || name;
if (url != null) {
href = url;
} else {
if (allRoutes == null) {
throw new Error('<RouteTo /> must be child of <RouteProvider />');
}
const rule = allRoutes[to];
if (!rule) {
throw new Error('No route found for name: ' + to);
}
href = getHref({
rule,
args,
queries,
hash
});
}
return /*#__PURE__*/React.createElement(Context.Consumer, null, context => {
if (href) {
context.routeTo(href);
}
return null;
});
}
function SyncRouterState({
children
}) {
const routeNameRef = useRef(null);
if (!children || typeof children !== 'function') {
throw new Error('<SyncRouterState /> requires a function as a child.');
}
return /*#__PURE__*/React.createElement(Context.Consumer, null, context => {
if (!context || context.route == null) return null;
if (routeNameRef.current == null || routeNameRef.current !== context.route.name) {
children({
route: context.route,
path: context.path
});
routeNameRef.current = context.route.name;
}
});
}
function RouteProvider({
routes,
initialPath,
children
}) {
const value = useRouterState();
if (allRoutes == null) {
allRoutes = getAllRoutes(routes);
}
useEffect(() => {
if (value.path == null) {
value.setPath(initialPath || window.location.pathname + window.location.search);
}
}, [value.path, value.setPath]);
useEffect(() => {
const onPop = ev => {
const url = window.location.pathname;
if (value.path !== url) {
window.history.replaceState(null, null, url);
value.setPath(url);
}
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, [value.path, value.setPath]);
return /*#__PURE__*/React.createElement(Context.Provider, {
value: value
}, children);
}
function Router(props) {
if (!props.routes) {
throw new Error('<Router /> must be given a routes object.');
}
const localRoutes = props.routes;
const context = useRouter();
if (context == null) {
throw new Error('<Router /> must be wrapped with <RouteProvider />.');
}
const {
path,
setRoute
} = context;
if (path == null) {
return null;
}
const pair = getRouteComponent(localRoutes, path);
if (pair == null) {
setRoute({
name: 'Not Found',
args: {},
notFound: true,
path
});
return null;
}
const [Component, newRoute] = pair;
if (newRoute) {
setRoute(newRoute);
}
const childProps = newRoute != null ? newRoute.args : {};
return typeof Component === 'function' ? /*#__PURE__*/React.createElement(Component, childProps) : Component;
}
function StackRouter({
routes: localRoutes,
limit = 2,
children
}) {
const stackRef = useRef([]);
if (!localRoutes) {
throw new Error('<StackRouter /> must be given a routes object.');
}
const context = useRouter();
if (context == null) {
throw new Error('<StackRouter /> must be wrapped with <RouteProvider />.');
}
useEffect(() => {
const onPop = () => {
stackRef.current = stackRef.current.slice(0, -1);
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, [stackRef]);
const {
path,
setRoute
} = context;
if (path == null) {
return null;
}
const pair = getRouteComponent(localRoutes, path);
if (pair == null) {
setRoute({
name: 'Not Found',
args: {},
notFound: true,
path
});
return null;
}
const [Component, newRoute] = pair;
if (newRoute) {
setRoute(newRoute);
const last = stackRef.current[stackRef.current.length - 1];
if (last == null || last.path !== path) {
if (last != null) {
stackRef.current[stackRef.current.length - 1].isBack = true;
}
stackRef.current = [].concat(stackRef.current, Object.assign({}, newRoute, {
Component,
path
}));
}
}
const stack = stackRef.current.length > limit ? stackRef.current.slice(-limit) : [].concat(stackRef.current);
return children({
stack
});
}
export { Link, RouteProvider, RouteTo, Router, StackRouter, SyncRouterState, exec, getHref, updateQuery, useRouter, useScrollToTop };
//# sourceMappingURL=router.modern.js.map