feat(ux): migrate Ctrl+K palette to command registry
This commit is contained in:
+264
-80
@@ -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();
|
||||
})();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user