diff options
Diffstat (limited to 'preact-router/src')
-rw-r--r-- | preact-router/src/index.d.ts | 71 | ||||
-rw-r--r-- | preact-router/src/index.js | 264 | ||||
-rw-r--r-- | preact-router/src/match.d.ts | 16 | ||||
-rw-r--r-- | preact-router/src/match.js | 36 | ||||
-rw-r--r-- | preact-router/src/util.js | 82 |
5 files changed, 469 insertions, 0 deletions
diff --git a/preact-router/src/index.d.ts b/preact-router/src/index.d.ts new file mode 100644 index 0000000..fed2cf6 --- /dev/null +++ b/preact-router/src/index.d.ts @@ -0,0 +1,71 @@ +import * as preact from 'preact'; + +export function route(url: string, replace?: boolean): boolean; +export function route(options: { url: string; replace?: boolean }): boolean; + +export function getCurrentUrl(): string; + +export interface Location { + pathname: string; + search: string; +} + +export interface CustomHistory { + listen(callback: (location: Location) => void): () => void; + location: Location; + push(path: string): void; + replace(path: string): void; +} + +export interface RoutableProps { + path?: string; + default?: boolean; +} + +export interface RouterOnChangeArgs { + router: Router; + url: string; + previous?: string; + active: preact.VNode[]; + current: preact.VNode; +} + +export interface RouterProps extends RoutableProps { + history?: CustomHistory; + static?: boolean; + url?: string; + onChange?: (args: RouterOnChangeArgs) => void; +} + +export class Router extends preact.Component<RouterProps, {}> { + canRoute(url: string): boolean; + getMatchingChildren( + children: preact.VNode[], + url: string, + invoke: boolean + ): preact.VNode[]; + routeTo(url: string): boolean; + render(props: RouterProps, {}): preact.VNode; +} + +export const subscribers: Array<(url: string) => void> + +type AnyComponent<Props> = + | preact.FunctionalComponent<Props> + | preact.ComponentConstructor<Props, any>; + +export interface RouteProps<Props> extends RoutableProps { + component: AnyComponent<Props>; +} + +export function Route<Props>( + props: RouteProps<Props> & Partial<Props> +): preact.VNode; + +export function Link(props: {activeClassName?: string} & preact.JSX.HTMLAttributes): preact.VNode; + +declare module 'preact' { + export interface Attributes extends RoutableProps {} +} + +export default Router; diff --git a/preact-router/src/index.js b/preact-router/src/index.js new file mode 100644 index 0000000..464ec54 --- /dev/null +++ b/preact-router/src/index.js @@ -0,0 +1,264 @@ +import { cloneElement, createElement, Component, toChildArray } from 'preact'; +import { exec, prepareVNodeForRanking, assign, pathRankSort } from './util'; + +let customHistory = null; + +const ROUTERS = []; + +const subscribers = []; + +const EMPTY = {}; + +function setUrl(url, type='push') { + if (customHistory && customHistory[type]) { + customHistory[type](url); + } + else if (typeof history!=='undefined' && history[type+'State']) { + history[type+'State'](null, null, url); + } +} + + +function getCurrentUrl() { + let url; + if (customHistory && customHistory.location) { + url = customHistory.location; + } + else if (customHistory && customHistory.getCurrentLocation) { + url = customHistory.getCurrentLocation(); + } + else { + url = typeof location!=='undefined' ? location : EMPTY; + } + return `${url.pathname || ''}${url.search || ''}`; +} + + + +function route(url, replace=false) { + if (typeof url!=='string' && url.url) { + replace = url.replace; + url = url.url; + } + + // only push URL into history if we can handle it + if (canRoute(url)) { + setUrl(url, replace ? 'replace' : 'push'); + } + + return routeTo(url); +} + + +/** Check if the given URL can be handled by any router instances. */ +function canRoute(url) { + for (let i=ROUTERS.length; i--; ) { + if (ROUTERS[i].canRoute(url)) return true; + } + return false; +} + + +/** Tell all router instances to handle the given URL. */ +function routeTo(url) { + let didRoute = false; + for (let i=0; i<ROUTERS.length; i++) { + if (ROUTERS[i].routeTo(url)===true) { + didRoute = true; + } + } + for (let i=subscribers.length; i--; ) { + subscribers[i](url); + } + return didRoute; +} + + +function routeFromLink(node) { + // only valid elements + if (!node || !node.getAttribute) return; + + let href = node.getAttribute('href'), + target = node.getAttribute('target'); + + // ignore links with targets and non-path URLs + if (!href || !href.match(/^\//g) || (target && !target.match(/^_?self$/i))) return; + + // attempt to route, if no match simply cede control to browser + return route(href); +} + + +function handleLinkClick(e) { + if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.button!==0) return; + routeFromLink(e.currentTarget || e.target || this); + return prevent(e); +} + + +function prevent(e) { + if (e) { + if (e.stopImmediatePropagation) e.stopImmediatePropagation(); + if (e.stopPropagation) e.stopPropagation(); + e.preventDefault(); + } + return false; +} + + +function delegateLinkHandler(e) { + // ignore events the browser takes care of already: + if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.button!==0) return; + + let t = e.target; + do { + if (String(t.nodeName).toUpperCase()==='A' && t.getAttribute('href')) { + if (t.hasAttribute('native')) return; + // if link is handled by the router, prevent browser defaults + if (routeFromLink(t)) { + return prevent(e); + } + } + } while ((t=t.parentNode)); +} + + +let eventListenersInitialized = false; + +function initEventListeners() { + if (eventListenersInitialized) return; + + if (typeof addEventListener==='function') { + if (!customHistory) { + addEventListener('popstate', () => { + routeTo(getCurrentUrl()); + }); + } + addEventListener('click', delegateLinkHandler); + } + eventListenersInitialized = true; +} + + +class Router extends Component { + constructor(props) { + super(props); + if (props.history) { + customHistory = props.history; + } + + this.state = { + url: props.url || getCurrentUrl() + }; + + initEventListeners(); + } + + shouldComponentUpdate(props) { + if (props.static!==true) return true; + return props.url!==this.props.url || props.onChange!==this.props.onChange; + } + + /** Check if the given URL can be matched against any children */ + canRoute(url) { + const children = toChildArray(this.props.children); + return this.getMatchingChildren(children, url, false).length > 0; + } + + /** Re-render children with a new URL to match against. */ + routeTo(url) { + this.setState({ url }); + + const didRoute = this.canRoute(url); + + // trigger a manual re-route if we're not in the middle of an update: + if (!this.updating) this.forceUpdate(); + + return didRoute; + } + + componentWillMount() { + ROUTERS.push(this); + this.updating = true; + } + + componentDidMount() { + if (customHistory) { + this.unlisten = customHistory.listen((location) => { + this.routeTo(`${location.pathname || ''}${location.search || ''}`); + }); + } + this.updating = false; + } + + componentWillUnmount() { + if (typeof this.unlisten==='function') this.unlisten(); + ROUTERS.splice(ROUTERS.indexOf(this), 1); + } + + componentWillUpdate() { + this.updating = true; + } + + componentDidUpdate() { + this.updating = false; + } + + getMatchingChildren(children, url, invoke) { + return children + .filter(prepareVNodeForRanking) + .sort(pathRankSort) + .map( vnode => { + let matches = exec(url, vnode.props.path, vnode.props); + if (matches) { + if (invoke !== false) { + let newProps = { url, matches }; + assign(newProps, matches); + delete newProps.ref; + delete newProps.key; + return cloneElement(vnode, newProps); + } + return vnode; + } + }).filter(Boolean); + } + + render({ children, onChange }, { url }) { + let active = this.getMatchingChildren(toChildArray(children), url, true); + + let current = active[0] || null; + + let previous = this.previousUrl; + if (url!==previous) { + this.previousUrl = url; + if (typeof onChange==='function') { + onChange({ + router: this, + url, + previous, + active, + current + }); + } + } + + return current; + } +} + +const Link = (props) => ( + createElement('a', assign({ onClick: handleLinkClick }, props)) +); + +const Route = props => createElement(props.component, props); + +Router.subscribers = subscribers; +Router.getCurrentUrl = getCurrentUrl; +Router.route = route; +Router.Router = Router; +Router.Route = Route; +Router.Link = Link; +Router.exec = exec; + +export { subscribers, getCurrentUrl, route, Router, Route, Link, exec }; +export default Router; diff --git a/preact-router/src/match.d.ts b/preact-router/src/match.d.ts new file mode 100644 index 0000000..9d800c3 --- /dev/null +++ b/preact-router/src/match.d.ts @@ -0,0 +1,16 @@ +import * as preact from 'preact'; + +import { Link as StaticLink, RoutableProps } from './'; + +export class Match extends preact.Component<RoutableProps, {}> { + render(): preact.VNode; +} + +export interface LinkProps extends preact.JSX.HTMLAttributes { + activeClassName?: string; + children?: preact.ComponentChildren; +} + +export function Link(props: LinkProps): preact.VNode; + +export default Match; diff --git a/preact-router/src/match.js b/preact-router/src/match.js new file mode 100644 index 0000000..90243d0 --- /dev/null +++ b/preact-router/src/match.js @@ -0,0 +1,36 @@ +import { h, Component } from 'preact'; +import { subscribers, getCurrentUrl, Link as StaticLink, exec } from 'preact-router'; + +export class Match extends Component { + update = url => { + this.nextUrl = url; + this.setState({}); + }; + componentDidMount() { + subscribers.push(this.update); + } + componentWillUnmount() { + subscribers.splice(subscribers.indexOf(this.update)>>>0, 1); + } + render(props) { + let url = this.nextUrl || getCurrentUrl(), + path = url.replace(/\?.+$/,''); + this.nextUrl = null; + return props.children({ + url, + path, + matches: exec(path, props.path, {}) !== false + }); + } +} + +export const Link = ({ activeClassName, path, ...props }) => ( + <Match path={path || props.href}> + { ({ matches }) => ( + <StaticLink {...props} class={[props.class || props.className, matches && activeClassName].filter(Boolean).join(' ')} /> + ) } + </Match> +); + +export default Match; +Match.Link = Link; diff --git a/preact-router/src/util.js b/preact-router/src/util.js new file mode 100644 index 0000000..8bd989c --- /dev/null +++ b/preact-router/src/util.js @@ -0,0 +1,82 @@ + +const EMPTY = {}; + +export function assign(obj, props) { + // eslint-disable-next-line guard-for-in + for (let i in props) { + obj[i] = props[i]; + } + return obj; +} + +export function exec(url, route, opts) { + let reg = /(?:\?([^#]*))?(#.*)?$/, + c = url.match(reg), + matches = {}, + ret; + if (c && c[1]) { + let p = c[1].split('&'); + for (let i=0; i<p.length; i++) { + let r = p[i].split('='); + matches[decodeURIComponent(r[0])] = decodeURIComponent(r.slice(1).join('=')); + } + } + url = segmentize(url.replace(reg, '')); + route = segmentize(route || ''); + let max = Math.max(url.length, route.length); + for (let i=0; i<max; i++) { + if (route[i] && route[i].charAt(0)===':') { + let param = route[i].replace(/(^:|[+*?]+$)/g, ''), + flags = (route[i].match(/[+*?]+$/) || EMPTY)[0] || '', + plus = ~flags.indexOf('+'), + star = ~flags.indexOf('*'), + 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 (opts.default!==true && ret===false) return false; + return matches; +} + +export function pathRankSort(a, b) { + return ( + (a.rank < b.rank) ? 1 : + (a.rank > b.rank) ? -1 : + (a.index - b.index) + ); +} + +// filter out VNodes without attributes (which are unrankeable), and add `index`/`rank` properties to be used in sorting. +export function prepareVNodeForRanking(vnode, index) { + vnode.index = index; + vnode.rank = rankChild(vnode); + return vnode.props; +} + +export function segmentize(url) { + return url.replace(/(^\/+|\/+$)/g, '').split('/'); +} + +export function rankSegment(segment) { + return segment.charAt(0)==':' ? (1 + '*+?'.indexOf(segment.charAt(segment.length-1))) || 4 : 5; +} + +export function rank(path) { + return segmentize(path).map(rankSegment).join(''); +} + +function rankChild(vnode) { + return vnode.props.default ? 0 : rank(vnode.props.path); +} |