457 lines
18 KiB
JavaScript
457 lines
18 KiB
JavaScript
(() => {
|
||
'use strict';
|
||
|
||
if (document.documentElement.dataset.uxGlobalNavigationReady === '1') return;
|
||
document.documentElement.dataset.uxGlobalNavigationReady = '1';
|
||
|
||
const state = {
|
||
activeView: null,
|
||
restoring: false,
|
||
query: '',
|
||
selectedIndex: 0,
|
||
commands: [],
|
||
previousFocus: null,
|
||
unsubscribeCommands: null,
|
||
};
|
||
|
||
const el = (tag, className, text) => {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text !== undefined) node.textContent = text;
|
||
return node;
|
||
};
|
||
|
||
function getActiveView() {
|
||
return document.querySelector('.sidebar-nav .nav-item.active[data-tab]')?.dataset.tab
|
||
|| document.querySelector('.tab-content.active')?.id
|
||
|| 'dashboard';
|
||
}
|
||
|
||
function getNavItem(view) {
|
||
return document.querySelector(`.sidebar-nav .nav-item[data-tab="${CSS.escape(view)}"]`);
|
||
}
|
||
|
||
function activateView(view, { replace = false } = {}) {
|
||
const item = getNavItem(view);
|
||
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();
|
||
requestAnimationFrame(() => {
|
||
state.activeView = getActiveView();
|
||
const url = new URL(window.location.href);
|
||
url.hash = `view=${encodeURIComponent(state.activeView)}`;
|
||
const method = replace ? 'replaceState' : 'pushState';
|
||
history[method]({ vendooView: state.activeView }, '', url);
|
||
state.restoring = false;
|
||
updateHistoryButtons();
|
||
});
|
||
return true;
|
||
}
|
||
|
||
function ensureInitialHistoryState() {
|
||
const hashView = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('view');
|
||
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);
|
||
state.activeView = initial;
|
||
if (initial !== getActiveView()) activateView(initial, { replace: true });
|
||
}
|
||
|
||
function updateHistoryButtons() {
|
||
const back = document.getElementById('ux-nav-back');
|
||
const forward = document.getElementById('ux-nav-forward');
|
||
if (back) back.disabled = history.length <= 1;
|
||
if (forward) forward.disabled = false;
|
||
}
|
||
|
||
function createHistoryControls() {
|
||
const context = document.querySelector('.topbar-context');
|
||
if (!context || document.getElementById('ux-nav-history')) return;
|
||
const controls = el('div', 'ux-nav-history');
|
||
controls.id = 'ux-nav-history';
|
||
controls.setAttribute('aria-label', 'Navigationsverlauf');
|
||
const back = el('button', 'ux-nav-history-button');
|
||
back.id = 'ux-nav-back';
|
||
back.type = 'button';
|
||
back.title = 'Zurück (Alt + Pfeil links)';
|
||
back.setAttribute('aria-label', 'Zurück');
|
||
back.textContent = '←';
|
||
const forward = el('button', 'ux-nav-history-button');
|
||
forward.id = 'ux-nav-forward';
|
||
forward.type = 'button';
|
||
forward.title = 'Vorwärts (Alt + Pfeil rechts)';
|
||
forward.setAttribute('aria-label', 'Vorwärts');
|
||
forward.textContent = '→';
|
||
back.addEventListener('click', () => history.back());
|
||
forward.addEventListener('click', () => history.forward());
|
||
controls.append(back, forward);
|
||
context.querySelector('.topbar-heading')?.before(controls);
|
||
updateHistoryButtons();
|
||
}
|
||
|
||
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' },
|
||
});
|
||
}
|
||
|
||
function createDialog() {
|
||
if (document.getElementById('ux-global-search')) return document.getElementById('ux-global-search');
|
||
const dialog = el('div', 'ux-global-search hidden');
|
||
dialog.id = 'ux-global-search';
|
||
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 = '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');
|
||
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 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 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();
|
||
state.query = query;
|
||
state.commands = getPaletteCommands(query);
|
||
state.selectedIndex = Math.min(state.selectedIndex, Math.max(0, state.commands.length - 1));
|
||
results.replaceChildren();
|
||
|
||
if (!state.commands.length) {
|
||
const empty = el('div', 'ux-global-search-empty');
|
||
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;
|
||
}
|
||
|
||
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();
|
||
};
|
||
|
||
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({ 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();
|
||
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();
|
||
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();
|
||
}
|
||
});
|
||
}
|
||
|
||
function observeNavigation() {
|
||
const nav = document.querySelector('.sidebar-nav');
|
||
if (!nav) return;
|
||
const observer = new MutationObserver(() => {
|
||
const next = getActiveView();
|
||
if (!next || next === state.activeView || state.restoring) return;
|
||
state.activeView = next;
|
||
const url = new URL(window.location.href);
|
||
url.hash = `view=${encodeURIComponent(next)}`;
|
||
history.pushState({ vendooView: next }, '', url);
|
||
updateHistoryButtons();
|
||
});
|
||
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');
|
||
const item = view ? getNavItem(view) : null;
|
||
if (!item || item.classList.contains('hidden') || item.getAttribute('aria-hidden') === 'true') return;
|
||
state.restoring = true;
|
||
item.click();
|
||
requestAnimationFrame(() => {
|
||
state.activeView = getActiveView();
|
||
state.restoring = false;
|
||
updateHistoryButtons();
|
||
});
|
||
});
|
||
|
||
function initialize() {
|
||
createHistoryControls();
|
||
ensureInitialHistoryState();
|
||
bindSearch();
|
||
observeNavigation();
|
||
window.addEventListener('beforeunload', () => state.unsubscribeCommands?.(), { once: true });
|
||
}
|
||
|
||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initialize, { once: true });
|
||
else initialize();
|
||
})();
|