Fix backup submenu hover gaps and keyboard interactions
CI / verify (push) Waiting to run

This commit is contained in:
Sucukdeluxe
2026-09-22 05:38:13 +02:00
parent 0f2348ffb2
commit f99260372b
4 changed files with 125 additions and 8 deletions
+2
View File
@@ -6,6 +6,7 @@ Multi-Hoster-Upload ist eine Electron-Desktopanwendung für Windows, die große
## Aktueller Zustand ## Aktueller Zustand
- Nach `2.1.49`, noch nicht veröffentlicht: Backup-Untermenü erhält eine durchgängige Hover-Fläche über den sichtbaren Abstand und 250 ms abbrechbare Schließverzögerung. Hover plus Klick bleibt offen, erneutes Betreten startet keine neue Öffnungsanimation; Tastaturfokus hält das Untermenü offen. Pfeil und Pfeiltasten folgen der linken Öffnungsrichtung, Escape schließt nur die aktuelle Untermenüebene. Fremde `animationend`-Ereignisse schließen kein übergeordnetes Panel mehr. Electron-Regression prüft DOM-Treffertests im Abstand, verzögerte Ein-/Austritte, Klick, Fokus und Escape bei 850 und 600 Pixel Fensterbreite. Kein Release beauftragt.
- Release `2.1.49` auf beiden Plattformen veröffentlicht und geprüft: Vidmoly-Sitzungsprüfung, getrennte Upload-Sperrmeldungen, Checkbox-Fokusfix und GB-Dateigrößenlimit. Live-Diagnose am 22.09.2026 bestätigt zuerst gültige Anmeldung mit `UPLOAD_DISABLED` (Upload-Konfiguration HTTP 403), danach erfolgreiche Anmeldung und vollständige Upload-Konfiguration (HTTP 200). Nutzer bestätigt funktionierende Anmeldung und gibt Release frei. Ein tatsächlicher Dateitransfer wurde dadurch nicht nachgewiesen. Ältere Angaben zu unveröffentlichten Änderungen darunter dokumentieren die Vorbereitung; diese Änderungen sind jetzt in `2.1.49` enthalten. - Release `2.1.49` auf beiden Plattformen veröffentlicht und geprüft: Vidmoly-Sitzungsprüfung, getrennte Upload-Sperrmeldungen, Checkbox-Fokusfix und GB-Dateigrößenlimit. Live-Diagnose am 22.09.2026 bestätigt zuerst gültige Anmeldung mit `UPLOAD_DISABLED` (Upload-Konfiguration HTTP 403), danach erfolgreiche Anmeldung und vollständige Upload-Konfiguration (HTTP 200). Nutzer bestätigt funktionierende Anmeldung und gibt Release frei. Ein tatsächlicher Dateitransfer wurde dadurch nicht nachgewiesen. Ältere Angaben zu unveröffentlichten Änderungen darunter dokumentieren die Vorbereitung; diese Änderungen sind jetzt in `2.1.49` enthalten.
- Vidmoly nach `2.1.48`, noch nicht veröffentlicht: Anmeldung wird über `/api/auth/me` statt anhand der Upload-Konfiguration verifiziert. Öffentliches Website-JavaScript am 22.09.2026 bestätigt unveränderte Upload-Felder sowie `DISK_FULL`, `UPLOAD_IP_BLACKLIST` und `UPLOAD_DISABLED`; diese Antworten werden jetzt getrennt und ohne Antwortdaten angezeigt. Upload übernimmt zusätzlich das aktuelle Webfeld `tos=1`, weiterhin ohne API-Key. HTTP-Fehler, fehlende Benutzer, OTP-Anforderung und ungültige Serveradressen werden geprüft. Die konkrete Serverantwort des gemeldeten Accounts war in den lokalen Logs nicht vorhanden; erfolgreicher Live-Login/Upload ist noch nicht bestätigt. - Vidmoly nach `2.1.48`, noch nicht veröffentlicht: Anmeldung wird über `/api/auth/me` statt anhand der Upload-Konfiguration verifiziert. Öffentliches Website-JavaScript am 22.09.2026 bestätigt unveränderte Upload-Felder sowie `DISK_FULL`, `UPLOAD_IP_BLACKLIST` und `UPLOAD_DISABLED`; diese Antworten werden jetzt getrennt und ohne Antwortdaten angezeigt. Upload übernimmt zusätzlich das aktuelle Webfeld `tos=1`, weiterhin ohne API-Key. HTTP-Fehler, fehlende Benutzer, OTP-Anforderung und ungültige Serveradressen werden geprüft. Die konkrete Serverantwort des gemeldeten Accounts war in den lokalen Logs nicht vorhanden; erfolgreicher Live-Login/Upload ist noch nicht bestätigt.
- Nach `2.1.48` lokal geändert, noch nicht veröffentlicht: Checkboxen in den Hoster-Einstellungen übernehmen beim Mausklick nicht mehr den Fokus-Schatten von Textfeldern. Tastatur-Fokusmarkierung bleibt erhalten; echter isolierter Electron-Test prüft An-/Abhaken, Tabulator, Leertaste und unveränderten Zahlenfeld-Fokus. - Nach `2.1.48` lokal geändert, noch nicht veröffentlicht: Checkboxen in den Hoster-Einstellungen übernehmen beim Mausklick nicht mehr den Fokus-Schatten von Textfeldern. Tastatur-Fokusmarkierung bleibt erhalten; echter isolierter Electron-Test prüft An-/Abhaken, Tabulator, Leertaste und unveränderten Zahlenfeld-Fokus.
@@ -88,6 +89,7 @@ npm audit --omit=dev
## Zuletzt verifiziert ## Zuletzt verifiziert
- Backup-Untermenü nach `2.1.49`: 838 Haupttests, 18 Servertests und Lint erfolgreich. Gezielter Electron-Test nach abschließender Fokuskorrektur erneut erfolgreich; ein Mausklick auf den Trigger hält das Menü beim Verlassen nicht unbeabsichtigt offen. Änderungen noch nicht veröffentlicht.
- Release `2.1.49`: Titel, Tags, englischer GitHub- und deutscher Forgejo-Changelog, jeweils vier Assets und Update-Metadaten geprüft. Alle acht veröffentlichten Dateien erneut heruntergeladen und Größe sowie SHA-512 mit lokalen Dateien verglichen. Echter Updater erkennt `2.1.49` ausgehend von `2.1.48` und akzeptiert englische Hinweise sowie Forgejo-Manifest. Öffentlicher Quellcheck einschließlich Screenshots erfolgreich (164 Dateien). Keine produktive Serveränderung erforderlich. - Release `2.1.49`: Titel, Tags, englischer GitHub- und deutscher Forgejo-Changelog, jeweils vier Assets und Update-Metadaten geprüft. Alle acht veröffentlichten Dateien erneut heruntergeladen und Größe sowie SHA-512 mit lokalen Dateien verglichen. Echter Updater erkennt `2.1.49` ausgehend von `2.1.48` und akzeptiert englische Hinweise sowie Forgejo-Manifest. Öffentlicher Quellcheck einschließlich Screenshots erfolgreich (164 Dateien). Keine produktive Serveränderung erforderlich.
- Releasevorbereitung `2.1.49`: 837 Haupttests und 18 Backup-API-Tests erfolgreich, Lint erfolgreich, Produktionsaudit ohne Schwachstellen. Installer 98.033.241 Bytes, Portable 97.808.535 Bytes, beide Produktversion `2.1.49`. Gepackte Vidmoly-, Upload-, UI- und Backupmodule entsprechen dem Quellstand; keine Nutzerdaten enthalten. Installer-SHA-512 und Größe stimmen mit dem Update-Manifest überein. - Releasevorbereitung `2.1.49`: 837 Haupttests und 18 Backup-API-Tests erfolgreich, Lint erfolgreich, Produktionsaudit ohne Schwachstellen. Installer 98.033.241 Bytes, Portable 97.808.535 Bytes, beide Produktversion `2.1.49`. Gepackte Vidmoly-, Upload-, UI- und Backupmodule entsprechen dem Quellstand; keine Nutzerdaten enthalten. Installer-SHA-512 und Größe stimmen mit dem Update-Manifest überein.
- Vidmoly-Änderung vom 22.09.2026: alle sieben neuen Regressionen und Lint erfolgreich. Hauptlauf: 836 von 837 Tests erfolgreich; bestehender Electron-Checkbox-Fokustest einmal mit Timingfehler, separater Wiederholungslauf der gesamten Datei mit 23 von 23 erfolgreich. Backup-API weiterhin 18 von 18 erfolgreich. Öffentlicher Quellcheck umfasst 164 Dateien. Keine Live-Anmeldung und kein Release durchgeführt. - Vidmoly-Änderung vom 22.09.2026: alle sieben neuen Regressionen und Lint erfolgreich. Hauptlauf: 836 von 837 Tests erfolgreich; bestehender Electron-Checkbox-Fokustest einmal mit Timingfehler, separater Wiederholungslauf der gesamten Datei mit 23 von 23 erfolgreich. Backup-API weiterhin 18 von 18 erfolgreich. Öffentlicher Quellcheck umfasst 164 Dateien. Keine Live-Anmeldung und kein Release durchgeführt.
+22 -7
View File
@@ -1585,7 +1585,8 @@ function initMenuBar() {
panel.classList.remove('menu-opening', 'menu-closing'); panel.classList.remove('menu-opening', 'menu-closing');
void panel.offsetHeight; void panel.offsetHeight;
panel.classList.add('menu-closing'); panel.classList.add('menu-closing');
const finish = () => { const finish = (event) => {
if (event && event.target !== panel) return;
if (!Object.is(panelTokens.get(panel), token)) return; if (!Object.is(panelTokens.get(panel), token)) return;
panel.style.display = 'none'; panel.style.display = 'none';
panel.classList.remove('menu-closing'); panel.classList.remove('menu-closing');
@@ -1623,30 +1624,44 @@ function initMenuBar() {
menuBar.querySelectorAll('.menu-submenu').forEach(sm => { menuBar.querySelectorAll('.menu-submenu').forEach(sm => {
const sub = sm.querySelector('.menu-submenu-dropdown'); const sub = sm.querySelector('.menu-submenu-dropdown');
const trigger = sm.querySelector('.menu-submenu-trigger'); const trigger = sm.querySelector('.menu-submenu-trigger');
let closeTimer = null;
const cancelClose = () => { clearTimeout(closeTimer); closeTimer = null; };
const opensLeft = () => window.getComputedStyle(sub).right !== 'auto';
const openSubmenu = (focusFirst = false) => { const openSubmenu = (focusFirst = false) => {
openPanel(sub); cancelClose();
if (trigger?.getAttribute('aria-expanded') !== 'true') openPanel(sub);
trigger?.setAttribute('aria-expanded', 'true'); trigger?.setAttribute('aria-expanded', 'true');
if (focusFirst) requestAnimationFrame(() => sub.querySelector('[data-menu-action]')?.focus()); if (focusFirst) requestAnimationFrame(() => sub.querySelector('[data-menu-action]')?.focus());
}; };
const closeSubmenu = () => { const closeSubmenu = () => {
cancelClose();
closePanel(sub); closePanel(sub);
trigger?.setAttribute('aria-expanded', 'false'); trigger?.setAttribute('aria-expanded', 'false');
}; };
sm.addEventListener('mouseenter', () => openSubmenu()); sm.addEventListener('mouseenter', () => openSubmenu());
sm.addEventListener('mouseleave', closeSubmenu); sm.addEventListener('mouseleave', () => {
cancelClose();
closeTimer = setTimeout(() => {
if (!sub.contains(document.activeElement)) closeSubmenu();
}, 250);
});
sm.addEventListener('focusin', cancelClose);
sm.addEventListener('focusout', (event) => {
if (!sm.contains(event.relatedTarget) && !sm.matches(':hover')) closeSubmenu();
});
trigger?.addEventListener('click', (event) => { trigger?.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
if (trigger.getAttribute('aria-expanded') === 'true') closeSubmenu(); openSubmenu();
else openSubmenu();
}); });
trigger?.addEventListener('keydown', (event) => { trigger?.addEventListener('keydown', (event) => {
if (!['Enter', ' ', 'ArrowRight', 'ArrowDown'].includes(event.key)) return; if (!['Enter', ' ', opensLeft() ? 'ArrowLeft' : 'ArrowRight', 'ArrowDown'].includes(event.key)) return;
event.preventDefault(); event.preventDefault();
openSubmenu(true); openSubmenu(true);
}); });
sub.addEventListener('keydown', (event) => { sub.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' && event.key !== 'ArrowLeft') return; if (event.key !== 'Escape' && event.key !== (opensLeft() ? 'ArrowRight' : 'ArrowLeft')) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation();
closeSubmenu(); closeSubmenu();
trigger?.focus(); trigger?.focus();
}); });
+19 -1
View File
@@ -130,7 +130,7 @@ body {
.menu-submenu-trigger:hover { background: var(--bg-card-hover); } .menu-submenu-trigger:hover { background: var(--bg-card-hover); }
.menu-submenu-trigger::after { .menu-submenu-trigger::after {
content: "\25B6"; content: "\25B6";
font-size: 8px; font-size: 10px;
color: var(--text-dim); color: var(--text-dim);
margin-left: 12px; margin-left: 12px;
} }
@@ -2754,6 +2754,24 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
right: calc(100% + 6px); right: calc(100% + 6px);
} }
.header-utilities [data-menu="datei"] .menu-submenu-trigger::after {
content: "\25C0";
}
.menu-submenu:has(> .menu-submenu-trigger[aria-expanded="true"])::after {
content: "";
position: absolute;
top: 0;
left: 100%;
width: 8px;
height: 100%;
}
.header-utilities [data-menu="datei"] .menu-submenu::after {
left: auto;
right: 100%;
}
.menu-dropdown-item, .menu-dropdown-item,
.menu-submenu-trigger { .menu-submenu-trigger {
min-height: 34px; min-height: 34px;
+82
View File
@@ -82,6 +82,88 @@ app.whenReady().then(async () => {
assert.equal(data.ok, true); assert.equal(data.ok, true);
}); });
test('backup submenu survives slow gap crossing, hover clicks and keyboard navigation', { skip: process.platform !== 'win32' }, t => {
const root = path.resolve(__dirname, '..');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-menu-hover-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const probe = path.join(directory, 'probe.cjs');
const result = path.join(directory, 'result.json');
fs.writeFileSync(probe, `
const { app, BrowserWindow } = require('electron');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
app.whenReady().then(async () => {
const root = process.env.MHU_MENU_ROOT;
const css = fs.readFileSync(path.join(root, 'renderer/styles.css'), 'utf8');
const html = fs.readFileSync(path.join(root, 'renderer/index.html'), 'utf8');
const source = fs.readFileSync(path.join(root, 'renderer/app.js'), 'utf8');
const init = source.slice(source.indexOf('function initMenuBar()'), source.indexOf('async function _handleMenuAction('));
const nav = html.match(/<nav class="menu-bar"[\\s\\S]*?<\\/nav>/)[0];
const win = new BrowserWindow({ show: false, width: 850, height: 650, webPreferences: { backgroundThrottling: false } });
const wc = win.webContents;
await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent('<style>' + css + '</style><div class="header-utilities" style="position:absolute;right:20px;top:30px">' + nav + '</div><button id="outside">Outside</button>'));
await wc.executeJavaScript('function _initMenuSettingsControls() {} function _syncMenuSettings() {} ' + init + '; initMenuBar();');
const js = code => wc.executeJavaScript(code);
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const state = () => js('(() => { const t=document.querySelector(".menu-submenu-trigger"); const s=document.querySelector(".menu-submenu-dropdown"); return { expanded:t.getAttribute("aria-expanded"), display:getComputedStyle(s).display, closing:s.classList.contains("menu-closing") }; })()');
const move = async (x, y) => {
await js('(() => { const hit=document.elementFromPoint(' + x + ',' + y + '); const next=hit?.closest(".menu-submenu"); const previous=window.testHoverMenu; if(previous!==next) { previous?.dispatchEvent(new MouseEvent("mouseleave",{relatedTarget:hit})); next?.dispatchEvent(new MouseEvent("mouseenter")); window.testHoverMenu=next; } })()');
await wait(60);
};
wc.focus();
for (const width of [850, 600]) {
win.setSize(width, 650);
await js('document.querySelector("[data-menu-trigger=datei]").click()');
await wait(240);
await js('document.getAnimations().forEach(animation=>animation.finish())');
const rect = await js('(() => { const r=document.querySelector(".menu-submenu-trigger").getBoundingClientRect(); return {left:r.left,top:r.top,width:r.width,height:r.height}; })()');
await move(rect.left + rect.width / 2, rect.top + rect.height / 2);
await wait(240);
await js('document.getAnimations().forEach(animation=>animation.finish())');
assert.equal((await state()).expanded, 'true', JSON.stringify({ rect, hit:await js('document.elementFromPoint(' + (rect.left + rect.width / 2) + ',' + (rect.top + rect.height / 2) + ')?.outerHTML') }));
await js('document.querySelector(".menu-submenu-trigger").click()');
assert.equal((await state()).expanded, 'true');
await move(rect.left - 3, rect.top + rect.height / 2);
await wait(350);
assert.equal((await state()).expanded, 'true');
const target = await js('(() => { const r=document.querySelector("[data-menu-action=backup-export]").getBoundingClientRect(); return {x:r.left+r.width/2,y:r.top+r.height/2}; })()');
await move(target.x, target.y);
assert.equal((await state()).closing, false);
await js('document.querySelector(".menu-submenu").dispatchEvent(new MouseEvent("mouseleave"))');
await wait(80);
await js('document.querySelector(".menu-submenu").dispatchEvent(new MouseEvent("mouseenter"))');
await wait(300);
assert.equal((await state()).expanded, 'true');
await js('document.querySelector(".menu-submenu-trigger").focus()');
await move(30, 400);
await wait(500);
assert.equal((await state()).display, 'none');
await js('document.querySelector(".menu-submenu-trigger").focus();document.querySelector(".menu-submenu-trigger").dispatchEvent(new KeyboardEvent("keydown",{key:"ArrowLeft",bubbles:true}))');
await wait(240);
assert.equal(await js('document.activeElement.dataset.menuAction'), 'backup-export');
await js('document.activeElement.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true}))');
await wait(240);
assert.equal((await state()).display, 'none');
assert.equal(await js('getComputedStyle(document.querySelector("[data-menu-dropdown=datei]")).display !== "none"'), true);
assert.equal(await js('document.activeElement.classList.contains("menu-submenu-trigger")'), true);
await js('document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true}))');
await wait(240);
}
win.destroy();
fs.writeFileSync(process.env.MHU_MENU_RESULT, JSON.stringify({ ok:true }));
app.exit(0);
}).catch(error => { fs.writeFileSync(process.env.MHU_MENU_RESULT, JSON.stringify({ error:error.stack })); app.exit(1); });
`);
const execution = spawnSync(path.join(root, 'node_modules/electron/dist/electron.exe'), [probe, '--user-data-dir=' + path.join(directory, 'profile')], {
cwd: root, windowsHide: true, encoding: 'utf8', timeout: 20000,
env: { ...process.env, MHU_MENU_ROOT: root, MHU_MENU_RESULT: result }
});
const data = fs.existsSync(result) ? JSON.parse(fs.readFileSync(result, 'utf8')) : {};
assert.equal(execution.status, 0, data.error || execution.stderr);
assert.equal(data.ok, true);
});
class TestBrowserWindow extends EventEmitter { class TestBrowserWindow extends EventEmitter {
constructor(options) { constructor(options) {
super(); super();