Partially recast the router API to be a lot more intuitive. (#2469)
* Recast the router API to be a lot more intuitive. Fixes #2387 Fixes #2072 Fixes quite a few issues reported on Gitter. For `m.route.Link`: - More intuitive - More accessible - More ergonomic - It can be disabled - It can be cancelled - It can be changed - Oh, and you can use it isomorphically. For `m.route.prefix` - You can *read* it. - You can write to it, of course. - It's literally just setting a property. For the router itself (and the rest of Mithril): - You can now `require("mithril")` and all its submodules without a DOM at all. There is a catch: you can't instantiate any routes, you can't mount anything, and you can't invoke `m.render` in any capacity. You can only use `m.route.Link`, `m.route.prefix`, hyperscript stuff, and `mithril/stream`, and you can use `m.request` with `background: true` if you use a global XHR polyfill. (You can't use `m.request` without `background: true` except with a DOM to redraw with.) The goal here is to try to get out of the way for simple testing and to defer the inevitable `TypeError`s for the relevant DOM methods to runtime. The factory requires no arguments, and in terms of globals, you can just figure out based on what errors are thrown what globals to define. Their values don't matter - they just need to be set to *something*, even if it's just `null` or `undefined`, before Mithril executes. Had to make quite a few other changes throughout the docs and tests to update them accordingly. Oh, and that massive router overhaul enabled me to do all this. Also, slip in a few drive-by fixes to the mocks so they're a little easier to work with and can accept more URLs. This was required for a few of the tests. * Update changelog + numbers, add forgotten bundle option * Add PR numbers to changelog [skip ci] * Allow continuing to the next match by returning `false`. * Update numbers again
This commit is contained in:
parent
ace4e77ace
commit
582bda56dc
19 changed files with 983 additions and 566 deletions
|
|
@ -18,7 +18,7 @@ mithril.js [](https://ww
|
|||
|
||||
## What is Mithril?
|
||||
|
||||
A modern client-side Javascript framework for building Single Page Applications. It's small (<!-- size -->9.31 KB<!-- /size --> gzipped), fast and provides routing and XHR utilities out of the box.
|
||||
A modern client-side Javascript framework for building Single Page Applications. It's small (<!-- size -->9.53 KB<!-- /size --> gzipped), fast and provides routing and XHR utilities out of the box.
|
||||
|
||||
Mithril is used by companies like Vimeo and Nike, and open source platforms like Lichess 👍.
|
||||
|
||||
|
|
|
|||
174
api/router.js
174
api/router.js
|
|
@ -1,6 +1,7 @@
|
|||
"use strict"
|
||||
|
||||
var Vnode = require("../render/vnode")
|
||||
var m = require("../render/hyperscript")
|
||||
var Promise = require("../promise/promise")
|
||||
|
||||
var buildPathname = require("../pathname/build")
|
||||
|
|
@ -11,9 +12,6 @@ var assign = require("../pathname/assign")
|
|||
var sentinel = {}
|
||||
|
||||
module.exports = function($window, mountRedraw) {
|
||||
var callAsync = typeof setImmediate === "function" ? setImmediate : setTimeout
|
||||
var supportsPushState = typeof $window.history.pushState === "function"
|
||||
var routePrefix = "#!"
|
||||
var fireAsync
|
||||
|
||||
function setPath(path, data, options) {
|
||||
|
|
@ -22,16 +20,19 @@ module.exports = function($window, mountRedraw) {
|
|||
fireAsync()
|
||||
var state = options ? options.state : null
|
||||
var title = options ? options.title : null
|
||||
if (options && options.replace) $window.history.replaceState(state, title, routePrefix + path)
|
||||
else $window.history.pushState(state, title, routePrefix + path)
|
||||
if (options && options.replace) $window.history.replaceState(state, title, route.prefix + path)
|
||||
else $window.history.pushState(state, title, route.prefix + path)
|
||||
}
|
||||
else {
|
||||
$window.location.href = routePrefix + path
|
||||
$window.location.href = route.prefix + path
|
||||
}
|
||||
}
|
||||
|
||||
var currentResolver = sentinel, component, attrs, currentPath, lastUpdate
|
||||
var route = function(root, defaultRoute, routes) {
|
||||
|
||||
var SKIP = route.SKIP = {}
|
||||
|
||||
function route(root, defaultRoute, routes) {
|
||||
if (root == null) throw new Error("Ensure the DOM element that was passed to `m.route` is not undefined")
|
||||
// 0 = start
|
||||
// 1 = init
|
||||
|
|
@ -49,7 +50,10 @@ module.exports = function($window, mountRedraw) {
|
|||
check: compileTemplate(route),
|
||||
}
|
||||
})
|
||||
var onremove, asyncId
|
||||
var callAsync = typeof setImmediate === "function" ? setImmediate : setTimeout
|
||||
var p = Promise.resolve()
|
||||
var scheduled = false
|
||||
var onremove
|
||||
|
||||
fireAsync = null
|
||||
|
||||
|
|
@ -62,12 +66,13 @@ module.exports = function($window, mountRedraw) {
|
|||
}
|
||||
|
||||
function resolveRoute() {
|
||||
scheduled = false
|
||||
// Consider the pathname holistically. The prefix might even be invalid,
|
||||
// but that's not our problem.
|
||||
var prefix = $window.location.hash
|
||||
if (routePrefix[0] !== "#") {
|
||||
if (route.prefix[0] !== "#") {
|
||||
prefix = $window.location.search + prefix
|
||||
if (routePrefix[0] !== "?") {
|
||||
if (route.prefix[0] !== "?") {
|
||||
prefix = $window.location.pathname + prefix
|
||||
if (prefix[0] !== "/") prefix = "/" + prefix
|
||||
}
|
||||
|
|
@ -77,58 +82,75 @@ module.exports = function($window, mountRedraw) {
|
|||
// optimized cons string.
|
||||
var path = prefix.concat()
|
||||
.replace(/(?:%[a-f89][a-f0-9])+/gim, decodeURIComponent)
|
||||
.slice(routePrefix.length)
|
||||
.slice(route.prefix.length)
|
||||
var data = parsePathname(path)
|
||||
|
||||
assign(data.params, $window.history.state)
|
||||
|
||||
for (var i = 0; i < compiled.length; i++) {
|
||||
function fail() {
|
||||
if (path === defaultRoute) throw new Error("Could not resolve default route " + defaultRoute)
|
||||
setPath(defaultRoute, null, {replace: true})
|
||||
}
|
||||
|
||||
loop(0)
|
||||
function loop(i) {
|
||||
// 0 = init
|
||||
// 1 = scheduled
|
||||
// 2 = done
|
||||
for (; i < compiled.length; i++) {
|
||||
if (compiled[i].check(data)) {
|
||||
var payload = compiled[i].component
|
||||
var route = compiled[i].route
|
||||
var update = lastUpdate = function(routeResolver, comp) {
|
||||
var matchedRoute = compiled[i].route
|
||||
var localComp = payload
|
||||
var update = lastUpdate = function(comp) {
|
||||
if (update !== lastUpdate) return
|
||||
if (comp === SKIP) return loop(i + 1)
|
||||
component = comp != null && (typeof comp.view === "function" || typeof comp === "function")? comp : "div"
|
||||
attrs = data.params, currentPath = path, lastUpdate = null
|
||||
currentResolver = routeResolver.render ? routeResolver : null
|
||||
currentResolver = payload.render ? payload : null
|
||||
if (state === 2) mountRedraw.redraw()
|
||||
else {
|
||||
state = 2
|
||||
mountRedraw.redraw.sync()
|
||||
}
|
||||
}
|
||||
if (payload.view || typeof payload === "function") update({}, payload)
|
||||
else {
|
||||
if (payload.onmatch) {
|
||||
Promise.resolve(payload.onmatch(data.params, path, route)).then(function(resolved) {
|
||||
update(payload, resolved)
|
||||
}, function () {
|
||||
if (path === defaultRoute) throw new Error("Could not resolve default route " + defaultRoute)
|
||||
setPath(defaultRoute, null, {replace: true})
|
||||
})
|
||||
// There's no understating how much I *wish* I could
|
||||
// use `async`/`await` here...
|
||||
if (payload.view || typeof payload === "function") {
|
||||
payload = {}
|
||||
update(localComp)
|
||||
}
|
||||
else update(payload, "div")
|
||||
else if (payload.onmatch) {
|
||||
p.then(function () {
|
||||
return payload.onmatch(data.params, path, matchedRoute)
|
||||
}).then(update, fail)
|
||||
}
|
||||
else update("div")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (path === defaultRoute) throw new Error("Could not resolve default route " + defaultRoute)
|
||||
setPath(defaultRoute, null, {replace: true})
|
||||
fail()
|
||||
}
|
||||
}
|
||||
|
||||
if (supportsPushState) {
|
||||
// Set it unconditionally so `m.route.set` and `m.route.Link` both work,
|
||||
// even if neither `pushState` nor `hashchange` are supported. It's
|
||||
// cleared if `hashchange` is used, since that makes it automatically
|
||||
// async.
|
||||
fireAsync = function() {
|
||||
if (!scheduled) {
|
||||
scheduled = true
|
||||
callAsync(resolveRoute)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof $window.history.pushState === "function") {
|
||||
onremove = function() {
|
||||
$window.removeEventListener("popstate", fireAsync, false)
|
||||
}
|
||||
$window.addEventListener("popstate", fireAsync = function() {
|
||||
if (asyncId) return
|
||||
asyncId = callAsync(function() {
|
||||
asyncId = null
|
||||
resolveRoute()
|
||||
})
|
||||
}, false)
|
||||
} else if (routePrefix[0] === "#") {
|
||||
$window.addEventListener("popstate", fireAsync, false)
|
||||
} else if (route.prefix[0] === "#") {
|
||||
fireAsync = null
|
||||
onremove = function() {
|
||||
$window.removeEventListener("hashchange", resolveRoute, false)
|
||||
}
|
||||
|
|
@ -160,25 +182,77 @@ module.exports = function($window, mountRedraw) {
|
|||
setPath(path, data, options)
|
||||
}
|
||||
route.get = function() {return currentPath}
|
||||
route.prefix = function(prefix) {routePrefix = prefix}
|
||||
var link = function(options, vnode) {
|
||||
vnode.dom.setAttribute("href", routePrefix + vnode.attrs.href)
|
||||
vnode.dom.onclick = function(e) {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.which === 2) return
|
||||
route.prefix = "#!"
|
||||
route.Link = {
|
||||
view: function(vnode) {
|
||||
var options = vnode.attrs.options
|
||||
// Remove these so they don't get overwritten
|
||||
var attrs = {}, onclick, href
|
||||
assign(attrs, vnode.attrs)
|
||||
attrs.component = null
|
||||
attrs.options = null
|
||||
attrs.key = null
|
||||
|
||||
// Do this now so we can get the most current `href` and `disabled`.
|
||||
// Those attributes may also be specified in the selector, and we
|
||||
// should honor that.
|
||||
var child = m(vnode.attrs.component || "a", attrs, vnode.children)
|
||||
|
||||
// Let's provide a *right* way to disable a route link, rather than
|
||||
// letting people screw up accessibility on accident.
|
||||
//
|
||||
// The attribute is coerced so users don't get surprised over
|
||||
// `disabled: 0` resulting in a button that's somehow routable
|
||||
// despite being visibly disabled.
|
||||
if (child.attrs.disabled = Boolean(child.attrs.disabled)) {
|
||||
child.attrs.href = null
|
||||
child.attrs["aria-disabled"] = "true"
|
||||
// If you *really* do want to do this on a disabled link, use
|
||||
// an `oncreate` hook to add it.
|
||||
child.attrs.onclick = null
|
||||
} else {
|
||||
onclick = child.attrs.onclick
|
||||
href = child.attrs.href
|
||||
child.attrs.href = route.prefix + href
|
||||
child.attrs.onclick = function(e) {
|
||||
var result
|
||||
if (typeof onclick === "function") {
|
||||
result = onclick.call(e.currentTarget, e)
|
||||
} else if (onclick == null || typeof onclick !== "object") {
|
||||
// do nothing
|
||||
} else if (typeof onclick.handleEvent === "function") {
|
||||
onclick.handleEvent(e)
|
||||
}
|
||||
|
||||
// Adapted from React Router's implementation:
|
||||
// https://github.com/ReactTraining/react-router/blob/520a0acd48ae1b066eb0b07d6d4d1790a1d02482/packages/react-router-dom/modules/Link.js
|
||||
//
|
||||
// Try to be flexible and intuitive in how we handle links.
|
||||
// Fun fact: links aren't as obvious to get right as you
|
||||
// would expect. There's a lot more valid ways to click a
|
||||
// link than this, and one might want to not simply click a
|
||||
// link, but right click or command-click it to copy the
|
||||
// link target, etc. Nope, this isn't just for blind people.
|
||||
if (
|
||||
// Skip if `onclick` prevented default
|
||||
result === false || !e.defaultPrevented &&
|
||||
// Ignore everything but left clicks
|
||||
(e.button === 0 || e.which === 0 || e.which === 1) &&
|
||||
// Let the browser handle `target=_blank`, etc.
|
||||
(!e.currentTarget.target || e.currentTarget.target === "_self") &&
|
||||
// No modifier keys
|
||||
!e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey
|
||||
) return
|
||||
e.preventDefault()
|
||||
e.redraw = false
|
||||
var href = this.getAttribute("href")
|
||||
if (href.indexOf(routePrefix) === 0) href = href.slice(routePrefix.length)
|
||||
route.set(href, undefined, options)
|
||||
route.set(href, null, options)
|
||||
}
|
||||
}
|
||||
route.link = function(args) {
|
||||
if (args.tag == null) return link.bind(link, args)
|
||||
return link({}, args)
|
||||
return child
|
||||
},
|
||||
}
|
||||
route.param = function(key) {
|
||||
if(typeof attrs !== "undefined" && typeof key !== "undefined") return attrs[key]
|
||||
return attrs
|
||||
return attrs && key != null ? attrs[key] : attrs
|
||||
}
|
||||
|
||||
return route
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -25,7 +25,7 @@ o.spec("route.get/route.set", function() {
|
|||
|
||||
mountRedraw = apiMountRedraw(coreRenderer($window), throttleMock.schedule, console)
|
||||
route = apiRouter($window, mountRedraw)
|
||||
route.prefix(prefix)
|
||||
route.prefix = prefix
|
||||
})
|
||||
|
||||
o.afterEach(function() {
|
||||
|
|
|
|||
10
docs/api.md
10
docs/api.md
|
|
@ -57,18 +57,18 @@ m.route.set("/home")
|
|||
var currentRoute = m.route.get()
|
||||
```
|
||||
|
||||
#### m.route.prefix(prefix) - [docs](route.md#mrouteprefix)
|
||||
#### m.route.prefix = prefix - [docs](route.md#mrouteprefix)
|
||||
|
||||
Call this before `m.route()`
|
||||
Invoke this before `m.route()` to change the routing prefix.
|
||||
|
||||
```javascript
|
||||
m.route.prefix("#!")
|
||||
m.route.prefix = "#!"
|
||||
```
|
||||
|
||||
#### m.route.link() - [docs](route.md#mroutelink)
|
||||
#### m(m.route.Link, ...) - [docs](route.md#mroutelink)
|
||||
|
||||
```javascript
|
||||
m("a[href='/Home']", {oncreate: m.route.link}, "Go to home page")
|
||||
m(m.route.Link, {href: "/Home"}, "Go to home page")
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -63,14 +63,14 @@ m.request("/api/v1/users", {background: true}).then(function() {
|
|||
|
||||
### After route changes
|
||||
|
||||
Mithril automatically redraws after [`m.route.set()`](route.md#mrouteset) calls (or route changes via links that use [`m.route.link`](route.md#mroutelink)
|
||||
Mithril automatically redraws after [`m.route.set()`](route.md#mrouteset) calls and after route changes via links using [`m.route.Link`](route.md#mroutelink).
|
||||
|
||||
```javascript
|
||||
var RoutedComponent = {
|
||||
view: function() {
|
||||
return [
|
||||
// a redraw happens asynchronously after the route changes
|
||||
m("a", {href: "/", oncreate: m.route.link}),
|
||||
m(m.route.Link, {href: "/"}),
|
||||
m("div", {
|
||||
onclick: function() {
|
||||
m.route.set("/")
|
||||
|
|
|
|||
|
|
@ -61,6 +61,18 @@
|
|||
- The `.schedule`, `.unschedule`, and `.render` properties of the former `redrawService` are all removed.
|
||||
- If you want to know how to work around it, look at the call to `mount` in Mithril's source for `m.route`. That should help you in finding ways around the removed feature. (It doesn't take that much more code.)
|
||||
- api: `m.version` has been removed. If you really need the version for whatever reason, just read the `version` field of `mithril/package.json` directly. ([#2466](https://github.com/MithrilJS/mithril.js/pull/2466) [@isiahmeadows](https://github.com/isiahmeadows))
|
||||
- route: `m.route.prefix(...)` is now `m.route.prefix = ...`. ([#2469](https://github.com/MithrilJS/mithril.js/pull/2469) [@isiahmeadows](https://github.com/isiahmeadows))
|
||||
- This is a fully fledged property, so you can not only write to it, but you can also read from it.
|
||||
- This aligns better with user intuition.
|
||||
- route: `m.route.link` function removed in favor of `m.route.Link` component. ([#2469](https://github.com/MithrilJS/mithril.js/pull/2469) [@isiahmeadows](https://github.com/isiahmeadows))
|
||||
- An optional `options` object is accepted as an attribute. This was initially targeting the old `m.route.link` function and was transferred to this. ([#1930](https://github.com/MithrilJS/mithril.js/pull/1930))
|
||||
- The new component handles many more edge cases around user interaction, including accessibility.
|
||||
- Link navigation can be disabled and cancelled.
|
||||
- Link targets can be trivially changed.
|
||||
- API: Full DOM no longer required to execute `require("mithril")`. You just need to set the necessary globals to *something*, even if `null` or `undefined`, so they can be properly used. ([#2469](https://github.com/MithrilJS/mithril.js/pull/2469) [@isiahmeadows](https://github.com/isiahmeadows))
|
||||
- This enables isomorphic use of `m.route.Link` and `m.route.prefix`.
|
||||
- This enables isomorphic use of `m.request`, provided the `background: true` option is set and that an `XMLHttpRequest` polyfill is included as necessary.
|
||||
- Note that methods requiring DOM operations will still throw errors, such as `m.render(...)`, `m.redraw()`, and `m.route(...)`.
|
||||
|
||||
|
||||
#### News
|
||||
|
|
@ -68,7 +80,6 @@
|
|||
- Mithril now only officially supports IE11, Firefox ESR, and the last two versions of Chrome/FF/Edge/Safari. ([#2296](https://github.com/MithrilJS/mithril.js/pull/2296))
|
||||
- API: Introduction of `m.redraw.sync()` ([#1592](https://github.com/MithrilJS/mithril.js/pull/1592))
|
||||
- API: Event handlers may also be objects with `handleEvent` methods ([#1949](https://github.com/MithrilJS/mithril.js/pull/1949), [#2222](https://github.com/MithrilJS/mithril.js/pull/2222)).
|
||||
- API: `m.route.link` accepts an optional `options` object ([#1930](https://github.com/MithrilJS/mithril.js/pull/1930))
|
||||
- API: `m.request` better error message on JSON parse error - ([#2195](https://github.com/MithrilJS/mithril.js/pull/2195), [@codeclown](https://github.com/codeclown))
|
||||
- API: `m.request` supports `timeout` as attr - ([#1966](https://github.com/MithrilJS/mithril.js/pull/1966))
|
||||
- API: `m.request` supports `responseType` as attr - ([#2193](https://github.com/MithrilJS/mithril.js/pull/2193))
|
||||
|
|
@ -88,6 +99,7 @@
|
|||
- API: `m.buildPathname` and `m.parsePathname` added. ([#2361](https://github.com/MithrilJS/mithril.js/pull/2361))
|
||||
- route: Use `m.mount(root, null)` to unsubscribe and clean up after a `m.route(root, ...)` call. ([#2453](https://github.com/MithrilJS/mithril.js/pull/2453))
|
||||
- render: new `redraw` parameter exposed any time a child event handler is used ([#2458](https://github.com/MithrilJS/mithril.js/pull/2458) [@isiahmeadows](https://github.com/isiahmeadows))
|
||||
- route: `m.route.SKIP` can be returned from route resolvers to skip to the next route ([#2469](https://github.com/MithrilJS/mithril.js/pull/2469) [@isiahmeadows](https://github.com/isiahmeadows))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ You can also use an HTML-like syntax called [JSX](jsx.md), using Babel to conver
|
|||
|
||||
Argument | Type | Required | Description
|
||||
------------ | ------------------------------------------ | -------- | ---
|
||||
`selector` | `String|Object` | Yes | A CSS selector or a [component](components.md)
|
||||
`selector` | `String|Object|Function` | Yes | A CSS selector or a [component](components.md)
|
||||
`attrs` | `Object` | No | HTML attributes or element properties
|
||||
`children` | `Array<Vnode>|String|Number|Boolean` | No | Child [vnodes](vnodes.md#structure). Can be written as [splat arguments](signatures.md#splats)
|
||||
**returns** | `Vnode` | | A [vnode](vnodes.md#structure)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ It's small (< 10kb gzip), fast and provides routing and XHR utilities out of the
|
|||
<div style="display:flex;margin:0 0 30px;">
|
||||
<div style="width:50%;">
|
||||
<h5>Download size</h5>
|
||||
<small>Mithril (9.4kb)</small>
|
||||
<small>Mithril (9.5kb)</small>
|
||||
<div style="animation:grow 0.08s;background:#1e5799;height:3px;margin:0 10px 10px 0;transform-origin:0;width:4%;"></div>
|
||||
<small style="color:#aaa;">Vue + Vue-Router + Vuex + fetch (40kb)</small>
|
||||
<div style="animation:grow 0.4s;background:#1e5799;height:3px;margin:0 10px 10px 0;transform-origin:0;width:20%"></div>
|
||||
|
|
|
|||
172
docs/route.md
172
docs/route.md
|
|
@ -6,8 +6,9 @@
|
|||
- [m.route.set](#mrouteset)
|
||||
- [m.route.get](#mrouteget)
|
||||
- [m.route.prefix](#mrouteprefix)
|
||||
- [m.route.link](#mroutelink)
|
||||
- [m.route.Link](#mroutelink)
|
||||
- [m.route.param](#mrouteparam)
|
||||
- [m.route.SKIP](#mrouteskip)
|
||||
- [RouteResolver](#routeresolver)
|
||||
- [routeResolver.onmatch](#routeresolveronmatch)
|
||||
- [routeResolver.render](#routeresolverrender)
|
||||
|
|
@ -24,6 +25,8 @@
|
|||
- [Authentication](#authentication)
|
||||
- [Preloading data](#preloading-data)
|
||||
- [Code splitting](#code-splitting)
|
||||
- [Typed routes](#typed-routes)
|
||||
- [Hidden routes](#typed-routes)
|
||||
- [Third-party integration](#third-party-integration)
|
||||
|
||||
---
|
||||
|
|
@ -92,6 +95,7 @@ m.route(document.body, {
|
|||
})
|
||||
m.route.set('/article/:articleid', {articleid: 1})
|
||||
```
|
||||
|
||||
##### m.route.get
|
||||
|
||||
Returns the last fully resolved routing path, without the prefix. It may differ from the path displayed in the location bar while an asynchronous route is [pending resolution](#code-splitting).
|
||||
|
|
@ -106,41 +110,107 @@ Argument | Type | Required | Description
|
|||
|
||||
Defines a router prefix. The router prefix is a fragment of the URL that dictates the underlying [strategy](#routing-strategies) used by the router.
|
||||
|
||||
`m.route.prefix(prefix)`
|
||||
`m.route.prefix = prefix`
|
||||
|
||||
Argument | Type | Required | Description
|
||||
----------------- | --------- | -------- | ---
|
||||
`prefix` | `String` | Yes | The prefix that controls the underlying [routing strategy](#routing-strategies) used by Mithril.
|
||||
**returns** | | | Returns `undefined`
|
||||
|
||||
##### m.route.link
|
||||
This is a simple property, so you can both read it and write to it.
|
||||
|
||||
This function can be used as the `oncreate` (and `onupdate`) hook in a `m("a")` vnode:
|
||||
##### m.route.Link
|
||||
|
||||
```JS
|
||||
m("a[href=/]", {oncreate: m.route.link})
|
||||
This component can create a dynamic routable link:
|
||||
|
||||
```javascript
|
||||
m(m.route.Link, {href: "/test"})
|
||||
```
|
||||
|
||||
Using `m.route.link` as a `oncreate` hook causes the link to behave as a router link (i.e. it navigates to the route specified in `href`, instead of navigating away from the current page to the URL specified in `href`.
|
||||
Using `m.route.Link` causes the link to behave as a router link - clicking it navigates to the route specified in `href`, instead of navigating away from the current page to the URL specified in `href`.
|
||||
|
||||
If the `href` attribute is not static, the `onupdate` hook must also be set:
|
||||
You can also set the `options` passed to `m.route.set` when the link is clicked by passing the `options` attribute:
|
||||
|
||||
```JS
|
||||
m("a", {href: someVariable, oncreate: m.route.link, onupdate: m.route.link})
|
||||
```javascript
|
||||
m(m.route.Link, {href: "/test", options: {replace: true}})
|
||||
|
||||
// You can even use URL templates this way, the same way you can with
|
||||
// `m.route.set`.
|
||||
m(m.route.Link, {href: "/edit/:id", options: {params: {id: item.id}}})
|
||||
```
|
||||
|
||||
`m.route.link` can also set the `options` passed to `m.route.set` when the link is clicked by calling the function in the lifecycle methods:
|
||||
You can pass other attributes, too, and you can also specify the tag name used.
|
||||
|
||||
```JS
|
||||
m("a[href=/]", {oncreate: m.route.link({replace: true})})
|
||||
```javascript
|
||||
m(m.route.Link, {
|
||||
// Any hyperscript selector is valid here - it's literally passed as the
|
||||
// first parameter to `m`.
|
||||
component: "span",
|
||||
options: {replace: true},
|
||||
href: "/test",
|
||||
disabled: false,
|
||||
class: "nav-link",
|
||||
"data-foo": 1,
|
||||
// and other attributes
|
||||
}, "link name")
|
||||
```
|
||||
|
||||
`m.route.link(args)`
|
||||
Magic attributes used by this component (except `href` and `disabled`) *are* removed while proxying, so you won't have an odd `component="span"` or `options="[object Object]"` attribute show up in your link's DOM node. The above component renders to this hyperscript, assuming the prefix is the default `#!`:
|
||||
|
||||
```javascript
|
||||
m("span", {
|
||||
href: "#!/test",
|
||||
onclick: function(e) {
|
||||
// ...
|
||||
},
|
||||
disabled: false, // Only if you specify it
|
||||
class: "nav-link",
|
||||
"data-foo": 1,
|
||||
// and other attributes
|
||||
})
|
||||
```
|
||||
|
||||
You can also prevent navigation by, in an `onclick` handler, invoking `ev.preventDefault()` or returning `false`. This is the same way you block other events, so it's pretty natural.
|
||||
|
||||
```javascript
|
||||
m(m.route.Link, {
|
||||
href: "/test",
|
||||
onclick: function(e) {
|
||||
// Do things...
|
||||
if (notReady()) e.preventDefault()
|
||||
}
|
||||
}, "link name")
|
||||
```
|
||||
|
||||
This supports full accessibility for both `a` and `button`, via a `disabled` attribute. This ensures [no `href` attribute or `onclick` handler is set](https://css-tricks.com/how-to-disable-links/) and that an `"aria-disabled": "true"` attribute *is* set. If you are passing an `onclick` handler already, that's dropped. (You can work around this by adding it directly in a [lifecycle hook](lifecycle.md).) The `disabled` attribute is itself proxied to the element or component, so you can disable routed `<button>`s and the like.
|
||||
|
||||
```javascript
|
||||
// This does the right thing and the accessible thing for you.
|
||||
m(m.route.Link, {disabled: disabled, href: "/test"}, "disabled")
|
||||
|
||||
// It renders to this hyperscript, assuming the prefix is the default one:
|
||||
m("a", {
|
||||
href: "#!/test",
|
||||
disabled: disabled,
|
||||
"aria-disabled": disabled ? "true" : false,
|
||||
onclick: disabled ? null : function(e) {
|
||||
// ...
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Do note that this doesn't also disable pointer events for you - you have to do that yourself through CSS - this only does the JS part. Also, the removal of `href` *can* break certain style sheets - if you're relying on this to style disabled links, you may need to update your stylesheets accordingly. Chances are, you're probably just looking it up via `a`, `.some-class`, or `#some-id`, and if you are, you're already good to go. If you're using `[href]` or `:link`, in most cases you can just remove them and it'll still work - it's pretty common to over-specify selectors. If you can't do either, check for both `[href]`/`:link` *and* the non-standard `[disabled]` attribute that was implicitly forwarded to the component.
|
||||
|
||||
`vnode = m(m.route.Link, attributes, children)`
|
||||
|
||||
Argument | Type | Required | Description
|
||||
----------------- | ---------------| -------- | ---
|
||||
`args` | `Vnode|Object` | Yes | This method is meant to be used as or in conjunction with an `<a>` [vnode](vnodes.md)'s [`oncreate` and `onupdate` hooks](lifecycle-methods.md)
|
||||
**returns** | `function` | | Returns the onclick handler function for the component
|
||||
---------------------- | ------------------------------------ | -------- | ---
|
||||
`attributes.href` | `Object` | Yes | The target route to navigate to.
|
||||
`attributes.component` | `String|Object|Function` | No | This sets the tag name to use. Must be a valid selector for [`m`](hyperscript.md) if given, defaults to `"a"`.
|
||||
`attributes.options` | `Object` | No | This sets the options passed to [`m.route.set`](#mrouteset).
|
||||
`attributes.disabled` | `Object` | No | This sets the options passed to [`m.route.set`](#mrouteset).
|
||||
`attributes` | `Object` | No | Other attributes to apply to the returned vnode may be passed.
|
||||
`children` | `Array<Vnode>|String|Number|Boolean` | No | Child [vnodes](vnodes.md) for this link.
|
||||
**returns** | `Vnode` | | A [vnode](vnodes.md).
|
||||
|
||||
##### m.route.param
|
||||
|
||||
|
|
@ -159,6 +229,10 @@ Argument | Type | Required | Description
|
|||
|
||||
Note that in the `onmatch` function of a RouteResolver, the new route hasn't yet been fully resolved, and `m.route.param()` will return the parameters of the previous route, if any. `onmatch` receives the parameters of the new route as an argument.
|
||||
|
||||
##### m.route.SKIP
|
||||
|
||||
A special value that can be returned from a [route resolver's `onmatch`](#routeresolveronmatch) to skip to the next route.
|
||||
|
||||
#### RouteResolver
|
||||
|
||||
A RouteResolver is a non-component object that contains an `onmatch` method and/or a `render` method. Both methods are optional, but at least one must be present.
|
||||
|
|
@ -216,9 +290,9 @@ Routing without page refreshes is made partially possible by the [`history.pushS
|
|||
|
||||
The routing strategy dictates how a library might actually implement routing. There are three general strategies that can be used to implement a SPA routing system, and each has different caveats:
|
||||
|
||||
- `m.route.prefix('#!')` (default) – Using the [fragment identifier](https://en.wikipedia.org/wiki/Fragment_identifier) (aka the hash) portion of the URL. A URL using this strategy typically looks like `http://localhost/#!/page1`
|
||||
- `m.route.prefix('?')` – Using the querystring. A URL using this strategy typically looks like `http://localhost/?/page1`
|
||||
- `m.route.prefix('')` – Using the pathname. A URL using this strategy typically looks like `http://localhost/page1`
|
||||
- `m.route.prefix = '#!'` (default) – Using the [fragment identifier](https://en.wikipedia.org/wiki/Fragment_identifier) (aka the hash) portion of the URL. A URL using this strategy typically looks like `http://localhost/#!/page1`
|
||||
- `m.route.prefix = '?'` – Using the querystring. A URL using this strategy typically looks like `http://localhost/?/page1`
|
||||
- `m.route.prefix = ''` – Using the pathname. A URL using this strategy typically looks like `http://localhost/page1`
|
||||
|
||||
Using the hash strategy is guaranteed to work in browsers that don't support `history.pushState`, because it can fall back to using `onhashchange`. Use this strategy if you want to keep the hashes purely local.
|
||||
|
||||
|
|
@ -262,8 +336,8 @@ In the example above, there are two components: `Home` and `Page1`. Each contain
|
|||
var Menu = {
|
||||
view: function() {
|
||||
return m("nav", [
|
||||
m("a[href=/]", {oncreate: m.route.link}, "Home"),
|
||||
m("a[href=/page1]", {oncreate: m.route.link}, "Page 1"),
|
||||
m(m.route.Link, {href: "/"}, "Home"),
|
||||
m(m.route.Link, {href: "/page1"}, "Page 1"),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
@ -284,11 +358,11 @@ Here we specify two routes: `/` and `/page1`, which render their respective comp
|
|||
|
||||
### Navigating to different routes
|
||||
|
||||
In the example above, the `Menu` component has two links. You can specify that their `href` attribute is a route URL (rather than being a regular link that navigates away from the current page), by adding the hook `{oncreate: m.route.link}`
|
||||
In the example above, the `Menu` component has two `m.route.Link`s. That creates an element, by default an `<a>`, and sets it up to where if the user clicks on it, it navigates to another route on its own. It doesn't navigate remotely, just locally.
|
||||
|
||||
You can also navigate programmatically, via `m.route.set(route)`. For example, `m.route.set("/page1")`.
|
||||
|
||||
When navigating to routes, there's no need to explicitly specify the router prefix. In other words, don't add the hashbang `#!` in front of the route path when linking via `m.route.link` or redirecting.
|
||||
When navigating between routes, the router prefix is handled for you. In other words, leave out the hashbang `#!` (or whatever prefix you set `m.route.prefix` to) when linking Mithril routes, including in both `m.route.set` and in `m.route.Link`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -411,17 +485,18 @@ The router prefix is a fragment of the URL that dictates the underlying [strateg
|
|||
|
||||
```javascript
|
||||
// set to pathname strategy
|
||||
m.route.prefix("")
|
||||
m.route.prefix = ""
|
||||
|
||||
// set to querystring strategy
|
||||
m.route.prefix("?")
|
||||
m.route.prefix = "?"
|
||||
|
||||
// set to hash without bang
|
||||
m.route.prefix("#")
|
||||
m.route.prefix = "#"
|
||||
|
||||
// set to pathname strategy on a non-root URL
|
||||
// e.g. if the app lives under `http://localhost/my-app` and something else lives under `http://localhost`
|
||||
m.route.prefix("/my-app")
|
||||
// e.g. if the app lives under `http://localhost/my-app` and something else
|
||||
// lives under `http://localhost`
|
||||
m.route.prefix = "/my-app"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -736,6 +811,43 @@ m.route(document.body, "/", {
|
|||
|
||||
---
|
||||
|
||||
### Typed routes
|
||||
|
||||
In certain advanced routing cases, you may want to constrain a value further than just the path itself, only matching something like a numeric ID. You can do that pretty easily by returning `m.route.SKIP` from a route.
|
||||
|
||||
```javascript
|
||||
m.route(document.body, "/", {
|
||||
"/view/:id": {
|
||||
onmatch: function(args) {
|
||||
if (!/^\d+$/.test(args.id)) return m.route.SKIP
|
||||
return ItemView
|
||||
},
|
||||
},
|
||||
"/view/:name": UserView,
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Hidden routes
|
||||
|
||||
In rare circumstances, you may want to hide certain routes for some users, but not all. For instance, a user might be prohibited from viewing a particular user, and instead of showing a permission error, you'd rather pretend it doesn't exist and redirect to a 404 view instead. In this case, you can use `m.route.SKIP` to just pretend the route doesn't exist.
|
||||
|
||||
```javascript
|
||||
m.route(document.body, "/", {
|
||||
"/user/:id": {
|
||||
onmatch: function(args) {
|
||||
return Model.checkViewable(args.id).then(function(viewable) {
|
||||
return viewable ? UserView : m.route.SKIP
|
||||
})
|
||||
},
|
||||
},
|
||||
"/:404...": PageNotFound,
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Third-party integration
|
||||
|
||||
In certain situations, you may find yourself needing to interoperate with another framework like React. Here's how you do it:
|
||||
|
|
|
|||
|
|
@ -53,3 +53,31 @@ For example, `parseFloat` has the signature `String -> Number`, i.e. it takes a
|
|||
|
||||
Functions with multiple arguments are denoted with parenthesis: `(String, Array) -> Number`
|
||||
|
||||
---
|
||||
|
||||
### Component signatures
|
||||
|
||||
Components are denoted via calls to `m`, but with the selector argument set to a constant named in the relevant prose:
|
||||
|
||||
`vnode = m(m.route.Link, attributes, children)`
|
||||
|
||||
Argument | Type | Required | Description
|
||||
---------------------- | ------------------------------------ | -------- | ---
|
||||
`attributes.href` | `Object` | Yes | The target route to navigate to.
|
||||
`attributes.component` | `String|Object|Function` | No | This sets the tag name to use. Must be a valid selector for [`m`](hyperscript.md) if given, defaults to `"a"`.
|
||||
`attributes.options` | `Object` | No | This sets the options passed to [`m.route.set`](#mrouteset).
|
||||
`attributes` | `Object` | No | Other attributes to apply to the returned vnode may be passed.
|
||||
`children` | `Array<Vnode>|String|Number|Boolean` | No | Child [vnodes](vnodes.md) for this link.
|
||||
**returns** | `Vnode` | | A [vnode](vnodes.md).
|
||||
|
||||
Children here, if specified, are assumed to be able to be written as [splat arguments](#splats), unless otherwise specified in prose.
|
||||
|
||||
An element with no sensible children and/or attributes may elect to elide the relevant parameter entirely, so it might look closer to this:
|
||||
|
||||
`vnode = m(Component, attributes)`
|
||||
|
||||
Argument | Type | Required | Description
|
||||
----------------- | -------- | -------- | ---
|
||||
`attributes.href` | `Object` | Yes | The
|
||||
`attributes` | `Object` | No | Other attributes to apply to the returned vnode
|
||||
**returns** | `Vnode` | | A [vnode](vnodes.md)
|
||||
|
|
|
|||
|
|
@ -447,13 +447,16 @@ module.exports = {
|
|||
oninit: User.loadList,
|
||||
view: function() {
|
||||
return m(".user-list", User.list.map(function(user) {
|
||||
return m("a.user-list-item", {href: "/edit/" + user.id, oncreate: m.route.link}, user.firstName + " " + user.lastName)
|
||||
return m(m.route.Link, {
|
||||
class: "user-list-item",
|
||||
href: "/edit/" + user.id,
|
||||
}, user.firstName + " " + user.lastName)
|
||||
}))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Here we changed `.user-list-item` to `a.user-list-item`. We added an `href` that references the route we want, and finally we added `oncreate: m.route.link`. This makes the link behave like a routed link (as opposed to merely behaving like a regular link). What this means is that clicking the link would change the part of URL that comes after the hashbang `#!` (thus changing the route without unloading the current HTML page)
|
||||
Here we swapped out the `.user-list-item` vnode with an `m.route.Link` with that class and the same children. We added an `href` that references the route we want. What this means is that clicking the link would change the part of URL that comes after the hashbang `#!` (thus changing the route without unloading the current HTML page). Behind the scenes, it uses an `<a>` to implement the link, and it all just works.
|
||||
|
||||
If you refresh the page in the browser, you should now be able to click on a person and be taken to a form. You should also be able to press the back button in the browser to go back from the form to the list of people.
|
||||
|
||||
|
|
@ -555,7 +558,7 @@ module.exports = {
|
|||
view: function(vnode) {
|
||||
return m("main.layout", [
|
||||
m("nav.menu", [
|
||||
m("a[href='/list']", {oncreate: m.route.link}, "Users")
|
||||
m(m.route.Link, {href: "/list"}, "Users")
|
||||
]),
|
||||
m("section", vnode.children)
|
||||
])
|
||||
|
|
@ -563,7 +566,7 @@ module.exports = {
|
|||
}
|
||||
```
|
||||
|
||||
This component is fairly straightforward, it has a `<nav>` with a link to the list of users. Similar to what we did to the `/edit` links, this link uses `m.route.link` to activate routing behavior in the link.
|
||||
This component is fairly straightforward, it has a `<nav>` with a link to the list of users. Similar to what we did to the `/edit` links, this link uses `m.route.Link` to create a routable link.
|
||||
|
||||
Notice there's also a `<section>` element with `vnode.children` as children. `vnode` is a reference to the vnode that represents an instance of the Layout component (i.e. the vnode returned by a `m(Layout)` call). Therefore, `vnode.children` refer to any children of that vnode.
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ var Header = {
|
|||
m("a[href='http://threaditjs.com']", "ThreaditJS Home"),
|
||||
]),
|
||||
m("h2", [
|
||||
m("a[href='/']", {oncreate: m.route.link}, "ThreaditJS: Mithril"),
|
||||
m(m.route.Link, {href: "/"}, "ThreaditJS: Mithril"),
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
|
@ -102,7 +102,7 @@ var Home = {
|
|||
threads.map(function(thread) {
|
||||
return [
|
||||
m("p", [
|
||||
m("a", {href: "/thread/" + thread.id, oncreate: m.route.link}, m.trust(T.trimTitle(thread.text))),
|
||||
m(m.route.Link, {href: "/thread/" + thread.id}, m.trust(T.trimTitle(thread.text))),
|
||||
]),
|
||||
m("p.comment_count", thread.comment_count + " comment(s)"),
|
||||
m("hr"),
|
||||
|
|
|
|||
|
|
@ -115,9 +115,9 @@ var Todos = {
|
|||
state.remaining === 1 ? " item left" : " items left",
|
||||
]),
|
||||
m("ul#filters", [
|
||||
m("li", m("a[href='/']", {oncreate: m.route.link, class: state.showing === "" ? "selected" : ""}, "All")),
|
||||
m("li", m("a[href='/active']", {oncreate: m.route.link, class: state.showing === "active" ? "selected" : ""}, "Active")),
|
||||
m("li", m("a[href='/completed']", {oncreate: m.route.link, class: state.showing === "completed" ? "selected" : ""}, "Completed")),
|
||||
m("li", m(m.route.Link, {href: "/", class: state.showing === "" ? "selected" : ""}, "All")),
|
||||
m("li", m(m.route.Link, {href: "/active", class: state.showing === "active" ? "selected" : ""}, "Active")),
|
||||
m("li", m(m.route.Link, {href: "/completed", class: state.showing === "completed" ? "selected" : ""}, "Completed")),
|
||||
]),
|
||||
m("button#clear-completed", {onclick: function() {state.dispatch("clear")}}, "Clear completed"),
|
||||
]) : null,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"dev": "node bundler/cli browser.js -output mithril.js -watch",
|
||||
"build": "npm run build-browser & npm run build-min",
|
||||
"build-browser": "node bundler/cli browser.js -output mithril.js",
|
||||
"build-min": "node bundler/cli browser.js -output mithril.min.js -minify",
|
||||
"build-min": "node bundler/cli browser.js -output mithril.min.js -minify -save",
|
||||
"precommit": "lint-staged",
|
||||
"lintdocs": "node docs/lint",
|
||||
"gendocs": "node docs/generate",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
var Vnode = require("../render/vnode")
|
||||
|
||||
module.exports = function($window) {
|
||||
var $doc = $window.document
|
||||
var $doc = $window && $window.document
|
||||
var currentRedraw
|
||||
|
||||
var nameSpace = {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ o.spec("render", function() {
|
|||
render = vdom($window)
|
||||
})
|
||||
|
||||
o("initializes without DOM", function() {
|
||||
vdom()
|
||||
})
|
||||
|
||||
o("renders plain text", function() {
|
||||
render(root, "a")
|
||||
o(root.childNodes.length).equals(1)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,21 @@ module.exports = function(options) {
|
|||
var spy = options.spy || function(f){return f}
|
||||
var spymap = []
|
||||
|
||||
// This way I'm not also implementing a partial `URL` polyfill. Based on the
|
||||
// regexp at http://urlregex.com/, but adapted to allow relative URLs and
|
||||
// care only about HTTP(S) URLs.
|
||||
var urlHash = "#[?!/+=&;%@.\\w_-]*"
|
||||
var urlQuery = "\\?[!/+=&;%@.\\w_-]*"
|
||||
var urlPath = "/[+~%/.\\w_-]*"
|
||||
var urlRelative = urlPath + "(?:" + urlQuery + ")?(?:" + urlHash + ")?"
|
||||
var urlDomain = "https?://[A-Za-z0-9][A-Za-z0-9.-]+[A-Za-z0-9]"
|
||||
var validURLRegex = new RegExp(
|
||||
"^" + urlDomain + "(" + urlRelative + ")?$|" +
|
||||
"^" + urlRelative + "$|" +
|
||||
"^" + urlQuery + "(?:" + urlHash + ")?$|" +
|
||||
"^" + urlHash + "$"
|
||||
)
|
||||
|
||||
var hasOwn = ({}.hasOwnProperty)
|
||||
|
||||
function registerSpies(element, spies) {
|
||||
|
|
@ -440,7 +455,13 @@ module.exports = function(options) {
|
|||
if (this.namespaceURI === "http://www.w3.org/2000/svg") {
|
||||
var val = this.hasAttribute("href") ? this.attributes.href.value : ""
|
||||
return {baseVal: val, animVal: val}
|
||||
} else return this.attributes["href"] === undefined ? "" : "[FIXME implement]"
|
||||
} else if (this.namespaceURI === "http://www.w3.org/1999/xhtml") {
|
||||
if (!this.hasAttribute("href")) return ""
|
||||
// HACK: if it's valid already, there's nothing to implement.
|
||||
var value = this.attributes.href.value
|
||||
if (validURLRegex.test(value)) return value
|
||||
}
|
||||
return "[FIXME implement]"
|
||||
},
|
||||
set: function(value) {
|
||||
// This is a readonly attribute for SVG, todo investigate MathML which may have yet another IDL
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ o.spec("api", function() {
|
|||
})
|
||||
o("m.route.prefix", function(done) {
|
||||
root = window.document.createElement("div")
|
||||
m.route.prefix("#")
|
||||
m.route.prefix = "#"
|
||||
m.route(root, "/a", {
|
||||
"/a": createComponent({view: function() {return m("div")}})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue