168 lines
6.7 KiB
JavaScript
Raw Normal View History

(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});
}));