summaryrefslogtreecommitdiff
path: root/preact-router/src
diff options
context:
space:
mode:
Diffstat (limited to 'preact-router/src')
-rw-r--r--preact-router/src/index.d.ts71
-rw-r--r--preact-router/src/index.js264
-rw-r--r--preact-router/src/match.d.ts16
-rw-r--r--preact-router/src/match.js36
-rw-r--r--preact-router/src/util.js82
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);
+}