feat(ux): migrate Ctrl+K palette to command registry

This commit is contained in:
Masterluke77
2026-07-14 13:24:55 +02:00
parent c9f2d1248d
commit a606847229
+264 -80
View File
@@ -4,30 +4,14 @@
if (document.documentElement.dataset.uxGlobalNavigationReady === '1') return;
document.documentElement.dataset.uxGlobalNavigationReady = '1';
const VIEW_LABELS = {
dashboard: ['Übersicht', 'Dashboard', 'Start'],
today: ['Aufgaben', 'Heute', 'Tagesarbeitsplatz'],
generator: ['Neuer Artikel', 'Artikel erstellen', 'Generator'],
history: ['Artikel', 'Listings', 'Artikelübersicht'],
'quality-center': ['Qualität', 'Qualitätscenter', 'Prüfen'],
publish: ['Veröffentlichen', 'Publish', 'Marktplätze'],
inventory: ['Lager', 'Bestand', 'Lagerorte'],
templates: ['Vorlagen', 'Templates'],
'media-library': ['Medien', 'Bilder', 'Galerie'],
'image-factory': ['Bildwerkstatt', 'Bildproduktion'],
'flux-studio': ['FLUX Studio', 'ComfyUI', 'AI Bilder'],
extensions: ['Erweiterungen', 'Extensions', 'Browser Erweiterung'],
settings: ['Einstellungen', 'Verbindungen', 'APIs'],
admin: ['Verwaltung', 'Benutzer', 'Admin'],
fees: ['Gebühren', 'Kosten'],
trash: ['Papierkorb', 'Gelöscht'],
};
const state = {
activeView: null,
restoring: false,
query: '',
selectedIndex: 0,
commands: [],
previousFocus: null,
unsubscribeCommands: null,
};
const el = (tag, className, text) => {
@@ -49,7 +33,7 @@
function activateView(view, { replace = false } = {}) {
const item = getNavItem(view);
if (!item || item.classList.contains('hidden')) return false;
if (!item || item.classList.contains('hidden') || item.getAttribute('aria-hidden') === 'true') return false;
state.restoring = true;
item.closest('details.ux-nav-section')?.setAttribute('open', '');
item.click();
@@ -67,7 +51,8 @@
function ensureInitialHistoryState() {
const hashView = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('view');
const initial = hashView && getNavItem(hashView) ? hashView : getActiveView();
const initialItem = hashView ? getNavItem(hashView) : null;
const initial = initialItem && !initialItem.classList.contains('hidden') && initialItem.getAttribute('aria-hidden') !== 'true' ? hashView : getActiveView();
const url = new URL(window.location.href);
url.hash = `view=${encodeURIComponent(initial)}`;
history.replaceState({ vendooView: initial }, '', url);
@@ -107,16 +92,19 @@
updateHistoryButtons();
}
function collectCommands() {
return Object.entries(VIEW_LABELS).map(([view, labels]) => ({
id: `view:${view}`,
view,
title: labels[0],
keywords: labels.join(' ').toLocaleLowerCase('de-DE'),
group: view === 'settings' || view === 'admin' || view === 'extensions' ? 'System' : 'Navigation',
})).filter(command => {
const item = getNavItem(command.view);
return item && !item.classList.contains('hidden');
function getRegistry() {
const registry = window.VendooCommands;
return registry && typeof registry.list === 'function' && typeof registry.execute === 'function' ? registry : null;
}
function getPaletteCommands(query = '') {
const registry = getRegistry();
if (!registry) return [];
return registry.list({
query,
includeDisabled: true,
includeHidden: false,
context: { source: 'command-palette' },
});
}
@@ -127,108 +115,299 @@
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
dialog.setAttribute('aria-labelledby', 'ux-global-search-title');
dialog.setAttribute('aria-describedby', 'ux-global-search-help');
const panel = el('section', 'ux-global-search-panel');
const header = el('header', 'ux-global-search-header');
const heading = el('div', 'ux-global-search-heading');
const title = el('h2', '', 'Suchen und öffnen');
title.id = 'ux-global-search-title';
const subtitle = el('p', '', 'Bereiche und registrierte Befehle sicher finden.');
heading.append(title, subtitle);
const close = el('button', 'ux-global-search-close', '×');
close.type = 'button';
close.setAttribute('aria-label', 'Suche schließen');
const inputWrap = el('div', 'ux-global-search-input-wrap');
const input = el('input', 'ux-global-search-input');
input.id = 'ux-global-search-input';
input.type = 'search';
input.placeholder = 'Seite, Bereich oder Einstellung suchen …';
input.placeholder = 'Bereich, Befehl oder Stichwort suchen …';
input.autocomplete = 'off';
input.spellcheck = false;
input.setAttribute('role', 'combobox');
input.setAttribute('aria-autocomplete', 'list');
input.setAttribute('aria-controls', 'ux-global-search-results');
input.setAttribute('aria-expanded', 'false');
const shortcut = el('kbd', '', 'Esc');
inputWrap.append(input, shortcut);
const status = el('div', 'ux-global-search-status');
status.id = 'ux-global-search-status';
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
status.setAttribute('aria-atomic', 'true');
const results = el('div', 'ux-global-search-results');
results.id = 'ux-global-search-results';
results.setAttribute('role', 'listbox');
results.setAttribute('aria-label', 'Verfügbare Befehle');
const footer = el('footer', 'ux-global-search-footer');
footer.textContent = '↑↓ auswählen · Enter öffnen · Esc schließen';
header.append(title, close);
panel.append(header, inputWrap, results, footer);
const help = el('span', '', '↑↓ auswählen · Enter bestätigen · Esc schließen');
help.id = 'ux-global-search-help';
footer.append(help);
header.append(heading, close);
panel.append(header, inputWrap, status, results, footer);
dialog.append(panel);
document.body.append(dialog);
const closeDialog = () => {
const isOpen = () => !dialog.classList.contains('hidden');
const updateSelection = ({ announce = false } = {}) => {
const options = [...results.querySelectorAll('[data-command-id]')];
if (!options.length) {
input.removeAttribute('aria-activedescendant');
return;
}
state.selectedIndex = Math.max(0, Math.min(state.selectedIndex, options.length - 1));
options.forEach((option, index) => option.setAttribute('aria-selected', index === state.selectedIndex ? 'true' : 'false'));
const selected = options[state.selectedIndex];
input.setAttribute('aria-activedescendant', selected.id);
selected.scrollIntoView({ block: 'nearest' });
if (announce) {
const command = state.commands[state.selectedIndex];
status.textContent = command?.enabled
? `${command.title} ausgewählt.`
: `${command?.title || 'Befehl'} nicht verfügbar: ${command?.disabledReason || 'Der Befehl ist deaktiviert.'}`;
}
};
const closeDialog = ({ restoreFocus = true } = {}) => {
if (!isOpen()) return;
dialog.classList.add('hidden');
dialog.setAttribute('aria-hidden', 'true');
document.body.classList.remove('ux-search-open');
state.query = '';
state.selectedIndex = 0;
state.commands = [];
input.value = '';
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
results.replaceChildren();
status.textContent = '';
if (restoreFocus && state.previousFocus instanceof HTMLElement && state.previousFocus.isConnected) {
state.previousFocus.focus({ preventScroll: true });
}
state.previousFocus = null;
};
const executeCommand = commandId => {
const registry = getRegistry();
const command = state.commands.find(item => item.id === commandId);
if (!registry || !command) return;
if (!command.enabled) {
status.textContent = command.disabledReason || 'Dieser Befehl ist derzeit nicht verfügbar.';
return;
}
const executed = registry.execute(command.id, { source: 'command-palette' });
if (executed) closeDialog();
else status.textContent = command.disabledReason || 'Der Befehl konnte nicht ausgeführt werden.';
};
const executeSelected = () => {
const selected = results.querySelector('[aria-selected="true"]');
const view = selected?.dataset.view;
if (!view) return;
closeDialog();
activateView(view);
const command = state.commands[state.selectedIndex];
if (command) executeCommand(command.id);
};
const appendSectionHeading = label => {
const headingNode = el('div', 'ux-global-search-section', label);
headingNode.setAttribute('role', 'presentation');
results.append(headingNode);
};
const appendCommand = (command, index) => {
const button = el('button', 'ux-global-search-result');
button.type = 'button';
button.id = `ux-global-search-option-${index}`;
button.dataset.commandId = command.id;
button.setAttribute('role', 'option');
button.setAttribute('aria-selected', index === state.selectedIndex ? 'true' : 'false');
button.setAttribute('aria-disabled', command.enabled ? 'false' : 'true');
if (!command.enabled) button.classList.add('is-disabled');
const icon = el('span', 'ux-global-search-result-icon', command.icon || '•');
icon.setAttribute('aria-hidden', 'true');
const copy = el('span', 'ux-global-search-result-copy');
const titleRow = el('span', 'ux-global-search-result-title');
titleRow.append(el('strong', '', command.title));
if (command.favorite) titleRow.append(el('span', 'ux-global-search-badge', 'Favorit'));
else if (command.recent) titleRow.append(el('span', 'ux-global-search-badge', 'Zuletzt'));
const description = el('small', '', command.enabled
? (command.description || command.group)
: (command.disabledReason || 'Der Befehl ist derzeit nicht verfügbar.'));
copy.append(titleRow, description);
const meta = el('span', 'ux-global-search-result-meta');
meta.append(el('span', 'ux-global-search-result-group', command.group));
if (command.shortcut) meta.append(el('kbd', '', command.shortcut));
else meta.append(el('span', 'ux-global-search-result-action', command.enabled ? 'Öffnen' : 'Nicht verfügbar'));
button.append(icon, copy, meta);
button.addEventListener('pointermove', () => {
if (state.selectedIndex === index) return;
state.selectedIndex = index;
updateSelection();
});
button.addEventListener('click', () => executeCommand(command.id));
results.append(button);
};
const render = () => {
const query = input.value.trim().toLocaleLowerCase('de-DE');
const query = input.value.trim();
state.query = query;
const commands = collectCommands().filter(command => !query || `${command.title} ${command.keywords} ${command.group}`.toLocaleLowerCase('de-DE').includes(query));
state.selectedIndex = Math.min(state.selectedIndex, Math.max(0, commands.length - 1));
state.commands = getPaletteCommands(query);
state.selectedIndex = Math.min(state.selectedIndex, Math.max(0, state.commands.length - 1));
results.replaceChildren();
if (!commands.length) {
if (!state.commands.length) {
const empty = el('div', 'ux-global-search-empty');
empty.append(el('strong', '', 'Keine passende Seite gefunden'), el('span', '', 'Versuche einen allgemeineren Begriff. Produktive Aktionen werden hier bewusst nicht automatisch ausgeführt.'));
empty.append(
el('strong', '', 'Kein passender Befehl gefunden'),
el('span', '', 'Versuche Titel, Bereich, Beschreibung oder ein anderes Stichwort. Produktive Aktionen werden niemals automatisch ausgeführt.'),
);
results.append(empty);
input.removeAttribute('aria-activedescendant');
status.textContent = 'Keine passenden Befehle.';
return;
}
commands.forEach((command, index) => {
const button = el('button', 'ux-global-search-result');
button.type = 'button';
button.dataset.view = command.view;
button.setAttribute('role', 'option');
button.setAttribute('aria-selected', index === state.selectedIndex ? 'true' : 'false');
const copy = el('span', 'ux-global-search-result-copy');
copy.append(el('strong', '', command.title), el('small', '', command.group));
button.append(copy, el('span', 'ux-global-search-result-action', 'Öffnen'));
button.addEventListener('mouseenter', () => { state.selectedIndex = index; render(); });
button.addEventListener('click', executeSelected);
results.append(button);
});
if (!query) {
let lastSection = '';
state.commands.forEach((command, index) => {
const section = command.favorite ? 'Favoriten' : command.recent ? 'Zuletzt geöffnet' : 'Alle Bereiche';
if (section !== lastSection) {
appendSectionHeading(section);
lastSection = section;
}
appendCommand(command, index);
});
} else {
state.commands.forEach(appendCommand);
}
status.textContent = `${state.commands.length} ${state.commands.length === 1 ? 'Befehl' : 'Befehle'} verfügbar.`;
updateSelection();
};
input.addEventListener('input', () => { state.selectedIndex = 0; render(); });
input.addEventListener('keydown', event => {
const count = results.querySelectorAll('[data-view]').length;
if (event.key === 'ArrowDown' && count) { event.preventDefault(); state.selectedIndex = (state.selectedIndex + 1) % count; render(); }
if (event.key === 'ArrowUp' && count) { event.preventDefault(); state.selectedIndex = (state.selectedIndex - 1 + count) % count; render(); }
if (event.key === 'Enter') { event.preventDefault(); executeSelected(); }
if (event.key === 'Escape') closeDialog();
});
close.addEventListener('click', closeDialog);
dialog.addEventListener('mousedown', event => { if (event.target === dialog) closeDialog(); });
dialog._open = () => {
const openDialog = trigger => {
if (isOpen()) return;
state.previousFocus = trigger instanceof HTMLElement ? trigger : document.activeElement;
dialog.classList.remove('hidden');
dialog.setAttribute('aria-hidden', 'false');
document.body.classList.add('ux-search-open');
input.setAttribute('aria-expanded', 'true');
state.selectedIndex = 0;
render();
requestAnimationFrame(() => input.focus());
requestAnimationFrame(() => input.focus({ preventScroll: true }));
};
input.addEventListener('input', () => {
state.selectedIndex = 0;
render();
});
input.addEventListener('keydown', event => {
const count = state.commands.length;
if (event.key === 'ArrowDown' && count) {
event.preventDefault();
state.selectedIndex = (state.selectedIndex + 1) % count;
updateSelection({ announce: true });
}
if (event.key === 'ArrowUp' && count) {
event.preventDefault();
state.selectedIndex = (state.selectedIndex - 1 + count) % count;
updateSelection({ announce: true });
}
if (event.key === 'Home' && count) {
event.preventDefault();
state.selectedIndex = 0;
updateSelection({ announce: true });
}
if (event.key === 'End' && count) {
event.preventDefault();
state.selectedIndex = count - 1;
updateSelection({ announce: true });
}
if (event.key === 'Enter') {
event.preventDefault();
executeSelected();
}
if (event.key === 'Escape') {
event.preventDefault();
closeDialog();
}
});
close.addEventListener('click', () => closeDialog());
dialog.addEventListener('mousedown', event => {
if (event.target === dialog) closeDialog();
});
dialog.addEventListener('keydown', event => {
if (event.key !== 'Tab') return;
const focusable = [...panel.querySelectorAll('button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])')]
.filter(node => !node.hidden && node.getClientRects().length > 0);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
dialog._open = openDialog;
dialog._close = closeDialog;
dialog._render = render;
return dialog;
}
function isEditableTarget(target) {
return target instanceof Element && Boolean(target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]'));
}
function bindSearch() {
const dialog = createDialog();
document.getElementById('topbar-search-btn')?.addEventListener('click', () => dialog._open());
const trigger = document.getElementById('topbar-search-btn');
trigger?.addEventListener('click', () => dialog._open(trigger));
const registry = getRegistry();
if (registry?.subscribe) {
state.unsubscribeCommands = registry.subscribe(() => {
if (!dialog.classList.contains('hidden')) dialog._render();
});
}
document.addEventListener('keydown', event => {
const modifier = /Mac|iPhone|iPad/.test(navigator.platform) ? event.metaKey : event.ctrlKey;
const paletteOpen = !dialog.classList.contains('hidden');
if (modifier && event.key.toLocaleLowerCase('de-DE') === 'k') {
if (isEditableTarget(event.target) && !paletteOpen) return;
event.preventDefault();
dialog.classList.contains('hidden') ? dialog._open() : dialog._close();
paletteOpen ? dialog._close() : dialog._open(event.target);
}
if (event.altKey && event.key === 'ArrowLeft') {
event.preventDefault();
history.back();
}
if (event.altKey && event.key === 'ArrowRight') {
event.preventDefault();
history.forward();
}
if (event.key === 'Escape' && paletteOpen) {
event.preventDefault();
dialog._close();
}
if (event.altKey && event.key === 'ArrowLeft') { event.preventDefault(); history.back(); }
if (event.altKey && event.key === 'ArrowRight') { event.preventDefault(); history.forward(); }
if (event.key === 'Escape' && !dialog.classList.contains('hidden')) dialog._close();
});
}
@@ -244,15 +423,19 @@
history.pushState({ vendooView: next }, '', url);
updateHistoryButtons();
});
nav.querySelectorAll('.nav-item[data-tab]').forEach(item => observer.observe(item, { attributes: true, attributeFilter: ['class'] }));
nav.querySelectorAll('.nav-item[data-tab]').forEach(item => observer.observe(item, {
attributes: true,
attributeFilter: ['class', 'aria-hidden'],
}));
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
}
window.addEventListener('popstate', event => {
const view = event.state?.vendooView || new URLSearchParams(window.location.hash.replace(/^#/, '')).get('view');
if (!view || !getNavItem(view)) return;
const item = view ? getNavItem(view) : null;
if (!item || item.classList.contains('hidden') || item.getAttribute('aria-hidden') === 'true') return;
state.restoring = true;
getNavItem(view).click();
item.click();
requestAnimationFrame(() => {
state.activeView = getActiveView();
state.restoring = false;
@@ -265,8 +448,9 @@
ensureInitialHistoryState();
bindSearch();
observeNavigation();
window.addEventListener('beforeunload', () => state.unsubscribeCommands?.(), { once: true });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initialize, { once: true });
else initialize();
})();
})();