feat(actions): add universal action registry foundation
This commit is contained in:
@@ -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 });
|
||||
})();
|
||||
Reference in New Issue
Block a user