feat(web): mobile bottom navigation with More drawer

Add an authenticated-only bottom bar below the md breakpoint with
Heroicon-style tabs for Dashboard, Timer, Time entries, Projects, and
More. More opens a slide-up sheet (backdrop, close, Escape) for
Invoices, Clients, Reports, and user Settings, gated by module flags
where applicable.

Align shell layout to Tailwind md (768px): sidebar hidden md:flex,
main md:ml-64 / md:ml-16 when collapsed, mobile hamburger md:hidden,
RTL mainContent margin reset at 767px. Main column uses pb-16 on
small screens so content clears the bar; bar and sheet use pb-safe
(env safe-area) with a Tailwind safelist and @layer utilities rule.

Remove the legacy six-slot FAB bottom nav from base.html.

Docs: README UI overview, CHANGELOG [Unreleased], UI_GUIDELINES layout
and file reference.
This commit is contained in:
Dries Peeters
2026-04-26 09:16:51 +02:00
parent 4eabe3cabd
commit b4e0b69796
8 changed files with 211 additions and 42 deletions
+91 -1
View File
@@ -3,7 +3,7 @@
const MobileUtils = {
TOUCH_TARGET_MIN: 44,
MOBILE_BREAKPOINT: 1024,
MOBILE_BREAKPOINT: 768,
SMALL_MOBILE_BREAKPOINT: 480,
isMobile() {
@@ -206,6 +206,95 @@ class MobilePerformance {
}
}
/** Slide-up "More" drawer for mobile bottom navigation (see partials/_bottom_nav.html). */
class BottomNavMoreDrawer {
constructor() {
this.btn = document.getElementById('bottomNavMoreBtn');
this.backdrop = document.getElementById('bottomNavMoreBackdrop');
this.panel = document.getElementById('bottomNavMorePanel');
this.closeBtn = document.getElementById('bottomNavMoreClose');
if (!this.btn || !this.backdrop || !this.panel) return;
this._onDocKeydown = this._onDocKeydown.bind(this);
this.init();
}
isOpen() {
return !this.backdrop.classList.contains('hidden');
}
open() {
if (!MobileUtils.isMobile()) return;
this.backdrop.classList.remove('hidden');
requestAnimationFrame(() => {
this.panel.classList.remove('pointer-events-none', 'translate-y-full');
this.panel.setAttribute('aria-hidden', 'false');
this.btn.setAttribute('aria-expanded', 'true');
document.body.classList.add('overflow-hidden');
});
}
close() {
this.panel.classList.add('translate-y-full', 'pointer-events-none');
this.panel.setAttribute('aria-hidden', 'true');
this.btn.setAttribute('aria-expanded', 'false');
document.body.classList.remove('overflow-hidden');
const hideBackdrop = () => {
this.backdrop.classList.add('hidden');
};
const reduced =
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced) {
hideBackdrop();
return;
}
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
hideBackdrop();
};
const onEnd = (e) => {
if (e.target !== this.panel || e.propertyName !== 'transform') return;
this.panel.removeEventListener('transitionend', onEnd);
clearTimeout(fallbackTimer);
finish();
};
this.panel.addEventListener('transitionend', onEnd);
const fallbackTimer = window.setTimeout(() => {
this.panel.removeEventListener('transitionend', onEnd);
finish();
}, 400);
}
_onDocKeydown(e) {
if (e.key === 'Escape' && this.isOpen()) {
this.close();
}
}
init() {
this.btn.addEventListener('click', (e) => {
e.preventDefault();
if (this.isOpen()) this.close();
else this.open();
});
this.backdrop.addEventListener('click', () => this.close());
if (this.closeBtn) {
this.closeBtn.addEventListener('click', () => this.close());
}
document.addEventListener('keydown', this._onDocKeydown);
this.panel.querySelectorAll('a.bottom-nav-more-link').forEach((a) => {
a.addEventListener('click', () => this.close());
});
}
}
class MobileOffline {
constructor() {
this.offlineToastId = null;
@@ -241,6 +330,7 @@ document.addEventListener('DOMContentLoaded', function() {
new MobileViewport();
new MobilePerformance();
new MobileOffline();
new BottomNavMoreDrawer();
});
window.MobileUtils = MobileUtils;
+7
View File
@@ -637,6 +637,13 @@
}
}
@layer utilities {
/* iOS home indicator / safe area (used by mobile bottom nav; safelist in tailwind.config.js) */
.pb-safe {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
}
/* Focus, skip link, responsive, reduced motion, high contrast (from enhanced-ui / ui-enhancements) */
:focus-visible { outline: 2px solid #4A90E2; outline-offset: 2px; }
.skip-link { position: absolute; top: -40px; left: 0; background: #4A90E2; color: white; padding: 8px 16px; z-index: 100; border-radius: 0 0 4px 0; }