Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
455 changes: 300 additions & 155 deletions packages/demo/src/app-routing.ts

Large diffs are not rendered by default.

133 changes: 133 additions & 0 deletions packages/demo/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { BindingEventService, createDomElement } from 'multiple-select-vanilla';

import { exampleRouting, navbarRouting } from './app-routing.js';
import mainHtml from './main.html?raw';
import { Renderer } from './renderer.js';

const pageLayoutGlobs = import.meta.glob<string>(['/src/**/*.html', '!/src/main.html'], { query: '?raw', import: 'default' });

export class App {
private bindings = new BindingEventService();
private renderer!: Renderer;
private currentRouteName = '';
private routeLoadId = 0;
private defaultRouteName = 'getting-started';
private routes = [...navbarRouting, ...exampleRouting.flatMap(group => group.routes)];

init() {
new Renderer(document.querySelector<HTMLElement>('#app')!).loadView(mainHtml);
this.renderer = new Renderer(document.querySelector<HTMLElement>('.panel-wm-content')!);
this.createRouteLinks();
window.addEventListener('popstate', this.loadCurrentRoute);
window.addEventListener('hashchange', this.loadCurrentRoute);
window.addEventListener('beforeunload', this.dispose);
this.loadCurrentRoute();
}

private loadCurrentRoute = () => {
const routeName = window.location.hash.replace(/^#\/?/, '') || this.defaultRouteName;
if (routeName !== this.currentRouteName) {
void this.loadRoute(routeName, false);
}
};

createRouteLinks() {
for (const navRoute of navbarRouting) {
// <li class="nav-item"><a class="nav-link" id="home">Home</a></li>
const liElm = createDomElement('li', { className: 'nav-item' });
const aElm = createDomElement(
'a',
{ id: navRoute.name, href: `#/${navRoute.name}`, className: 'nav-link', textContent: navRoute.title },
liElm,
);
this.bindings.bind(aElm, 'click', this.clickEventListener);
document.querySelector('.navbar-nav')?.appendChild(liElm);
}

for (const groupRoute of exampleRouting) {
const groupLiElm = createDomElement('li', { className: 'm-1' });
createDomElement('p', { className: 'navbar-vertical-label mb-1', textContent: groupRoute.name }, groupLiElm);
document.querySelector('.nav-pills')?.appendChild(groupLiElm);

for (const singleRoute of groupRoute.routes) {
// <li class="nav-item"><a class="nav-link" id="example01">Single Select</a></li>
const liElm = createDomElement('li', { className: 'nav-item' });
const aElm = createDomElement('a', {
id: singleRoute.name,
href: `#/${singleRoute.name}`,
className: 'nav-link',
textContent: singleRoute.title,
});
this.bindings.bind(aElm, 'click', this.clickEventListener);

liElm.appendChild(aElm);
document.querySelector('.nav-pills')?.appendChild(liElm);
}
}
}

async loadRoute(routeName: string, changeBrowserState = true) {
const route = this.routes.find(candidate => candidate.name === routeName) ?? navbarRouting[0];
const loadId = ++this.routeLoadId;
this.currentRouteName = route.name;
this.renderer.dispose();
this.renderer.renderLoading();
this.updateActiveLinks(route.name);

// Update history when navigation starts so Back also cancels a pending route load.
const hash = `#/${route.name}`;
if (changeBrowserState && window.location.hash !== hash) {
window.history.pushState({}, '', hash);
} else if (!changeBrowserState && window.location.hash !== hash) {
window.history.replaceState({}, '', hash);
}
document.title = `Multiple-Select-Vanilla · ${route.name}`;

try {
const loadView = pageLayoutGlobs[route.view];
if (!loadView) {
throw new Error(`Missing view: ${route.view}`);
}
const [vmModule, html] = await Promise.all([route.viewModel(), loadView()]);
// A slower navigation must never mount over the user's latest destination.
if (loadId !== this.routeLoadId) {
return;
}
this.renderer.loadViewModel(vmModule.default);
this.renderer.loadView(html);
this.renderer.mount();
} catch (error) {
if (loadId !== this.routeLoadId) {
return;
}
console.error(`Unable to load route "${route.name}"`, error);
this.renderer.renderError('Unable to load this page. Select the link again to retry.');
}
}

private clickEventListener = (event: Event) => {
const mouseEvent = event as MouseEvent;
if (mouseEvent.button > 0 || mouseEvent.ctrlKey || mouseEvent.metaKey || mouseEvent.shiftKey || mouseEvent.altKey) {
return;
}
event.preventDefault();
void this.loadRoute((event.currentTarget as HTMLAnchorElement).id);
};

private updateActiveLinks(routeName: string) {
document.querySelectorAll('.panel-wm-left a.nav-link,.navbar-nav a.nav-link').forEach(link => {
link.classList.toggle('active', link.id === routeName || (routeName === 'examples' && link.id === 'example01'));
});
document.getElementById(routeName)?.scrollIntoView({ block: 'nearest' });
}

private dispose = () => {
++this.routeLoadId;
this.bindings.dispose();
window.removeEventListener('popstate', this.loadCurrentRoute);
window.removeEventListener('hashchange', this.loadCurrentRoute);
window.removeEventListener('beforeunload', this.dispose);
this.renderer?.dispose();
this.currentRouteName = '';
};
}
16 changes: 16 additions & 0 deletions packages/demo/src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface ViewModel {
mount?: () => void;
unmount?: () => void;
}

export interface ViewRouter {
name: string;
view: string;
viewModel: () => Promise<{ default: new () => ViewModel }>;
title: string;
}

export interface RouteGroup {
name: string;
routes: ViewRouter[];
}
197 changes: 2 additions & 195 deletions packages/demo/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,201 +1,8 @@
import 'bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import DOMPurify from 'dompurify';
import 'font-awesome/css/font-awesome.css';
import { createDomElement, emptyElement } from 'multiple-select-vanilla';

import { exampleRouting, navbarRouting } from './app-routing.js';
import mainHtml from './main.html?raw';
import { App } from './app.js';
import './style.scss';

const pageLayoutGlobs = import.meta.glob('/src/./**/*.html', { query: '?raw', eager: true, import: 'default' });

interface ViewRouter {
name: string;
view: string;
viewModel: any;
title: string;
}

interface ViewModel {
mount?: () => void;
unmount?: () => void;
}

class Main {
loading = true;
currentModel?: ViewModel;
currentRouter?: ViewRouter;
defaultRouteName = 'getting-started';
stateBangChar = '#/';
baseUrl = window.location.origin + window.location.pathname;
viewModelObj: any = {};

async init() {
const location = window.location;
document.querySelector<HTMLDivElement>('#app')!.innerHTML = DOMPurify.sanitize(mainHtml, {
RETURN_TRUSTED_TYPE: true,
}) as unknown as string;

let route = location.hash.replace(this.stateBangChar, '');
if (!route || route === '/' || route === '#') {
route = this.defaultRouteName;
}

// create all routes and always load first route Example01
this.createRouteLinks();
this.loadRoute(route);

// on page load make route active, when changing route afterward that will be covered by each nav click events
Array.from(document.querySelectorAll('.panel-wm-left a.nav-link,.navbar-nav a.nav-link')).forEach(link => {
if (route.includes(link.id)) {
link.classList.add('active');
}
});

// re-render on browser history navigation change
window.onpopstate = () => {
const winLoc = window.location;
const prevRoute = winLoc.hash.replace(this.stateBangChar, '');

// change active link to previous route
this.removeAllActiveLinks();
const navItemElm = document.querySelector(`#${prevRoute}`);
if (navItemElm) {
navItemElm.scrollIntoView();
navItemElm.classList.add('active');
}
this.loadRoute(prevRoute || this.defaultRouteName, false);
};
}

createRouteLinks() {
for (const navRoute of navbarRouting) {
// <li class="nav-item"><a class="nav-link" id="home">Home</a></li>
const liElm = createDomElement('li', { className: 'nav-item' });
const aElm = createDomElement('a', { id: navRoute.name, className: 'nav-link', textContent: navRoute.title }, liElm);
aElm.addEventListener('click', this.clickEventListener.bind(this));
document.querySelector('.navbar-nav')?.appendChild(liElm);
}

for (const groupRoute of exampleRouting) {
const groupLiElm = createDomElement('li', { className: 'm-1' });
createDomElement('p', { className: 'navbar-vertical-label mb-1', textContent: groupRoute.name }, groupLiElm);
document.querySelector('.nav-pills')?.appendChild(groupLiElm);

for (const singleRoute of groupRoute.routes) {
// <li class="nav-item"><a class="nav-link" id="example01">Single Select</a></li>
const liElm = createDomElement('li', { className: 'nav-item' });
const aElm = createDomElement('a', {
id: singleRoute.name,
className: 'nav-link',
textContent: singleRoute.title,
});
aElm.addEventListener('click', this.clickEventListener.bind(this));

liElm.appendChild(aElm);
document.querySelector('.nav-pills')?.appendChild(liElm);
}
}
}

async loadRoute(routeName: string, changeBrowserState = true) {
const contentElm = document.querySelector('.panel-wm-content') as HTMLElement;
emptyElement(contentElm);
contentElm.classList.add('cloak');
let foundRouter = navbarRouting.find(r => r.name === routeName);

if (foundRouter?.name === 'examples') {
const exampleElm = document.querySelector('.nav-pills .nav-item a.nav-link');
exampleElm?.classList.add('active');
} else {
for (const groupRoute of exampleRouting) {
const found = (groupRoute.routes as ViewRouter[]).find(r => r.name === routeName);
if (found) {
foundRouter = found;
}
}
}

if (this.currentModel) {
this.unmountCurrentVM(this.currentModel, this.currentRouter);
}

if (foundRouter) {
this.currentRouter = foundRouter;
document.querySelector('.panel-wm-content')!.innerHTML = DOMPurify.sanitize(pageLayoutGlobs[foundRouter.view] as string, {
RETURN_TRUSTED_TYPE: true,
}) as unknown as string;
const vm = new foundRouter.viewModel() as ViewModel;
this.currentModel = vm;
(window as any)[foundRouter.name] = vm.mount?.();

// before leaving the page/SPA, we'll unmount everything
window.onbeforeunload = () => {
contentElm.classList.add('cloak');
vm.unmount?.();
this.removeAllActiveLinks(true);
this.unmountAll();
if (foundRouter?.name) {
delete (window as any)[foundRouter.name];
}
};
}

if (changeBrowserState) {
window.history.pushState({}, routeName, `${this.baseUrl}${this.stateBangChar}${routeName}`);
}
document.title = `Multiple-Select-Vanilla · ${routeName}`;
contentElm.classList.remove('cloak');
}

async clickEventListener(e: Event) {
// remove any active links
this.removeAllActiveLinks();

// change active link to new route
const targetElm = e.target as HTMLElement;
targetElm.classList.toggle('active');

this.loadRoute(targetElm.id);
}

removeAllActiveLinks(unbindListeners = false) {
document.querySelectorAll('.panel-wm-left a.nav-link,.navbar-nav a.nav-link').forEach(link => {
link.classList.remove('active');
if (unbindListeners) {
link.removeEventListener('click', this.clickEventListener.bind(this) as EventListener);
}
});
}

unmountCurrentVM(vm: ViewModel, vr?: ViewRouter) {
vm.unmount?.();
if (vr) {
delete (window as any)[vr.name];
}
}

unmountAll() {
for (const vmKey of Object.keys(this.viewModelObj)) {
const viewModel = this.viewModelObj[vmKey];
if (typeof viewModel?.unmount === 'function') {
viewModel?.unmount();

// also clear all of its variable references to avoid detached elements
for (const ref of Object.keys(viewModel)) {
viewModel[ref] = null;
}
}
// nullify the object and then delete them to make sure they will be removed by the garbage collector
(<any>window)[vmKey] = null;
this.viewModelObj[vmKey] = null;
delete (<any>window)[vmKey];
delete this.viewModelObj[vmKey];
}
}
}

// execute main init
const main = new Main();
main.init();
new App().init();
Loading
Loading