-
Notifications
You must be signed in to change notification settings - Fork 0
/
x-router.html
290 lines (252 loc) · 10.6 KB
/
x-router.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
<!--
`x-router`
A modular lightweight client-side router built with webcomponents. The router is used by composing multiple x-router elements
that are each responsible for its own path. For example, the pathname: `/first/second` would require two x-router elements. One responsible
for the pathname `/first` and one responsible for the pathname `/second`. The x-router element responsible for the `/first` pathname is the
main router and require the attribute main. The x-router element responsible for the `/second` pathname is a subrouter. A subrouter has a route
property that the previous router has to set. Each router fires the event `route-change` every time the router's property changes.
Please look in the api reference for more information about the `route-change` event.
Normally the x-router element takes a number of child elements called x-route, although they are not required. If a x-route element is
added as a child they require two attributes: `pattern` and `name`. Pattern takes a regex string used to match the route's pathname. If the
pattern matches the `route-change` event will contain the `name` attribute. If none of the patterns match the name property will be null.
The order of the `x-route` children matters and will return the first match.
```
<x-router main>
<x-route name="home" pattern="^/$"></x-route>
<x-route name="category" pattern="^/category$"></x-route>
</x-router>
<x-router route="[[subroute]]">
<x-route name="clothes" pattern="^/clothes$"></x-route>
<x-route name="shoes" pattern="^/shoes$"></x-route>
</x-router>
```
@demo demo/index.html
-->
<script>
class WCRouter extends HTMLElement {
/*
* Fired when the route changes. Detail object contains:
* - `currentPathname` - the current path for the route.
* - `parentPathname` - the parent path for the route.
* - `pathname` - the full path (window.location.pathName).
* - `queryObject` - the query parameters as an object.
* - `hash` - the hash part of the url.
* - `name` - the name of the route defined in `x-route` element. If no match it will be null.
* - `state` - the `window.history.state` object for the route. Will only contain a state object if the route is `active`.
* - `active` - a boolean. Will be true if route exists and the route does not have a subroute.
* - `subroute` - an object that should be passed to a subroute.
* @event route-change
*/
constructor() {
super();
this._route = { index: 0 };
}
static get is() {
return `x-router`;
}
connectedCallback() {
if (this.hasAttribute(`main`)) {
window.addEventListener('popstate', this._popStateEvent.bind(this));
document.body.addEventListener('click', this._clickEvent.bind(this));
this._dispatchRouteChangeEvent();
}
}
disconnectedCallback() {
if (this.hasAttribute(`main`)) {
window.removeEventListener('popstate', this._popStateEvent);
document.body.removeEventListener('click', this._clickEvent);
}
}
/*
* Handle the pop state event.
*/
_popStateEvent() {
this._dispatchRouteChangeEvent();
}
/*
* Handle the global click event. This is a click event on the body (necessary evil) that hijacks the click event
* for all anchor tags. Tries to out early if it can.
*/
_clickEvent(event) {
if (event.defaultPrevented) {
return;
}
const eventPath = event.composedPath();
let anchor = null;
for (let i = 0; i < eventPath.length; i++) {
let element = eventPath[i];
if (element.tagName === `A` && element.href) {
anchor = element;
break;
}
}
if (!anchor) {
return;
}
if ((anchor.target === `_blank`) || (anchor.target === `_top`) || (anchor.target === `_parent`)) {
return;
}
const url = new URL(anchor.href)
if ((url.protocol !== `http:`) && (url.protocol !== `https:`)) {
return;
}
event.preventDefault();
if (!this._isEqualHref(anchor.href)) {
this._go(anchor.href, null, anchor.hasAttribute(`replace`));
}
}
/*
* Trigger a route change. Will only trigger a route change for its own route and subroutes not a parents route.
* Will use history.pushState to navigate.
*/
push(pathname, state) {
if (!this._isEqualHref(pathname)) {
this._go(pathname, state, false);
}
}
/*
* Trigger a route change. Will only trigger a route change for its own route and subroutes not a parents route.
* Will use history.replaceState to navigate.
*/
replace(pathname, state) {
if (!this._isEqualHref(pathname)) {
this._go(pathname, state, true);
}
}
/*
* Trigger a route change. Will only trigger a route change for its own route and subroutes not a parents route.
* A truthy replace parameter will use history.replaceState instead of history.pushState to navigate.
*/
_go(pathname, state, replace) {
const paths = this._splitPathname(window.location.pathname);
const newPathname = this._currentPathnameFromPaths(paths, pathname, this.route.index);
if (newPathname) {
if (replace) {
window.history.replaceState(state, ``, newPathname);
} else {
window.history.pushState(state, ``, newPathname);
}
this._dispatchRouteChangeEvent();
} else {
console.warn(`Route is outside its index`);
}
}
/*
* Observe the subroute object and respond to changes.
*/
set route(route) {
this._route = route;
if (this._route.index !== 0) {
const paths = this._splitPathname(window.location.pathname);
this._dispatchRouteChangeEvent();
}
}
/*
* Returns the route.
*/
get route() {
return this._route;
}
/*
* Fires event `route-change` with information about current route.
*/
_dispatchRouteChangeEvent() {
const children = this._getRouteChildren();
const paths = this._splitPathname(window.location.pathname);
const currentPathname = paths[this.route.index] || null;
const parentPathname = paths.slice(0, this.route.index).join(``);
const pathname = window.decodeURIComponent(window.location.pathname);
const queryObject = this._queryStringToObject(window.location.search.substring(1));
const hash = window.decodeURIComponent(window.location.hash.substring(1));
const name = this._findChildNameForPath(children, currentPathname);
const active = (`${parentPathname}${currentPathname}` === pathname);
const state = (active) ? window.history.state : null;
const subroute = {
index: this.route.index + 1
};
const detail = {
currentPathname: currentPathname,
parentPathname: parentPathname,
pathname: pathname,
queryObject: queryObject,
hash: hash,
name: name,
state: state,
active: active,
subroute: subroute
};
this.dispatchEvent(new CustomEvent('route-change', {detail: detail}, {bubbles: true, composed: true}))
}
/**
* Checks if the href are the same.
*/
_isEqualHref(pathname) {
const url = new URL(pathname, `${window.location.protocol}${window.location.host}`);
if (url.href === window.location.href) {
return true;
} else {
false;
}
}
/*
* Transforms the query string to an object.
*/
_queryStringToObject(queryString) {
let queryObject = {};
const decodedQueryString = window.decodeURIComponent(queryString);
decodedQueryString.split(`&`).forEach((q) => {
const parameter = q.split(`=`);
if (parameter.length === 2) {
queryObject[window.decodeURIComponent(parameter[0])] = window.decodeURIComponent(parameter[1]);
}
});
return queryObject;
}
/*
* Finds all child elements with selector `x-route[pattern][name]`.
*/
_getRouteChildren() {
return this.querySelectorAll(`x-route[pattern][name]`);
}
/*
* Create the pathname for the current route. Will not change the pathname for a route higher up
* in the route hierarchy.
*/
_currentPathnameFromPaths(paths, pathName, index) {
if (index < paths.length) {
const parentPaths = paths.slice(0, index);
parentPaths.push(pathName);
return parentPaths.join(``);
} else {
return null;
}
}
/*
* Finds the name of the path by mathing the child's regex pattern to the path.
*/
_findChildNameForPath(children, path) {
for (let i = 0; i < children.length; i++) {
const child = children[i];
const pattern = child.getAttribute(`pattern`);
const regex = new RegExp(pattern);
if (regex.test(path)) {
return child.getAttribute(`name`);
}
}
return null;
}
/*
* Splits the pathname to an array.
*/
_splitPathname(pathname) {
if (pathname === `/`) {
return [`/`];
} else {
return window.decodeURIComponent(pathname).split(`/`)
.filter((pathname) => { return pathname !== ``; })
.map((pathname => { return `/${pathname}` }));
}
}
}
window.customElements.define(WCRouter.is, WCRouter);
</script>