Files
TimeTracker/app/static/mobile.js
T
Dries Peeters b4e0b69796 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.
2026-04-26 09:16:51 +02:00

337 lines
11 KiB
JavaScript

/* Mobile Enhancements for TimeTracker
Works with the app's sidebar navigation and Tailwind CSS UI */
const MobileUtils = {
TOUCH_TARGET_MIN: 44,
MOBILE_BREAKPOINT: 768,
SMALL_MOBILE_BREAKPOINT: 480,
isMobile() {
return window.innerWidth < this.MOBILE_BREAKPOINT;
},
isSmallMobile() {
return window.innerWidth <= this.SMALL_MOBILE_BREAKPOINT;
},
isTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
},
isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent);
}
};
class MobileSidebar {
constructor() {
this.sidebar = document.getElementById('sidebar');
this.toggleBtn = document.getElementById('mobileSidebarBtn');
this.overlay = document.getElementById('sidebarOverlay');
if (!this.sidebar) return;
this.init();
}
init() {
if (this.toggleBtn) {
this.toggleBtn.addEventListener('click', () => this.toggle());
}
if (this.overlay) {
this.overlay.addEventListener('click', () => this.close());
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this.close();
});
this.sidebar.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => {
if (MobileUtils.isMobile()) this.close();
});
});
}
toggle() {
this.sidebar.classList.toggle('-translate-x-full');
if (this.overlay) this.overlay.classList.toggle('hidden');
}
close() {
this.sidebar.classList.add('-translate-x-full');
if (this.overlay) this.overlay.classList.remove('hidden');
this.overlay && this.overlay.classList.add('hidden');
}
}
class MobileForms {
constructor() {
this.init();
}
init() {
if (MobileUtils.isIOS()) {
document.querySelectorAll('input, select, textarea').forEach(el => {
const computed = window.getComputedStyle(el);
if (parseFloat(computed.fontSize) < 16) {
el.style.fontSize = '16px';
}
});
}
document.querySelectorAll('input, select, textarea').forEach(el => {
el.addEventListener('focus', () => {
if (MobileUtils.isMobile()) {
setTimeout(() => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 300);
}
});
});
this.initFileInputs();
this.initCharCounters();
this.initSubmitButtons();
}
initFileInputs() {
document.querySelectorAll('input[type="file"]').forEach(input => {
input.addEventListener('change', () => {
const preview = document.getElementById(input.id + '-preview');
const filenameEl = document.getElementById(input.id + '-filename');
if (preview && filenameEl && input.files.length > 0) {
filenameEl.textContent = input.files[0].name;
preview.classList.remove('hidden');
}
});
const dropZone = input.closest('label');
if (dropZone) {
['dragenter', 'dragover'].forEach(evt => {
dropZone.addEventListener(evt, (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(evt => {
dropZone.addEventListener(evt, (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
});
});
dropZone.addEventListener('drop', (e) => {
if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
});
}
});
}
initCharCounters() {
document.querySelectorAll('.char-counter[data-for]').forEach(counter => {
const textarea = document.getElementById(counter.dataset.for);
if (textarea) {
textarea.addEventListener('input', () => {
counter.textContent = textarea.value.length;
});
}
});
}
initSubmitButtons() {
document.querySelectorAll('button[data-loading-text]').forEach(btn => {
const form = btn.closest('form');
if (form) {
form.addEventListener('submit', () => {
if (form.checkValidity && !form.checkValidity()) return;
const original = btn.innerHTML;
btn.dataset.originalHtml = original;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>' + btn.dataset.loadingText;
btn.disabled = true;
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = original;
}, 15000);
});
}
});
}
}
class MobileViewport {
constructor() {
this.init();
}
init() {
this.handleViewportChange();
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => this.handleViewportChange(), 200);
});
window.addEventListener('orientationchange', () => {
setTimeout(() => this.handleViewportChange(), 150);
});
}
handleViewportChange() {
document.body.classList.toggle('mobile-view', MobileUtils.isMobile());
document.body.classList.toggle('small-mobile-view', MobileUtils.isSmallMobile());
}
}
class MobilePerformance {
constructor() {
this.init();
}
init() {
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
}
observer.unobserve(img);
}
});
});
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));
}
document.querySelectorAll('img:not([loading])').forEach(img => {
img.loading = 'lazy';
});
}
}
/** 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;
this.init();
}
init() {
window.addEventListener('offline', () => {
if (window.toastManager) {
this.offlineToastId = window.toastManager.warning(
'Some features may not work properly.',
"You're offline",
0
);
}
});
window.addEventListener('online', () => {
if (window.toastManager && this.offlineToastId) {
window.toastManager.dismiss(this.offlineToastId);
this.offlineToastId = null;
window.toastManager.success('Connection restored', "You're online", 3000);
}
});
}
}
document.addEventListener('DOMContentLoaded', function() {
if (window._mobileInitDone) return;
window._mobileInitDone = true;
new MobileSidebar();
new MobileForms();
new MobileViewport();
new MobilePerformance();
new MobileOffline();
new BottomNavMoreDrawer();
});
window.MobileUtils = MobileUtils;