feat(actions): add universal action registry foundation

This commit is contained in:
Masterluke77
2026-07-14 13:56:01 +02:00
parent 7bc8a81bba
commit 3b462ffff4
+615
View File
@@ -0,0 +1,615 @@
(() => {
'use strict';
if (document.documentElement.dataset.uxActionRegistryReady === '1') return;
document.documentElement.dataset.uxActionRegistryReady = '1';
const registry = new Map();
const listeners = new Set();
const commandDisposers = new Map();
const running = new Map();
const mountedSurfaces = new Set();
const elementContexts = new WeakMap();
const DANGER_LEVELS = new Set(['none', 'caution', 'destructive']);
const CONCURRENCY_MODES = new Set(['single', 'parallel']);
let confirmationProvider = null;
let auditSink = null;
let domObserver = null;
const normalizeStringList = value => Array.isArray(value)
? [...new Set(value.map(item => String(item || '').trim()).filter(Boolean))]
: [];
const normalizeNumber = (value, fallback = 0) => {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
};
function normalizeSelection(value) {
if (!value || typeof value !== 'object') return Object.freeze({ min: 0, max: null });
const min = Math.max(0, Math.floor(normalizeNumber(value.min, 0)));
const maxCandidate = value.max === null || value.max === undefined ? null : Math.max(min, Math.floor(normalizeNumber(value.max, min)));
return Object.freeze({ min, max: maxCandidate });
}
function normalizeConfirmation(value, action) {
if (value === false && action.danger !== 'destructive') return null;
const source = value && typeof value === 'object' ? value : {};
if (value !== true && !source.title && !source.message && action.danger !== 'destructive') return null;
return Object.freeze({
title: source.title ?? (action.danger === 'destructive' ? `${action.title} bestätigen` : action.title),
message: source.message ?? (action.danger === 'destructive'
? 'Diese Aktion kann Daten oder einen Betriebszustand dauerhaft verändern. Prüfe den Kontext und bestätige bewusst.'
: 'Möchtest du diese Aktion ausführen?'),
confirmLabel: source.confirmLabel ?? (action.danger === 'destructive' ? 'Verbindlich ausführen' : 'Ausführen'),
cancelLabel: source.cancelLabel ?? 'Abbrechen',
phrase: source.phrase ?? '',
});
}
function normalizeAction(input) {
if (!input || typeof input !== 'object') throw new TypeError('Action muss ein Objekt sein.');
const id = String(input.id || '').trim();
const title = String(input.title || '').trim();
if (!id || !title) throw new TypeError('Action benötigt id und title.');
if (typeof input.handler !== 'function') throw new TypeError(`Action ${id} benötigt einen handler.`);
const danger = DANGER_LEVELS.has(input.danger) ? input.danger : (input.danger === true ? 'destructive' : 'none');
const concurrency = CONCURRENCY_MODES.has(input.concurrency) ? input.concurrency : 'single';
const base = {
id,
title,
description: String(input.description || ''),
icon: String(input.icon || '•'),
group: String(input.group || 'Allgemein'),
aliases: normalizeStringList(input.aliases),
keywords: normalizeStringList(input.keywords),
surfaces: normalizeStringList(input.surfaces),
shortcut: String(input.shortcut || '').trim(),
permission: input.permission ?? null,
danger,
priority: normalizeNumber(input.priority, 0),
visible: input.visible ?? null,
enabled: input.enabled ?? null,
disabledReason: String(input.disabledReason || ''),
contexts: normalizeStringList(input.contexts),
selection: normalizeSelection(input.selection),
concurrency,
allowInInput: input.allowInInput === true,
handler: input.handler,
rollback: typeof input.rollback === 'function' ? input.rollback : null,
commandId: input.commandId ? String(input.commandId) : `action:${id}`,
};
return Object.freeze({ ...base, confirm: normalizeConfirmation(input.confirm, base) });
}
const emit = (type, detail = {}) => {
const payload = Object.freeze({ ...detail });
window.dispatchEvent(new CustomEvent(`vendoo:${type}`, { detail: payload }));
listeners.forEach(listener => {
try { listener({ type, detail: payload }); } catch {}
});
if (typeof auditSink === 'function') {
try { auditSink({ type, detail: payload }); } catch {}
}
};
function evaluateRule(rule, context, fallback) {
if (typeof rule === 'function') {
try { return Boolean(rule(context)); } catch { return false; }
}
if (typeof rule === 'boolean') return rule;
return fallback;
}
function evaluatePermission(permission, context) {
if (permission === null || permission === undefined || permission === '') return true;
if (typeof permission === 'function') {
try { return Boolean(permission(context)); } catch { return false; }
}
if (typeof permission === 'boolean') return permission;
const resolver = context?.canPermission || window.VendooPermissions?.can;
if (typeof resolver !== 'function') return false;
try {
if (Array.isArray(permission)) return permission.every(value => resolver.call(window.VendooPermissions, value, context));
return Boolean(resolver.call(window.VendooPermissions, permission, context));
} catch {
return false;
}
}
function selectionCount(context) {
if (Number.isFinite(Number(context?.selectionCount))) return Math.max(0, Number(context.selectionCount));
if (Array.isArray(context?.selection)) return context.selection.length;
if (context?.selection instanceof Set || context?.selection instanceof Map) return context.selection.size;
return 0;
}
function matchesContext(action, context) {
if (!action.contexts.length) return true;
const supplied = new Set([
...normalizeStringList(context?.contexts),
...(context?.scope ? [String(context.scope)] : []),
...(context?.view ? [String(context.view)] : []),
]);
return action.contexts.some(value => supplied.has(value));
}
function resolveAction(actionOrId, context = {}) {
const action = typeof actionOrId === 'string' ? registry.get(actionOrId) : actionOrId;
if (!action) return null;
const contextMatches = matchesContext(action, context);
const permissionGranted = evaluatePermission(action.permission, context);
const visible = contextMatches && permissionGranted && evaluateRule(action.visible, context, true);
const count = selectionCount(context);
const selectionValid = count >= action.selection.min && (action.selection.max === null || count <= action.selection.max);
const isRunning = running.has(action.id);
const enabled = visible
&& selectionValid
&& !(action.concurrency === 'single' && isRunning)
&& evaluateRule(action.enabled, context, true);
let disabledReason = '';
if (!enabled) {
if (!visible) disabledReason = action.disabledReason || 'Diese Aktion ist im aktuellen Kontext nicht verfügbar.';
else if (!selectionValid) {
if (count < action.selection.min) disabledReason = action.disabledReason || `Mindestens ${action.selection.min} Auswahl erforderlich.`;
else disabledReason = action.disabledReason || `Höchstens ${action.selection.max} Elemente können gleichzeitig verarbeitet werden.`;
} else if (isRunning) disabledReason = 'Diese Aktion wird bereits ausgeführt.';
else disabledReason = action.disabledReason || 'Diese Aktion ist derzeit nicht verfügbar.';
}
return Object.freeze({ visible, enabled, disabledReason, running: isRunning, selectionCount: count });
}
function list({ surface = null, group = null, context = {}, includeDisabled = true, includeHidden = false } = {}) {
return [...registry.values()]
.map(action => Object.freeze({ ...action, ...resolveAction(action, context) }))
.filter(action => (!surface || action.surfaces.includes(surface))
&& (!group || action.group === group)
&& (includeHidden || action.visible)
&& (includeDisabled || action.enabled))
.sort((left, right) => right.priority - left.priority
|| left.group.localeCompare(right.group, 'de-DE', { sensitivity: 'base' })
|| left.title.localeCompare(right.title, 'de-DE', { sensitivity: 'base' }));
}
function syncCommandAdapter(action) {
commandDisposers.get(action.id)?.();
commandDisposers.delete(action.id);
if (!action.surfaces.includes('command-palette') || !window.VendooCommands?.register) return;
const dispose = window.VendooCommands.register({
id: action.commandId,
title: action.title,
description: action.description,
icon: action.icon,
group: action.group,
aliases: action.aliases,
keywords: action.keywords,
shortcut: action.shortcut,
kind: 'action',
visible: context => Boolean(resolveAction(action, context)?.visible),
enabled: context => Boolean(resolveAction(action, context)?.enabled),
disabledReason: action.disabledReason || 'Diese Aktion ist im aktuellen Kontext nicht verfügbar.',
execute: context => {
void execute(action.id, { ...context, source: context?.source || 'command-palette' });
return true;
},
});
commandDisposers.set(action.id, dispose);
}
function register(input, { replace = false } = {}) {
const action = normalizeAction(input);
if (registry.has(action.id) && !replace) throw new Error(`Action-ID ist bereits registriert: ${action.id}`);
if (registry.has(action.id)) unregister(action.id);
registry.set(action.id, action);
syncCommandAdapter(action);
emit('actions-changed', { actionId: action.id, operation: 'register' });
scheduleRefresh();
return () => unregister(action.id);
}
function registerMany(actions, options = {}) {
if (!Array.isArray(actions)) throw new TypeError('registerMany erwartet ein Array.');
const disposers = actions.map(action => register(action, options));
return () => disposers.reverse().forEach(dispose => dispose());
}
function unregister(id) {
const actionId = String(id);
const removed = registry.delete(actionId);
commandDisposers.get(actionId)?.();
commandDisposers.delete(actionId);
if (removed) {
emit('actions-changed', { actionId, operation: 'unregister' });
scheduleRefresh();
}
return removed;
}
function resolveText(value, context, fallback = '') {
if (typeof value === 'function') {
try { return String(value(context) ?? fallback); } catch { return fallback; }
}
return String(value ?? fallback);
}
function createElement(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function requestDefaultConfirmation(action, context) {
if (!document.body || typeof document.createElement !== 'function') return Promise.resolve(false);
const config = action.confirm || normalizeConfirmation(true, action);
return new Promise(resolve => {
const overlay = createElement('div', 'vd-action-confirm-overlay');
overlay.setAttribute('role', 'presentation');
const dialog = createElement('section', 'vd-action-confirm-dialog');
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
dialog.setAttribute('aria-labelledby', 'vd-action-confirm-title');
dialog.setAttribute('aria-describedby', 'vd-action-confirm-message');
dialog.dataset.tone = action.danger;
const header = createElement('header', 'vd-action-confirm-header');
const marker = createElement('span', 'vd-action-confirm-marker', action.danger === 'destructive' ? '!' : '?');
marker.setAttribute('aria-hidden', 'true');
const heading = createElement('div', 'vd-action-confirm-heading');
const title = createElement('h2', '', resolveText(config.title, context, `${action.title} bestätigen`));
title.id = 'vd-action-confirm-title';
const risk = createElement('span', 'vd-action-confirm-risk', action.danger === 'destructive' ? 'Destruktive Aktion' : 'Bestätigung erforderlich');
heading.append(risk, title);
header.append(marker, heading);
const message = createElement('p', 'vd-action-confirm-message', resolveText(config.message, context));
message.id = 'vd-action-confirm-message';
const phrase = resolveText(config.phrase, context).trim();
let phraseInput = null;
const body = createElement('div', 'vd-action-confirm-body');
body.append(message);
if (phrase) {
const label = createElement('label', 'vd-action-confirm-phrase');
label.append(createElement('span', '', `Zur Bestätigung „${phrase}“ eingeben`));
phraseInput = createElement('input');
phraseInput.type = 'text';
phraseInput.autocomplete = 'off';
phraseInput.spellcheck = false;
label.append(phraseInput);
body.append(label);
}
const footer = createElement('footer', 'vd-action-confirm-footer');
const cancel = createElement('button', 'vd-action-confirm-cancel', resolveText(config.cancelLabel, context, 'Abbrechen'));
cancel.type = 'button';
const confirm = createElement('button', 'vd-action-confirm-submit', resolveText(config.confirmLabel, context, 'Ausführen'));
confirm.type = 'button';
if (phraseInput) confirm.disabled = true;
footer.append(cancel, confirm);
dialog.append(header, body, footer);
overlay.append(dialog);
document.body.append(overlay);
document.body.classList.add('vd-action-confirm-open');
const previousFocus = document.activeElement;
let settled = false;
const finish = value => {
if (settled) return;
settled = true;
document.removeEventListener('keydown', onKeydown, true);
overlay.remove();
document.body.classList.remove('vd-action-confirm-open');
if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus({ preventScroll: true });
resolve(value);
};
const focusables = () => [...dialog.querySelectorAll('button:not(:disabled), input:not(:disabled), [tabindex]:not([tabindex="-1"])')];
const onKeydown = event => {
if (event.key === 'Escape') {
event.preventDefault();
finish(false);
return;
}
if (event.key !== 'Tab') return;
const items = focusables();
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKeydown, true);
cancel.addEventListener('click', () => finish(false));
confirm.addEventListener('click', () => finish(true));
overlay.addEventListener('mousedown', event => { if (event.target === overlay) finish(false); });
phraseInput?.addEventListener('input', () => { confirm.disabled = phraseInput.value.trim() !== phrase; });
requestAnimationFrame(() => (phraseInput || cancel).focus({ preventScroll: true }));
});
}
async function requestConfirmation(action, context) {
if (!action.confirm && action.danger !== 'destructive') return true;
const provider = confirmationProvider || requestDefaultConfirmation;
try { return Boolean(await provider(action, context)); } catch { return false; }
}
async function execute(id, context = {}) {
const action = registry.get(String(id));
if (!action) return false;
const status = resolveAction(action, context);
if (!status?.visible || !status.enabled) {
emit('action-blocked', { actionId: action.id, source: context?.source || 'api', reason: status?.disabledReason || 'Aktion nicht verfügbar.' });
return false;
}
if (!(await requestConfirmation(action, context))) {
emit('action-cancelled', { actionId: action.id, source: context?.source || 'api', reason: 'confirmation-declined' });
return false;
}
const startedAt = Date.now();
if (action.concurrency === 'single') running.set(action.id, startedAt);
emit('action-executing', { actionId: action.id, source: context?.source || 'api', danger: action.danger });
scheduleRefresh();
try {
const result = await action.handler(Object.freeze({ ...context, actionId: action.id }));
if (result === false) {
emit('action-cancelled', { actionId: action.id, source: context?.source || 'api', reason: 'handler-declined', durationMs: Date.now() - startedAt });
return false;
}
emit('action-executed', { actionId: action.id, source: context?.source || 'api', durationMs: Date.now() - startedAt });
return true;
} catch (error) {
let rolledBack = false;
if (action.rollback) {
try {
await action.rollback(Object.freeze({ ...context, actionId: action.id, error }));
rolledBack = true;
emit('action-rolled-back', { actionId: action.id, source: context?.source || 'api' });
} catch (rollbackError) {
emit('action-rollback-failed', {
actionId: action.id,
source: context?.source || 'api',
message: rollbackError?.message || String(rollbackError),
});
}
}
emit('action-failed', {
actionId: action.id,
source: context?.source || 'api',
message: error?.message || String(error),
rolledBack,
durationMs: Date.now() - startedAt,
});
return false;
} finally {
if (action.concurrency === 'single') running.delete(action.id);
scheduleRefresh();
}
}
function parseElementContext(element) {
const stored = elementContexts.get(element) || {};
let declared = {};
const raw = element.dataset.vendooActionContext;
if (raw) {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) declared = parsed;
} catch {}
}
const scope = element.closest?.('[data-vendoo-action-scope]')?.dataset.vendooActionScope;
return {
...stored,
...declared,
scope: declared.scope || stored.scope || scope || window.VendooNavigation?.currentView || 'global',
view: declared.view || stored.view || window.VendooNavigation?.currentView || null,
source: declared.source || stored.source || element.dataset.vendooActionSource || 'dom',
trigger: element,
};
}
function syncElement(element) {
const actionId = element?.dataset?.vendooAction;
if (!actionId) return;
const action = registry.get(actionId);
const status = action ? resolveAction(action, parseElementContext(element)) : null;
const enabled = Boolean(status?.enabled);
element.setAttribute('aria-disabled', enabled ? 'false' : 'true');
if ('disabled' in element) element.disabled = !enabled;
element.classList.toggle('vd-action-running', Boolean(status?.running));
element.classList.toggle('vd-action-destructive', action?.danger === 'destructive');
element.classList.toggle('vd-action-caution', action?.danger === 'caution');
if (!element.dataset.vendooActionOriginalTitle) element.dataset.vendooActionOriginalTitle = element.getAttribute('title') || '';
const originalTitle = element.dataset.vendooActionOriginalTitle;
element.title = enabled ? originalTitle : (status?.disabledReason || 'Aktion nicht verfügbar.');
}
function refreshDom(root = document) {
root.querySelectorAll?.('[data-vendoo-action]').forEach(syncElement);
if (root.matches?.('[data-vendoo-action]')) syncElement(root);
}
function scheduleRefresh() {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => refresh());
else refresh();
}
function refresh(actionId = null) {
if (actionId && registry.has(actionId)) syncCommandAdapter(registry.get(actionId));
else if (!actionId) registry.forEach(syncCommandAdapter);
refreshDom();
mountedSurfaces.forEach(entry => {
try { entry.render(); } catch {}
});
emit('action-state-changed', { actionId });
}
function createActionButton(action, context, variant) {
const button = createElement('button', 'vd-action-button');
button.type = 'button';
button.dataset.vendooAction = action.id;
button.dataset.actionVariant = variant;
button.dataset.actionDanger = action.danger;
elementContexts.set(button, context);
const icon = createElement('span', 'vd-action-button-icon', action.icon);
icon.setAttribute('aria-hidden', 'true');
const copy = createElement('span', 'vd-action-button-copy');
copy.append(createElement('strong', '', action.title));
if (action.description && variant !== 'toolbar') copy.append(createElement('small', '', action.description));
button.append(icon, copy);
if (action.shortcut && variant !== 'sheet') button.append(createElement('kbd', '', action.shortcut));
syncElement(button);
return button;
}
function renderSurface(container, { surface, context = {}, variant = 'toolbar', includeDisabled = true, emptyText = '' } = {}) {
if (!container || typeof container.replaceChildren !== 'function') throw new TypeError('renderSurface benötigt einen DOM-Container.');
if (!surface) throw new TypeError('renderSurface benötigt einen surface-Namen.');
const actions = list({ surface, context, includeDisabled });
container.classList.add('vd-action-surface');
container.dataset.actionSurface = surface;
container.dataset.actionVariant = variant;
container.replaceChildren();
if (!actions.length && emptyText) container.append(createElement('span', 'vd-action-surface-empty', emptyText));
else actions.forEach(action => container.append(createActionButton(action, context, variant)));
return actions;
}
function mountSurface(container, options = {}) {
const contextProvider = typeof options.context === 'function' ? options.context : () => (options.context || {});
const entry = {
container,
render: () => renderSurface(container, { ...options, context: contextProvider() }),
};
mountedSurfaces.add(entry);
entry.render();
return () => {
mountedSurfaces.delete(entry);
container.replaceChildren();
container.classList.remove('vd-action-surface');
delete container.dataset.actionSurface;
delete container.dataset.actionVariant;
};
}
function normalizeShortcut(value) {
const aliases = { control: 'Ctrl', ctrl: 'Ctrl', cmd: 'Meta', command: 'Meta', meta: 'Meta', option: 'Alt', alt: 'Alt', shift: 'Shift' };
const parts = String(value || '').split('+').map(part => part.trim()).filter(Boolean);
const modifiers = [];
let key = '';
parts.forEach(part => {
const normalized = aliases[part.toLocaleLowerCase('en-US')];
if (normalized) modifiers.push(normalized);
else key = part.length === 1 ? part.toLocaleUpperCase('en-US') : part;
});
return [...new Set(modifiers)].sort((a, b) => ['Ctrl', 'Alt', 'Shift', 'Meta'].indexOf(a) - ['Ctrl', 'Alt', 'Shift', 'Meta'].indexOf(b)).concat(key || []).join('+');
}
function eventShortcut(event) {
const parts = [];
if (event.ctrlKey) parts.push('Ctrl');
if (event.altKey) parts.push('Alt');
if (event.shiftKey) parts.push('Shift');
if (event.metaKey) parts.push('Meta');
const key = event.key.length === 1 ? event.key.toLocaleUpperCase('en-US') : event.key;
if (!['Control', 'Alt', 'Shift', 'Meta'].includes(key)) parts.push(key);
return normalizeShortcut(parts.join('+'));
}
function isEditableTarget(target) {
return Boolean(target?.closest?.('input, textarea, select, [contenteditable="true"], [role="textbox"]'));
}
function handleShortcut(event) {
if (event.defaultPrevented) return;
const shortcut = eventShortcut(event);
if (!shortcut) return;
const context = { source: 'shortcut', event, scope: window.VendooNavigation?.currentView || 'global', view: window.VendooNavigation?.currentView || null };
const candidates = list({ surface: 'shortcut', context, includeDisabled: false })
.filter(action => normalizeShortcut(action.shortcut) === shortcut)
.filter(action => action.allowInInput || !isEditableTarget(event.target));
if (!candidates.length) return;
const highest = candidates[0].priority;
const winners = candidates.filter(action => action.priority === highest);
event.preventDefault();
if (winners.length !== 1) {
emit('action-shortcut-conflict', { shortcut, actionIds: winners.map(action => action.id) });
return;
}
void execute(winners[0].id, context);
}
function bindDom() {
document.addEventListener('click', event => {
const trigger = event.target?.closest?.('[data-vendoo-action]');
if (!trigger) return;
const actionId = trigger.dataset.vendooAction;
const status = resolveAction(actionId, parseElementContext(trigger));
if (!status?.enabled) {
event.preventDefault();
emit('action-blocked', { actionId, source: 'dom', reason: status?.disabledReason || 'Aktion nicht verfügbar.' });
return;
}
event.preventDefault();
void execute(actionId, parseElementContext(trigger));
});
document.addEventListener('keydown', handleShortcut);
['vendoo:modules-changed', 'vendoo:navigation-changed', 'vendoo:selection-changed', 'vendoo:permissions-changed']
.forEach(type => window.addEventListener(type, () => scheduleRefresh()));
if ('MutationObserver' in window && document.body) {
domObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) refreshDom(node);
}));
});
domObserver.observe(document.body, { childList: true, subtree: true });
window.addEventListener('beforeunload', () => domObserver?.disconnect(), { once: true });
}
refreshDom();
}
window.VendooActions = Object.freeze({
register,
registerMany,
unregister,
list,
get: id => registry.get(String(id)) || null,
resolve: (id, context = {}) => resolveAction(String(id), context),
canExecute: (id, context = {}) => Boolean(resolveAction(String(id), context)?.enabled),
execute,
refresh,
renderSurface,
mountSurface,
normalizeShortcut,
setConfirmationProvider(provider) {
if (provider !== null && typeof provider !== 'function') throw new TypeError('Confirmation Provider muss eine Funktion oder null sein.');
confirmationProvider = provider;
},
setAuditSink(sink) {
if (sink !== null && typeof sink !== 'function') throw new TypeError('Audit Sink muss eine Funktion oder null sein.');
auditSink = sink;
},
get running() { return [...running.keys()]; },
subscribe(listener) {
if (typeof listener !== 'function') throw new TypeError('Listener muss eine Funktion sein.');
listeners.add(listener);
return () => listeners.delete(listener);
},
});
bindDom();
emit('actions-ready', { count: registry.size });
})();