mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
feat(web): mobile navigation drawer + responsive CSS gap fixes
Phones previously got the desktop layout squeezed: ~12 system tabs plus one tab per installed plugin wrapped into many rows of small pill buttons, and the header's settings-search and system-stats widgets were dropped entirely (hidden below their breakpoints, never relocated). - Off-canvas nav drawer below md: the existing nav markup (system tab row + #plugin-tabs-row, including dynamically injected plugin tabs) is wrapped in a #site-nav container that CSS repositions into a slide-in drawer on small screens. Same DOM nodes, same @click handlers, nothing duplicated. Tabs become full-width rows with 44px+ touch targets. A hamburger button (md:hidden) in the header and a backdrop toggle the new mobileNavOpen Alpine state (added to both app() definitions, mirroring activeTab). Clicking any tab, a search result, or the backdrop closes the drawer. At md+ hard CSS guards make all drawer styles inert - desktop renders exactly as before. - Header widgets relocated, not hidden: placeHeaderWidgets() in app.js moves the #settings-search-wrap and #system-stats nodes (same elements, listeners intact - both are looked up by id from SSE/search code, so they must never be duplicated) into the drawer below md and back into the header above it, via a matchMedia listener. - Fixed 13 breakpoint utility classes that templates referenced but app.css never defined (sm:block, sm:grid-cols-2, sm:text-sm, md:block, md:w-auto, lg:block, lg:flex, lg:w-64, xl:grid-cols-2/3, 2xl:grid-cols-2/3/4). This was a live bug: 'hidden sm:block' on the search box and 'hidden lg:flex' on the stats meant BOTH were invisible at every screen width. Audit method (repeatable): diff classes used in templates vs defined in app.css. - Mobile modal sizing: one global rule caps .modal-content at 95vw/90vh with internal scroll below 640px - covers every modal without per-template changes. - Horizontal-scroll affordance: pure-CSS edge-fade shadows on .overflow-x-auto containers (scrolling-shadows technique), plus larger in-table touch targets below md. Validation: breakpoint used-vs-defined audit now returns zero gaps; Jinja parse of base.html passes; all changes to desktop behavior are additive (new utilities) or scoped inside max-width media queries. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d2b49c4ff2
commit
1ae3b7fa24
@@ -413,6 +413,9 @@ a, button, input, select, textarea {
|
|||||||
/* Responsive breakpoints */
|
/* Responsive breakpoints */
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
.sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||||
|
.sm\:block { display: block; }
|
||||||
|
.sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.sm\:text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
@@ -421,6 +424,8 @@ a, button, input, select, textarea {
|
|||||||
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
.md\:flex { display: flex; }
|
.md\:flex { display: flex; }
|
||||||
.md\:hidden { display: none; }
|
.md\:hidden { display: none; }
|
||||||
|
.md\:block { display: block; }
|
||||||
|
.md\:w-auto { width: auto; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
@@ -431,9 +436,14 @@ a, button, input, select, textarea {
|
|||||||
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
|
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
|
||||||
.lg\:gap-x-3 { column-gap: 0.75rem; }
|
.lg\:gap-x-3 { column-gap: 0.75rem; }
|
||||||
.lg\:gap-x-6 { column-gap: 1.5rem; }
|
.lg\:gap-x-6 { column-gap: 1.5rem; }
|
||||||
|
.lg\:block { display: block; }
|
||||||
|
.lg\:flex { display: flex; }
|
||||||
|
.lg\:w-64 { width: 16rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 1280px) {
|
||||||
|
.xl\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.xl\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
.xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
.xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
.xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
.xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||||
.xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
.xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||||
@@ -446,6 +456,9 @@ a, button, input, select, textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1536px) {
|
@media (min-width: 1536px) {
|
||||||
|
.2xl\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.2xl\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.2xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
.2xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
.2xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||||
.2xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
.2xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||||
.2xl\:grid-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
.2xl\:grid-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||||
@@ -456,6 +469,129 @@ a, button, input, select, textarea {
|
|||||||
.2xl\:space-x-8 > * + * { margin-left: 2rem; }
|
.2xl\:space-x-8 > * + * { margin-left: 2rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Mobile navigation drawer =====
|
||||||
|
Below md the #site-nav wrapper becomes an off-canvas drawer; at md and up
|
||||||
|
none of these rules apply and the nav renders exactly as before. */
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.site-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 60;
|
||||||
|
width: min(85vw, 320px);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.site-nav.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
box-shadow: 0 0 24px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
/* Tabs become full-width rows with >=44px touch targets */
|
||||||
|
.site-nav .nav-tab {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.site-nav nav.-mb-px {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.nav-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 55;
|
||||||
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
/* Header widgets relocated into the drawer (see placeHeaderWidgets in
|
||||||
|
app.js). The originals carry `hidden`/breakpoint classes tuned for the
|
||||||
|
header, so re-enable them explicitly in the drawer context. */
|
||||||
|
#drawer-widgets #settings-search-wrap {
|
||||||
|
display: block !important;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
#drawer-widgets #settings-search-wrap input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#drawer-widgets #settings-search-results {
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 50vh;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
#drawer-widgets #system-stats {
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
/* Larger touch targets inside horizontally scrolling tables */
|
||||||
|
.overflow-x-auto table button {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.overflow-x-auto table input:not([type="checkbox"]),
|
||||||
|
.overflow-x-auto table select {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.overflow-x-auto table input[type="checkbox"] {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
/* Hard guards: even if mobileNavOpen was left true when the viewport
|
||||||
|
crossed the breakpoint, the drawer/backdrop must render as plain
|
||||||
|
in-flow nav on desktop. */
|
||||||
|
.site-nav {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
border-right: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background-color: transparent;
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
.nav-backdrop {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
#drawer-widgets {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile modal sizing: every .modal-content dialog fits the viewport with
|
||||||
|
internal scrolling instead of overflowing it. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.modal-content {
|
||||||
|
width: 95vw !important;
|
||||||
|
max-width: 95vw !important;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edge-fade hint that a container scrolls horizontally (pure CSS,
|
||||||
|
Lea Verou scrolling-shadows technique — backgrounds sit behind content). */
|
||||||
|
.overflow-x-auto {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, var(--color-surface) 30%, rgba(255, 255, 255, 0)) left / 24px 100%,
|
||||||
|
linear-gradient(270deg, var(--color-surface) 30%, rgba(255, 255, 255, 0)) right / 24px 100%,
|
||||||
|
radial-gradient(farthest-side at 0 50%, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0)) left / 12px 100%,
|
||||||
|
radial-gradient(farthest-side at 100% 50%, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0)) right / 12px 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-attachment: local, local, scroll, scroll;
|
||||||
|
}
|
||||||
|
|
||||||
/* HTMX loading states */
|
/* HTMX loading states */
|
||||||
.htmx-request .loading {
|
.htmx-request .loading {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -273,3 +273,38 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== 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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -173,7 +173,14 @@
|
|||||||
|
|
||||||
function setActiveTab(tab) {
|
function setActiveTab(tab) {
|
||||||
var data = getAppData();
|
var data = getAppData();
|
||||||
if (data) { data.activeTab = tab; return true; }
|
if (data) {
|
||||||
|
data.activeTab = tab;
|
||||||
|
// Navigating from a search result should also dismiss the mobile
|
||||||
|
// nav drawer (harmless no-op on desktop, where the drawer CSS
|
||||||
|
// doesn't apply).
|
||||||
|
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -552,6 +552,7 @@
|
|||||||
window.app = function() {
|
window.app = function() {
|
||||||
return {
|
return {
|
||||||
activeTab: isAPMode ? 'wifi' : 'overview',
|
activeTab: isAPMode ? 'wifi' : 'overview',
|
||||||
|
mobileNavOpen: false,
|
||||||
installedPlugins: [],
|
installedPlugins: [],
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -874,6 +875,16 @@
|
|||||||
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16" style="max-width: 100%;">
|
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16" style="max-width: 100%;">
|
||||||
<div class="flex justify-between items-center h-16">
|
<div class="flex justify-between items-center h-16">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
|
<!-- Mobile nav toggle (hamburger) — hidden at md and up -->
|
||||||
|
<button type="button"
|
||||||
|
class="theme-toggle-btn p-2 rounded-md mr-2 md:hidden"
|
||||||
|
@click="mobileNavOpen = !mobileNavOpen"
|
||||||
|
:aria-expanded="mobileNavOpen ? 'true' : 'false'"
|
||||||
|
aria-controls="site-nav"
|
||||||
|
aria-label="Open navigation menu"
|
||||||
|
title="Menu">
|
||||||
|
<i class="fas fa-bars" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
<h1 class="text-xl font-bold text-gray-900">
|
<h1 class="text-xl font-bold text-gray-900">
|
||||||
<i class="fas fa-tv text-blue-600 mr-2"></i>
|
<i class="fas fa-tv text-blue-600 mr-2"></i>
|
||||||
LED Matrix Control - v3
|
LED Matrix Control - v3
|
||||||
@@ -881,7 +892,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connection status and theme toggle -->
|
<!-- Connection status and theme toggle -->
|
||||||
<div class="flex items-center space-x-4">
|
<div id="header-widgets" class="flex items-center space-x-4">
|
||||||
<!-- Global settings search -->
|
<!-- Global settings search -->
|
||||||
<div class="relative hidden sm:block" id="settings-search-wrap">
|
<div class="relative hidden sm:block" id="settings-search-wrap">
|
||||||
<input id="settings-search"
|
<input id="settings-search"
|
||||||
@@ -919,7 +930,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System stats (populated via SSE) -->
|
<!-- System stats (populated via SSE) -->
|
||||||
<div class="hidden lg:flex items-center space-x-4 text-sm text-gray-600 xl:space-x-6 2xl:space-x-8">
|
<div id="system-stats" class="hidden lg:flex items-center space-x-4 text-sm text-gray-600 xl:space-x-6 2xl:space-x-8">
|
||||||
<span id="cpu-stat" class="flex items-center space-x-1">
|
<span id="cpu-stat" class="flex items-center space-x-1">
|
||||||
<i class="fas fa-microchip"></i>
|
<i class="fas fa-microchip"></i>
|
||||||
<span>--%</span>
|
<span>--%</span>
|
||||||
@@ -993,6 +1004,19 @@
|
|||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
|
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
|
||||||
|
<!-- Backdrop shown behind the mobile nav drawer (no-op at md and up) -->
|
||||||
|
<div class="nav-backdrop" x-show="mobileNavOpen" x-transition.opacity
|
||||||
|
@click="mobileNavOpen = false" aria-hidden="true" style="display: none;"></div>
|
||||||
|
|
||||||
|
<!-- Navigation: static tab rows at md and up; off-canvas drawer below md.
|
||||||
|
The drawer reuses the exact same tab buttons/handlers — CSS repositions
|
||||||
|
this container, nothing is duplicated. Clicking any tab closes the drawer. -->
|
||||||
|
<div id="site-nav" class="site-nav" :class="mobileNavOpen ? 'open' : ''"
|
||||||
|
@click="if ($event.target.closest('.nav-tab')) mobileNavOpen = false">
|
||||||
|
<!-- Below md, the header's settings-search and system-stats widgets are
|
||||||
|
moved (not copied) into this slot by placeHeaderWidgets() in app.js -->
|
||||||
|
<div id="drawer-widgets"></div>
|
||||||
|
|
||||||
<!-- Navigation tabs -->
|
<!-- Navigation tabs -->
|
||||||
<nav class="mb-8">
|
<nav class="mb-8">
|
||||||
<!-- First row - System tabs -->
|
<!-- First row - System tabs -->
|
||||||
@@ -1073,6 +1097,8 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
</div> <!-- /#site-nav -->
|
||||||
|
|
||||||
|
|
||||||
<!-- Tab content -->
|
<!-- Tab content -->
|
||||||
<div id="tab-content" class="space-y-6">
|
<div id="tab-content" class="space-y-6">
|
||||||
@@ -1695,6 +1721,7 @@
|
|||||||
window.location.hostname.startsWith('192.168.4.');
|
window.location.hostname.startsWith('192.168.4.');
|
||||||
return isAPMode ? 'wifi' : 'overview';
|
return isAPMode ? 'wifi' : 'overview';
|
||||||
})(),
|
})(),
|
||||||
|
mobileNavOpen: false,
|
||||||
installedPlugins: [],
|
installedPlugins: [],
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
|||||||
Reference in New Issue
Block a user