Source code
src/atrium/components/page.js
This file is included in this documentation build. It is displayed as code and is not executed.
/**
* Load trusted page modules on demand and own their Alpine mounting and teardown lifecycle.
*
* @author Laurent Declercq l.declercq@agon-innovation.ch
* @version 20260921
*/
/**
* Parse a compiled page template without accepting multiple or missing route roots.
*
* @param {string} html - Trusted HTML exported by the application build.
* @returns {Element} Detached page root ready for Alpine initialization.
* @throws {Error} When the compiled page does not contain exactly one root element.
*/
export function pageRoot(html) {
const template = document.createElement('template');
template.innerHTML = html;
const root = template.content.firstElementChild;
if (!root || root.nextElementSibling) {
throw new Error('A page must contain exactly one root element.');
}
return root;
}
/**
* Create a route-owned outlet for a consumer's compiled template and controller module.
*
* @param {object} route - Consumer route metadata and optional capability.
* @param {Record<string, Function>} pages - Build-generated dynamic page importers.
* @param {object} context - Alpine runtime, preferences, and consumer-owned service dependencies.
* @returns {object} Alpine provider with explicit loading, recovery, and cleanup state.
*/
export function pageOutlet(route, pages, context) {
let generation = 0;
let mounted = null;
let permitted;
return {
loading: false,
failed: false,
/**
* Recover a cached module failure while preserving the full current URL and its query state.
*
* @returns {void} Requests a fresh browser document at the current location.
*/
reload() {
window.location.reload();
},
/**
* Load the selected route and observe presentation permission changes.
*
* @returns {void} Installs the permission watcher and starts the first permitted load.
*/
init() {
this.$watch(
'$store.session.capabilities',
/**
* Reconcile permission changes without resetting an already permitted page.
*
* @returns {void} Updates the mounted page only when permission changes.
*/
() => this.syncPermission()
);
this.syncPermission();
},
/**
* Mount newly allowed content or tear down a page whose capability was removed.
*
* @returns {void} Starts a fresh lifecycle only when permission changes.
*/
syncPermission() {
const allowed = this.$store.session.can(route.capability);
if (allowed !== permitted) {
permitted = allowed;
this.load();
}
},
/**
* Release the mounted page's requests, timers, listeners, and Alpine effects.
*
* @returns {void} Removes only this outlet's owned page root.
*/
unmount() {
if (mounted) {
context.Alpine.mutateDom(
/**
* Run explicit teardown while the DOM observer is suspended.
*
* @returns {void} Destroys and removes the owned subtree exactly once.
*/
() => {
context.Alpine.destroyTree(mounted);
mounted.remove();
mounted = null;
}
);
}
},
/**
* Import one trusted page, register its controllers, and mount only the latest result.
* Dynamic module imports cannot be aborted, so generation ownership rejects late completions.
*
* @returns {Promise<void>} Resolves after mounting, permission refusal, or recoverable failure.
*/
async load() {
const request = ++generation;
this.unmount();
this.failed = false;
this.loading = false;
if (!this.$store.session.can(route.capability)) {
return;
}
this.loading = true;
try {
const page = await pages[route.path]();
if (request !== generation) {
return;
}
const root = pageRoot(page.html);
page.register?.(context);
context.Alpine.mutateDom(
/**
* Mount compiled application markup after its named providers are available.
*
* @returns {void} Initializes the new page beneath the existing shell scope.
*/
() => {
mounted = root;
this.$refs.content.append(root);
context.Alpine.initTree(root);
}
);
this.$nextTick(
/**
* Move keyboard focus after the newly loaded page finishes rendering.
*
* @returns {void} Focuses the page heading when navigation still owns this outlet.
*/
() => {
if (request === generation) {
root.querySelector('h1')?.focus();
}
}
);
} catch {
if (request === generation) {
this.unmount();
this.failed = true;
}
} finally {
if (request === generation) {
this.loading = false;
}
}
},
/**
* Invalidate pending module results and release the page before route removal.
*
* @returns {void} Ends ownership without affecting the persistent shell.
*/
destroy() {
generation += 1;
this.unmount();
}
};
}