feat: reveal available updates beside speed
Collapse the header update action completely when no update exists and animate the green action outward to the left of the fixed bandwidth widget only after a real update is detected.
This commit is contained in:
+8
-1
@@ -801,6 +801,7 @@ function _setHeaderUpdateLabel(text) {
|
||||
|
||||
function _syncHeaderUpdateState() {
|
||||
const button = document.getElementById('headerUpdateBtn');
|
||||
const slot = document.getElementById('headerUpdateSlot');
|
||||
const available = !!(_knownUpdateInfo && _knownUpdateInfo.available);
|
||||
const version = available ? String(_knownUpdateInfo.remoteVersion || '').replace(/^v/i, '') : '';
|
||||
const hint = _updateCheckBusy
|
||||
@@ -808,10 +809,15 @@ function _syncHeaderUpdateState() {
|
||||
: available
|
||||
? `Update v${version || 'unbekannt'} verfügbar. Klicken zum Installieren.`
|
||||
: 'Nach Aktualisierungen suchen';
|
||||
if (slot) {
|
||||
slot.classList.toggle('is-visible', available);
|
||||
slot.setAttribute('aria-hidden', available ? 'false' : 'true');
|
||||
}
|
||||
if (button) {
|
||||
button.classList.toggle('update-available', available);
|
||||
button.classList.toggle('is-checking', _updateCheckBusy);
|
||||
button.disabled = _updateCheckBusy;
|
||||
button.disabled = !available || _updateCheckBusy;
|
||||
button.tabIndex = available ? 0 : -1;
|
||||
button.setAttribute('aria-busy', _updateCheckBusy ? 'true' : 'false');
|
||||
button.setAttribute('aria-label', hint);
|
||||
button.title = hint;
|
||||
@@ -843,6 +849,7 @@ async function requestUpdateCheck({ forceRefresh = true } = {}) {
|
||||
} else if (result && result.error) {
|
||||
showCopyToast('Updateprüfung fehlgeschlagen');
|
||||
} else {
|
||||
_knownUpdateInfo = null;
|
||||
showCopyToast('Kein Update verfügbar');
|
||||
}
|
||||
return result;
|
||||
|
||||
+6
-4
@@ -54,6 +54,12 @@
|
||||
</div>
|
||||
<div class="header-spacer" aria-hidden="true"></div>
|
||||
<div class="header-cluster header-utilities">
|
||||
<div class="header-update-slot" id="headerUpdateSlot" aria-hidden="true">
|
||||
<button class="header-update-button" id="headerUpdateBtn" title="Nach Aktualisierungen suchen" aria-label="Nach Aktualisierungen suchen" data-tooltip="Nach Aktualisierungen suchen" tabindex="-1" disabled>
|
||||
<svg class="header-action-icon" aria-hidden="true"><use href="#icon-download"></use></svg>
|
||||
<span class="header-update-label">Update</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="upload-speed-sparkline" id="uploadSpeedSparkline" title="Aktuelle Upload-Geschwindigkeit" aria-label="Aktuelle Upload-Geschwindigkeit">
|
||||
<span class="upload-speed-chart" aria-hidden="true">
|
||||
<span class="upload-speed-baseline"></span>
|
||||
@@ -61,10 +67,6 @@
|
||||
</span>
|
||||
<strong id="uploadSpeedValue">0 B/s</strong>
|
||||
</div>
|
||||
<button class="header-update-button" id="headerUpdateBtn" title="Nach Aktualisierungen suchen" aria-label="Nach Aktualisierungen suchen" data-tooltip="Nach Aktualisierungen suchen">
|
||||
<svg class="header-action-icon" aria-hidden="true"><use href="#icon-download"></use></svg>
|
||||
<span class="header-update-label">Update</span>
|
||||
</button>
|
||||
<span class="header-divider" aria-hidden="true"></span>
|
||||
<nav class="menu-bar" id="menuBar" aria-label="Anwendungsmenüs">
|
||||
<div class="menu-bar-item" data-menu="datei">
|
||||
|
||||
+38
-1
@@ -2109,6 +2109,29 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.header-update-slot {
|
||||
width: 0;
|
||||
flex: 0 0 0;
|
||||
height: 32px;
|
||||
margin-right: -4px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: width .28s cubic-bezier(.22, 1, .36, 1), flex-basis .28s cubic-bezier(.22, 1, .36, 1), margin-right .28s cubic-bezier(.22, 1, .36, 1), opacity .18s ease, visibility 0s linear .28s;
|
||||
}
|
||||
|
||||
.header-update-slot.is-visible {
|
||||
width: 146px;
|
||||
flex-basis: 146px;
|
||||
margin-right: 0;
|
||||
overflow: visible;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.header-update-button {
|
||||
position: relative;
|
||||
width: 146px;
|
||||
@@ -2127,7 +2150,16 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
transition: background-color .14s, transform .14s;
|
||||
opacity: 0;
|
||||
transform: translateX(14px);
|
||||
pointer-events: none;
|
||||
transition: background-color .14s, transform .28s cubic-bezier(.22, 1, .36, 1), opacity .18s ease;
|
||||
}
|
||||
|
||||
.header-update-slot.is-visible .header-update-button {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.header-update-button.update-available {
|
||||
@@ -4040,6 +4072,11 @@ input[type="checkbox"] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header-update-slot.is-visible {
|
||||
width: 34px;
|
||||
flex-basis: 34px;
|
||||
}
|
||||
|
||||
.header-update-label,
|
||||
.version-label {
|
||||
display: none;
|
||||
|
||||
@@ -154,11 +154,16 @@ test('header occupies its final geometry before asynchronous initialization', ()
|
||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(projectRoot, 'renderer', 'styles.css'), 'utf8');
|
||||
const updateButton = html.match(/<button class="header-update-button"[^>]*id="headerUpdateBtn"[^>]*>/u)?.[0] || '';
|
||||
const updateSlotIndex = html.indexOf('class="header-update-slot"');
|
||||
const speedWidgetIndex = html.indexOf('id="uploadSpeedSparkline"');
|
||||
const firstFrameInitialization = appSource.lastIndexOf('\ninitializeStaticHeader();');
|
||||
const asynchronousInitialization = appSource.lastIndexOf('\ninit().then(');
|
||||
|
||||
assert.doesNotMatch(updateButton, /\shidden(?:\s|>)/u);
|
||||
assert.match(css, /\.header-update-button\s*\{[^}]*width:\s*146px;[^}]*flex:\s*0 0 146px;/su);
|
||||
assert.ok(updateSlotIndex >= 0 && updateSlotIndex < speedWidgetIndex);
|
||||
assert.match(css, /\.header-update-slot\s*\{[^}]*width:\s*0;[^}]*flex:\s*0 0 0;[^}]*overflow:\s*hidden;/su);
|
||||
assert.match(css, /\.header-update-slot\.is-visible\s*\{[^}]*width:\s*146px;[^}]*flex-basis:\s*146px;/su);
|
||||
assert.match(css, /\.header-update-slot\.is-visible\s+\.header-update-button\s*\{[^}]*opacity:\s*1;[^}]*transform:\s*translateX\(0\);/su);
|
||||
assert.match(css, /\.version-badge\s*\{[^}]*min-width:\s*48px;/su);
|
||||
assert.notEqual(firstFrameInitialization, -1);
|
||||
assert.ok(firstFrameInitialization < asynchronousInitialization);
|
||||
|
||||
+10
-10
@@ -185,8 +185,8 @@ setTimeout(async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const startupUpdateState = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [_knownUpdateInfo?.remoteVersion, button?.hidden, getComputedStyle(button).display, document.getElementById("updateBanner")?.style.display].join("|"); })()');
|
||||
check('Startup update survives pending renderer initialization', startupUpdateState === '9.9.8|false|flex|flex');
|
||||
const startupUpdateState = await wc.executeJavaScript('(() => { const slot = document.getElementById("headerUpdateSlot"); return [_knownUpdateInfo?.remoteVersion, slot?.classList.contains("is-visible"), slot?.getAttribute("aria-hidden"), document.getElementById("updateBanner")?.style.display].join("|"); })()');
|
||||
check('Startup update survives pending renderer initialization', startupUpdateState === '9.9.8|true|false|flex');
|
||||
await wc.executeJavaScript('_knownUpdateInfo = null; closeUpdateDialog(); _syncHeaderUpdateState();');
|
||||
|
||||
const germanStartupReady = await waitUntil(() => wc.executeJavaScript('document.documentElement.lang + "|" + document.getElementById("languageInput")?.value + "|" + [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(",")'));
|
||||
@@ -290,8 +290,8 @@ setTimeout(async () => {
|
||||
const headerUpdateButtonExists = await wc.executeJavaScript('Boolean(document.getElementById("headerUpdateBtn"))');
|
||||
check('App header exposes the update action', headerUpdateButtonExists);
|
||||
|
||||
const initialHeaderUpdateVisibility = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.hidden, getComputedStyle(button).display].join("|"); })()');
|
||||
check('Header update action keeps its layout slot before an update is available', initialHeaderUpdateVisibility === 'false|flex');
|
||||
const initialHeaderUpdateVisibility = await wc.executeJavaScript('(() => { const slot = document.getElementById("headerUpdateSlot"); const button = document.getElementById("headerUpdateBtn"); return [slot?.classList.contains("is-visible"), slot?.getAttribute("aria-hidden"), Math.round(slot?.getBoundingClientRect().width || 0), button?.disabled, button?.tabIndex].join("|"); })()');
|
||||
check('Header update action is fully collapsed before an update is available', initialHeaderUpdateVisibility === 'false|true|0|true|-1');
|
||||
|
||||
const initialUpdateLabel = await wc.executeJavaScript('document.querySelector("#headerUpdateBtn .header-update-label")?.textContent?.trim()');
|
||||
check('App header uses the compact update label', initialUpdateLabel === 'Update');
|
||||
@@ -1198,20 +1198,20 @@ setTimeout(async () => {
|
||||
document.getElementById('headerUpdateBtn').click();
|
||||
})()\`);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const coordinatedUpdateBusy = await wc.executeJavaScript('(() => { const manual = document.getElementById("manualUpdateCheckBtn"); const header = document.getElementById("headerUpdateBtn"); return [manual?.disabled, manual?.getAttribute("aria-busy"), manual?.textContent?.trim(), header?.disabled, header?.getAttribute("aria-busy"), header?.hidden].join("|"); })()');
|
||||
check('All update entry points share one in-flight check', updateCheckCallCount === 1 && coordinatedUpdateBusy === 'true|true|Prüfe…|true|true|false');
|
||||
const coordinatedUpdateBusy = await wc.executeJavaScript('(() => { const manual = document.getElementById("manualUpdateCheckBtn"); const header = document.getElementById("headerUpdateBtn"); const slot = document.getElementById("headerUpdateSlot"); return [manual?.disabled, manual?.getAttribute("aria-busy"), manual?.textContent?.trim(), header?.disabled, header?.getAttribute("aria-busy"), slot?.classList.contains("is-visible")].join("|"); })()');
|
||||
check('All update entry points share one in-flight check without revealing the header action', updateCheckCallCount === 1 && coordinatedUpdateBusy === 'true|true|Prüfe…|true|true|false');
|
||||
updateCheckResolvers.splice(0).forEach(resolve => resolve({ available: false, error: 'Simulierter Netzwerkfehler' }));
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
const coordinatedUpdateError = await wc.executeJavaScript('(() => { const manual = document.getElementById("manualUpdateCheckBtn"); const header = document.getElementById("headerUpdateBtn"); return [manual?.disabled, header?.disabled, header?.hidden, manual?.textContent?.trim(), document.getElementById("copyToast")?.textContent?.trim()].join("|"); })()');
|
||||
check('Settings update check uses the shared error contract', coordinatedUpdateError === 'false|false|false|Nach Updates suchen|Updateprüfung fehlgeschlagen');
|
||||
const coordinatedUpdateError = await wc.executeJavaScript('(() => { const manual = document.getElementById("manualUpdateCheckBtn"); const header = document.getElementById("headerUpdateBtn"); const slot = document.getElementById("headerUpdateSlot"); return [manual?.disabled, header?.disabled, slot?.classList.contains("is-visible"), manual?.textContent?.trim(), document.getElementById("copyToast")?.textContent?.trim()].join("|"); })()');
|
||||
check('Settings update check uses the shared error contract', coordinatedUpdateError === 'false|true|false|Nach Updates suchen|Updateprüfung fehlgeschlagen');
|
||||
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
|
||||
|
||||
await wc.executeJavaScript('requestUpdateCheck(); true');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
updateCheckResolvers.splice(0).forEach(resolve => resolve({ available: false }));
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
const noUpdateHeaderVisibility = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.hidden, getComputedStyle(button).display].join("|"); })()');
|
||||
check('Successful no-update result keeps the header action in its stable slot', noUpdateHeaderVisibility === 'false|flex');
|
||||
const noUpdateHeaderVisibility = await wc.executeJavaScript('(() => { const slot = document.getElementById("headerUpdateSlot"); const button = document.getElementById("headerUpdateBtn"); return [slot?.classList.contains("is-visible"), Math.round(slot?.getBoundingClientRect().width || 0), button?.disabled].join("|"); })()');
|
||||
check('Successful no-update result keeps the header action collapsed', noUpdateHeaderVisibility === 'false|0|true');
|
||||
|
||||
const settingsNavigation = await wc.executeJavaScript('(() => { const buttons = [...document.querySelectorAll(".settings-nav-button")]; return [buttons.length, buttons.map(button => button.textContent.trim()).join("|"), document.querySelector(".settings-nav-button.active")?.dataset.settingsPage, document.getElementById("settingsSearchInput")?.placeholder].join("::"); })()');
|
||||
check('Settings use the task-based sidebar navigation', settingsNavigation === '8::Allgemein|Uploads|Automatik|Benachrichtigungen|Logs & Support|Fernsteuerung|Diagnose-Zugriff|Backup & Übertragen::allgemein::Einstellungen durchsuchen');
|
||||
|
||||
Reference in New Issue
Block a user