);
11 | constructAbsolutePath: ((path: PathFragment) => string);
12 | parent: IRouterSlot | null | undefined;
13 | queryParentRouterSlot: (() => IRouterSlot
| null);
14 | }
15 |
16 | export type IRoutingInfo = {
17 | slot: IRouterSlot,
18 | match: IRouteMatch
19 | };
20 |
21 | export type CustomResolver = ((info: IRoutingInfo) => boolean | void | Promise | Promise);
22 | export type Guard = ((info: IRoutingInfo) => boolean | Promise);
23 | export type Cancel = (() => boolean);
24 |
25 | export type PageComponent = HTMLElement;
26 | export type ModuleResolver = Promise<{default: any; /*PageComponent*/}>;
27 | export type Class = {new (...args: any[]): T;};
28 | export type Setup = ((component: PageComponent, info: IRoutingInfo) => void);
29 |
30 | export type RouterTree = {slot: IRouterSlot} & {child?: RouterTree} | null | undefined;
31 | export type PathMatch = "prefix" | "suffix" | "full" | "fuzzy";
32 |
33 | /**
34 | * The base route interface.
35 | * D = the data type of the data
36 | */
37 | export interface IRouteBase {
38 |
39 | // The path for the route fragment
40 | path: PathFragment;
41 |
42 | // Optional metadata
43 | data?: D;
44 |
45 | // If guard returns false, the navigation is not allowed
46 | guards?: Guard[];
47 |
48 | // The type of match.
49 | // - If "prefix" router-slot will try to match the first part of the path.
50 | // - If "suffix" router-slot will try to match the last part of the path.
51 | // - If "full" router-slot will try to match the entire path.
52 | // - If "fuzzy" router-slot will try to match an arbitrary part of the path.
53 | pathMatch?: PathMatch;
54 | }
55 |
56 | /**
57 | * Route type used for redirection.
58 | */
59 | export interface IRedirectRoute extends IRouteBase {
60 |
61 | // The paths the route should redirect to. Can either be relative or absolute.
62 | redirectTo: string;
63 |
64 | // Whether the query should be preserved when redirecting.
65 | preserveQuery?: boolean;
66 | }
67 |
68 | /**
69 | * Route type used to resolve and stamp components.
70 | */
71 | export interface IComponentRoute extends IRouteBase {
72 |
73 | // The component loader (should return a module with a default export)
74 | component: Class | ModuleResolver | PageComponent | (() => Class) | (() => PageComponent) | (() => ModuleResolver);
75 |
76 | // A custom setup function for the instance of the component.
77 | setup?: Setup;
78 | }
79 |
80 | /**
81 | * Route type used to take control of how the route should resolve.
82 | */
83 | export interface IResolverRoute extends IRouteBase {
84 |
85 | // A custom resolver that handles the route change
86 | resolve: CustomResolver;
87 | }
88 |
89 | export type IRoute = IRedirectRoute | IComponentRoute | IResolverRoute;
90 | export type PathFragment = string;
91 | export type IPathFragments = {
92 | consumed: PathFragment,
93 | rest: PathFragment
94 | }
95 |
96 | export interface IRouteMatch {
97 | route: IRoute;
98 | params: Params,
99 | fragments: IPathFragments;
100 | match: RegExpMatchArray;
101 | }
102 |
103 | export type PushStateEvent = CustomEvent;
104 | export type ReplaceStateEvent = CustomEvent;
105 | export type ChangeStateEvent = CustomEvent;
106 | export type WillChangeStateEvent = CustomEvent<{ url?: string | null, eventName: GlobalRouterEvent}>;
107 | export type NavigationStartEvent = CustomEvent>;
108 | export type NavigationSuccessEvent = CustomEvent>;
109 | export type NavigationCancelEvent = CustomEvent>;
110 | export type NavigationErrorEvent = CustomEvent>;
111 | export type NavigationEndEvent = CustomEvent>;
112 |
113 | export type Params = {[key: string]: string};
114 | export type Query = {[key: string]: string};
115 |
116 | export type EventListenerSubscription = (() => void);
117 |
118 | /**
119 | * RouterSlot related events.
120 | */
121 | export type RouterSlotEvent = "changestate";
122 |
123 | /**
124 | * History related events.
125 | */
126 | export type GlobalRouterEvent =
127 |
128 | // An event triggered when a new state is added to the history.
129 | "pushstate"
130 |
131 | // An event triggered when the current state is replaced in the history.
132 | | "replacestate"
133 |
134 | // An event triggered when a state in the history is popped from the history.
135 | | "popstate"
136 |
137 | // An event triggered when the state changes (eg. pop, push and replace)
138 | | "changestate"
139 |
140 | // A cancellable event triggered before the history state changes.
141 | | "willchangestate"
142 |
143 | // An event triggered when navigation starts.
144 | | "navigationstart"
145 |
146 | // An event triggered when navigation is canceled. This is due to a route guard returning false during navigation.
147 | | "navigationcancel"
148 |
149 | // An event triggered when navigation fails due to an unexpected error.
150 | | "navigationerror"
151 |
152 | // An event triggered when navigation successfully completes.
153 | | "navigationsuccess"
154 |
155 | // An event triggered when navigation ends.
156 | | "navigationend";
157 |
158 | export interface ISlashOptions {
159 | start: boolean;
160 | end: boolean;
161 | }
162 |
163 | /* Extend the global event handlers map with the router related events */
164 | declare global {
165 | interface GlobalEventHandlersEventMap {
166 | "pushstate": PushStateEvent,
167 | "replacestate": ReplaceStateEvent,
168 | "popstate": PopStateEvent,
169 | "changestate": ChangeStateEvent,
170 | "navigationstart": NavigationStartEvent,
171 | "navigationend": NavigationEndEvent,
172 | "navigationsuccess": NavigationSuccessEvent,
173 | "navigationcancel": NavigationCancelEvent,
174 | "navigationerror": NavigationErrorEvent,
175 | "willchangestate": WillChangeStateEvent
176 | }
177 | }
--------------------------------------------------------------------------------
/src/lib/router-link.ts:
--------------------------------------------------------------------------------
1 | import { GLOBAL_ROUTER_EVENTS_TARGET } from "./config";
2 | import { EventListenerSubscription, GlobalRouterEvent, IRouterSlot, PathFragment } from "./model";
3 | import { addListener, isPathActive, queryParentRouterSlot, queryString, removeListeners, slashify } from "./util";
4 |
5 | const template = document.createElement("template");
6 | template.innerHTML = ``;
7 |
8 | /**
9 | * Router link.
10 | * @slot - Default content.
11 | */
12 | export class RouterLink extends HTMLElement {
13 |
14 | private listeners: EventListenerSubscription[] = [];
15 | private _context: IRouterSlot | null = null;
16 |
17 | static get observedAttributes () {
18 | return [
19 | "disabled"
20 | ];
21 | }
22 |
23 | /**
24 | * The path of the navigation.
25 | * @attr
26 | */
27 | set path (value: string | PathFragment) {
28 | this.setAttribute("path", value);
29 | }
30 |
31 | get path (): string | PathFragment {
32 | return this.getAttribute("path") || "/";
33 | }
34 |
35 | /**
36 | * Whether the element is disabled or not.
37 | * @attr
38 | */
39 | get disabled (): boolean {
40 | return this.hasAttribute("disabled");
41 | }
42 |
43 | set disabled (value: boolean) {
44 | value ? this.setAttribute("disabled", "") : this.removeAttribute("disabled");
45 | }
46 |
47 | /**
48 | * Whether the element is active or not.
49 | * @attr
50 | */
51 | get active (): boolean {
52 | return this.hasAttribute("active");
53 | }
54 |
55 | set active (value: boolean) {
56 | value ? this.setAttribute("active", "") : this.removeAttribute("active");
57 | }
58 |
59 | /**
60 | * Whether the focus should be delegated.
61 | * @attr
62 | */
63 | get delegateFocus (): boolean {
64 | return this.hasAttribute("delegateFocus");
65 | }
66 |
67 | set delegateFocus (value: boolean) {
68 | value ? this.setAttribute("delegateFocus", "") : this.removeAttribute("delegateFocus");
69 | }
70 |
71 | /**
72 | * Whether the query should be preserved or not.
73 | * @attr
74 | */
75 | get preserveQuery (): boolean {
76 | return this.hasAttribute("preservequery");
77 | }
78 |
79 | set preserveQuery (value: boolean) {
80 | value ? this.setAttribute("preservequery", "") : this.removeAttribute("preservequery");
81 | }
82 |
83 | /**
84 | * The current router slot context.
85 | */
86 | get context (): IRouterSlot | null {
87 | return this._context;
88 | }
89 |
90 | set context (value: IRouterSlot | null) {
91 | this._context = value;
92 | }
93 |
94 | /**
95 | * Returns the absolute path.
96 | */
97 | get absolutePath (): string {
98 | return this.constructAbsolutePath(this.path);
99 | }
100 |
101 | constructor () {
102 | super();
103 |
104 | this.navigate = this.navigate.bind(this);
105 | this.updateActive = this.updateActive.bind(this);
106 |
107 | // Attach the template
108 | const shadow = this.attachShadow({mode: "open", delegatesFocus: this.delegateFocus});
109 | shadow.appendChild(template.content.cloneNode(true));
110 | }
111 |
112 | /**
113 | * Hooks up the element.
114 | */
115 | connectedCallback () {
116 | this.listeners.push(
117 | addListener(this, "click", e => this.navigate(this.path, e)),
118 | addListener(this, "keydown", (e: KeyboardEvent) => e.code === "Enter" || e.code === "Space" ? this.navigate(this.path, e) : undefined),
119 | addListener(GLOBAL_ROUTER_EVENTS_TARGET, "navigationend", this.updateActive),
120 | addListener(GLOBAL_ROUTER_EVENTS_TARGET, "changestate", this.updateActive)
121 | );
122 |
123 | // Query the nearest router
124 | this.context = queryParentRouterSlot(this);
125 |
126 | // Set the role to tell the rest of the world that this is a link
127 | this.setAttribute("role", "link");
128 |
129 | // Updates the tab index if none has been set by the library user
130 | if (!this.hasAttribute("tabindex")) {
131 | this.updateTabIndex();
132 | }
133 | }
134 |
135 | /**
136 | * Tear down listeners.
137 | */
138 | disconnectedCallback () {
139 | removeListeners(this.listeners);
140 | }
141 |
142 | /**
143 | * Reacts to attribute changed callback.
144 | * @param name
145 | * @param oldValue
146 | * @param newValue
147 | */
148 | attributeChangedCallback (name: string, oldValue: unknown, newValue: unknown) {
149 |
150 | // Updates the tab index when disabled changes
151 | if (name === "disabled") {
152 | this.updateTabIndex();
153 | }
154 | }
155 |
156 | private updateTabIndex () {
157 | this.tabIndex = this.disabled ? -1 : 0;
158 | }
159 |
160 | /**
161 | * Returns the absolute path constructed relative to the context.
162 | * If no router parent was found the path property is the absolute one.
163 | */
164 | constructAbsolutePath (path: string) {
165 |
166 | // If a router context is present, navigate relative to that one
167 | if (this.context != null) {
168 | return this.context.constructAbsolutePath(path);
169 | }
170 |
171 | return slashify(path, {end: false});
172 | }
173 |
174 |
175 | /**
176 | * Updates whether the route is active or not.
177 | */
178 | protected updateActive () {
179 | const active = isPathActive(this.absolutePath);
180 | if (active !== this.active) {
181 | this.active = active;
182 | }
183 | }
184 |
185 | /**
186 | * Navigates to the specified path.
187 | */
188 | navigate (path: string, e?: Event) {
189 |
190 | // If disabled, we just prevent the navigation already now.
191 | if (e != null && this.disabled) {
192 | e.preventDefault();
193 | e.stopPropagation();
194 | return;
195 | }
196 |
197 | history.pushState(null, "", `${this.absolutePath}${this.preserveQuery ? queryString() : ""}`);
198 | }
199 | }
200 |
201 | window.customElements.define("router-link", RouterLink);
202 |
203 | declare global {
204 | interface HTMLElementTagNameMap {
205 | "router-link": RouterLink;
206 | }
207 | }
--------------------------------------------------------------------------------
/src/lib/router-slot.ts:
--------------------------------------------------------------------------------
1 | import { GLOBAL_ROUTER_EVENTS_TARGET, ROUTER_SLOT_TAG_NAME } from "./config";
2 | import { Cancel, EventListenerSubscription, GlobalRouterEvent, IPathFragments, IRoute, IRouteMatch, IRouterSlot, IRoutingInfo, Params, PathFragment, RouterSlotEvent } from "./model";
3 | import { addListener, constructAbsolutePath, dispatchGlobalRouterEvent, dispatchRouteChangeEvent, ensureAnchorHistory, ensureHistoryEvents, handleRedirect, isRedirectRoute, isResolverRoute, matchRoutes, pathWithoutBasePath, queryParentRouterSlot, removeListeners, resolvePageComponent, shouldNavigate } from "./util";
4 |
5 | const template = document.createElement("template");
6 | template.innerHTML = ``;
7 |
8 | // Patches the history object and ensures the correct events.
9 | ensureHistoryEvents();
10 |
11 | // Ensure the anchor tags uses the history API
12 | ensureAnchorHistory();
13 |
14 | /**
15 | * Slot for a node in the router tree.
16 | * @slot - Default content.
17 | * @event changestate - Dispatched when the router slot state changes.
18 | */
19 | export class RouterSlot extends HTMLElement implements IRouterSlot {
20 |
21 | /**
22 | * Listeners on the router.
23 | */
24 | private listeners: EventListenerSubscription[] = [];
25 |
26 | /**
27 | * The available routes.
28 | */
29 | private _routes: IRoute[] = [];
30 | get routes (): IRoute[] {
31 | return this._routes;
32 | }
33 |
34 | set routes (routes: IRoute[]) {
35 | this.clear();
36 | this.add(routes);
37 | }
38 |
39 | /**
40 | * The parent router.
41 | * Is REQUIRED if this router is a child.
42 | * When set, the relevant listeners are added or teared down because they depend on the parent.
43 | */
44 | _parent: IRouterSlot | null | undefined;
45 | get parent (): IRouterSlot
| null | undefined {
46 | return this._parent;
47 | }
48 |
49 | set parent (router: IRouterSlot
| null | undefined) {
50 | this.detachListeners();
51 | this._parent = router;
52 | this.attachListeners();
53 | }
54 |
55 | /**
56 | * Whether the router is a root router.
57 | */
58 | get isRoot (): boolean {
59 | return this.parent == null;
60 | }
61 |
62 | /**
63 | * The current route match.
64 | */
65 | private _routeMatch: IRouteMatch | null = null;
66 |
67 | get match (): IRouteMatch | null {
68 | return this._routeMatch;
69 | }
70 |
71 | /**
72 | * The current route of the match.
73 | */
74 | get route (): IRoute | null {
75 | return this.match != null ? this.match.route : null;
76 | }
77 |
78 | /**
79 | * The current path fragment of the match
80 | */
81 | get fragments (): IPathFragments | null {
82 | return this.match != null ? this.match.fragments : null;
83 | }
84 |
85 | /**
86 | * The current params of the match.
87 | */
88 | get params (): Params | null {
89 | return this.match != null ? this.match.params : null;
90 | }
91 |
92 | /**
93 | * Hooks up the element.
94 | */
95 | constructor () {
96 | super();
97 |
98 | this.render = this.render.bind(this);
99 |
100 | // Attach the template
101 | const shadow = this.attachShadow({mode: "open"});
102 | shadow.appendChild(template.content.cloneNode(true));
103 | }
104 |
105 | /**
106 | * Query the parent router slot when the router slot is connected.
107 | */
108 | connectedCallback () {
109 | this.parent = this.queryParentRouterSlot();
110 | }
111 |
112 | /**
113 | * Tears down the element.
114 | */
115 | disconnectedCallback () {
116 | this.detachListeners();
117 | }
118 |
119 | /**
120 | * Queries the parent router.
121 | */
122 | queryParentRouterSlot (): IRouterSlot | null {
123 | return queryParentRouterSlot
(this);
124 | }
125 |
126 | /**
127 | * Returns an absolute path relative to the router slot.
128 | * @param path
129 | */
130 | constructAbsolutePath (path: PathFragment): string {
131 | return constructAbsolutePath(this, path);
132 | }
133 |
134 | /**
135 | * Adds routes to the router.
136 | * Navigates automatically if the router slot is the root and is connected.
137 | * @param routes
138 | * @param navigate
139 | */
140 | add (routes: IRoute[], navigate: boolean = this.isRoot && this.isConnected): void {
141 |
142 | // Add the routes to the array
143 | this._routes.push(...routes);
144 |
145 | // Register that the path has changed so the correct route can be loaded.
146 | if (navigate) {
147 | this.render().then();
148 | }
149 | }
150 |
151 | /**
152 | * Removes all routes.
153 | */
154 | clear (): void {
155 | this._routes.length = 0;
156 | }
157 |
158 | /**
159 | * Each time the path changes, load the new path.
160 | */
161 | async render (): Promise {
162 |
163 | // When using ShadyDOM the disconnectedCallback in the child router slot is called async
164 | // in a microtask. This means that when using the ShadyDOM polyfill, sometimes child router slots
165 | // would not clear event listeners from the parent router slots and therefore route even though
166 | // it was no longer in the DOM. The solution is to check whether the isConnected flag is false
167 | // before rendering the path.
168 | if (!this.isConnected) {
169 | return;
170 | }
171 |
172 | // Either choose the parent fragment or the current path if no parent exists.
173 | // The root router slot will always use the entire path.
174 | const pathFragment = this.parent != null && this.parent.fragments != null
175 | ? this.parent.fragments.rest
176 | : pathWithoutBasePath();
177 |
178 | // Route to the path
179 | await this.renderPath(pathFragment);
180 | }
181 |
182 | /**
183 | * Attaches listeners, either globally or on the parent router.
184 | */
185 | protected attachListeners (): void {
186 |
187 | // Add listeners that updates the route
188 | this.listeners.push(
189 | this.parent != null
190 |
191 | // Attach child router listeners
192 | ? addListener(this.parent, "changestate", this.render)
193 |
194 | // Add global listeners.
195 | : addListener(GLOBAL_ROUTER_EVENTS_TARGET, "changestate", this.render)
196 | );
197 | }
198 |
199 | /**
200 | * Clears the children in the DOM.
201 | */
202 | protected clearChildren () {
203 | while (this.firstChild != null) {
204 | this.firstChild.parentNode!.removeChild(this.firstChild);
205 | }
206 | }
207 |
208 | /**
209 | * Detaches the listeners.
210 | */
211 | protected detachListeners (): void {
212 | removeListeners(this.listeners);
213 | }
214 |
215 | /**
216 | * Loads a new path based on the routes.
217 | * Returns true if a navigation was made to a new page.
218 | */
219 | protected async renderPath (path: string | PathFragment): Promise {
220 |
221 | // Find the corresponding route.
222 | const match = matchRoutes(this._routes, path);
223 |
224 | // Ensure that a route was found, otherwise we just clear the current state of the route.
225 | if (match == null) {
226 | this._routeMatch = null;
227 | return false;
228 | }
229 |
230 | const {route} = match;
231 | const info: IRoutingInfo = {match, slot: this};
232 |
233 | try {
234 |
235 | // Only change route if its a new route.
236 | const navigate = shouldNavigate(this.match, match);
237 | if (navigate) {
238 |
239 | // Listen for another push state event. If another push state event happens
240 | // while we are about to navigate we have to cancel.
241 | let navigationInvalidated = false;
242 | const cancelNavigation = () => navigationInvalidated = true;
243 | const removeChangeListener: EventListenerSubscription = addListener(GLOBAL_ROUTER_EVENTS_TARGET, "changestate", cancelNavigation, {once: true});
244 |
245 | // Cleans up the routing by removing listeners and restoring the match from before
246 | const cleanup = () => {
247 | removeChangeListener();
248 | };
249 |
250 | // Cleans up and dispatches a global event that a navigation was cancelled.
251 | const cancel: Cancel = () => {
252 | cleanup();
253 | dispatchGlobalRouterEvent("navigationcancel", info);
254 | dispatchGlobalRouterEvent("navigationend", info);
255 | return false;
256 | };
257 |
258 | // Dispatch globally that a navigation has started
259 | dispatchGlobalRouterEvent("navigationstart", info);
260 |
261 | // Check whether the guards allow us to go to the new route.
262 | if (route.guards != null) {
263 | for (const guard of route.guards) {
264 | if (!(await guard(info))) {
265 | return cancel();
266 | }
267 | }
268 | }
269 |
270 | // Redirect if necessary
271 | if (isRedirectRoute(route)) {
272 | cleanup();
273 | handleRedirect(this, route);
274 | return false;
275 | }
276 |
277 | // Handle custom resolving if necessary
278 | else if (isResolverRoute(route)) {
279 |
280 | // The resolve will handle the rest of the navigation. This includes whether or not the navigation
281 | // should be cancelled. If the resolve function returns false we cancel the navigation.
282 | if ((await route.resolve(info)) === false) {
283 | return cancel();
284 | }
285 | }
286 |
287 | // If the component provided is a function (and not a class) call the function to get the promise.
288 | else {
289 | const page = await resolvePageComponent(route, info);
290 |
291 | // Cancel the navigation if another navigation event was sent while this one was loading
292 | if (navigationInvalidated) {
293 | return cancel();
294 | }
295 |
296 | // Remove the old page by clearing the slot
297 | this.clearChildren();
298 |
299 | // Store the new route match before we append the new page to the DOM.
300 | // We do this to ensure that we can find the match in the connectedCallback of the page.
301 | this._routeMatch = match;
302 |
303 | // Append the new page
304 | this.appendChild(page);
305 | }
306 |
307 | // Remember to cleanup after the navigation
308 | cleanup();
309 | }
310 |
311 | // Store the new route match
312 | this._routeMatch = match;
313 |
314 | // Always dispatch the route change event to notify the children that something happened.
315 | // This is because the child routes might have to change routes further down the tree.
316 | // The event is dispatched in an animation frame to allow route children to make the initial render first
317 | // and hook up the new router slot.
318 | requestAnimationFrame(() => {
319 | dispatchRouteChangeEvent(this, info);
320 | });
321 |
322 | // Dispatch globally that a navigation has ended.
323 | if (navigate) {
324 | dispatchGlobalRouterEvent("navigationsuccess", info);
325 | dispatchGlobalRouterEvent("navigationend", info);
326 | }
327 |
328 | return navigate;
329 |
330 | } catch (e) {
331 | dispatchGlobalRouterEvent("navigationerror", info);
332 | dispatchGlobalRouterEvent("navigationend", info);
333 | throw e;
334 | }
335 | }
336 | }
337 |
338 | window.customElements.define(ROUTER_SLOT_TAG_NAME, RouterSlot);
339 |
340 | declare global {
341 | interface HTMLElementTagNameMap {
342 | "router-slot": RouterSlot;
343 | }
344 | }
345 |
--------------------------------------------------------------------------------
/src/lib/util/anchor.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Hook up a click listener to the window that, for all anchor tags
3 | * that has a relative HREF, uses the history API instead.
4 | */
5 | export function ensureAnchorHistory () {
6 | window.addEventListener("click", (e: MouseEvent) => {
7 |
8 | // Find the target by using the composed path to get the element through the shadow boundaries.
9 | const $anchor = ("composedPath" in e as any) ? e.composedPath().find($elem => $elem instanceof HTMLAnchorElement) : e.target;
10 |
11 | // Abort if the event is not about the anchor tag
12 | if ($anchor == null || !($anchor instanceof HTMLAnchorElement)) {
13 | return;
14 | }
15 |
16 | // Get the HREF value from the anchor tag
17 | const href = $anchor.href;
18 |
19 | // Only handle the anchor tag if the follow holds true:
20 | // - The HREF is relative to the origin of the current location.
21 | // - The target is targeting the current frame.
22 | // - The anchor doesn't have the attribute [data-router-slot]="disabled"
23 | if (!href.startsWith(location.origin) ||
24 | ($anchor.target !== "" && $anchor.target !== "_self") ||
25 | $anchor.dataset["routerSlot"] === "disabled") {
26 | return;
27 | }
28 |
29 | // Remove the origin from the start of the HREF to get the path
30 | const path = $anchor.pathname;
31 |
32 | // Prevent the default behavior
33 | e.preventDefault();
34 |
35 | // Change the history!
36 | history.pushState(null, "", path);
37 | });
38 | }
--------------------------------------------------------------------------------
/src/lib/util/events.ts:
--------------------------------------------------------------------------------
1 | import { GLOBAL_ROUTER_EVENTS_TARGET } from "../config";
2 | import { EventListenerSubscription, GlobalRouterEvent, IRoute, IRoutingInfo } from "../model";
3 |
4 | /**
5 | * Dispatches a did change route event.
6 | * @param $elem
7 | * @param {IRoute} detail
8 | */
9 | export function dispatchRouteChangeEvent ($elem: HTMLElement, detail: IRoutingInfo) {
10 | $elem.dispatchEvent(new CustomEvent("changestate", {detail}));
11 | }
12 |
13 | /**
14 | * Dispatches an event on the window object.
15 | * @param name
16 | * @param detail
17 | */
18 | export function dispatchGlobalRouterEvent (name: GlobalRouterEvent, detail?: IRoutingInfo) {
19 | GLOBAL_ROUTER_EVENTS_TARGET.dispatchEvent(new CustomEvent(name, {detail}));
20 | // if ("debugRouterSlot" in window) {
21 | // console.log(`%c [router-slot]: ${name}`, `color: #286ee0`, detail);
22 | // }
23 | }
24 |
25 | /**
26 | * Adds an event listener (or more) to an element and returns a function to unsubscribe.
27 | * @param $elem
28 | * @param type
29 | * @param listener
30 | * @param options
31 | */
32 | export function addListener ($elem: EventTarget,
33 | type: eventType[] | eventType,
34 | listener: ((e: T) => void),
35 | options?: boolean | AddEventListenerOptions): EventListenerSubscription {
36 | const types = Array.isArray(type) ? type : [type];
37 | types.forEach(t => $elem.addEventListener(t, listener as EventListenerOrEventListenerObject, options));
38 | return () => types.forEach(
39 | t => $elem.removeEventListener(t, listener as EventListenerOrEventListenerObject, options));
40 | }
41 |
42 |
43 | /**
44 | * Removes the event listeners in the array.
45 | * @param listeners
46 | */
47 | export function removeListeners (listeners: EventListenerSubscription[]) {
48 | listeners.forEach(unsub => unsub());
49 | }
50 |
--------------------------------------------------------------------------------
/src/lib/util/history.ts:
--------------------------------------------------------------------------------
1 | import { GLOBAL_ROUTER_EVENTS_TARGET, HISTORY_PATCH_NATIVE_KEY } from "../config";
2 | import { GlobalRouterEvent } from "../model";
3 | import { dispatchGlobalRouterEvent } from "./events";
4 |
5 | // Mapping a history functions to the events they are going to dispatch.
6 | export const historyPatches: [string, GlobalRouterEvent[]][] = [
7 | ["pushState", ["pushstate", "changestate"]],
8 | ["replaceState", ["replacestate", "changestate"]],
9 | ["forward", ["pushstate", "changestate"]],
10 | ["go", ["pushstate", "changestate"]],
11 |
12 | // We need to handle the popstate a little differently when it comes to the change state event.
13 | ["back", ["popstate"]],
14 | ];
15 |
16 |
17 | /**
18 | * Patches the history object by ensuring correct events are dispatches when the history changes.
19 | */
20 | export function ensureHistoryEvents() {
21 | for (const [name, events] of historyPatches) {
22 | for (const event of events) {
23 | attachCallback(history, name, event);
24 | }
25 | }
26 |
27 | // The popstate is the only event natively dispatched when using the hardware buttons.
28 | // Therefore we need to handle this case a little different. To ensure the changestate event
29 | // is fired also when the hardware back button is used, we make sure to listen for the popstate
30 | // event and dispatch a change state event right after. The reason for the setTimeout is because we
31 | // want the popstate event to bubble up before the changestate event is dispatched.
32 | window.addEventListener("popstate", (e: PopStateEvent) => {
33 |
34 | // Check if the state should be allowed to change
35 | if (shouldCancelChangeState({eventName: "popstate"})) {
36 | e.preventDefault();
37 | e.stopPropagation();
38 | return;
39 | }
40 |
41 | // Dispatch the global router event to change the routes after the popstate has bubbled up
42 | setTimeout(() => dispatchGlobalRouterEvent("changestate"), 0);
43 | }
44 | );
45 | }
46 |
47 | /**
48 | * Attaches a global router event after the native function on the object has been invoked.
49 | * Stores the original function at the _name.
50 | * @param obj
51 | * @param functionName
52 | * @param eventName
53 | */
54 | export function attachCallback(obj: any, functionName: string, eventName: GlobalRouterEvent) {
55 | const func = obj[functionName];
56 | saveNativeFunction(obj, functionName, func);
57 | obj[functionName] = (...args: any[]) => {
58 |
59 | // If its push/replace state we want to send the url to the should cancel change state event
60 | const url = args.length > 2 ? args[2] : null;
61 |
62 | // Check if the state should be allowed to change
63 | if (shouldCancelChangeState({url, eventName})) return;
64 |
65 | // Navigate
66 | func.apply(obj, args);
67 | dispatchGlobalRouterEvent(eventName)
68 | };
69 | }
70 |
71 | /**
72 | * Saves the native function on the history object.
73 | * @param obj
74 | * @param name
75 | * @param func
76 | */
77 | export function saveNativeFunction(obj: any, name: string, func: (() => void)) {
78 |
79 | // Ensure that the native object exists.
80 | if (obj[HISTORY_PATCH_NATIVE_KEY] == null) {
81 | obj[HISTORY_PATCH_NATIVE_KEY] = {};
82 | }
83 |
84 | // Save the native function.
85 | obj[HISTORY_PATCH_NATIVE_KEY][`${name}`] = func.bind(obj);
86 | }
87 |
88 | /**
89 | * Dispatches and event and returns whether the state change should be cancelled.
90 | * The state will be considered as cancelled if the "willChangeState" event was cancelled.
91 | */
92 | function shouldCancelChangeState(data: { url?: string | null, eventName: GlobalRouterEvent }): boolean {
93 | return !GLOBAL_ROUTER_EVENTS_TARGET.dispatchEvent(new CustomEvent("willchangestate", {
94 | cancelable: true,
95 | detail: data
96 | }));
97 | }
98 |
99 | // Expose the native history functions.
100 | declare global {
101 | interface History {
102 | "native": {
103 | "back": ((distance?: any) => void);
104 | "forward": ((distance?: any) => void);
105 | "go": ((delta?: any) => void);
106 | "pushState": ((data: any, title?: string, url?: string | null) => void);
107 | "replaceState": ((data: any, title?: string, url?: string | null) => void);
108 | }
109 | }
110 | }
--------------------------------------------------------------------------------
/src/lib/util/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./events";
2 | export * from "./history";
3 | export * from "./router";
4 | export * from "./shadow";
5 | export * from "./url";
6 | export * from "./anchor";
--------------------------------------------------------------------------------
/src/lib/util/router.ts:
--------------------------------------------------------------------------------
1 | import { CATCH_ALL_WILDCARD, DEFAULT_PATH_MATCH, PARAM_IDENTIFIER, TRAVERSE_FLAG } from "../config";
2 | import { IComponentRoute, IRedirectRoute, IResolverRoute, IRoute, IRouteMatch, IRouterSlot, ModuleResolver, PageComponent, Params, PathFragment, RouterTree, IRoutingInfo } from "../model";
3 | import { constructPathWithBasePath, path as getPath, queryString, stripSlash } from "./url";
4 |
5 | /**
6 | * Determines whether the path is active.
7 | * If the full path starts with the path and is followed by the end of the string or a "/" the path is considered active.
8 | * @param path
9 | * @param fullPath
10 | */
11 | export function isPathActive (path: string | PathFragment, fullPath: string = getPath()): boolean {
12 | return new RegExp(`^${stripSlash(path)}(\/|$)`, "gm").test(stripSlash(fullPath));
13 | }
14 |
15 | /**
16 | * Matches a route.
17 | * @param route
18 | * @param path
19 | */
20 | export function matchRoute (route: IRoute, path: string | PathFragment): IRouteMatch | null {
21 |
22 | // We start by preparing the route path by replacing the param names with a regex that matches everything
23 | // until either the end of the path or the next "/". While replacing the param placeholders we make sure
24 | // to store the names of the param placeholders.
25 | const paramNames: string[] = [];
26 | const routePath = stripSlash(route.path.replace(PARAM_IDENTIFIER, (substring: string, ...args: string[]) => {
27 | paramNames.push(args[0]);
28 | return `([^\/]+)`;
29 | }));
30 |
31 | // Construct the regex to match with the path or fragment
32 | // If path is wildcard:
33 | // - We match with /^/ to not consume any characters.
34 | // If path is empty and pathmatch is not full
35 | // - We match with /^/ to not consume any characters.
36 | // If pathmatch is prefix
37 | // - We start the match with [/]? to allow a slash in front of the path.
38 | // - We end the match with (?:/|$) to make sure the match ends at either the end of the fragment or end of the path.
39 | // If pathmatch is suffix:
40 | // - We start the match with .*? to allow anything to be in front of what we are trying to match.
41 | // - We end the match with $ to make sure the match ends at the end of the path.
42 | // If pathmatch is full:
43 | // - We end the match with $ to make sure the match ends at the end of the path.
44 | // If pathmatch is fuzzy
45 | // - We start the match with .*? to allow anything to be in front of what we are trying to match.
46 | // - We end the match with .*? to allow anything to be after what we are trying to match.
47 | // All matches starts with ^ to make sure the match is done from the beginning of the path.
48 | const regex = route.path === CATCH_ALL_WILDCARD || (route.path.length === 0 && route.pathMatch != "full" ) ? /^/ : (() => {
49 | switch (route.pathMatch || DEFAULT_PATH_MATCH) {
50 | case "full": return new RegExp(`^${routePath}\/?$`);
51 | case "suffix": return new RegExp(`^.*?${routePath}\/?$`);
52 | case "fuzzy": return new RegExp(`^.*?${routePath}.*?$`);
53 | case "prefix": default: return new RegExp(`^[\/]?${routePath}(?:\/|$)`);
54 | }
55 | })();
56 |
57 | // Check if there's a match
58 | const match = path.match(regex);
59 | if (match != null) {
60 |
61 | // Match the param names with the matches. The matches starts from index 1 which is the
62 | // reason why we add 1. match[0] is the entire string.
63 | const params = paramNames.reduce((acc: Params, name: string, i: number) => {
64 | acc[name] = match[i + 1];
65 | return acc;
66 | }, {});
67 |
68 | // Split up the path into two fragments: the one consumed and the rest.
69 | const consumed = stripSlash(path.slice(0, match[0].length));
70 | const rest = stripSlash(path.slice(match[0].length, path.length));
71 |
72 | return {
73 | route,
74 | match,
75 | params,
76 | fragments: {
77 | consumed,
78 | rest
79 | }
80 | };
81 | }
82 |
83 |
84 | return null;
85 | }
86 |
87 | /**
88 | * Matches the first route that matches the given path.
89 | * @param routes
90 | * @param path
91 | */
92 | export function matchRoutes (routes: IRoute[], path: string | PathFragment): IRouteMatch | null {
93 | for (const route of routes) {
94 | const match = matchRoute(route, path);
95 | if (match != null) {
96 | return match;
97 | }
98 | }
99 |
100 | return null;
101 | }
102 |
103 | /**
104 | * Returns the page from the route.
105 | * If the component provided is a function (and not a class) call the function to get the promise.
106 | * @param route
107 | * @param info
108 | */
109 | export async function resolvePageComponent (route: IComponentRoute, info: IRoutingInfo): Promise {
110 |
111 | // Figure out if the component were given as an import or class.
112 | let cmp = route.component;
113 | if (cmp instanceof Function) {
114 | try {
115 | cmp = (cmp as Function)();
116 | } catch (err) {
117 |
118 | // The invocation most likely failed because the function is a class.
119 | // If it failed due to the "new" keyword not being used, the error will be of type "TypeError".
120 | // This is the most reliable way to check whether the provided function is a class or a function.
121 | if (!(err instanceof TypeError)) {
122 | throw err;
123 | }
124 | }
125 | }
126 |
127 | // Load the module or component.
128 | const moduleClassOrPage = await Promise.resolve(cmp);
129 |
130 | // Instantiate the component
131 | let component!: PageComponent;
132 | if (!(moduleClassOrPage instanceof HTMLElement)) {
133 | component = new (moduleClassOrPage.default ? moduleClassOrPage.default : moduleClassOrPage)() as PageComponent;
134 | } else {
135 | component = cmp as PageComponent;
136 | }
137 |
138 | // Setup the component using the callback.
139 | if (route.setup != null) {
140 | route.setup(component, info);
141 | }
142 |
143 | return component;
144 | }
145 |
146 | /**
147 | * Determines if a route is a redirect route.
148 | * @param route
149 | */
150 | export function isRedirectRoute (route: IRoute): route is IRedirectRoute {
151 | return "redirectTo" in route;
152 | }
153 |
154 | /**
155 | * Determines if a route is a resolver route.
156 | * @param route
157 | */
158 | export function isResolverRoute (route: IRoute): route is IResolverRoute {
159 | return "resolve" in route;
160 | }
161 |
162 | /**
163 | * Traverses the router tree up to the root route.
164 | * @param slot
165 | */
166 | export function traverseRouterTree (slot: IRouterSlot): {tree: RouterTree, depth: number} {
167 |
168 | // Find the nodes from the route up to the root route
169 | let routes: IRouterSlot[] = [slot];
170 | while (slot.parent != null) {
171 | slot = slot.parent;
172 | routes.push(slot);
173 | }
174 |
175 | // Create the tree
176 | const tree: RouterTree = routes.reduce((acc: RouterTree, slot: IRouterSlot) => {
177 | return {slot, child: acc};
178 | }, undefined);
179 |
180 | const depth = routes.length;
181 |
182 | return {tree, depth};
183 | }
184 |
185 | /**
186 | * Generates a path based on the router tree.
187 | * @param tree
188 | * @param depth
189 | */
190 | export function getFragments (tree: RouterTree, depth: number): PathFragment[] {
191 | let child = tree;
192 | const fragments: PathFragment[] = [];
193 |
194 | // Look through all of the path fragments
195 | while (child != null && child.slot.match != null && depth > 0) {
196 | fragments.push(child.slot.match.fragments.consumed);
197 | child = child.child;
198 | depth--;
199 | }
200 |
201 | return fragments;
202 | }
203 |
204 | /**
205 | * Constructs the correct absolute path based on a router.
206 | * - Handles relative paths: "mypath"
207 | * - Handles absolute paths: "/mypath"
208 | * - Handles traversing paths: "../../mypath"
209 | * @param slot
210 | * @param path
211 | */
212 | export function constructAbsolutePath (slot: IRouterSlot,
213 | path: string | PathFragment = ""): string {
214 |
215 | // Grab the router tree
216 | const {tree, depth} = traverseRouterTree(slot);
217 |
218 | // If the path starts with "/" we treat it as an absolute path
219 | // and therefore don't continue because it is already absolute.
220 | if (!path.startsWith("/")) {
221 | let traverseDepth = 0;
222 |
223 | // If the path starts with "./" we can remove that part
224 | // because we know the path is relative to its route.
225 | if (path.startsWith("./")) {
226 | path = path.slice(2);
227 | }
228 |
229 | // Match with the traverse flag.
230 | const match = path.match(new RegExp(TRAVERSE_FLAG, "g"));
231 | if (match != null) {
232 |
233 | // If the path matched with the traverse flag we know that we have to construct
234 | // a route until a certain depth. The traverse depth is the amount of "../" in the path
235 | // and the depth is the part of the path we a slicing away.
236 | traverseDepth = match.length;
237 |
238 | // Count the amount of characters that the matches add up to and remove it from the path.
239 | const length = match.reduce((acc: number, m: string) => acc + m.length, 0);
240 | path = path.slice(length);
241 | }
242 |
243 | // Grab the fragments and construct the new path, taking the traverse depth into account.
244 | // Always subtract at least 1 because we the path is relative to its parent.
245 | // Filter away the empty fragments from the path.
246 | const fragments = getFragments(tree, depth - 1 - traverseDepth).filter(fragment => fragment.length > 0);
247 | path = `${fragments.join("/")}${fragments.length > 0 ? "/" : ""}${path}`;
248 | }
249 |
250 | // Add the base path in front of the path. If the path is already absolute, the path wont get the base path added.
251 | return constructPathWithBasePath(path, {end: false});
252 | }
253 |
254 | /**
255 | * Handles a redirect.
256 | * @param slot
257 | * @param route
258 | */
259 | export function handleRedirect (slot: IRouterSlot, route: IRedirectRoute) {
260 | history.replaceState(history.state, "", `${constructAbsolutePath(slot, route.redirectTo)}${route.preserveQuery ? queryString() : ""}`);
261 | }
262 |
263 | /**
264 | * Determines whether the navigation should start based on the current match and the new match.
265 | * @param currentMatch
266 | * @param newMatch
267 | */
268 | export function shouldNavigate (currentMatch: IRouteMatch | null, newMatch: IRouteMatch) {
269 |
270 | // If the current match is not defined we should always route.
271 | if (currentMatch == null) {
272 | return true;
273 | }
274 |
275 | // Extract information about the matches
276 | const {route: currentRoute, fragments: currentFragments} = currentMatch;
277 | const {route: newRoute, fragments: newFragments} = newMatch;
278 |
279 | const isSameRoute = currentRoute == newRoute;
280 | const isSameFragments = currentFragments.consumed == newFragments.consumed;
281 |
282 | // Only navigate if the URL consumption is new or if the two routes are no longer the same.
283 | return !isSameFragments || !isSameRoute;
284 | }
--------------------------------------------------------------------------------
/src/lib/util/shadow.ts:
--------------------------------------------------------------------------------
1 | import { ROUTER_SLOT_TAG_NAME } from "../config";
2 | import { IRouterSlot } from "../model";
3 |
4 | /**
5 | * Queries the parent router.
6 | * @param $elem
7 | */
8 | export function queryParentRouterSlot ($elem: Element): IRouterSlot | null {
9 | return queryParentRoots>($elem, ROUTER_SLOT_TAG_NAME);
10 | }
11 |
12 | /**
13 | * Traverses the roots and returns the first match.
14 | * The minRoots parameter indicates how many roots should be traversed before we started matching with the query.
15 | * @param $elem
16 | * @param query
17 | * @param minRoots
18 | * @param roots
19 | */
20 | export function queryParentRoots ($elem: Element, query: string, minRoots: number = 0, roots: number = 0): T | null {
21 |
22 | // Grab the rood node and query it
23 | const $root = ($elem).getRootNode();
24 |
25 | // If we are at the right level or above we can query!
26 | if (roots >= minRoots) {
27 |
28 | // See if there's a match
29 | const match = $root.querySelector(query);
30 | if (match != null && match != $elem) {
31 | return match;
32 | }
33 | }
34 |
35 | // If a parent root with a host doesn't exist we don't continue the traversal
36 | const $rootRootNode = $root.getRootNode();
37 | if ($rootRootNode.host == null) {
38 | return null;
39 | }
40 |
41 | // We continue the traversal if there was not matches
42 | return queryParentRoots($rootRootNode.host, query, minRoots, ++roots);
43 | }
44 |
--------------------------------------------------------------------------------
/src/lib/util/url.ts:
--------------------------------------------------------------------------------
1 | import { ISlashOptions, Params, Query } from "../model";
2 |
3 | const $anchor = document.createElement("a");
4 |
5 | /**
6 | * The current path of the location.
7 | * As default slashes are included at the start and end.
8 | * @param options
9 | */
10 | export function path (options: Partial = {}): string {
11 | return slashify(window.location.pathname, options);
12 | }
13 |
14 | /**
15 | * Returns the path without the base path.
16 | * @param options
17 | */
18 | export function pathWithoutBasePath (options: Partial = {}): string {
19 | return slashify(stripStart(path(), basePath()), options);
20 | }
21 |
22 | /**
23 | * Returns the base path as defined in the tag in the head in a reliable way.
24 | * If eg. is defined this function will return "/router-slot/".
25 | *
26 | * An alternative would be to use regex on document.baseURI,
27 | * but that will be unreliable in some cases because it
28 | * doesn't use the built in HTMLHyperlinkElementUtils.
29 | *
30 | * To make this method more performant we could cache the anchor element.
31 | * As default it will return the base path with slashes in front and at the end.
32 | */
33 | export function basePath (options: Partial = {}): string {
34 | return constructPathWithBasePath(".", options);
35 | }
36 |
37 | /**
38 | * Creates an URL using the built in HTMLHyperlinkElementUtils.
39 | * An alternative would be to use regex on document.baseURI,
40 | * but that will be unreliable in some cases because it
41 | * doesn't use the built in HTMLHyperlinkElementUtils.
42 | *
43 | * As default it will return the base path with slashes in front and at the end.
44 | * @param path
45 | * @param options
46 | */
47 | export function constructPathWithBasePath (path: string, options: Partial = {}) {
48 | $anchor.href = path;
49 | return slashify($anchor.pathname, options);
50 | }
51 |
52 | /**
53 | * Removes the start of the path that matches the part.
54 | * @param path
55 | * @param part
56 | */
57 | export function stripStart (path: string, part: string) {
58 | return path.replace(new RegExp(`^${part}`), "");
59 | }
60 |
61 | /**
62 | * Returns the query string.
63 | */
64 | export function queryString (): string {
65 | return window.location.search;
66 | }
67 |
68 | /**
69 | * Returns the params for the current path.
70 | * @returns Params
71 | */
72 | export function query (): Query {
73 | return toQuery(queryString().substr(1));
74 | }
75 |
76 | /**
77 | * Strips the slash from the start and end of a path.
78 | * @param path
79 | */
80 | export function stripSlash (path: string): string {
81 | return slashify(path, {start: false, end: false});
82 | }
83 |
84 | /**
85 | * Ensures the path starts and ends with a slash
86 | * @param path
87 | */
88 | export function ensureSlash (path: string): string {
89 | return slashify(path, {start: true, end: true});
90 | }
91 |
92 | /**
93 | * Makes sure that the start and end slashes are present or not depending on the options.
94 | * @param path
95 | * @param start
96 | * @param end
97 | */
98 | export function slashify (path: string, {start = true, end = true}: Partial = {}): string {
99 | path = start && !path.startsWith("/") ? `/${path}` : (!start && path.startsWith("/") ? path.slice(1) : path);
100 | return end && !path.endsWith("/") ? `${path}/` : (!end && path.endsWith("/") ? path.slice(0, path.length - 1) : path);
101 | }
102 |
103 | /**
104 | * Turns a query string into an object.
105 | * @param {string} queryString (example: ("test=123&hejsa=LOL&wuhuu"))
106 | * @returns {Query}
107 | */
108 | export function toQuery (queryString: string): Query {
109 |
110 | // If the query does not contain anything, return an empty object.
111 | if (queryString.length === 0) {
112 | return {};
113 | }
114 |
115 | // Grab the atoms (["test=123", "hejsa=LOL", "wuhuu"])
116 | const atoms = queryString.split("&");
117 |
118 | // Split by the values ([["test", "123"], ["hejsa", "LOL"], ["wuhuu"]])
119 | const arrayMap = atoms.map(atom => atom.split("="));
120 |
121 | // Assign the values to an object ({ test: "123", hejsa: "LOL", wuhuu: "" })
122 | return Object.assign({}, ...arrayMap.map(arr => ({
123 | [decodeURIComponent(arr[0])]: (arr.length > 1 ? decodeURIComponent(arr[1]) : "")
124 | })));
125 | }
126 |
127 | /**
128 | * Turns a query object into a string query.
129 | * @param query
130 | */
131 | export function toQueryString (query: Query): string {
132 | return Object.entries(query)
133 | .map(([key, value]) => `${key}${value != "" ? `=${encodeURIComponent(value)}` : ""}`)
134 | .join("&");
135 | }
136 |
--------------------------------------------------------------------------------
/src/test/anchor.test.ts:
--------------------------------------------------------------------------------
1 | import { ensureAnchorHistory } from "../lib/util/anchor";
2 | import { ensureHistoryEvents } from "../lib/util/history";
3 | import { path } from "../lib/util/url";
4 | import { addBaseTag, clearHistory } from "./test-helpers";
5 |
6 | const testPath = `/about`;
7 |
8 | describe("anchor", () => {
9 | const {expect} = chai;
10 | let $anchor!: HTMLAnchorElement;
11 |
12 | before(() => {
13 | ensureHistoryEvents();
14 | ensureAnchorHistory();
15 | addBaseTag();
16 | });
17 | beforeEach(() => {
18 | document.body.innerHTML = `
19 | Anchor
20 | `;
21 |
22 | $anchor = document.body.querySelector("#anchor")!;
23 | });
24 | after(() => {
25 | clearHistory();
26 | });
27 |
28 | it("[ensureAnchorHistory] should change anchors to use history API", done => {
29 | window.addEventListener("pushstate", () => {
30 | expect(path({end: false})).to.equal(testPath);
31 | done();
32 | });
33 |
34 | $anchor.click();
35 | });
36 |
37 | it("[ensureAnchorHistory] should not change anchors with target _blank", done => {
38 | window.addEventListener("pushstate", () => {
39 | expect(true).to.equal(false);
40 | });
41 |
42 | $anchor.target = "_blank";
43 | $anchor.click();
44 | done();
45 | });
46 |
47 | it("[ensureAnchorHistory] should not change anchors with [data-router-slot]='disabled'", done => {
48 | window.addEventListener("pushstate", () => {
49 | expect(true).to.equal(false);
50 | });
51 |
52 | $anchor.setAttribute("data-router-slot", "disabled");
53 | $anchor.click();
54 | done();
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/src/test/history.test.ts:
--------------------------------------------------------------------------------
1 | import { GLOBAL_ROUTER_EVENTS_TARGET } from "../lib/config";
2 | import { ensureHistoryEvents, historyPatches } from "../lib/util/history";
3 | import { clearHistory } from "./test-helpers";
4 |
5 | describe("history", () => {
6 | before(() => {
7 | ensureHistoryEvents();
8 | });
9 | beforeEach(() => {
10 | });
11 | after(() => {
12 | clearHistory();
13 | });
14 |
15 | it("[ensureHistoryEvents] should patch history object", (done) => {
16 | const expectedEventCount = historyPatches.reduce((acc, patch) => acc + patch[1].length, 0);
17 | let eventCount = 0;
18 |
19 | // Checks whether the amount of events that have been called is correct.
20 | const testExpectedEventCount = () => {
21 | if (eventCount >= expectedEventCount) {
22 | done();
23 | }
24 | };
25 |
26 | // Hook up expected events
27 | for (const [name, events] of historyPatches) {
28 | for (const event of events) {
29 | GLOBAL_ROUTER_EVENTS_TARGET.addEventListener(event, () => {
30 | eventCount += 1;
31 | testExpectedEventCount();
32 | }, {once: true});
33 | }
34 | }
35 |
36 | // Dispatch events with garbage data (the data doesn't matter)
37 | for (const [name] of historyPatches) {
38 | (history)[name](...["", "", ""]);
39 | }
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/src/test/router-slot.test.ts:
--------------------------------------------------------------------------------
1 | import { html, LitElement, PropertyValues } from "lit";
2 | import { customElement, query } from "lit/decorators.js";
3 | import { IRoute } from "../lib/model";
4 | import { RouterSlot } from "../lib/router-slot";
5 | import "../lib/router-slot";
6 | import { ensureHistoryEvents } from "../lib/util/history";
7 | import { traverseRouterTree } from "../lib/util/router";
8 | import { queryParentRouterSlot } from "../lib/util/shadow";
9 | import { path } from "../lib/util/url";
10 | import { clearHistory } from "./test-helpers";
11 |
12 | class RouterElement extends LitElement {
13 | @query("#slot") $slot!: RouterSlot;
14 |
15 | protected routes!: IRoute[];
16 |
17 | firstUpdated(props: PropertyValues) {
18 | super.firstUpdated(props);
19 | this.$slot.add(this.routes);
20 | }
21 |
22 | render() {
23 | return html`
24 |
25 | `;
26 | }
27 | }
28 |
29 | @customElement("leaf-element")
30 | class LeafElement extends LitElement {
31 | render() {
32 | return html`
33 | Leaf
34 | `;
35 | }
36 | }
37 |
38 | const pageOneRoutes: IRoute[] = [
39 | {
40 | path: "leaf-one",
41 | component: LeafElement
42 | },
43 | {
44 | path: "**",
45 | redirectTo: "leaf-one"
46 | }
47 | ];
48 |
49 | @customElement("page-one")
50 | class PageOne extends RouterElement {
51 | routes = pageOneRoutes;
52 | }
53 |
54 | const pageTwoRoutes: IRoute[] = [
55 | {
56 | path: "leaf-two",
57 | component: LeafElement
58 | },
59 | {
60 | path: "**",
61 | redirectTo: "leaf-two"
62 | }
63 | ];
64 |
65 | @customElement("page-two")
66 | class PageTwo extends RouterElement {
67 | routes = pageTwoRoutes;
68 | }
69 |
70 | // Main routes
71 | const mainRoutes: IRoute[] = [
72 | {
73 | path: "one",
74 | component: PageOne
75 | },
76 | {
77 | path: "two/:id",
78 | component: PageTwo
79 | },
80 | {
81 | path: "**",
82 | redirectTo: "one"
83 | }
84 | ];
85 |
86 | @customElement("root-element")
87 | class RootElement extends RouterElement {
88 | routes = mainRoutes;
89 | }
90 |
91 | describe("router-slot", () => {
92 | const {expect} = chai;
93 | let $root!: RootElement;
94 |
95 | before(() => {
96 | ensureHistoryEvents();
97 |
98 | const $base = document.createElement("base");
99 | $base.href = `/`;
100 | document.head.appendChild($base);
101 | });
102 | beforeEach(() => {
103 | document.body.innerHTML = `
104 |
105 | `;
106 |
107 | $root = document.body.querySelector("root-element")!;
108 | });
109 | after(() => {
110 | clearHistory();
111 | });
112 |
113 | // TODO: Listen for events and do this more elegant
114 | function waitForNavigation(cb: (() => void)) {
115 | setTimeout(cb, 100);
116 | }
117 |
118 | it("should redirect properly down the router tree", () => {
119 | waitForNavigation(() => {
120 | expect(path()).to.equal(`/one/leaf-one/`);
121 | });
122 | });
123 |
124 | it("should have correct isRoot value", (done) => {
125 | waitForNavigation(() => {
126 | const $pageOne = $root.$slot.querySelector("page-one")!;
127 |
128 | expect($root.$slot.isRoot).to.be.true;
129 | expect($pageOne.$slot.isRoot).to.be.false;
130 | done();
131 | });
132 | });
133 |
134 | it("should find correct parent router slots", (done) => {
135 | waitForNavigation(() => {
136 | const $pageOne = $root.$slot.querySelector("page-one")!;
137 | const $leafElement = $pageOne.$slot.querySelector("leaf-element")!;
138 |
139 | expect(queryParentRouterSlot($leafElement)).to.equal($pageOne.$slot);
140 | expect(queryParentRouterSlot($pageOne)).to.equal($root.$slot);
141 | done();
142 | });
143 | });
144 |
145 | it("should construct correct router tree", (done) => {
146 | waitForNavigation(() => {
147 | const $pageOne = $root.$slot.querySelector("page-one")!;
148 |
149 | expect(traverseRouterTree($pageOne.$slot).depth).to.equal(2);
150 | expect(traverseRouterTree($root.$slot).depth).to.equal(1);
151 | done();
152 | });
153 | });
154 |
155 | it("should pick up params", (done) => {
156 | waitForNavigation(() => {
157 | const param = "1234";
158 | history.pushState(null, "", `two/${param}`);
159 |
160 | waitForNavigation(() => {
161 | expect(path()).to.equal(`/two/${param}/leaf-two/`);
162 | expect(JSON.stringify($root.$slot.params)).to.equal(JSON.stringify({id: param}));
163 | done();
164 | });
165 | });
166 | });
167 | });
168 |
--------------------------------------------------------------------------------
/src/test/router.test.ts:
--------------------------------------------------------------------------------
1 | import { IRoute, IRouteMatch } from "../lib/model";
2 | import { matchRoute } from "../lib/util/router";
3 |
4 | const component = document.createElement("div");
5 | const TEST_CASES: {route: IRoute, path: string, expectedMatch: IRouteMatch | null | any}[] = [
6 | {
7 | route: {
8 | path: "**",
9 | redirectTo: "404"
10 | },
11 | path: "wrong/path",
12 | expectedMatch: {
13 | "route": {
14 | "path": "**",
15 | "redirectTo": "404"
16 | },
17 | "match": [
18 | ""
19 | ],
20 | "params": {},
21 | "fragments": {
22 | "consumed": "",
23 | "rest": "wrong/path"
24 | }
25 | }
26 | },
27 | {
28 | route: {
29 | path: "home",
30 | component
31 | },
32 | path: "home",
33 | expectedMatch: {
34 | "route": {
35 | "path": "home",
36 | "component": {}
37 | },
38 | "match": [
39 | "home"
40 | ],
41 | "params": {},
42 | "fragments": {
43 | "consumed": "home",
44 | "rest": ""
45 | }
46 | }
47 | },
48 | {
49 | route: {
50 | path: "user/:id/edit",
51 | component
52 | },
53 | path: "user/1234/edit",
54 | expectedMatch: {
55 | "route": {
56 | "path": "user/:id/edit",
57 | "component": {}
58 | },
59 | "match": [
60 | "user/1234/edit",
61 | "1234"
62 | ],
63 | "params": {
64 | "id": "1234"
65 | },
66 | "fragments": {
67 | "consumed": "user/1234/edit",
68 | "rest": ""
69 | }
70 | }
71 | },
72 | {
73 | route: {
74 | path: "",
75 | component
76 | },
77 | path: "test",
78 | expectedMatch: {
79 | "route": {
80 | "path": "",
81 | "component": {}
82 | },
83 | "match": [
84 | ""
85 | ],
86 | "params": {},
87 | "fragments": {
88 | "consumed": "",
89 | "rest": "test"
90 | }
91 | }
92 | },
93 | {
94 | route: {
95 | path: "",
96 | component
97 | },
98 | path: "/test",
99 | expectedMatch: {
100 | "route": {
101 | "path": "",
102 | "component": {}
103 | },
104 | "match": [
105 | ""
106 | ],
107 | "params": {},
108 | "fragments": {
109 | "consumed": "",
110 | "rest": "test"
111 | }
112 | }
113 | },
114 | {
115 | route: {
116 | path: "",
117 | component
118 | },
119 | path: "test",
120 | expectedMatch: {
121 | "route": {
122 | "path": "",
123 | "component": {}
124 | },
125 | "match": [
126 | ""
127 | ],
128 | "params": {},
129 | "fragments": {
130 | "consumed": "",
131 | "rest": "test"
132 | }
133 | }
134 | },
135 | {
136 | route: {
137 | path: "",
138 | pathMatch: "full",
139 | component
140 | },
141 | path: "test",
142 | expectedMatch: null
143 | },
144 | {
145 | route: {
146 | path: "overview",
147 | pathMatch: "suffix",
148 | component
149 | },
150 | path: "home/overview",
151 | expectedMatch: {
152 | "route": {
153 | "path": "overview",
154 | "pathMatch": "suffix",
155 | "component": {},
156 | },
157 | "match": [
158 | "home/overview"
159 | ],
160 | "params": {},
161 | "fragments": {
162 | "consumed": "home/overview",
163 | "rest": ""
164 | }
165 | }
166 | },
167 | {
168 | route: {
169 | path: "manage",
170 | pathMatch: "fuzzy",
171 | component
172 | },
173 | path: "users/manage/invite",
174 | expectedMatch: {
175 | "route": {
176 | "path": "manage",
177 | "pathMatch": "fuzzy",
178 | "component": {}
179 | },
180 | "match": [
181 | "users/manage/invite"
182 | ],
183 | "params": {},
184 | "fragments": {
185 | "consumed": "users/manage/invite",
186 | "rest": ""
187 | }
188 | }
189 | },
190 | ];
191 |
192 | describe("router", () => {
193 | const {expect} = chai;
194 |
195 | it("[matchRoute] should match the correct route", () => {
196 | for (const {route, path, expectedMatch} of TEST_CASES) {
197 | const match = matchRoute(route, path);
198 | expect(JSON.stringify(match)).to.equal(JSON.stringify(expectedMatch));
199 | }
200 | });
201 | });
202 |
--------------------------------------------------------------------------------
/src/test/test-helpers.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Clears the entire history.
3 | */
4 | export function clearHistory () {
5 | const length = history.length;
6 | for (let i = 0; i < length; i++) {
7 | history.back();
8 | }
9 | }
10 |
11 | /**
12 | * Add base element to head.
13 | * @param path
14 | */
15 | export function addBaseTag (path: string = "/") {
16 | const $base = document.createElement("base");
17 | $base.href = `/`;
18 | document.head.appendChild($base);
19 | return $base;
20 | }
21 |
22 | /**
23 | * Wait X ms.
24 | * @param ms
25 | */
26 | export function wait (ms: number) {
27 | return new Promise(res => setTimeout(res, ms));
28 | }
--------------------------------------------------------------------------------
/src/test/url.test.ts:
--------------------------------------------------------------------------------
1 | import { basePath, path, query, toQuery, toQueryString } from "../lib/util/url";
2 | import { addBaseTag, clearHistory } from "./test-helpers";
3 |
4 | describe("url", () => {
5 | const {expect} = chai;
6 | let $base: HTMLBaseElement;
7 |
8 | before(() => {
9 | $base = addBaseTag();
10 | });
11 | beforeEach(async () => {
12 | $base.href = `/`;
13 | });
14 | after(() => {
15 | clearHistory();
16 | });
17 |
18 | it("[currentPath] should return the correct current path", () => {
19 | history.pushState(null, "", "");
20 | expect(path()).to.equal(`/`);
21 |
22 | history.pushState(null, "", "/");
23 | expect(path()).to.equal(`/`);
24 |
25 | history.pushState(null, "", "cool");
26 | expect(path()).to.equal(`/cool/`);
27 |
28 | history.pushState(null, "", "login/forgot-password");
29 | expect(path()).to.equal(`/login/forgot-password/`);
30 | });
31 |
32 | it("[basepath] should return correct base path", () => {
33 | const basePaths = [
34 | [`/my/path/`, `/my/path/`],
35 | [`/my-other-path/index.html`, `/my-other-path/`],
36 | [`https://cdpn.io/boomboom/v2/index.html?key=iFrameKey-ca757c8e-dad1-d965-1aed-7cabdaa22462`, `/boomboom/v2/`],
37 | ];
38 |
39 | for (const [path, expected] of basePaths) {
40 | $base.href = path;
41 | expect(basePath()).to.equal(expected);
42 | }
43 | });
44 |
45 | it("[query] should return the correct query", () => {
46 | history.pushState(null, "", "?key1=value1&key2=value2");
47 | expect(JSON.stringify(query())).to.equal(`{"key1":"value1","key2":"value2"}`);
48 | });
49 |
50 | it("[toQuery] should return the correct query object", () => {
51 | expect(JSON.stringify(toQuery("test=1234&redirect"))).to.equal(JSON.stringify({test: "1234", redirect: ""}))
52 | });
53 |
54 | it("[toQueryString] should return the correct query string", () => {
55 | expect(toQueryString({test: "1234", redirect: ""})).to.equal("test=1234&redirect");
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./node_modules/@appnest/web-config/tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "dist",
5 | "declaration": true,
6 | "target": "es2017",
7 | "importHelpers": true,
8 | "lib": [
9 | "es2015.promise",
10 | "dom",
11 | "es7",
12 | "es6",
13 | "es2017",
14 | "es2017.object",
15 | "es2015.proxy",
16 | "esnext"
17 | ]
18 | },
19 | "include": [
20 | "src/lib/**/*"
21 | ]
22 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./node_modules/@appnest/web-config/tsconfig.json"
3 | }
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./node_modules/@appnest/web-config/tslint.json"
3 | }
--------------------------------------------------------------------------------
/typings.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------