diff --git a/public/global-navigation-ux.js b/public/global-navigation-ux.js index f1d1ab7..60e3395 100644 --- a/public/global-navigation-ux.js +++ b/public/global-navigation-ux.js @@ -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(); -})(); \ No newline at end of file +})();