UNPKG

@srfnstack/fntags

Version:

<p align="center"> <img alt="fntags header" src="https://raw.githubusercontent.com/SRFNStack/fntags/master/docs/fntags_header.gif"> </p>

320 lines (293 loc) 9.26 kB
/// <reference path="fntags.mjs" name="fntags"/> /** * @module fnroute */ import { fnstate, getAttrs, h, isAttrs, renderNode } from './fntags.mjs' /** * An element that is displayed only if the current route starts with elements path attribute. * * For example, * route({path: "/proc"}, * div( * "proc", * div({path: "/cpuinfo"}, * "cpuinfo" * ) * ) * ) * * You can override this behavior by setting the attribute, absolute to any value * * route({path: "/usr"}, * div( * "proc", * div({path: "/cpuinfo", absolute: true}, * "cpuinfo" * ) * ) * ) * * @param {(Object|Node)[]} children The attributes and children of this element. * @returns {HTMLDivElement} A div element that will only be displayed if the current route starts with the path attribute. */ export function route (...children) { const attrs = getAttrs(children) children = children.filter(c => !isAttrs(c)) const routeEl = h('div', attrs) const display = routeEl.style.display const path = routeEl.getAttribute('path') if (!path) { throw new Error('route must have a string path attribute') } routeEl.updateRoute = () => { while (routeEl.firstChild) { routeEl.removeChild(routeEl.firstChild) } // this forces a re-render on route change routeEl.append(...children.map(c => renderNode(typeof c === 'function' ? c() : c))) routeEl.style.display = display } return routeEl } /** * An element that only renders the first route that matches and updates when the route is changed * The primary purpose of this element is to provide catchall routes for not found pages and path variables * @param {(Object|Node)[]} children * @returns {Node|(()=>Node)} */ export function routeSwitch (...children) { const sw = h('div', getAttrs(children)) return pathState.bindAs( () => { while (sw.firstChild) { sw.removeChild(sw.firstChild) } for (const child of children) { const path = child.getAttribute('path') if (path) { const shouldDisplay = shouldDisplayRoute(path, !!child.absolute || child.getAttribute('absolute') === 'true') if (shouldDisplay) { routeState.currentRoute = path updatePathParameters() child.updateRoute(true) sw.append(child) return sw } } } } ) } function updatePathParameters () { const path = routeState.currentRoute const currentPath = pathState().currentPath const pathParts = path.split('/') const currentPathParts = currentPath.split('/') const parameters = { idx: [] } for (let i = 0; i < pathParts.length; i++) { const part = pathParts[i] if (part.startsWith(':')) { parameters[part.substring(1)] = currentPathParts[i] } } pathParameters(parameters) } /** * A link element that is a link to another route in this single page app * @param {(Object|Node)[]} children The attributes of the anchor element and any children * @returns {HTMLAnchorElement} An anchor element that will navigate to the specified route when clicked */ export function fnlink (...children) { let context = null if (children[0] && children[0].context) { context = children[0].context } const a = h('a', ...children) const to = a.getAttribute('to') if (!to) { throw new Error('fnlink must have a "to" string attribute').stack } a.addEventListener('click', (e) => { e.preventDefault() e.stopPropagation() goTo(to, context) }) a.setAttribute( 'href', makePath(to) ) return a } /** * A function to navigate to the specified route * @param {string} route The route to navigate to * @param {any} context Data related to the route change * @param {boolean} replace Whether to replace the state or push it. pushState is used by default. * @param {boolean} silent Prevent route change events from being emitted for this route change */ export function goTo (route, context = {}, replace = false, silent = false) { const newPath = window.location.origin + makePath(route) const patch = { currentPath: route.split(/[#?]/)[0], context } const oldPathState = pathState() const newPathState = Object.assign({}, oldPathState, patch) if (!silent) { try { emit(beforeRouteChange, newPathState, oldPathState) } catch (e) { console.log('Path change cancelled', e) return } } if (replace) { history.replaceState({}, route, newPath) } else { history.pushState({}, route, newPath) } setTimeout(() => { pathState.assign({ currentPath: route.split(/[#?]/)[0], context }) if (!silent) { emit(afterRouteChange, newPathState, oldPathState) } if (newPath.indexOf('#') > -1) { const el = document.getElementById(decodeURIComponent(newPath.split('#')[1])) el && el.scrollIntoView() } else { window.scrollTo(0, 0) } if (!silent) { emit(routeChangeComplete, newPathState, oldPathState) } }) } const ensureOnlyLeadingSlash = (part) => removeTrailingSlash(part.startsWith('/') ? part : '/' + part) const removeTrailingSlash = part => part.endsWith('/') && part.length > 1 ? part.slice(0, -1) : part /** * Key value pairs of path parameter names to their values * @typedef {Object} PathParameters */ /** * The path parameters of the current route * @type {import('./fntags.mjs').FnState<PathParameters>} */ export const pathParameters = fnstate({}) /** * The path information for a route * @typedef {{currentPath: string, rootPath: string, context: any}} PathState */ /** * The current path state * @type {import('./fntags.mjs').FnState<PathState>} */ export const pathState = fnstate( { rootPath: ensureOnlyLeadingSlash(window.location.pathname), currentPath: ensureOnlyLeadingSlash(window.location.pathname), context: null }) const routeState = { currentRoute: null } /** * @typedef {string} RouteEvent */ /** * Before the route is changed * @type {RouteEvent} */ export const beforeRouteChange = 'beforeRouteChange' /** * After the route is changed * @type {RouteEvent} */ export const afterRouteChange = 'afterRouteChange' /** * After the route is changed and the route element is rendered * @type {RouteEvent} */ export const routeChangeComplete = 'routeChangeComplete' const eventListeners = { [beforeRouteChange]: [], [afterRouteChange]: [], [routeChangeComplete]: [] } const emit = (event, newPathState, oldPathState) => { for (const fn of eventListeners[event]) fn(newPathState, oldPathState) } /** * Listen for routing events * @param event a string event to listen for * @param handler A function that will be called when the event occurs. * The function receives the new and old pathState objects, in that order. * @return {()=>void} a function to stop listening with the passed handler. */ export function listenFor (event, handler) { if (!eventListeners[event]) { throw new Error(`Invalid event. Must be one of ${Object.keys(eventListeners)}`) } eventListeners[event].push(handler) return () => { const i = eventListeners[event].indexOf(handler) if (i > -1) { return eventListeners[event].splice(i, 1) } } } /** * Set the root path of the app. This is necessary to make deep linking work in cases where the same html file is served from all paths. * @param {string} rootPath The root path of the app */ export function setRootPath (rootPath) { return pathState.assign({ rootPath: ensureOnlyLeadingSlash(rootPath), currentPath: ensureOnlyLeadingSlash(window.location.pathname.replace(new RegExp('^' + rootPath), '')) || '/' }) } window.addEventListener( 'popstate', () => { const oldPathState = pathState() const patch = { currentPath: ensureOnlyLeadingSlash(window.location.pathname.replace(new RegExp('^' + pathState().rootPath), '')) || '/' } const newPathState = Object.assign({}, oldPathState, patch) try { emit(beforeRouteChange, newPathState, oldPathState) } catch (e) { console.trace('Path change cancelled', e) goTo(oldPathState.currentPath, oldPathState.context, true, true) return } pathState.assign(patch) emit(afterRouteChange, newPathState, oldPathState) emit(routeChangeComplete, newPathState, oldPathState) } ) const makePath = path => (pathState().rootPath === '/' ? '' : pathState().rootPath) + ensureOnlyLeadingSlash(path) const makePathPattern = (path, absolute) => { const pathParts = path.replace(/[?#].*/, '').replace(/\/$/, '').split('/') return '^' + pathParts.map(part => { if (part.startsWith(':')) { return '([^/]+)' } else { return part } }).join('/') + (absolute ? '/?$' : '(/.*|$)') } const shouldDisplayRoute = (route, isAbsolute) => { const path = makePath(route) const currPath = window.location.pathname const pattern = makePathPattern(path, isAbsolute) if (isAbsolute) { return currPath === path || currPath === (path + '/') || currPath.match(pattern) } else { return !!currPath.match(pattern) } }