UNPKG

@app-elements/router

Version:
509 lines (427 loc) 11.5 kB
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