├── .gitignore ├── LICENSE ├── README.md ├── docs ├── index.html ├── styles.css └── xiao-es5.min.js ├── images └── xiao.png ├── package.json ├── xiao-es5.js ├── xiao-es5.min.js ├── xiao.js └── xiao.min.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Xiao](images/xiao.png) 2 | 3 | > **xiǎo**: small, young 4 | 5 | Xiao (pronounced "Shh!" + "Ow!") is a small, accessible, framework agnostic, dependency free, browser-driven routing system. Make single-page applications with progressive enhancement. See the **docs** folder for the working demo, or view it here. 6 | 7 | ## Install 8 | 9 | ``` 10 | npm i xiao-router 11 | ``` 12 | 13 | ## Include 14 | 15 | Xiao is just a small script you include in your web page. You have the option of using the ES5 or slightly smaller but less well-supported ES6 version. 16 | 17 | ### ES5 18 | 19 | ```html 20 | 21 | ``` 22 | 23 | ### ES6 24 | 25 | ```html 26 | 27 | ``` 28 | 29 | ## Routes 30 | 31 | In Xiao, routes are just subdocuments of web pages with metadata and (sometimes) methods attached to them. Each is identified by an `id` which corresponds to both a page element (say `id="home"`) and a hash fragment (say `#home`). 32 | 33 | Before initializing your Xiao app, you define a routes array. Only the route `id` is mandatory. 34 | 35 | ```js 36 | const routes = [ 37 | { 38 | id: 'home' 39 | }, 40 | { 41 | id: 'about' 42 | }, 43 | { 44 | id: 'upload' 45 | } 46 | ] 47 | ``` 48 | 49 | This routes array is supplied as the first argument of the instantiated Xiao app. The default route — the one the user is directed to when hitting the root of the application — is the second mandatory argument. 50 | 51 | ```js 52 | const app = new Xiao(routes, 'home') 53 | ``` 54 | 55 | Whether written by hand or via templating, each route is just an element with an `id` in an HTML document: 56 | 57 | ```html 58 |
59 | 60 |
61 | ``` 62 | 63 | On initialization, Xiao gives each element corresponding to a route `role="region"` then calculates a value for an `aria-label`, to further identify the route to assistive technologies. The label is: 64 | 65 | * The text content of the route element's first `

` or `

` if it exists 66 | * Or the content of the route object's `label` property (e.g. `label: 'About my project'`) 67 | * Or — as a fallback — the route object's `id` 68 | 69 | ```html 70 |
71 | 72 |
73 | ``` 74 | 75 | ## Traversing a Xiao routed app 76 | 77 | When a user navigates to a hash fragment, Xiao determines if that hash fragment either 78 | 79 | * Corresponds to an element that corresponds to a route in the routes array 80 | * Corresponds to an element _inside_ an element that corresponds to a route in the routes array 81 | 82 | In default operation, whether you navigate to a route element or a route child element, the previous route element is hidden and the new one revealed. For keyboard accessibility, focus is sent to the newly revealed route element. 83 | 84 | ### Current links 85 | 86 | Links corresponding to currently active routes receive the `aria-current="true"` attribution: 87 | 88 | ```html 89 | Home 90 | ``` 91 | 92 | This identifies current links to assistive technologies and doubles as a styling hook where desired. 93 | 94 | ```css 95 | nav [aria-current] { 96 | border-bottom: 2px solid; 97 | } 98 | ``` 99 | 100 | ### The `` 101 | 102 | It is recommended that the `<title>` value you supply is the name of the app. Xiao appends the label for the current route after a separator. 103 | 104 | ```html 105 | <title>My App | Home 106 | ``` 107 | 108 | ## The `arrived` and `departed` methods 109 | 110 | You can hook into lifecycle events for routes to perform operations. In Xiao, these are named `arrived` and `departed`. You simply add them as properties on the route object. 111 | 112 | ```js 113 | { 114 | id: 'about', 115 | label: 'About my project'. 116 | arrived(elem, params, routes) { 117 | // Add a class, pull in some dynamic content, whatever 118 | }, 119 | departed(elem, params, routes) { 120 | // Save some settings, remove some content, whatever 121 | } 122 | } 123 | ``` 124 | 125 | As you can see, there are three parameters available in each case: 126 | 127 | * **elem** (node): the HTML element that corresponds to the route (carries the route `id`) 128 | * **params** (object): Any params passed to the route via a query string (e.g. `?foo=bar&ding=dong` will be passed as `{foo: 'bar', ding: 'dong'}`). In a well-formed URL, the parameters should precede the hash (e.g. `href="?foo=bar#myRouteElem"`) 129 | * **routes** (object): The whole routes array, for convenience 130 | 131 | ## Events 132 | 133 | ### `reroute` 134 | 135 | Whenever a new route is invoked, the `reroute` event is dispatched from `window`. This allows you to affect any part of the page in response to a reroute. Secreted in this `CustomEvent`'s `details` object are the old and new route objects. 136 | 137 | ```js 138 | window.addEventListener('reroute', e => { 139 | console.log('Old route:', e.detail.oldRoute) 140 | console.log('New route:', e.detail.newRoute) 141 | }) 142 | ``` 143 | 144 | ## Rerouting programmatically 145 | 146 | Xiao capitalizes on standard browser behavior, letting you use links and hash fragments to invoke routes. However, there will be times you want to reroute the user programmatically. A redirect maybe. For this, you can use the `reroute` method. 147 | 148 | The first argument is the desired route (or route child element) id and the second any params you may want to supply. 149 | 150 | ```js 151 | app.reroute('login', '?redirect=true') 152 | ``` 153 | 154 | ## Settings 155 | 156 | The third (optional) argument when instantiating a Xiao app is the settings object. These are the options: 157 | 158 | * **separator**: The string used to separate the app's name from the route label in the `` (default: "|") 159 | * **showHide**: Whether to show only one route at a time. If set to `false`, routes are all persistently visible and the browser navigates between them like standard hash fragments (default: `true`) 160 | * **arrived**: Like the arrived method available for individual routes, but applies to all routes (see above) 161 | * **departed**: Like the departed method available for individual routes, but applies to all routes (see above) 162 | 163 | ## Framework independence 164 | 165 | Xiao is just a simple router which respects browser standards. The actual functionality you provide within individual Xiao routes is totally up to you. You can use plain JavaScript, React or Vue components, whatever you like. With Xiao, simple single-page applications can be just that: simple. But you can add as many dependencies and as much code to a Xiao skeleton as you like. 166 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="utf-8"> 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 | <meta http-equiv="X-UA-Compatible" content="IE=edge"> 7 | <link rel="stylesheet" href="styles.css"> 8 | <title>Xiao Demo 9 | 10 | 11 |
12 | 13 | 18 | 19 | 27 |
28 |
29 |
30 |

Welcome to the Xiao demo page

31 |

Xiao is a small, accessible, framework agnostic, browser-driven routing system. Make single page applications with progressive enhancement. This demo is an incomplete exploration of Xiao. See the Github repository for more.

32 |

This is the default route, home, and just contains static content, loaded with the web page from the server. Note the id="home" attribution in the HTML:

33 |
<div id="home">
 34 |   <h1>A demo built with Xiao</h1>
 35 |   <p>Xiao is a small, accessible, framework agnostic, browser-driven routing system. Make single page applications with progressive enhancement and without the complexity.</p>
 36 |   <p>This is the default route, <code>home</code>, and just contains static content, loaded with the web page from the server. Note the <code>id="home"</code> attribution in the HTML:</p>
 37 | </div>
38 |

To make Xiao recognize this element as a route element, you add it to a routes array when you instantiate the app.

39 |
var app = new Xiao([{ id: 'home', label: 'Home'  }], 'home')
40 |

The second argument defines the default route, which will be activated if no route hash is provided in the web address on page load.

41 |

Accessibility semantics

42 |

Route elements are given assistive technology-accessible information when a Xiao app is initialized. Note role="region" and aria-label="Home" (the value of which is taken from either the route element's first heading or the route object's label property):

43 |
<div id="home" role="region" aria-label="Home">
 44 |   <h1>A demo built with Xiao</h1>
 45 |   <p>Xiao is a small, accessible, framework agnostic, browser-driven routing system. Make single-page applications with progressive enhancement and without the complexity.</p>
 46 |   <p>This is the default route, <code>home</code>, and just contains static content, loaded with the web page from the server. Note the <code>id="home"</code> attribution in the HTML:</p>
 47 | </div>
48 |

Focus management

49 |

Whenever the hash changes in a Xiao routed app's URL, Xiao determines if it corresponds to a route or an element inside a route. If so, it will move focus to that element. This will move keyboard users into the correct position, and elicit the announcement of accessibility information in screen readers. For example: "main, region, home".

50 |

Try opening developer tools, clicking this Subsections link, then entering document.activeElement into your console. The active element is the focused element.

51 |
52 |
53 |

Route lifecycles

54 |

Routes have a lifecycle that begins with the user's arrival at the route and ends with their departure. You can tap into these lifecycle events with the arrived and departed methods.

55 |

For example, I can record the arrival time as a timestamp and write it to the page, like this:

56 |
id: 'lifecycle',
 57 | label: 'Route lifecycle',
 58 | arrived(elem) {
 59 |   var time = Date.now();
 60 |   var timeElem = elem.querySelector('.timestamp')
 61 |   timeElem.textContent = `Route arrival timestamp: ${time}`
 62 | }
63 |
64 |

If you refresh the page, the timestamp updates. It also updates when you move to another route and come back. Obviously, this is a silly example. You're more likely to use the arrived method to load XHR content and/or compile some templates. That sort of thing.

65 |

The departed method

66 |

The departed method fires when the user leaves the route. One use may be to save some route-related data to localStorage. Below, you should see the message "Welcome!" if it is your first time at this route or "Welcome back!" if you've been here before.

67 |
68 |

We set the storage item on departed

69 |
departed(elem) {
 70 |   window.localStorage.setItem('visited', true)
 71 | }
 72 | 
73 |

…and retrieve it on arrived.

74 |
arrived(elem) {
 75 |   var visited = window.localStorage.getItem('visited')
 76 |   var msg = visited ? "Welcome back!" : "Welcome!"
 77 |   elem.querySelector('.welcome').textContent = msg
 78 | }
 79 | 
80 |

(Note that the departed function only executes when the page's URL changes, not when the page is reloaded.)

81 |

Parameters

82 |

In these examples, we just used the elem parameter. There are three parameters you can supply to the lifecycle functions in total. In order:

83 |
    84 |
  • elem: the route element (the element carrying the route id and containing the route content)
  • 85 |
  • params: any params passed to the route in a query string, converted to an object
  • 86 |
  • routes: the whole routes object for convenience
  • 87 |
88 |
89 |
90 |

Subsections

91 |

When you link to a subsection from within a route that is already active, it just behaves as a basic hash fragment and no JavaScript is fired.

92 |

A subsection

93 |

Alternatively, if you link to a subsection in a different route, like the subsection about focus management on the Home page, the route is revealed, events and methods fired, and the subsection brought into view.

94 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque dignissim molestie diam. Integer fermentum nisi eget vehicula tristique. Vivamus leo justo, pharetra quis euismod ac, vestibulum non neque. Ut eget est sem. Ut quis ultricies mauris. Donec mauris tortor, posuere non mi ac, accumsan rutrum sapien.

95 |

Phasellus suscipit libero blandit sapien feugiat, vel mattis velit venenatis. Aenean eu ligula viverra tellus accumsan cursus ut et lorem. Donec non laoreet ex. In vitae turpis felis. Nam sollicitudin sit amet orci et hendrerit. Nulla congue dictum finibus.

96 |

Ut ut ligula porttitor purus dictum faucibus. Aliquam suscipit dui in augue pharetra, sed pellentesque dolor fringilla. Donec ornare est eget sapien tristique rutrum. Nulla facilisi. Praesent nec euismod leo. Morbi nunc dui, rhoncus quis massa ut, mollis gravida mauris. Praesent quis nisl ut ipsum ultrices mollis. Vivamus at ex orci.

97 |
98 |
99 |

Using params

100 |

The point of single-page applications is that different routes have access to the same working data. But sometimes you'll want to pass specific pieces of data between routes, based on user choices. This is what query strings are for. Just add them in the URL of a route link:

101 |
<a href="?up=down&left=right#params">params</a>
102 |

Xiao converts the query string into an object and makes it available in the route object's arrived and departed methods.

103 |
{
104 |   id: 'params',
105 |   arrived(elem, params, routes) {
106 |     var paramsElem = elem.querySelector('.params-object')
107 |     paramsElem.textContent = JSON.stringify(params)
108 |   }
109 | }
110 |

Try changing the params in the URL and refreshing the page to affect the live result below:

111 |
112 |
113 |
114 | 118 | 119 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /docs/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: calc(1em + 0.5vw); 3 | font-family: Futura, sans-serif; 4 | } 5 | 6 | * { 7 | margin: 0; 8 | padding: 0; 9 | color: inherit; 10 | box-sizing: border-box; 11 | } 12 | 13 | * + * { 14 | margin-top: 1.5rem; 15 | } 16 | 17 | br, 18 | dt, 19 | dd, 20 | th, 21 | td, 22 | option, 23 | [hidden] + *, 24 | li + li, 25 | body, 26 | .main-and-footer { 27 | margin-top: 0; 28 | } 29 | 30 | body { 31 | max-width: 35rem; 32 | margin: 1.5rem auto; 33 | line-height: 1.5; 34 | padding: 0 1.5rem; 35 | } 36 | 37 | h1, h2, h3 { 38 | line-height: 1.125; 39 | } 40 | 41 | h1 { 42 | font-size: 1.66rem; 43 | } 44 | 45 | h2 { 46 | font-size: 1.33rem; 47 | } 48 | 49 | h3 { 50 | font-size: 1rem; 51 | } 52 | 53 | main { 54 | margin-top: 3rem; 55 | } 56 | 57 | nav ul { 58 | display: flex; 59 | justify-content: space-between; 60 | align-items: center; 61 | list-style: none; 62 | flex-flow: row wrap; 63 | } 64 | 65 | nav li { 66 | line-height: 1; 67 | } 68 | 69 | nav a { 70 | text-decoration: none; 71 | line-height: 1; 72 | } 73 | 74 | nav [aria-current] { 75 | border-bottom: 4px solid; 76 | transition: border 0.25s ease-in; 77 | } 78 | 79 | [tabindex="-1"]:focus, 80 | div:not([tabindex]):focus { 81 | outline: none; 82 | } 83 | 84 | header { 85 | text-align: center; 86 | } 87 | 88 | header svg { 89 | height: 4rem; 90 | width: auto; 91 | } 92 | 93 | pre, .params-object { 94 | font-size: 0.75rem; 95 | padding: 1rem; 96 | overflow-x: auto; 97 | border: 1px solid; 98 | font-family: monospace; 99 | } 100 | 101 | .hl { 102 | background: #000; 103 | outline: 2px solid #000; 104 | color: #fff; 105 | } 106 | 107 | .box { 108 | padding: 1em; 109 | border: 1px solid; 110 | outline: 1px solid; 111 | outline-offset: 2px; 112 | } 113 | 114 | main ul { 115 | padding-left: 2rem; 116 | } 117 | 118 | footer { 119 | padding-top: 1rem; 120 | border-top: 1px solid; 121 | font-size: 0.85rem; 122 | margin-top: 3rem; 123 | text-align: center; 124 | } 125 | 126 | footer * + * { 127 | margin-top: 0.5rem; 128 | } 129 | 130 | footer code { 131 | padding: 0.5rem 1rem; 132 | background: #000; 133 | color: #fff; 134 | } 135 | 136 | @keyframes flash { 137 | 0% { color: #fff } 138 | 100% { color: #000 } 139 | } 140 | 141 | #a-subsection:target, #focus-management:target { 142 | animation: flash 1s ease-in; 143 | } 144 | 145 | @media (max-width: 550px) { 146 | nav li { 147 | width: 50%; 148 | padding: 0.5rem; 149 | } 150 | 151 | nav a { 152 | display: inline-block; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /docs/xiao-es5.min.js: -------------------------------------------------------------------------------- 1 | (function(window){"use strict";function Xiao(routes,defaultId,options){var _this=this;options=options||{};this.settings={showHide:true,separator:"|",arrived:null,departed:null};for(var setting in options){if(options.hasOwnProperty(setting)){this.settings[setting]=options[setting]}}this.ids=routes.map(function(route){return route.id});this.title=document.title;this.firstRun=true;var elem=function elem(id){return document.getElementById(id)};var url=function url(){return window.location.href};var each=Array.prototype.forEach;var routeById=function routeById(id){return routes.find(function(route){return id===route.id})};var linksById=function linksById(id){return document.querySelectorAll('[href*="#'+id+'"]')};var idByURL=function idByURL(string){return string.indexOf("#")>-1?string.match(/#.*?(\?|$)/gi)[0].replace("?","").substr(1):null};var paramsToObj=function paramsToObj(string){var query=string.indexOf("#")>-1?string.match(/\?.*?(#|$)/gi)[0].replace("#","").substr(1):null;return query?JSON.parse('{"'+decodeURI(query).replace(/"/g,'\\"').replace(/&/g,'","').replace(/=/g,'":"')+'"}'):null};var routeExists=function routeExists(id){return _this.ids.find(function(route){return elem(route).contains(elem(id))})};var getLabel=function getLabel(route){var h=elem(route.id).querySelector("h1, h2");return h?h.textContent:route.label?route.label:route.id};var reconfigure=function reconfigure(newRoute,oldRoute,oldURL,focusId){if(_this.settings.showHide){_this.ids.forEach(function(id){elem(id).hidden=true})}var newRegion=elem(newRoute);if(newRegion){if(_this.settings.showHide){newRegion.hidden=false}if(!_this.firstRun){elem(focusId).setAttribute("tabindex","-1");elem(focusId).focus()}else{_this.firstRun=false}}var oldParams=oldURL?paramsToObj(oldURL):null;if(oldRoute&&routeExists(oldRoute)){if(_this.settings.departed){_this.settings.departed(elem(oldRoute),oldParams,routes)}if(routeById(oldRoute).departed){routeById(oldRoute).departed(elem(oldRoute),oldParams,routes)}}var newParams=paramsToObj(url());if(_this.settings.arrived){_this.settings.arrived(elem(newRoute),newParams,routes)}if(routeById(newRoute).arrived){routeById(newRoute).arrived(elem(newRoute),newParams,routes)}each.call(document.querySelectorAll("[aria-current]"),function(link){link.removeAttribute("aria-current")});each.call(linksById(newRoute),function(link){link.setAttribute("aria-current","true")});document.title=_this.title+" "+_this.settings.separator+" "+getLabel(routeById(newRoute));if(_this.settings.showHide&&newRoute===focusId){document.documentElement.scrollTop=0;document.body.scrollTop=0}var reroute=new CustomEvent("reroute",{detail:{newRoute:routeById(newRoute),oldRoute:routeById(oldRoute)}});window.dispatchEvent(reroute)};window.addEventListener("load",function(e){routes.forEach(function(route){var region=elem(route.id);region.setAttribute("role","region");region.setAttribute("aria-label",getLabel(route))});var hash=idByURL(url());if(!hash||!routeExists(hash)){_this.reroute(defaultId)}else{reconfigure(routeExists(hash),null,null,hash)}});window.addEventListener("hashchange",function(e){var id=idByURL(url());var newRoute=routeExists(id);var oldId=e.oldURL.indexOf("#")>-1?idByURL(e.oldURL):null;var oldRoute=oldId?routeExists(oldId):null;if(newRoute&&newRoute!==oldRoute){var focusId=id===newRoute?newRoute:id;reconfigure(newRoute,oldRoute,e.oldURL||null,focusId)}})}Xiao.prototype.reroute=function(id,params){window.location.hash=(params||"")+id;return this};if(typeof module!=="undefined"&&typeof module.exports!=="undefined"){module.exports=Xiao}else{window.Xiao=Xiao}})(this); -------------------------------------------------------------------------------- /images/xiao.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Heydon/xiao/aa73cdf3ab78b4a6b817f47987bd692bc7cb5a33/images/xiao.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xiao-router", 3 | "version": "0.1.6", 4 | "description": "A small, accessible, browser-driven single-page routing system.", 5 | "main": "xiao.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "lint": "standard ./xiao.js", 9 | "uglify": "uglifyjs xiao.js -o xiao.min.js && uglifyjs xiao-es5.js -o xiao-es5.min.js", 10 | "extract-version": "cat package.json | grep version | head -1 | awk -F: '{ print $2 }' | sed 's/[\",]//g' | tr -d '[[:space:]]'", 11 | "add-version": "echo \"/*! xiao $(npm run extract-version --silent) — © Heydon Pickering */\n$(cat xiao.min.js)\" > xiao.min.js", 12 | "build": "npm run uglify && npm run add-version", 13 | "es5": "babel xiao.js -o xiao-es5.js --presets=es2015-script", 14 | "precommit": "npm run lint && npm run build && cp xiao-es5.min.js ./docs/xiao-es5.min.js" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/Heydon/xiao.git" 19 | }, 20 | "keywords": [], 21 | "author": "Heydon Pickering", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/Heydon/xiao/issues" 25 | }, 26 | "homepage": "https://github.com/Heydon/xiao#readme", 27 | "devDependencies": { 28 | "babel-cli": "^6.26.0", 29 | "babel-preset-es2015-script": "^1.1.0", 30 | "husky": "^0.13.3", 31 | "standard": "^10.0.2", 32 | "uglify-es": "^3.1.0" 33 | } 34 | } -------------------------------------------------------------------------------- /xiao-es5.js: -------------------------------------------------------------------------------- 1 | /* global CustomEvent */ 2 | 3 | (function (window) { 4 | 'use strict'; 5 | 6 | function Xiao(routes, defaultId, options) { 7 | var _this = this; 8 | 9 | options = options || {}; 10 | this.settings = { 11 | showHide: true, 12 | separator: '|', 13 | arrived: null, 14 | departed: null 15 | }; 16 | 17 | for (var setting in options) { 18 | if (options.hasOwnProperty(setting)) { 19 | this.settings[setting] = options[setting]; 20 | } 21 | } 22 | 23 | this.ids = routes.map(function (route) { 24 | return route.id; 25 | }); 26 | this.title = document.title; 27 | this.firstRun = true; 28 | 29 | var elem = function elem(id) { 30 | return document.getElementById(id); 31 | }; 32 | 33 | var url = function url() { 34 | return window.location.href; 35 | }; 36 | 37 | var each = Array.prototype.forEach; 38 | 39 | var routeById = function routeById(id) { 40 | return routes.find(function (route) { 41 | return id === route.id; 42 | }); 43 | }; 44 | 45 | var linksById = function linksById(id) { 46 | return document.querySelectorAll('[href*="#' + id + '"]'); 47 | }; 48 | 49 | var idByURL = function idByURL(string) { 50 | return string.indexOf('#') > -1 ? string.match(/#.*?(\?|$)/gi)[0].replace('?', '').substr(1) : null; 51 | }; 52 | 53 | var paramsToObj = function paramsToObj(string) { 54 | var query = string.indexOf('#') > -1 ? string.match(/\?.*?(#|$)/gi)[0].replace('#', '').substr(1) : null; 55 | return query ? JSON.parse('{"' + decodeURI(query).replace(/"/g, '\\"').replace(/&/g, '","').replace(/=/g, '":"') + '"}') : null; 56 | }; 57 | 58 | var routeExists = function routeExists(id) { 59 | return _this.ids.find(function (route) { 60 | return elem(route).contains(elem(id)); 61 | }); 62 | }; 63 | 64 | var getLabel = function getLabel(route) { 65 | var h = elem(route.id).querySelector('h1, h2'); 66 | return h ? h.textContent : route.label ? route.label : route.id; 67 | }; 68 | 69 | var reconfigure = function reconfigure(newRoute, oldRoute, oldURL, focusId) { 70 | if (_this.settings.showHide) { 71 | _this.ids.forEach(function (id) { 72 | elem(id).hidden = true; 73 | }); 74 | } 75 | 76 | var newRegion = elem(newRoute); 77 | if (newRegion) { 78 | if (_this.settings.showHide) { 79 | newRegion.hidden = false; 80 | } 81 | if (!_this.firstRun) { 82 | elem(focusId).setAttribute('tabindex', '-1'); 83 | elem(focusId).focus(); 84 | } else { 85 | _this.firstRun = false; 86 | } 87 | } 88 | 89 | var oldParams = oldURL ? paramsToObj(oldURL) : null; 90 | 91 | if (oldRoute && routeExists(oldRoute)) { 92 | if (_this.settings.departed) { 93 | _this.settings.departed(elem(oldRoute), oldParams, routes); 94 | } 95 | if (routeById(oldRoute).departed) { 96 | routeById(oldRoute).departed(elem(oldRoute), oldParams, routes); 97 | } 98 | } 99 | 100 | var newParams = paramsToObj(url()); 101 | if (_this.settings.arrived) { 102 | _this.settings.arrived(elem(newRoute), newParams, routes); 103 | } 104 | if (routeById(newRoute).arrived) { 105 | routeById(newRoute).arrived(elem(newRoute), newParams, routes); 106 | } 107 | 108 | each.call(document.querySelectorAll('[aria-current]'), function (link) { 109 | link.removeAttribute('aria-current'); 110 | }); 111 | each.call(linksById(newRoute), function (link) { 112 | link.setAttribute('aria-current', 'true'); 113 | }); 114 | 115 | document.title = _this.title + ' ' + _this.settings.separator + ' ' + getLabel(routeById(newRoute)); 116 | 117 | if (_this.settings.showHide && newRoute === focusId) { 118 | document.documentElement.scrollTop = 0; 119 | document.body.scrollTop = 0; 120 | } 121 | 122 | var reroute = new CustomEvent('reroute', { 123 | detail: { 124 | newRoute: routeById(newRoute), 125 | oldRoute: routeById(oldRoute) 126 | } 127 | }); 128 | window.dispatchEvent(reroute); 129 | }; 130 | 131 | window.addEventListener('load', function (e) { 132 | routes.forEach(function (route) { 133 | var region = elem(route.id); 134 | region.setAttribute('role', 'region'); 135 | region.setAttribute('aria-label', getLabel(route)); 136 | }); 137 | 138 | var hash = idByURL(url()); 139 | 140 | if (!hash || !routeExists(hash)) { 141 | _this.reroute(defaultId); 142 | } else { 143 | reconfigure(routeExists(hash), null, null, hash); 144 | } 145 | }); 146 | 147 | window.addEventListener('hashchange', function (e) { 148 | var id = idByURL(url()); 149 | var newRoute = routeExists(id); 150 | var oldId = e.oldURL.indexOf('#') > -1 ? idByURL(e.oldURL) : null; 151 | var oldRoute = oldId ? routeExists(oldId) : null; 152 | 153 | if (newRoute && newRoute !== oldRoute) { 154 | var focusId = id === newRoute ? newRoute : id; 155 | reconfigure(newRoute, oldRoute, e.oldURL || null, focusId); 156 | } 157 | }); 158 | } 159 | 160 | Xiao.prototype.reroute = function (id, params) { 161 | window.location.hash = (params || '') + id; 162 | return this; 163 | }; 164 | 165 | if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { 166 | module.exports = Xiao; 167 | } else { 168 | window.Xiao = Xiao; 169 | } 170 | })(this); 171 | -------------------------------------------------------------------------------- /xiao-es5.min.js: -------------------------------------------------------------------------------- 1 | (function(window){"use strict";function Xiao(routes,defaultId,options){var _this=this;options=options||{};this.settings={showHide:true,separator:"|",arrived:null,departed:null};for(var setting in options){if(options.hasOwnProperty(setting)){this.settings[setting]=options[setting]}}this.ids=routes.map(function(route){return route.id});this.title=document.title;this.firstRun=true;var elem=function elem(id){return document.getElementById(id)};var url=function url(){return window.location.href};var each=Array.prototype.forEach;var routeById=function routeById(id){return routes.find(function(route){return id===route.id})};var linksById=function linksById(id){return document.querySelectorAll('[href*="#'+id+'"]')};var idByURL=function idByURL(string){return string.indexOf("#")>-1?string.match(/#.*?(\?|$)/gi)[0].replace("?","").substr(1):null};var paramsToObj=function paramsToObj(string){var query=string.indexOf("#")>-1?string.match(/\?.*?(#|$)/gi)[0].replace("#","").substr(1):null;return query?JSON.parse('{"'+decodeURI(query).replace(/"/g,'\\"').replace(/&/g,'","').replace(/=/g,'":"')+'"}'):null};var routeExists=function routeExists(id){return _this.ids.find(function(route){return elem(route).contains(elem(id))})};var getLabel=function getLabel(route){var h=elem(route.id).querySelector("h1, h2");return h?h.textContent:route.label?route.label:route.id};var reconfigure=function reconfigure(newRoute,oldRoute,oldURL,focusId){if(_this.settings.showHide){_this.ids.forEach(function(id){elem(id).hidden=true})}var newRegion=elem(newRoute);if(newRegion){if(_this.settings.showHide){newRegion.hidden=false}if(!_this.firstRun){elem(focusId).setAttribute("tabindex","-1");elem(focusId).focus()}else{_this.firstRun=false}}var oldParams=oldURL?paramsToObj(oldURL):null;if(oldRoute&&routeExists(oldRoute)){if(_this.settings.departed){_this.settings.departed(elem(oldRoute),oldParams,routes)}if(routeById(oldRoute).departed){routeById(oldRoute).departed(elem(oldRoute),oldParams,routes)}}var newParams=paramsToObj(url());if(_this.settings.arrived){_this.settings.arrived(elem(newRoute),newParams,routes)}if(routeById(newRoute).arrived){routeById(newRoute).arrived(elem(newRoute),newParams,routes)}each.call(document.querySelectorAll("[aria-current]"),function(link){link.removeAttribute("aria-current")});each.call(linksById(newRoute),function(link){link.setAttribute("aria-current","true")});document.title=_this.title+" "+_this.settings.separator+" "+getLabel(routeById(newRoute));if(_this.settings.showHide&&newRoute===focusId){document.documentElement.scrollTop=0;document.body.scrollTop=0}var reroute=new CustomEvent("reroute",{detail:{newRoute:routeById(newRoute),oldRoute:routeById(oldRoute)}});window.dispatchEvent(reroute)};window.addEventListener("load",function(e){routes.forEach(function(route){var region=elem(route.id);region.setAttribute("role","region");region.setAttribute("aria-label",getLabel(route))});var hash=idByURL(url());if(!hash||!routeExists(hash)){_this.reroute(defaultId)}else{reconfigure(routeExists(hash),null,null,hash)}});window.addEventListener("hashchange",function(e){var id=idByURL(url());var newRoute=routeExists(id);var oldId=e.oldURL.indexOf("#")>-1?idByURL(e.oldURL):null;var oldRoute=oldId?routeExists(oldId):null;if(newRoute&&newRoute!==oldRoute){var focusId=id===newRoute?newRoute:id;reconfigure(newRoute,oldRoute,e.oldURL||null,focusId)}})}Xiao.prototype.reroute=function(id,params){window.location.hash=(params||"")+id;return this};if(typeof module!=="undefined"&&typeof module.exports!=="undefined"){module.exports=Xiao}else{window.Xiao=Xiao}})(this); -------------------------------------------------------------------------------- /xiao.js: -------------------------------------------------------------------------------- 1 | /* global CustomEvent */ 2 | 3 | (function (window) { 4 | 'use strict' 5 | 6 | function Xiao (routes, defaultId, options) { 7 | options = options || {} 8 | this.settings = { 9 | showHide: true, 10 | separator: '|', 11 | arrived: null, 12 | departed: null 13 | } 14 | 15 | for (var setting in options) { 16 | if (options.hasOwnProperty(setting)) { 17 | this.settings[setting] = options[setting] 18 | } 19 | } 20 | 21 | this.ids = routes.map(route => route.id) 22 | this.title = document.title 23 | this.firstRun = true 24 | 25 | var elem = id => { 26 | return document.getElementById(id) 27 | } 28 | 29 | var url = () => { 30 | return window.location.href 31 | } 32 | 33 | var each = Array.prototype.forEach 34 | 35 | var routeById = id => { 36 | return routes.find(route => id === route.id) 37 | } 38 | 39 | var linksById = id => { 40 | return document.querySelectorAll('[href*="#' + id + '"]') 41 | } 42 | 43 | var idByURL = string => { 44 | return string.includes('#') ? string.match(/#.*?(\?|$)/gi)[0].replace('?', '').substr(1) : null 45 | } 46 | 47 | var paramsToObj = string => { 48 | var query = string.includes('?') ? string.match(/\?.*?(#|$)/gi)[0].replace('#', '').substr(1) : null 49 | return query ? JSON.parse('{"' + decodeURI(query).replace(/"/g, '\\"').replace(/&/g, '","').replace(/=/g, '":"') + '"}') : null 50 | } 51 | 52 | var routeExists = id => { 53 | return this.ids.find(route => elem(route).contains(elem(id))) 54 | } 55 | 56 | var getLabel = route => { 57 | var h = elem(route.id).querySelector('h1, h2') 58 | return h ? h.textContent : route.label ? route.label : route.id 59 | } 60 | 61 | var reconfigure = (newRoute, oldRoute, oldURL, focusId) => { 62 | if (this.settings.showHide) { 63 | this.ids.forEach(id => { 64 | elem(id).hidden = true 65 | }) 66 | } 67 | 68 | var newRegion = elem(newRoute) 69 | if (newRegion) { 70 | if (this.settings.showHide) { 71 | newRegion.hidden = false 72 | } 73 | if (!this.firstRun) { 74 | elem(focusId).setAttribute('tabindex', '-1') 75 | elem(focusId).focus() 76 | } else { 77 | this.firstRun = false 78 | } 79 | } 80 | 81 | var oldParams = oldURL ? paramsToObj(oldURL) : null 82 | 83 | if (oldRoute && routeExists(oldRoute)) { 84 | if (this.settings.departed) { 85 | this.settings.departed(elem(oldRoute), oldParams, routes) 86 | } 87 | if (routeById(oldRoute).departed) { 88 | routeById(oldRoute).departed(elem(oldRoute), oldParams, routes) 89 | } 90 | } 91 | 92 | var newParams = paramsToObj(url()) 93 | if (this.settings.arrived) { 94 | this.settings.arrived(elem(newRoute), newParams, routes) 95 | } 96 | if (routeById(newRoute).arrived) { 97 | routeById(newRoute).arrived(elem(newRoute), newParams, routes) 98 | } 99 | 100 | each.call(document.querySelectorAll('[aria-current]'), link => { 101 | link.removeAttribute('aria-current') 102 | }) 103 | each.call(linksById(newRoute), link => { 104 | link.setAttribute('aria-current', 'true') 105 | }) 106 | 107 | document.title = `${this.title} ${this.settings.separator} ${getLabel(routeById(newRoute))}` 108 | 109 | if (this.settings.showHide && newRoute === focusId) { 110 | document.documentElement.scrollTop = 0 111 | document.body.scrollTop = 0 112 | } 113 | 114 | var reroute = new CustomEvent('reroute', { 115 | detail: { 116 | newRoute: routeById(newRoute), 117 | oldRoute: routeById(oldRoute) 118 | } 119 | }) 120 | window.dispatchEvent(reroute) 121 | } 122 | 123 | window.addEventListener('load', e => { 124 | routes.forEach(route => { 125 | var region = elem(route.id) 126 | region.setAttribute('role', 'region') 127 | region.setAttribute('aria-label', getLabel(route)) 128 | }) 129 | 130 | var hash = idByURL(url()) 131 | 132 | if (!hash || !routeExists(hash)) { 133 | this.reroute(defaultId) 134 | } else { 135 | reconfigure(routeExists(hash), null, null, hash) 136 | } 137 | }) 138 | 139 | window.addEventListener('hashchange', e => { 140 | var id = idByURL(url()) 141 | var newRoute = routeExists(id) 142 | var oldId = e.oldURL.includes('#') ? idByURL(e.oldURL) : null 143 | var oldRoute = oldId ? routeExists(oldId) : null 144 | 145 | if (newRoute && newRoute !== oldRoute) { 146 | var focusId = id === newRoute ? newRoute : id 147 | reconfigure(newRoute, oldRoute, e.oldURL || null, focusId) 148 | } 149 | }) 150 | } 151 | 152 | Xiao.prototype.reroute = function (id, params) { 153 | window.location.hash = (params || '') + id 154 | return this 155 | } 156 | 157 | if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { 158 | module.exports = Xiao 159 | } else { 160 | window.Xiao = Xiao 161 | } 162 | }(this)) 163 | -------------------------------------------------------------------------------- /xiao.min.js: -------------------------------------------------------------------------------- 1 | /*! xiao 0.1.6 — © Heydon Pickering */ 2 | (function(window){"use strict";function Xiao(routes,defaultId,options){options=options||{};this.settings={showHide:true,separator:"|",arrived:null,departed:null};for(var setting in options){if(options.hasOwnProperty(setting)){this.settings[setting]=options[setting]}}this.ids=routes.map(route=>route.id);this.title=document.title;this.firstRun=true;var elem=id=>{return document.getElementById(id)};var url=()=>{return window.location.href};var each=Array.prototype.forEach;var routeById=id=>{return routes.find(route=>id===route.id)};var linksById=id=>{return document.querySelectorAll('[href*="#'+id+'"]')};var idByURL=string=>{return string.includes("#")?string.match(/#.*?(\?|$)/gi)[0].replace("?","").substr(1):null};var paramsToObj=string=>{var query=string.includes("?")?string.match(/\?.*?(#|$)/gi)[0].replace("#","").substr(1):null;return query?JSON.parse('{"'+decodeURI(query).replace(/"/g,'\"').replace(/&/g,'","').replace(/=/g,'":"')+'"}'):null};var routeExists=id=>{return this.ids.find(route=>elem(route).contains(elem(id)))};var getLabel=route=>{var h=elem(route.id).querySelector("h1, h2");return h?h.textContent:route.label?route.label:route.id};var reconfigure=(newRoute,oldRoute,oldURL,focusId)=>{if(this.settings.showHide){this.ids.forEach(id=>{elem(id).hidden=true})}var newRegion=elem(newRoute);if(newRegion){if(this.settings.showHide){newRegion.hidden=false}if(!this.firstRun){elem(focusId).setAttribute("tabindex","-1");elem(focusId).focus()}else{this.firstRun=false}}var oldParams=oldURL?paramsToObj(oldURL):null;if(oldRoute&&routeExists(oldRoute)){if(this.settings.departed){this.settings.departed(elem(oldRoute),oldParams,routes)}if(routeById(oldRoute).departed){routeById(oldRoute).departed(elem(oldRoute),oldParams,routes)}}var newParams=paramsToObj(url());if(this.settings.arrived){this.settings.arrived(elem(newRoute),newParams,routes)}if(routeById(newRoute).arrived){routeById(newRoute).arrived(elem(newRoute),newParams,routes)}each.call(document.querySelectorAll("[aria-current]"),link=>{link.removeAttribute("aria-current")});each.call(linksById(newRoute),link=>{link.setAttribute("aria-current","true")});document.title=`${this.title} ${this.settings.separator} ${getLabel(routeById(newRoute))}`;if(this.settings.showHide&&newRoute===focusId){document.documentElement.scrollTop=0;document.body.scrollTop=0}var reroute=new CustomEvent("reroute",{detail:{newRoute:routeById(newRoute),oldRoute:routeById(oldRoute)}});window.dispatchEvent(reroute)};window.addEventListener("load",e=>{routes.forEach(route=>{var region=elem(route.id);region.setAttribute("role","region");region.setAttribute("aria-label",getLabel(route))});var hash=idByURL(url());if(!hash||!routeExists(hash)){this.reroute(defaultId)}else{reconfigure(routeExists(hash),null,null,hash)}});window.addEventListener("hashchange",e=>{var id=idByURL(url());var newRoute=routeExists(id);var oldId=e.oldURL.includes("#")?idByURL(e.oldURL):null;var oldRoute=oldId?routeExists(oldId):null;if(newRoute&&newRoute!==oldRoute){var focusId=id===newRoute?newRoute:id;reconfigure(newRoute,oldRoute,e.oldURL||null,focusId)}})}Xiao.prototype.reroute=function(id,params){window.location.hash=(params||"")+id;return this};if(typeof module!=="undefined"&&typeof module.exports!=="undefined"){module.exports=Xiao}else{window.Xiao=Xiao}})(this); 3 | --------------------------------------------------------------------------------