Skip to content

Commit 9c1ac02

Browse files
authored
chore(demo): lazy-load vanilla SPA routes (#549)
1 parent bb3190b commit 9c1ac02

6 files changed

Lines changed: 597 additions & 350 deletions

File tree

‎packages/demo/src/app-routing.ts‎

Lines changed: 300 additions & 155 deletions
Large diffs are not rendered by default.

‎packages/demo/src/app.ts‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { BindingEventService, createDomElement } from 'multiple-select-vanilla';
2+
3+
import { exampleRouting, navbarRouting } from './app-routing.js';
4+
import mainHtml from './main.html?raw';
5+
import { Renderer } from './renderer.js';
6+
7+
const pageLayoutGlobs = import.meta.glob<string>(['/src/**/*.html', '!/src/main.html'], { query: '?raw', import: 'default' });
8+
9+
export class App {
10+
private bindings = new BindingEventService();
11+
private renderer!: Renderer;
12+
private currentRouteName = '';
13+
private routeLoadId = 0;
14+
private defaultRouteName = 'getting-started';
15+
private routes = [...navbarRouting, ...exampleRouting.flatMap(group => group.routes)];
16+
17+
init() {
18+
new Renderer(document.querySelector<HTMLElement>('#app')!).loadView(mainHtml);
19+
this.renderer = new Renderer(document.querySelector<HTMLElement>('.panel-wm-content')!);
20+
this.createRouteLinks();
21+
window.addEventListener('popstate', this.loadCurrentRoute);
22+
window.addEventListener('hashchange', this.loadCurrentRoute);
23+
window.addEventListener('beforeunload', this.dispose);
24+
this.loadCurrentRoute();
25+
}
26+
27+
private loadCurrentRoute = () => {
28+
const routeName = window.location.hash.replace(/^#\/?/, '') || this.defaultRouteName;
29+
if (routeName !== this.currentRouteName) {
30+
void this.loadRoute(routeName, false);
31+
}
32+
};
33+
34+
createRouteLinks() {
35+
for (const navRoute of navbarRouting) {
36+
// <li class="nav-item"><a class="nav-link" id="home">Home</a></li>
37+
const liElm = createDomElement('li', { className: 'nav-item' });
38+
const aElm = createDomElement(
39+
'a',
40+
{ id: navRoute.name, href: `#/${navRoute.name}`, className: 'nav-link', textContent: navRoute.title },
41+
liElm,
42+
);
43+
this.bindings.bind(aElm, 'click', this.clickEventListener);
44+
document.querySelector('.navbar-nav')?.appendChild(liElm);
45+
}
46+
47+
for (const groupRoute of exampleRouting) {
48+
const groupLiElm = createDomElement('li', { className: 'm-1' });
49+
createDomElement('p', { className: 'navbar-vertical-label mb-1', textContent: groupRoute.name }, groupLiElm);
50+
document.querySelector('.nav-pills')?.appendChild(groupLiElm);
51+
52+
for (const singleRoute of groupRoute.routes) {
53+
// <li class="nav-item"><a class="nav-link" id="example01">Single Select</a></li>
54+
const liElm = createDomElement('li', { className: 'nav-item' });
55+
const aElm = createDomElement('a', {
56+
id: singleRoute.name,
57+
href: `#/${singleRoute.name}`,
58+
className: 'nav-link',
59+
textContent: singleRoute.title,
60+
});
61+
this.bindings.bind(aElm, 'click', this.clickEventListener);
62+
63+
liElm.appendChild(aElm);
64+
document.querySelector('.nav-pills')?.appendChild(liElm);
65+
}
66+
}
67+
}
68+
69+
async loadRoute(routeName: string, changeBrowserState = true) {
70+
const route = this.routes.find(candidate => candidate.name === routeName) ?? navbarRouting[0];
71+
const loadId = ++this.routeLoadId;
72+
this.currentRouteName = route.name;
73+
this.renderer.dispose();
74+
this.renderer.renderLoading();
75+
this.updateActiveLinks(route.name);
76+
77+
// Update history when navigation starts so Back also cancels a pending route load.
78+
const hash = `#/${route.name}`;
79+
if (changeBrowserState && window.location.hash !== hash) {
80+
window.history.pushState({}, '', hash);
81+
} else if (!changeBrowserState && window.location.hash !== hash) {
82+
window.history.replaceState({}, '', hash);
83+
}
84+
document.title = `Multiple-Select-Vanilla · ${route.name}`;
85+
86+
try {
87+
const loadView = pageLayoutGlobs[route.view];
88+
if (!loadView) {
89+
throw new Error(`Missing view: ${route.view}`);
90+
}
91+
const [vmModule, html] = await Promise.all([route.viewModel(), loadView()]);
92+
// A slower navigation must never mount over the user's latest destination.
93+
if (loadId !== this.routeLoadId) {
94+
return;
95+
}
96+
this.renderer.loadViewModel(vmModule.default);
97+
this.renderer.loadView(html);
98+
this.renderer.mount();
99+
} catch (error) {
100+
if (loadId !== this.routeLoadId) {
101+
return;
102+
}
103+
console.error(`Unable to load route "${route.name}"`, error);
104+
this.renderer.renderError('Unable to load this page. Select the link again to retry.');
105+
}
106+
}
107+
108+
private clickEventListener = (event: Event) => {
109+
const mouseEvent = event as MouseEvent;
110+
if (mouseEvent.button > 0 || mouseEvent.ctrlKey || mouseEvent.metaKey || mouseEvent.shiftKey || mouseEvent.altKey) {
111+
return;
112+
}
113+
event.preventDefault();
114+
void this.loadRoute((event.currentTarget as HTMLAnchorElement).id);
115+
};
116+
117+
private updateActiveLinks(routeName: string) {
118+
document.querySelectorAll('.panel-wm-left a.nav-link,.navbar-nav a.nav-link').forEach(link => {
119+
link.classList.toggle('active', link.id === routeName || (routeName === 'examples' && link.id === 'example01'));
120+
});
121+
document.getElementById(routeName)?.scrollIntoView({ block: 'nearest' });
122+
}
123+
124+
private dispose = () => {
125+
++this.routeLoadId;
126+
this.bindings.dispose();
127+
window.removeEventListener('popstate', this.loadCurrentRoute);
128+
window.removeEventListener('hashchange', this.loadCurrentRoute);
129+
window.removeEventListener('beforeunload', this.dispose);
130+
this.renderer?.dispose();
131+
this.currentRouteName = '';
132+
};
133+
}

‎packages/demo/src/interfaces.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export interface ViewModel {
2+
mount?: () => void;
3+
unmount?: () => void;
4+
}
5+
6+
export interface ViewRouter {
7+
name: string;
8+
view: string;
9+
viewModel: () => Promise<{ default: new () => ViewModel }>;
10+
title: string;
11+
}
12+
13+
export interface RouteGroup {
14+
name: string;
15+
routes: ViewRouter[];
16+
}

‎packages/demo/src/main.ts‎

Lines changed: 2 additions & 195 deletions
Original file line numberDiff line numberDiff line change
@@ -1,201 +1,8 @@
11
import 'bootstrap';
22
import 'bootstrap/dist/css/bootstrap.min.css';
3-
import DOMPurify from 'dompurify';
43
import 'font-awesome/css/font-awesome.css';
5-
import { createDomElement, emptyElement } from 'multiple-select-vanilla';
64

7-
import { exampleRouting, navbarRouting } from './app-routing.js';
8-
import mainHtml from './main.html?raw';
5+
import { App } from './app.js';
96
import './style.scss';
107

11-
const pageLayoutGlobs = import.meta.glob('/src/./**/*.html', { query: '?raw', eager: true, import: 'default' });
12-
13-
interface ViewRouter {
14-
name: string;
15-
view: string;
16-
viewModel: any;
17-
title: string;
18-
}
19-
20-
interface ViewModel {
21-
mount?: () => void;
22-
unmount?: () => void;
23-
}
24-
25-
class Main {
26-
loading = true;
27-
currentModel?: ViewModel;
28-
currentRouter?: ViewRouter;
29-
defaultRouteName = 'getting-started';
30-
stateBangChar = '#/';
31-
baseUrl = window.location.origin + window.location.pathname;
32-
viewModelObj: any = {};
33-
34-
async init() {
35-
const location = window.location;
36-
document.querySelector<HTMLDivElement>('#app')!.innerHTML = DOMPurify.sanitize(mainHtml, {
37-
RETURN_TRUSTED_TYPE: true,
38-
}) as unknown as string;
39-
40-
let route = location.hash.replace(this.stateBangChar, '');
41-
if (!route || route === '/' || route === '#') {
42-
route = this.defaultRouteName;
43-
}
44-
45-
// create all routes and always load first route Example01
46-
this.createRouteLinks();
47-
this.loadRoute(route);
48-
49-
// on page load make route active, when changing route afterward that will be covered by each nav click events
50-
Array.from(document.querySelectorAll('.panel-wm-left a.nav-link,.navbar-nav a.nav-link')).forEach(link => {
51-
if (route.includes(link.id)) {
52-
link.classList.add('active');
53-
}
54-
});
55-
56-
// re-render on browser history navigation change
57-
window.onpopstate = () => {
58-
const winLoc = window.location;
59-
const prevRoute = winLoc.hash.replace(this.stateBangChar, '');
60-
61-
// change active link to previous route
62-
this.removeAllActiveLinks();
63-
const navItemElm = document.querySelector(`#${prevRoute}`);
64-
if (navItemElm) {
65-
navItemElm.scrollIntoView();
66-
navItemElm.classList.add('active');
67-
}
68-
this.loadRoute(prevRoute || this.defaultRouteName, false);
69-
};
70-
}
71-
72-
createRouteLinks() {
73-
for (const navRoute of navbarRouting) {
74-
// <li class="nav-item"><a class="nav-link" id="home">Home</a></li>
75-
const liElm = createDomElement('li', { className: 'nav-item' });
76-
const aElm = createDomElement('a', { id: navRoute.name, className: 'nav-link', textContent: navRoute.title }, liElm);
77-
aElm.addEventListener('click', this.clickEventListener.bind(this));
78-
document.querySelector('.navbar-nav')?.appendChild(liElm);
79-
}
80-
81-
for (const groupRoute of exampleRouting) {
82-
const groupLiElm = createDomElement('li', { className: 'm-1' });
83-
createDomElement('p', { className: 'navbar-vertical-label mb-1', textContent: groupRoute.name }, groupLiElm);
84-
document.querySelector('.nav-pills')?.appendChild(groupLiElm);
85-
86-
for (const singleRoute of groupRoute.routes) {
87-
// <li class="nav-item"><a class="nav-link" id="example01">Single Select</a></li>
88-
const liElm = createDomElement('li', { className: 'nav-item' });
89-
const aElm = createDomElement('a', {
90-
id: singleRoute.name,
91-
className: 'nav-link',
92-
textContent: singleRoute.title,
93-
});
94-
aElm.addEventListener('click', this.clickEventListener.bind(this));
95-
96-
liElm.appendChild(aElm);
97-
document.querySelector('.nav-pills')?.appendChild(liElm);
98-
}
99-
}
100-
}
101-
102-
async loadRoute(routeName: string, changeBrowserState = true) {
103-
const contentElm = document.querySelector('.panel-wm-content') as HTMLElement;
104-
emptyElement(contentElm);
105-
contentElm.classList.add('cloak');
106-
let foundRouter = navbarRouting.find(r => r.name === routeName);
107-
108-
if (foundRouter?.name === 'examples') {
109-
const exampleElm = document.querySelector('.nav-pills .nav-item a.nav-link');
110-
exampleElm?.classList.add('active');
111-
} else {
112-
for (const groupRoute of exampleRouting) {
113-
const found = (groupRoute.routes as ViewRouter[]).find(r => r.name === routeName);
114-
if (found) {
115-
foundRouter = found;
116-
}
117-
}
118-
}
119-
120-
if (this.currentModel) {
121-
this.unmountCurrentVM(this.currentModel, this.currentRouter);
122-
}
123-
124-
if (foundRouter) {
125-
this.currentRouter = foundRouter;
126-
document.querySelector('.panel-wm-content')!.innerHTML = DOMPurify.sanitize(pageLayoutGlobs[foundRouter.view] as string, {
127-
RETURN_TRUSTED_TYPE: true,
128-
}) as unknown as string;
129-
const vm = new foundRouter.viewModel() as ViewModel;
130-
this.currentModel = vm;
131-
(window as any)[foundRouter.name] = vm.mount?.();
132-
133-
// before leaving the page/SPA, we'll unmount everything
134-
window.onbeforeunload = () => {
135-
contentElm.classList.add('cloak');
136-
vm.unmount?.();
137-
this.removeAllActiveLinks(true);
138-
this.unmountAll();
139-
if (foundRouter?.name) {
140-
delete (window as any)[foundRouter.name];
141-
}
142-
};
143-
}
144-
145-
if (changeBrowserState) {
146-
window.history.pushState({}, routeName, `${this.baseUrl}${this.stateBangChar}${routeName}`);
147-
}
148-
document.title = `Multiple-Select-Vanilla · ${routeName}`;
149-
contentElm.classList.remove('cloak');
150-
}
151-
152-
async clickEventListener(e: Event) {
153-
// remove any active links
154-
this.removeAllActiveLinks();
155-
156-
// change active link to new route
157-
const targetElm = e.target as HTMLElement;
158-
targetElm.classList.toggle('active');
159-
160-
this.loadRoute(targetElm.id);
161-
}
162-
163-
removeAllActiveLinks(unbindListeners = false) {
164-
document.querySelectorAll('.panel-wm-left a.nav-link,.navbar-nav a.nav-link').forEach(link => {
165-
link.classList.remove('active');
166-
if (unbindListeners) {
167-
link.removeEventListener('click', this.clickEventListener.bind(this) as EventListener);
168-
}
169-
});
170-
}
171-
172-
unmountCurrentVM(vm: ViewModel, vr?: ViewRouter) {
173-
vm.unmount?.();
174-
if (vr) {
175-
delete (window as any)[vr.name];
176-
}
177-
}
178-
179-
unmountAll() {
180-
for (const vmKey of Object.keys(this.viewModelObj)) {
181-
const viewModel = this.viewModelObj[vmKey];
182-
if (typeof viewModel?.unmount === 'function') {
183-
viewModel?.unmount();
184-
185-
// also clear all of its variable references to avoid detached elements
186-
for (const ref of Object.keys(viewModel)) {
187-
viewModel[ref] = null;
188-
}
189-
}
190-
// nullify the object and then delete them to make sure they will be removed by the garbage collector
191-
(<any>window)[vmKey] = null;
192-
this.viewModelObj[vmKey] = null;
193-
delete (<any>window)[vmKey];
194-
delete this.viewModelObj[vmKey];
195-
}
196-
}
197-
}
198-
199-
// execute main init
200-
const main = new Main();
201-
main.init();
8+
new App().init();

0 commit comments

Comments
 (0)