WebUI-Kopfleiste: Aufklappmenues statt elf Tabs
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s

Umsetzung von #541, folgt auf #532.

Elf Tabs nebeneinander waren nicht mehr zu ueberblicken. Die Leiste hat jetzt
vier Eintraege, gruppiert nach dem, was man dort tut:

- Dashboard
- Analytics: Analytics, Accounting, Supervisor
- Module: Moduleinstellungen (die allgemeine Modulansicht), danach alle Module
- Einstellungen: Einstellungen, Jobs, Konten, Terminal / Logs

Supervisor und Accounting stehen unter Analytics und zusaetzlich in der
Modulliste, weil dort alle Module stehen sollen. Hervorgehoben wird das
Oberelement nur an ihrem Hauptplatz (Analytics); der zweite Zugang traegt
data-secondary. Sonst leuchteten bei geoeffnetem Supervisor zwei Menues.

switchView() findet den aktiven Eintrag jetzt ueber data-view statt ueber eine
Teilzeichenkette im onclick-Attribut. Das alte Verfahren hing am Zufall der
Namen: Eine Ansicht, deren Name in einer anderen enthalten ist, haette beide
markiert.

Die Modulliste zeigt den Zustand aus /api/modules: Ein deaktiviertes Modul
erscheint ausgegraut mit "deaktiviert" und ist nicht anklickbar, statt in eine
leere Ansicht zu fuehren. Ein Modul, das der Server fuehrt, fuer das es im
WebUI aber keine Ansicht gibt, wird mit "keine Ansicht" gelistet.

Bedienbar mit Maus und Tastatur: Klick ausserhalb und Esc schliessen (Esc gibt
den Fokus ans Oberelement zurueck), Pfeil nach unten oeffnet und springt in das
Menue, Pfeiltasten laufen um und ueberspringen deaktivierte Eintraege, Tab
verlaesst das Menue. aria-haspopup, aria-expanded und aria-current sind gesetzt.

Unter 760 px haengen die Menues an der ganzen Leiste statt am Eintrag. Bei
400 px ragte "Module" sonst 35 px ueber den rechten Rand. Ein Nachmessen per
Skript taugt dagegen nicht: In der Mobilansicht waechst der Layout-Viewport mit
dem Ueberstand mit, und die Messung meldet genau dann "passt".

Die Modulansicht heisst jetzt wie ihr Menueeintrag "Moduleinstellungen".

Geprueft auf der Linux-Test-VM im Browser: Alle 12 Eintraege schalten die
richtige Ansicht und heben das richtige Oberelement hervor, das Menue schliesst
danach; Tastaturbedienung, deaktiviertes und unbekanntes Modul (simuliert)
verhalten sich wie beschrieben. Vermessen bei 400, 700, 768 und 1024 px: kein
Menue ragt aus dem Fenster, die Seite scrollt nie waagerecht. 556 von 556 Tests
bestanden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-11 10:33:41 +02:00
co-authored by Claude Opus 5
parent 4c556be57d
commit 2c0ab79dcd
3 changed files with 331 additions and 17 deletions
+142 -4
View File
@@ -6,6 +6,8 @@ let allLogs = [];
// Init on DOM ready
document.addEventListener('DOMContentLoaded', () => {
initNavMenus();
refreshNavModules();
initSseLogs();
refreshDashboard();
loadDashboardAccounts();
@@ -18,12 +20,10 @@ document.addEventListener('DOMContentLoaded', () => {
// Navigation
function switchView(viewName) {
currentView = viewName;
document.querySelectorAll('.nav-link').forEach(btn => btn.classList.remove('active'));
markActiveNav(viewName);
closeNavDropdowns();
document.querySelectorAll('.view-panel').forEach(p => p.classList.remove('active'));
const activeBtn = Array.from(document.querySelectorAll('.nav-link')).find(b => b.getAttribute('onclick')?.includes(viewName));
if (activeBtn) activeBtn.classList.add('active');
const panel = document.getElementById(`view-${viewName}`);
if (panel) panel.classList.add('active');
@@ -49,6 +49,143 @@ function switchView(viewName) {
}
}
// ==================== Kopfleiste: Aufklappmenues (#541) ====================
/**
* Hebt den Eintrag der aktiven Ansicht hervor und dazu das Oberelement seines Menues -
* sonst sieht man bei geschlossenem Menue nicht, wo man ist. Supervisor und Accounting
* stehen zweimal im Menue; das Oberelement leuchtet nur an ihrem Hauptplatz (Analytics),
* der zweite Zugang in der Modulliste traegt data-secondary.
*/
function markActiveNav(viewName) {
document.querySelectorAll('.header-nav [data-view]').forEach(el => {
const on = el.dataset.view === viewName;
el.classList.toggle('active', on);
if (on) el.setAttribute('aria-current', 'page');
else el.removeAttribute('aria-current');
});
document.querySelectorAll('.nav-dropdown').forEach(dd => {
const home = dd.querySelector(`[data-view="${viewName}"]:not([data-secondary])`);
dd.querySelector('.nav-dropdown-toggle').classList.toggle('active', !!home);
});
}
function toggleNavDropdown(toggle) {
const menu = toggle.closest('.nav-dropdown').querySelector('.nav-menu');
const willOpen = menu.hidden;
closeNavDropdowns();
if (willOpen) {
menu.hidden = false;
toggle.setAttribute('aria-expanded', 'true');
}
}
function closeNavDropdowns() {
document.querySelectorAll('.nav-dropdown').forEach(dd => {
dd.querySelector('.nav-menu').hidden = true;
dd.querySelector('.nav-dropdown-toggle').setAttribute('aria-expanded', 'false');
});
}
function navMenuItems(menu) {
return Array.from(menu.querySelectorAll('.nav-menu-item')).filter(b => !b.disabled);
}
function initNavMenus() {
// Klick ausserhalb schliesst. Der Klick auf ein Oberelement selbst laeuft ueber
// toggleNavDropdown und kommt hier mit closest('.nav-dropdown') an - bleibt also offen.
document.addEventListener('click', e => {
if (!e.target.closest('.nav-dropdown')) closeNavDropdowns();
});
document.querySelector('.header-nav')?.addEventListener('keydown', e => {
const dd = e.target.closest('.nav-dropdown');
if (!dd) return;
const toggle = dd.querySelector('.nav-dropdown-toggle');
const menu = dd.querySelector('.nav-menu');
if (e.key === 'Escape' && !menu.hidden) {
e.preventDefault();
closeNavDropdowns();
toggle.focus();
return;
}
// Pfeil nach unten auf dem Oberelement oeffnet und springt in das Menue.
if (e.target === toggle && e.key === 'ArrowDown') {
e.preventDefault();
if (menu.hidden) toggleNavDropdown(toggle);
navMenuItems(menu)[0]?.focus();
return;
}
if (menu.contains(e.target) && ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) {
e.preventDefault();
const items = navMenuItems(menu);
const i = items.indexOf(e.target);
const next = e.key === 'Home' ? 0
: e.key === 'End' ? items.length - 1
: e.key === 'ArrowDown' ? (i + 1) % items.length
: (i - 1 + items.length) % items.length;
items[next]?.focus();
return;
}
// Tab verlaesst das Menue, statt durch alle Eintraege zu wandern.
if (e.key === 'Tab' && !menu.hidden) closeNavDropdowns();
});
}
/**
* Zeigt deaktivierte Module im Menue als deaktiviert und ergaenzt die Modulliste um
* Module, die der Server fuehrt, fuer die es im WebUI aber noch keine Ansicht gibt.
* Ohne Uebergabe wird die Liste selbst geholt; loadModules() reicht ihre mit durch.
*/
async function refreshNavModules(modules) {
if (!modules) {
try {
const res = await fetch('/api/modules');
if (!res.ok) return;
modules = await res.json();
} catch (e) {
return;
}
}
const isActive = new Map(modules.map(m => [m.name.toLowerCase(), m.isActive]));
document.querySelectorAll('.header-nav [data-module]').forEach(item => {
const off = isActive.get(item.dataset.module.toLowerCase()) === false;
item.disabled = off;
item.title = off ? 'Modul ist deaktiviert – siehe Moduleinstellungen' : '';
const hint = item.querySelector('.nav-menu-hint');
if (hint) hint.textContent = off ? 'deaktiviert' : '';
});
const list = document.getElementById('navModuleList');
if (!list) return;
list.querySelectorAll('[data-no-view]').forEach(e => e.remove());
const shown = new Set(Array.from(list.querySelectorAll('[data-module]')).map(e => e.dataset.module.toLowerCase()));
modules.filter(m => !shown.has(m.name.toLowerCase())).forEach(m => {
const item = document.createElement('button');
item.className = 'nav-menu-item';
item.setAttribute('role', 'menuitem');
item.disabled = true;
item.dataset.noView = '1';
item.title = 'Für dieses Modul gibt es im WebUI noch keine eigene Ansicht.';
const label = document.createElement('span');
label.textContent = m.name;
const hint = document.createElement('span');
hint.className = 'nav-menu-hint';
hint.textContent = m.isActive ? 'keine Ansicht' : 'deaktiviert';
item.append(label, hint);
list.appendChild(item);
});
}
// Dashboard Refresh
async function refreshDashboard() {
try {
@@ -337,6 +474,7 @@ async function loadModules() {
const res = await fetch('/api/modules');
if (!res.ok) return;
const modules = await res.json();
refreshNavModules(modules);
const container = document.getElementById('modulesContainer');
container.innerHTML = modules.map(m => {