mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* fix(web): harden, polish and optimize the web UI per the September 2026 audit Works through docs/archive/WEB_UI_AUDIT_2026-09.md (health 8/20). Implementation integrity (P0) - app.css now defines every utility class the templates and JS use, including .hidden, so the ~145 JS show/hide toggles work. Button reset, and base component rules (.btn, .form-control) wrapped in :where() so utility classes on the same element win. New static-audit test fails when a used utility class has no rule. Accessibility - Focus rings render (the old ring rule referenced undefined variables); one :focus-visible outline everywhere; skip link; labelled nav landmarks. - Shared dialog helper (js/utils/dialog.js): role/aria-modal, focus trap, Escape, focus return, applied to every modal. - Named icon-only buttons and labelled ~70 form fields. - Toasts announced once; errors persist >= 10s; one showNotification. - Captive WiFi page: live region, timeouts, dark mode, 16px inputs. Performance (Pi Zero 2 W) - SSE streams and tab timers pause when hidden or off-tab; the display stream only runs while a preview is visible. app-shell.js deferred. - Widget scripts served as one versioned bundle (/assets/widgets.js): 52 -> 21 script tags, 66 -> 35 requests on first load. - Stdlib gzip fallback when flask-compress is missing: first-load JS/CSS 1358 KB -> 291 KB on the wire. SSE untouched. Theming and responsive - File managers, form fields and Fonts upload on theme tokens; bare inputs themed in dark mode; no more white surfaces. - No horizontal overflow at 375px on any tab; 44px touch targets on coarse pointers; reduced-motion respected; header title truncates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): clear Codacy findings on #568 - json-file-manager: focus-trap releases kept in a Map (no dynamic property access or delete; no value-returning forEach callback) - notification / schedule-picker: style and day-label lookups via Map - app.js: move the pending-queue assignment out of the expression - diff_viewer / error_handler: named function declarations instead of arrow consts No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: check the OAuth widget ships in the widget bundle base.html no longer tags widget scripts one by one; they load through /assets/widgets.js. Assert the page requests the bundle and the bundle contains google-oauth.js, which is what the test was protecting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): address review feedback on #568 - widget bundle version fingerprints every file (name, mtime_ns, size) - gzip fallback appends Accept-Encoding to an existing Vary header - dialog helper: releasing a non-top dialog no longer moves focus out of the dialog the user is in - labels: file-upload targets its file input; fallback config fields get label for/id pairs; native color input has a fallback name - utility audit also reads class names inside bound :class expressions Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): give the native color-picker input an accessible name CodeRabbit flagged this on PR #568 as an outside-diff finding (never posted inline, so it was missed in the round of fixes that addressed the other 6 review comments). The <input type="color"> only carried a title attribute; screen readers don't reliably announce title, and there's no other label naming the control when showHexInput is false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): clear Codacy findings in app-shell.js - drop the unused catch binding on the SSE JSON parse - move the pending-notification queue assignment out of the expression No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): contain plugin widgets/ dir and bound style-editor retries From CodeRabbit review on #568 (code that arrived with the main merge): - serve_plugin_widget resolves widgets/ with resolve_under before resolving the manifest script under it, so a symlinked widgets directory can't become the containment base (CWE-22). New test. - style-editor init stops polling after ~10s when the widget never registers and leaves the plain fallback fields in place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
560 lines
22 KiB
JavaScript
560 lines
22 KiB
JavaScript
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx, debugLog */
|
|
// LED Matrix v3 JavaScript
|
|
// Additional helpers for HTMX and Alpine.js integration
|
|
|
|
// Global notification system — implemented by widgets/notification.js.
|
|
// This fallback only exists if nothing defined showNotification earlier; it
|
|
// hands off to the widget when registered, otherwise queues the message for
|
|
// the widget to show once it loads.
|
|
if (typeof window.showNotification !== 'function') {
|
|
window.showNotification = function(message, type = 'info') {
|
|
const registry = window.LEDMatrixWidgets;
|
|
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
|
|
if (widget && typeof widget.show === 'function') {
|
|
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
|
|
}
|
|
if (!Array.isArray(window.__pendingNotifications)) {
|
|
window.__pendingNotifications = [];
|
|
}
|
|
window.__pendingNotifications.push([message, type]);
|
|
document.dispatchEvent(new CustomEvent('show-notification', {
|
|
detail: { message, type }
|
|
}));
|
|
// User-facing last resort, so never gated
|
|
console.info(`${(type && type.type) || type}: ${message}`);
|
|
};
|
|
}
|
|
|
|
// HTMX response handlers
|
|
document.body.addEventListener('htmx:beforeRequest', function(event) {
|
|
// Show loading states for buttons
|
|
const btn = event.target.closest('button, .btn');
|
|
if (btn) {
|
|
btn.classList.add('loading');
|
|
const textEl = btn.querySelector('.btn-text');
|
|
if (textEl) textEl.style.opacity = '0.5';
|
|
}
|
|
});
|
|
|
|
document.body.addEventListener('htmx:afterRequest', function(event) {
|
|
// Remove loading states
|
|
const btn = event.target.closest('button, .btn');
|
|
if (btn) {
|
|
btn.classList.remove('loading');
|
|
const textEl = btn.querySelector('.btn-text');
|
|
if (textEl) textEl.style.opacity = '1';
|
|
}
|
|
|
|
// Handle response notifications
|
|
const response = event.detail.xhr;
|
|
if (response && response.responseText) {
|
|
try {
|
|
const data = JSON.parse(response.responseText);
|
|
if (data.message) {
|
|
showNotification(data.message, data.status || 'info');
|
|
}
|
|
} catch {
|
|
// Not JSON, ignore
|
|
}
|
|
}
|
|
|
|
// Main-config saves (display hardware, rotation/durations, general) only
|
|
// take effect after a display-service restart — surface the reminder
|
|
// banner. Plugin config saves apply live and are deliberately excluded.
|
|
try {
|
|
const cfg = event.detail.requestConfig;
|
|
if (cfg && cfg.verb === 'post' &&
|
|
(cfg.path || '').includes('/api/v3/config/main') &&
|
|
response && response.status >= 200 && response.status < 300) {
|
|
window.showRestartPending();
|
|
}
|
|
} catch { /* banner is best-effort */ }
|
|
});
|
|
|
|
// ===== Unsaved-changes guard =====
|
|
// Plugin config panels are Alpine x-if templates: navigating away DESTROYS
|
|
// the panel and revisiting re-fetches it, silently discarding any edits.
|
|
// (System tabs use x-show + data-loaded and persist, so they're exempt.)
|
|
// Track dirty forms and confirm before a lossy navigation.
|
|
(function() {
|
|
function markDirty(e) {
|
|
const form = e.target && e.target.closest ? e.target.closest('form') : null;
|
|
if (form) form.setAttribute('data-dirty', '');
|
|
}
|
|
document.body.addEventListener('input', markDirty);
|
|
document.body.addEventListener('change', markDirty);
|
|
|
|
// A successful submit makes the form clean again
|
|
document.body.addEventListener('htmx:afterRequest', function(event) {
|
|
const xhr = event.detail.xhr;
|
|
const form = event.detail.elt && event.detail.elt.closest ? event.detail.elt.closest('form') : null;
|
|
if (form && xhr && xhr.status >= 200 && xhr.status < 300) {
|
|
form.removeAttribute('data-dirty');
|
|
}
|
|
});
|
|
|
|
// Capture phase so this runs before Alpine's bubbling @click switches tabs
|
|
document.addEventListener('click', function(e) {
|
|
const tabBtn = e.target && e.target.closest ? e.target.closest('.nav-tab') : null;
|
|
if (!tabBtn) return;
|
|
const lossy = Array.prototype.filter.call(
|
|
document.querySelectorAll('.plugin-config-tab form[data-dirty]'),
|
|
function(f) { return f.offsetParent !== null; }
|
|
);
|
|
if (lossy.length === 0) return;
|
|
if (!window.confirm('You have unsaved plugin settings — leaving this page will discard them. Leave anyway?')) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
}
|
|
}, true);
|
|
|
|
// Full page unload loses every panel's edits
|
|
window.addEventListener('beforeunload', function(e) {
|
|
const dirty = Array.prototype.some.call(
|
|
document.querySelectorAll('form[data-dirty]'),
|
|
function(f) { return f.offsetParent !== null; }
|
|
);
|
|
if (dirty) {
|
|
e.preventDefault();
|
|
e.returnValue = '';
|
|
}
|
|
});
|
|
})();
|
|
|
|
// ===== Restart-pending banner =====
|
|
// Shown after restart-requiring saves; persists across tab switches (and
|
|
// reloads, via sessionStorage) until the display restarts or it's dismissed.
|
|
window.showRestartPending = function(message) {
|
|
try {
|
|
sessionStorage.setItem('ledmatrix-restart-pending', '1');
|
|
// Persisted alongside the flag: a code update and a config save want
|
|
// different wording, and the banner outlives the page that raised it.
|
|
if (message) sessionStorage.setItem('ledmatrix-restart-pending-text', message);
|
|
else sessionStorage.removeItem('ledmatrix-restart-pending-text');
|
|
} catch { /* private browsing */ }
|
|
const banner = document.getElementById('restart-pending-banner');
|
|
const text = document.getElementById('restart-pending-text');
|
|
if (text) {
|
|
// Without the else-branch a config save inherited whatever wording the
|
|
// previous update left in the DOM: showRestartPending() clears the
|
|
// stored text but used to leave the element itself alone. The default
|
|
// is read back from the server-rendered copy rather than duplicated
|
|
// here, so the template stays the one place that owns the string.
|
|
if (text.dataset.defaultText === undefined) {
|
|
text.dataset.defaultText = text.textContent.trim();
|
|
}
|
|
text.textContent = message || text.dataset.defaultText;
|
|
}
|
|
if (banner) banner.style.display = 'block';
|
|
};
|
|
|
|
window.dismissRestartPending = function() {
|
|
try {
|
|
sessionStorage.removeItem('ledmatrix-restart-pending');
|
|
sessionStorage.removeItem('ledmatrix-restart-pending-text');
|
|
} catch { /* no-op */ }
|
|
const banner = document.getElementById('restart-pending-banner');
|
|
if (banner) banner.style.display = 'none';
|
|
};
|
|
|
|
window.restartPendingNow = function() {
|
|
const btn = document.getElementById('restart-pending-btn');
|
|
if (btn) btn.disabled = true;
|
|
fetch('/api/v3/system/action', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action: 'restart_display_service' })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
showNotification(data.message || 'Display restarting…', data.status || 'success');
|
|
window.dismissRestartPending();
|
|
})
|
|
.catch(err => {
|
|
showNotification('Error restarting display: ' + err.message, 'error');
|
|
})
|
|
.finally(() => { if (btn) btn.disabled = false; });
|
|
};
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
try {
|
|
if (sessionStorage.getItem('ledmatrix-restart-pending') === '1') {
|
|
const banner = document.getElementById('restart-pending-banner');
|
|
const saved = sessionStorage.getItem('ledmatrix-restart-pending-text');
|
|
const text = document.getElementById('restart-pending-text');
|
|
if (text && saved) text.textContent = saved;
|
|
if (banner) banner.style.display = 'block';
|
|
}
|
|
} catch { /* no-op */ }
|
|
});
|
|
|
|
// SSE streams (and window.reconnectSSE) are owned by window.LEDStreams in
|
|
// js/app-shell.js — do not open EventSources for stats/display here.
|
|
|
|
// Utility functions
|
|
window.hexToRgb = function(hex) {
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
return result ? {
|
|
r: parseInt(result[1], 16),
|
|
g: parseInt(result[2], 16),
|
|
b: parseInt(result[3], 16)
|
|
} : null;
|
|
};
|
|
|
|
window.rgbToHex = function(r, g, b) {
|
|
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
|
};
|
|
|
|
// Form validation helpers
|
|
window.validateForm = function(form) {
|
|
const inputs = form.querySelectorAll('input[required], select[required], textarea[required]');
|
|
let isValid = true;
|
|
|
|
inputs.forEach(input => {
|
|
if (!input.value.trim()) {
|
|
input.classList.add('border-red-500');
|
|
isValid = false;
|
|
} else {
|
|
input.classList.remove('border-red-500');
|
|
}
|
|
});
|
|
|
|
return isValid;
|
|
};
|
|
|
|
// Auto-resize textareas
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const textareas = document.querySelectorAll('textarea');
|
|
textareas.forEach(textarea => {
|
|
textarea.addEventListener('input', function() {
|
|
this.style.height = 'auto';
|
|
this.style.height = this.scrollHeight + 'px';
|
|
});
|
|
});
|
|
});
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', function(e) {
|
|
// Ctrl/Cmd + R to refresh
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
|
|
e.preventDefault();
|
|
location.reload();
|
|
}
|
|
|
|
// Ctrl/Cmd + S to save current form
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
e.preventDefault();
|
|
const form = document.querySelector('form');
|
|
if (form) {
|
|
form.dispatchEvent(new Event('submit'));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Plugin management helpers
|
|
window.installPlugin = function(pluginId) {
|
|
fetch('/api/v3/plugins/install', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
showNotification(data.message, data.status);
|
|
if (data.status === 'success') {
|
|
// Refresh plugin list
|
|
htmx.ajax('GET', '/v3/partials/plugins', '#plugins-content');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showNotification('Error installing plugin: ' + error.message, 'error');
|
|
});
|
|
};
|
|
|
|
// Font management helpers
|
|
window.uploadFont = function(fileInput) {
|
|
const file = fileInput.files[0];
|
|
if (!file) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append('font_file', file);
|
|
formData.append('font_family', file.name.replace(/\.[^/.]+$/, '').toLowerCase().replace(/[^a-z0-9]/g, '_'));
|
|
|
|
fetch('/api/v3/fonts/upload', {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
showNotification(data.message, data.status);
|
|
if (data.status === 'success') {
|
|
// Refresh fonts list
|
|
htmx.ajax('GET', '/v3/partials/fonts', '#fonts-content');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showNotification('Error uploading font: ' + error.message, 'error');
|
|
});
|
|
};
|
|
|
|
// Tab switching helper
|
|
window.switchTab = function(tabName) {
|
|
// Update Alpine.js active tab if available
|
|
if (window.Alpine) {
|
|
// Dispatch event for Alpine.js
|
|
const event = new CustomEvent('switch-tab', {
|
|
detail: { tab: tabName }
|
|
});
|
|
document.dispatchEvent(event);
|
|
}
|
|
};
|
|
|
|
// Error handling for unhandled promise rejections
|
|
window.addEventListener('unhandledrejection', function(event) {
|
|
console.error('Unhandled promise rejection:', event.reason);
|
|
showNotification('An unexpected error occurred', 'error');
|
|
});
|
|
|
|
// Performance monitoring
|
|
window.performanceMonitor = {
|
|
startTime: performance.now(),
|
|
|
|
mark: function(name) {
|
|
if (window.performance.mark) {
|
|
performance.mark(name);
|
|
}
|
|
},
|
|
|
|
measure: function(name, start, end) {
|
|
if (window.performance.measure) {
|
|
performance.measure(name, start, end);
|
|
}
|
|
},
|
|
|
|
getMeasures: function() {
|
|
if (window.performance && window.performance.getEntriesByType) {
|
|
return window.performance.getEntriesByType('measure');
|
|
}
|
|
return [];
|
|
},
|
|
|
|
getMetrics: function() {
|
|
if (!window.performance || !window.performance.getEntriesByType) {
|
|
return {};
|
|
}
|
|
|
|
const navigation = window.performance.getEntriesByType('navigation')[0];
|
|
const paint = window.performance.getEntriesByType('paint');
|
|
const resources = window.performance.getEntriesByType('resource');
|
|
|
|
return {
|
|
domContentLoaded: navigation ? navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart : 0,
|
|
loadComplete: navigation ? navigation.loadEventEnd - navigation.fetchStart : 0,
|
|
firstPaint: paint.find(p => p.name === 'first-paint')?.startTime || 0,
|
|
firstContentfulPaint: paint.find(p => p.name === 'first-contentful-paint')?.startTime || 0,
|
|
resourceCount: resources.length,
|
|
totalResourceSize: resources.reduce((sum, r) => sum + (r.transferSize || 0), 0),
|
|
measures: this.measures
|
|
};
|
|
},
|
|
|
|
logMetrics: function() {
|
|
const metrics = this.getMetrics();
|
|
console.group('Performance Metrics');
|
|
debugLog('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms');
|
|
debugLog('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms');
|
|
debugLog('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms');
|
|
debugLog('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms');
|
|
debugLog('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB');
|
|
if (Object.keys(metrics.measures || {}).length > 0) {
|
|
debugLog('Custom Measures:', metrics.measures);
|
|
}
|
|
console.groupEnd();
|
|
}
|
|
};
|
|
|
|
// Initialize performance monitoring
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
window.performanceMonitor.mark('app-start');
|
|
|
|
// Log metrics after page load
|
|
window.addEventListener('load', function() {
|
|
setTimeout(() => {
|
|
window.performanceMonitor.mark('app-loaded');
|
|
window.performanceMonitor.measure('app-load-time', 'app-start', 'app-loaded');
|
|
if (window.location.search.includes('debug=perf')) {
|
|
window.performanceMonitor.logMetrics();
|
|
}
|
|
}, 100);
|
|
});
|
|
});
|
|
|
|
// ===== Floating live preview =====
|
|
// A mini preview of the display, available on every tab except Overview
|
|
// (which has the full-size one). Open/closed state persists per browser;
|
|
// frames arrive via the existing SSE stream (updateDisplayPreview in
|
|
// app-shell.js feeds #floating-preview-img).
|
|
window.toggleFloatingPreview = function(open) {
|
|
try { localStorage.setItem('ledmatrix-floating-preview', open ? '1' : '0'); } catch { /* no-op */ }
|
|
window.updateFloatingPreviewVisibility();
|
|
};
|
|
|
|
// Preset widths the size button cycles through (px). Desktop users can also
|
|
// drag the panel's native resize handle (CSS resize: both).
|
|
const FLOATING_PREVIEW_SIZES = [192, 256, 384, 512];
|
|
|
|
window.applyFloatingPreviewSize = function() {
|
|
const panel = document.getElementById('floating-preview');
|
|
if (!panel) return;
|
|
let size = 256;
|
|
try { size = parseInt(localStorage.getItem('ledmatrix-floating-preview-size'), 10) || 256; } catch { /* no-op */ }
|
|
panel.style.width = size + 'px';
|
|
// Clear any manual drag-resize height so the image's aspect ratio rules
|
|
panel.style.height = '';
|
|
};
|
|
|
|
window.cycleFloatingPreviewSize = function() {
|
|
let size = 256;
|
|
try { size = parseInt(localStorage.getItem('ledmatrix-floating-preview-size'), 10) || 256; } catch { /* no-op */ }
|
|
const idx = FLOATING_PREVIEW_SIZES.indexOf(size);
|
|
const next = FLOATING_PREVIEW_SIZES[(idx + 1) % FLOATING_PREVIEW_SIZES.length];
|
|
try { localStorage.setItem('ledmatrix-floating-preview-size', String(next)); } catch { /* no-op */ }
|
|
window.applyFloatingPreviewSize();
|
|
};
|
|
|
|
window.updateFloatingPreviewVisibility = function(tab) {
|
|
const panel = document.getElementById('floating-preview');
|
|
const toggle = document.getElementById('floating-preview-toggle');
|
|
if (!panel || !toggle) return;
|
|
let active = tab;
|
|
if (!active) {
|
|
const el = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
|
const data = el && el._x_dataStack && el._x_dataStack[0];
|
|
active = data && data.activeTab;
|
|
}
|
|
const onOverview = active === 'overview';
|
|
let open = false;
|
|
try { open = localStorage.getItem('ledmatrix-floating-preview') === '1'; } catch { /* no-op */ }
|
|
const showPanel = !onOverview && open;
|
|
panel.style.display = showPanel ? 'block' : 'none';
|
|
toggle.style.display = (!onOverview && !open) ? 'flex' : 'none';
|
|
if (showPanel) {
|
|
window.applyFloatingPreviewSize();
|
|
// Show the last cached frame immediately — SSE only pushes on
|
|
// display changes, so a freshly opened panel would otherwise stay
|
|
// empty until the next change.
|
|
const img = document.getElementById('floating-preview-img');
|
|
if (img && !img.src && window._lastPreviewFrame) {
|
|
img.src = 'data:image/png;base64,' + window._lastPreviewFrame;
|
|
}
|
|
}
|
|
// The display SSE stream is only open while a preview is on screen
|
|
if (window.LEDStreams) window.LEDStreams.refresh();
|
|
};
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
window.updateFloatingPreviewVisibility();
|
|
});
|
|
|
|
// Run a plugin on the real display for 60s via the existing on-demand API
|
|
// and open the floating preview so the effect is visible while configuring.
|
|
window.previewPluginNow = function(pluginId) {
|
|
fetch('/api/v3/display/on-demand/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId, duration: 60 })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
showNotification(data.message || ('Previewing ' + pluginId + ' for 60 seconds'),
|
|
data.status || 'success');
|
|
if (data.status === 'success') window.toggleFloatingPreview(true);
|
|
})
|
|
.catch(err => {
|
|
showNotification('Preview failed: ' + err.message, 'error');
|
|
});
|
|
};
|
|
|
|
// ===== Nav accessibility =====
|
|
// aria-current tracks the active tab. Buttons are matched by their Alpine
|
|
// @click expression ("activeTab = '<tab>'"), which works for both the static
|
|
// system tabs and the dynamically injected plugin tabs.
|
|
window.updateNavAriaCurrent = function(tab) {
|
|
document.querySelectorAll('.nav-tab').forEach(function(btn) {
|
|
const expr = btn.getAttribute('@click') || btn.getAttribute('x-on:click') || '';
|
|
const isCurrent = expr.indexOf("activeTab = '" + tab + "'") !== -1;
|
|
if (isCurrent) {
|
|
btn.setAttribute('aria-current', 'page');
|
|
} else {
|
|
btn.removeAttribute('aria-current');
|
|
}
|
|
});
|
|
};
|
|
|
|
// Escape closes the mobile nav drawer and returns focus to the hamburger;
|
|
// opening the drawer moves focus to its first tab.
|
|
(function() {
|
|
function appData() {
|
|
const el = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
|
return el && el._x_dataStack && el._x_dataStack[0];
|
|
}
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key !== 'Escape') return;
|
|
const data = appData();
|
|
if (data && data.mobileNavOpen) {
|
|
data.mobileNavOpen = false;
|
|
const burger = document.querySelector('[aria-controls="site-nav"]');
|
|
if (burger) burger.focus();
|
|
}
|
|
});
|
|
document.addEventListener('click', function(e) {
|
|
const burger = e.target && e.target.closest
|
|
? e.target.closest('[aria-controls="site-nav"]') : null;
|
|
if (!burger) return;
|
|
// The click handler toggles mobileNavOpen; focus the first tab once
|
|
// the drawer has slid in (matches the CSS transition timing).
|
|
setTimeout(function() {
|
|
const data = appData();
|
|
if (data && data.mobileNavOpen) {
|
|
const first = document.querySelector('#site-nav .nav-tab');
|
|
if (first) first.focus();
|
|
}
|
|
}, 120);
|
|
});
|
|
})();
|
|
|
|
// ===== Mobile nav: header-widget relocation =====
|
|
// Below the md breakpoint the settings-search box and system-stats block are
|
|
// MOVED (same DOM nodes, listeners intact) from the header into the nav
|
|
// drawer's #drawer-widgets slot; at md and up they move back. Single-instance
|
|
// constraint: settings-search.js and the SSE stats updater both look these
|
|
// elements up by id, so they must never be duplicated.
|
|
window.placeHeaderWidgets = function() {
|
|
const drawer = document.getElementById('drawer-widgets');
|
|
const header = document.getElementById('header-widgets');
|
|
const search = document.getElementById('settings-search-wrap');
|
|
const stats = document.getElementById('system-stats');
|
|
if (!drawer || !header) return;
|
|
|
|
const desktop = window.matchMedia('(min-width: 768px)').matches;
|
|
if (desktop) {
|
|
// Restore original header order: search before the theme toggle,
|
|
// stats as the last item.
|
|
const themeToggle = document.getElementById('theme-toggle');
|
|
if (search && search.parentElement !== header) {
|
|
header.insertBefore(search, themeToggle || null);
|
|
}
|
|
if (stats && stats.parentElement !== header) {
|
|
header.appendChild(stats);
|
|
}
|
|
} else {
|
|
if (search && search.parentElement !== drawer) drawer.appendChild(search);
|
|
if (stats && stats.parentElement !== drawer) drawer.appendChild(stats);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
window.placeHeaderWidgets();
|
|
window.matchMedia('(min-width: 768px)').addEventListener('change', window.placeHeaderWidgets);
|
|
});
|