168 lines
6.7 KiB
JavaScript
168 lines
6.7 KiB
JavaScript
(function (root, factory) {
|
|
'use strict';
|
|
const foundation = typeof module === 'object' && module.exports ? require('./foundation.js') : root.HermesHuxFoundation;
|
|
const api = factory(foundation);
|
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
else root.HermesHuxShell = api;
|
|
}(typeof globalThis === 'object' ? globalThis : this, function (foundation) {
|
|
'use strict';
|
|
|
|
function element(doc, tag, attributes, text) {
|
|
const node = doc.createElement(tag);
|
|
Object.entries(attributes || {}).forEach(([name, value]) => node.setAttribute(name, value));
|
|
if (text) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function createShell(options) {
|
|
const settings = options || {};
|
|
const client = settings.client;
|
|
const doc = settings.document || (typeof document === 'object' ? document : null);
|
|
if (!client || !doc) throw new TypeError('HUX shell requires a client and document');
|
|
const extensions = new Map();
|
|
const instanceId = /^[a-z][a-z0-9-]{0,30}$/.test(settings.instanceId || '') ? settings.instanceId : 'main';
|
|
let root = null;
|
|
let unsubscribe = null;
|
|
let selected = null;
|
|
|
|
function register(extension) {
|
|
if (!extension || !/^[a-z][a-z0-9-]{2,48}$/.test(extension.id || '')) {
|
|
throw new TypeError('Extension id must be stable kebab-case');
|
|
}
|
|
if (!foundation.FLAGS.includes(extension.flag) || extension.flag === 'hux.foundation') {
|
|
throw new TypeError('Extension requires a registered feature flag');
|
|
}
|
|
if (typeof extension.render !== 'function' || !String(extension.label || '').trim()) {
|
|
throw new TypeError('Extension requires a label and renderer');
|
|
}
|
|
if (extensions.has(extension.id)) throw new TypeError('Extension id already registered');
|
|
extensions.set(extension.id, Object.freeze({
|
|
id: extension.id,
|
|
flag: extension.flag,
|
|
label: String(extension.label).slice(0, 80),
|
|
order: Number.isFinite(extension.order) ? extension.order : 100,
|
|
render: extension.render,
|
|
}));
|
|
if (root) render(client.getState());
|
|
return function remove() {
|
|
extensions.delete(extension.id);
|
|
if (selected === extension.id) selected = null;
|
|
if (root) render(client.getState());
|
|
};
|
|
}
|
|
|
|
function status(message, retry) {
|
|
const box = element(doc, 'div', {'class': 'hux-shell__status', 'role': 'status', 'aria-live': 'polite'}, message);
|
|
if (retry) {
|
|
const button = element(doc, 'button', {'class': 'hux-shell__retry', 'type': 'button'}, 'Try again');
|
|
button.addEventListener('click', () => client.negotiate());
|
|
box.appendChild(button);
|
|
}
|
|
root.appendChild(box);
|
|
}
|
|
|
|
function showExtension(extension, panels, tabs) {
|
|
selected = extension.id;
|
|
tabs.forEach((tab) => {
|
|
const active = tab.dataset.extensionId === selected;
|
|
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
tab.setAttribute('tabindex', active ? '0' : '-1');
|
|
});
|
|
panels.forEach((panel) => { panel.hidden = panel.dataset.extensionId !== selected; });
|
|
}
|
|
|
|
function moveTab(event, tabs, index) {
|
|
const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End'];
|
|
if (!keys.includes(event.key)) return;
|
|
event.preventDefault();
|
|
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 :
|
|
(index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
|
|
tabs[next].focus();
|
|
tabs[next].click();
|
|
}
|
|
|
|
function renderReady() {
|
|
const active = Array.from(extensions.values())
|
|
.filter((extension) => client.enabled(extension.flag))
|
|
.sort((left, right) => left.order - right.order || left.id.localeCompare(right.id));
|
|
if (!active.length) {
|
|
root.hidden = true;
|
|
return;
|
|
}
|
|
root.hidden = false;
|
|
const heading = element(doc, 'h2', {'class': 'hux-shell__title'}, 'Hermes workspace');
|
|
const tabsNode = element(doc, 'div', {'class': 'hux-shell__tabs', 'role': 'tablist', 'aria-label': 'Hermes workspace'});
|
|
const body = element(doc, 'div', {'class': 'hux-shell__body'});
|
|
const tabs = [];
|
|
const panels = [];
|
|
active.forEach((extension) => {
|
|
const tabId = `hux-${instanceId}-tab-${extension.id}`;
|
|
const panelId = `hux-${instanceId}-panel-${extension.id}`;
|
|
const tab = element(doc, 'button', {
|
|
'class': 'hux-shell__tab', 'type': 'button', 'role': 'tab', 'id': tabId,
|
|
'aria-controls': panelId, 'data-extension-id': extension.id,
|
|
}, extension.label);
|
|
const panel = element(doc, 'section', {
|
|
'class': 'hux-shell__panel', 'role': 'tabpanel', 'id': panelId,
|
|
'aria-labelledby': tabId, 'data-extension-id': extension.id,
|
|
});
|
|
try {
|
|
extension.render(Object.freeze({client, container: panel}));
|
|
} catch (_) {
|
|
panel.replaceChildren(element(doc, 'div', {
|
|
'class': 'hux-shell__status', 'role': 'status', 'aria-live': 'polite',
|
|
}, 'This workspace panel is temporarily unavailable.'));
|
|
}
|
|
tab.addEventListener('click', () => showExtension(extension, panels, tabs));
|
|
tab.addEventListener('keydown', (event) => moveTab(event, tabs, tabs.indexOf(tab)));
|
|
tabsNode.appendChild(tab);
|
|
body.appendChild(panel);
|
|
tabs.push(tab);
|
|
panels.push(panel);
|
|
});
|
|
root.appendChild(heading);
|
|
root.appendChild(tabsNode);
|
|
root.appendChild(body);
|
|
const choice = active.find((extension) => extension.id === selected) || active[0];
|
|
showExtension(choice, panels, tabs);
|
|
}
|
|
|
|
function render(state) {
|
|
if (!root) return;
|
|
root.replaceChildren();
|
|
root.setAttribute('data-hux-state', state.phase);
|
|
if (state.phase === 'loading') {
|
|
root.hidden = false;
|
|
status('Loading Hermes workspace…', false);
|
|
} else if (state.phase === 'error') {
|
|
root.hidden = false;
|
|
status(state.error || 'Hermes workspace is unavailable.', true);
|
|
} else if (state.phase === 'ready') renderReady();
|
|
else root.hidden = true;
|
|
}
|
|
|
|
function mount(target) {
|
|
if (root) throw new Error('HUX shell is already mounted');
|
|
root = element(doc, 'aside', {
|
|
'class': 'hux-shell', 'aria-label': 'Hermes workspace',
|
|
'data-hux-version': foundation.API_VERSION,
|
|
});
|
|
root.hidden = true;
|
|
target.appendChild(root);
|
|
unsubscribe = client.subscribe(render);
|
|
return root;
|
|
}
|
|
|
|
function destroy() {
|
|
if (unsubscribe) unsubscribe();
|
|
if (root && root.parentNode) root.parentNode.removeChild(root);
|
|
root = null;
|
|
unsubscribe = null;
|
|
}
|
|
|
|
return Object.freeze({destroy, mount, register});
|
|
}
|
|
|
|
return Object.freeze({createShell});
|
|
}));
|