Files
vendoo/public/global-navigation-ux.js

457 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
'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();
})();