Skip to content

Instantly share code, notes, and snippets.

@zilveer
Created July 21, 2025 00:38
Show Gist options
  • Select an option

  • Save zilveer/23999b0b33ce5b223dd1e3c7f1cf45f5 to your computer and use it in GitHub Desktop.

Select an option

Save zilveer/23999b0b33ce5b223dd1e3c7f1cf45f5 to your computer and use it in GitHub Desktop.
Menu better nodaloffcsnvas
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Advanced Routable Bootstrap Menus (Font Awesome)</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet">
<style>
:root {
--primary-color: #007bff;
--primary-hover-color: #0056b3;
--light-blue-hover: #e9f5ff;
--text-color: #333;
--secondary-text-color: #6c757d;
--border-color: #e9ecef;
--header-bg-color: #f8f9fa;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f4f7f6;
color: var(--text-color);
}
.container {
background-color: #ffffff;
padding: 30px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
margin-top: 50px;
}
h2 {
color: var(--primary-color);
margin-bottom: 25px;
font-weight: 600;
}
.menu-container {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
}
.menu-level {
position: absolute;
width: 100%;
height: 100%;
/* Adjusted transition for smoother slide */
transition: transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
background: white;
box-sizing: border-box;
padding-bottom: 60px;
overflow-x: hidden;
overflow-y: auto; /* Allow vertical scrolling within menu levels */
}
.menu-level.slide-right { transform: translateX(100%); }
.menu-level.slide-left { transform: translateX(-100%); }
.menu-level.active { transform: translateX(0); }
.menu-item {
padding: 0.9rem 1.2rem;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.2s, transform 0.1s ease-out;
font-size: 1.05rem;
color: #495057;
}
.menu-item:hover { background: var(--light-blue-hover); transform: translateX(3px); }
.menu-item i { margin-right: 12px; color: var(--primary-color); min-width: 20px; text-align: center; }
.menu-item-content { flex-grow: 1; overflow: hidden; }
.menu-item-description { font-size: 0.85rem; color: var(--secondary-text-color); margin-top: 2px; }
.menu-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
border-top: 1px solid #dee2e6;
padding: 0.8rem 1rem;
background: var(--header-bg-color);
display: flex;
justify-content: space-between;
align-items: center;
min-height: 60px;
gap: 1rem;
}
.footer-action-group { display: flex; gap: 0.5rem; align-items: center; }
.modal-content, .offcanvas { display: flex; flex-direction: column; }
/* FIX: Remove fixed height for modal content, it will adjust to content */
.modal-content {
/* height: 550px; <-- Removed */
border-radius: 0.75rem;
overflow: hidden;
}
/* Ensure fullscreen modal content is always 100% height */
.modal-fullscreen .modal-content {
height: 100%;
border-radius: 0;
}
.offcanvas-body, .modal-body { flex-grow: 1; padding: 0; position: relative; overflow: hidden; }
/* FIX: Fixed height for header and title to the left */
.modal-header, .offcanvas-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 65px; /* Fixed height */
padding: 1rem;
background-color: var(--header-bg-color);
border-bottom: 1px solid #dee2e6;
flex-shrink: 0;
}
.header-zone { display: flex; align-items: center; gap: 10px; }
.header-zone.left { flex: 1 1 0; justify-content: flex-start; }
.header-zone.center {
flex: 2 1 0;
justify-content: center;
flex-direction: column;
align-items: flex-start; /* Align title and center actions to the left */
text-align: left; /* Ensure text within is also left-aligned */
}
.header-zone.right { flex: 1 1 0; justify-content: flex-end; }
.modal-header h5, .offcanvas-header h5 { margin: 0; font-weight: 700; font-size: 1.25rem; color: #343a40; }
.header-action-group { display: flex; align-items: center; gap: 10px; }
.header-action-item { color: var(--secondary-text-color); cursor: pointer; display: flex; align-items: center; }
.header-back { background: none; border: none; color: var(--primary-color); cursor: pointer; padding: 0.25rem; font-size: 1.25rem; }
.header-back:hover { color: var(--primary-hover-color); }
.modal.modal-zoom .modal-dialog {
transform: scale(0.9);
opacity: 0;
transition: transform 0.25s ease-out, opacity 0.25s ease-out;
}
.modal.modal-zoom.show .modal-dialog {
transform: scale(1);
opacity: 1;
}
.state-indicator { position: fixed; top: 20px; right: 20px; z-index: 1050; background: rgba(0, 123, 255, 0.15); border: 1px solid var(--primary-color); border-radius: 6px; padding: 0.7rem 1.2rem; font-size: 0.9rem; max-width: 350px; color: var(--primary-hover-color); box-shadow: 0 2px 10px rgba(0, 123, 255, 0.2); opacity: 0; transform: translateY(-20px); transition: opacity 0.3s ease-out, transform 0.3s ease-out; }
.state-indicator.show { opacity: 1; transform: translateY(0); }
.menu-group-title { padding: 0.5rem 1.2rem; font-size: 0.85rem; font-weight: 600; color: var(--secondary-text-color); background-color: var(--header-bg-color); border-bottom: 1px solid var(--border-color); margin-top: 0.5rem; text-transform: uppercase; }
.menu-group-title:first-child { margin-top: 0; }
.modal.modal-top .modal-dialog { top: 0; margin-top: 20px; transform: translate(0, 0) !important; }
.modal.modal-bottom .modal-dialog { bottom: 0; margin-bottom: 20px; top: auto; transform: translate(0, 0) !important; }
/* .modal-fullscreen .modal-content { height: 100%; } <-- Moved up */
.offcanvas.offcanvas-fullscreen.offcanvas-slide-up { transform: translateY(100%); left: 0; top: 0; width: 100%; height: 100%; bottom: auto; right: auto; }
.offcanvas.offcanvas-fullscreen.offcanvas-slide-up.show { transform: translateY(0%); }
.offcanvas.offcanvas-fullscreen.offcanvas-slide-right { transform: translateX(-100%); left: 0; top: 0; width: 100%; height: 100%; bottom: auto; right: auto; }
.offcanvas.offcanvas-fullscreen.offcanvas-slide-right.show { transform: translateX(0%); }
</style>
</head>
<body>
<div class="container mt-4">
<h2>Advanced Routable Bootstrap Menus</h2>
<p class="lead text-muted">Demonstrates nested menus within Modals and Offcanvas components using a custom router and state management.</p>
<div class="d-flex gap-3 mb-4 flex-wrap">
<button class="btn btn-primary" onclick="Router.navigate('modal/profile', {position: 'center'})">Profile Modal (Default)</button>
<button class="btn btn-secondary" onclick="Router.navigate('offcanvas/settings', {position: 'end'})">Settings Offcanvas (Default)</button>
<button class="btn btn-info text-white" onclick="Router.navigate('modal/about', {position: 'center'})">About Us Modal (Default)</button>
<hr class="w-100 my-2">
<h5>Modal Position Examples:</h5>
<button class="btn btn-primary" onclick="Router.navigate('modal/profile', {position: 'top'})">Modal: Top</button>
<button class="btn btn-primary" onclick="Router.navigate('modal/profile', {position: 'bottom'})">Modal: Bottom</button>
<button class="btn btn-primary" onclick="Router.navigate('modal/profile', {position: 'center'})">Modal: Center</button>
<button class="btn btn-primary" onclick="Router.navigate('modal/profile', {position: 'fullscreen'})">Modal: Fullscreen</button>
<hr class="w-100 my-2">
<h5>Offcanvas Position Examples:</h5>
<button class="btn btn-secondary" onclick="Router.navigate('offcanvas/settings', {position: 'top'})">Offcanvas: Top</button>
<button class="btn btn-secondary" onclick="Router.navigate('offcanvas/settings', {position: 'bottom'})">Offcanvas: Bottom</button>
<button class="btn btn-secondary" onclick="Router.navigate('offcanvas/settings', {position: 'start'})">Offcanvas: Left</button>
<button class="btn btn-secondary" onclick="Router.navigate('offcanvas/settings', {position: 'end'})">Offcanvas: Right</button>
<button class="btn btn-secondary" onclick="Router.navigate('offcanvas/settings', {position: 'fullscreen'})">Offcanvas: Fullscreen (Slide Up)</button>
<button class="btn btn-secondary" onclick="Router.navigate('offcanvas/settings', {position: 'fullscreen-right'})">Offcanvas: Fullscreen (Slide Right)</button>
</div>
<div class="mb-4 d-flex align-items:center">
<button class="btn btn-outline-primary btn-sm me-2" onclick="saveAppState()">Save State</button>
<button class="btn btn-outline-secondary btn-sm me-2" onclick="loadAppState()">Load State</button>
<button class="btn btn-outline-danger btn-sm" onclick="clearAppState()">Clear State</button>
<span class="ms-auto text-muted">Current route: <span id="currentRoute" class="fw-bold text-primary">#</span></span>
</div>
</div>
<div id="stateIndicator" class="state-indicator" role="status" aria-live="polite" style="display: none;"></div>
<div class="modal modal-zoom" id="menuModal" tabindex="-1" aria-labelledby="menuModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<div class="header-zone left">
<button id="modalBackButton" class="header-back" style="display: none;" aria-label="Go Back"><i class="fa-solid fa-chevron-left"></i></button>
</div>
<div class="header-zone center">
<h5 class="modal-title" id="menuModalLabel">Menu</h5>
<div id="modalHeaderActionsCenter" class="header-action-group"></div>
</div>
<div class="header-zone right">
<div id="modalHeaderActionsRight" class="header-action-group"></div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
</div>
<div class="modal-body"><div id="modalMenuContainer" class="menu-container"></div></div>
</div>
</div>
</div>
<div class="offcanvas" id="menuOffcanvas" tabindex="-1" aria-labelledby="menuOffcanvasLabel">
<div class="offcanvas-header">
<div class="header-zone left">
<button id="offcanvasBackButton" class="header-back" style="display: none;" aria-label="Go Back"><i class="fa-solid fa-chevron-left"></i></button>
</div>
<div class="header-zone center">
<h5 class="offcanvas-title" id="menuOffcanvasLabel">Menu</h5>
<div id="offcanvasHeaderActionsCenter" class="header-action-group"></div>
</div>
<div class="header-zone right">
<div id="offcanvasHeaderActionsRight" class="header-action-group"></div>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
</div>
<div class="offcanvas-body"><div id="offcanvasMenuContainer" class="menu-container"></div></div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.bundle.min.js"></script>
<script>
// Router Library (UNCHANGED)
(function(root, factory) { if (typeof define === 'function' && define.amd) { define([], factory(root)); } else if (typeof exports === 'object') { module.exports = factory(root); } else { root.Router = factory(root); } })(typeof global !== "undefined" ? global : this.window || this.global, function(root) { 'use strict'; const HASH_PREFIX = '#'; const SLASH_SUFFIX = /\/$/; const internal = { current: null, routes: [], history: [], }; const normalizeHash = (hash) => hash.replace(new RegExp(`^${HASH_PREFIX}`), '').replace(SLASH_SUFFIX, ''); const router = { getFragment: () => normalizeHash(window.location.hash), add: (route, handler) => { const paramNames = []; const regexString = `^${route.replace(/:([a-zA-Z0-9_]+)(\*)?/g, (match, paramName, isGreedy) => { paramNames.push(paramName); return isGreedy ? '(.+)' : '([^/]+)'; })}$`; internal.routes.push({ handler, route: new RegExp(regexString), paramNames }); return router; }, apply: (frg) => { const fragment = frg || router.getFragment(); for (const routeObj of internal.routes) { const matches = fragment.match(routeObj.route); if (matches) { matches.shift(); const params = {}; routeObj.paramNames.forEach((name, i) => { params[name] = matches[i]; }); internal.current = fragment; if (internal.history[internal.history.length - 1] !== fragment) { internal.history.push(fragment); } routeObj.handler.call({}, params); return router; } } if (fragment === '') { const emptyRoute = internal.routes.find(r => r.route.source === '^$'); if (emptyRoute) emptyRoute.handler.call({}); internal.current = ''; internal.history = []; } return router; }, start: () => { const handleHashChange = () => { if (internal.current !== router.getFragment()) router.apply(); }; window.addEventListener('hashchange', handleHashChange); router.apply(); return router; }, navigate: (path, options = {}) => { router.__currentNavigationOptions = options; const newFragment = path || ''; if (router.getFragment() === newFragment) { router.apply(newFragment); } else { window.location.hash = newFragment; } return router; }, back: () => { if (internal.history.length > 1) { internal.history.pop(); window.location.hash = `${HASH_PREFIX}${internal.history.pop()}`; } else { internal.history = []; window.location.hash = ''; } router.__currentNavigationOptions = {}; return router; }, }; return router; });
// State Management and Utils (UNCHANGED)
const AppState = { data: { userProfile: { name: 'John Doe', email: 'john@example.com', darkMode: false, notifications: true, twoFactor: true }, settings: { language: 'en', timezone: 'UTC', autoSave: true, debugMode: false, generalNotices: true, promoEmails: false }, notifications: { email: true, sms: false, push: true, mentions: true }, privacy: { locationServices: true, personalizedAds: false } }, get(key) { return key.split('.').reduce((o, i) => (o && o.hasOwnProperty(i) ? o[i] : undefined), this.data); }, set(key, value) { const keys = key.split('.'); const lastKey = keys.pop(); let obj = this.data; for (const k of keys) { if (!obj[k] || typeof obj[k] !== 'object') obj[k] = {}; obj = obj[k]; } obj[lastKey] = value; this.showStateUpdate(`Updated ${key}: ${JSON.stringify(value)}`); }, toggle(key) { this.set(key, !this.get(key)); }, showStateUpdate(message, isError = false) { const indicator = document.getElementById('stateIndicator'); indicator.textContent = message; indicator.classList.remove('alert-danger', 'alert-info'); indicator.classList.add(isError ? 'alert-danger' : 'alert-info'); indicator.style.display = 'block'; indicator.classList.add('show'); setTimeout(() => { indicator.classList.remove('show'); setTimeout(() => { indicator.style.display = 'none'; }, 300); }, 2000); } };
const saveAppState = () => { try { const json = JSON.stringify(AppState.data, null, 2); const blob = new Blob([json], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'app_state.json'; a.click(); URL.revokeObjectURL(a.href); } catch (e) { AppState.showStateUpdate('Failed to save state', true); } };
const loadAppState = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json'; input.onchange = e => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { try { const loadedData = JSON.parse(event.target.result); AppState.data = loadedData; AppState.showStateUpdate('State loaded successfully'); Router.apply(); } catch (err) { AppState.showStateUpdate('Error loading state: Invalid JSON', true); } }; reader.readAsText(file); }; input.click(); };
const clearAppState = () => { AppState.data = { userProfile: { name: 'John Doe', email: 'john@example.com', darkMode: false, notifications: true, twoFactor: true }, settings: { language: 'en', timezone: 'UTC', autoSave: true, debugMode: false, generalNotices: true, promoEmails: false }, notifications: { email: true, sms: false, push: true, mentions: true }, privacy: { locationServices: true, personalizedAds: false } }; AppState.showStateUpdate('State cleared to defaults'); Router.apply(); };
// Menu Configuration (UNCHANGED)
const MenuConfig = { modal: { profile: { title: 'User Profile', type: 'menu', headerActions: { right: [{ type: 'badge', label: () => AppState.get('userProfile.name') || 'User', icon: 'fa-solid fa-user-circle', class: 'bg-primary', action: {id: 'viewProfileBadge'} }, { type: 'icon', icon: 'fa-solid fa-bell', class: 'text-info', action: { id: 'showNotifications' } }] }, items: [{ label: () => AppState.get('userProfile.name') || 'John Doe', icon: 'fa-solid fa-user-circle', route: 'profile/edit', description: 'Edit your profile information' }, { label: 'Account Settings', icon: 'fa-solid fa-gear', route: 'profile/settings', description: 'Manage account preferences' }, { label: 'Security', icon: 'fa-solid fa-shield-halved', route: 'profile/security', description: 'Security and privacy settings' }, { label: 'Billing', icon: 'fa-solid fa-credit-card', action: { id: 'showBillingInfo' } }], footerActions: { left: [{ label: 'Sign Out', icon: 'fa-solid fa-right-from-bracket', class: 'btn btn-sm btn-outline-danger', action: { id: 'signOut' } }], right: [{ label: 'Help', icon: 'fa-solid fa-question-circle', class: 'btn btn-sm btn-outline-secondary', action: { id: 'openHelp' } }] } }, 'profile/edit': { title: 'Edit Profile', type: 'form', headerActions: { right: [{ type: 'icon', icon: 'fa-solid fa-pencil', class: 'text-secondary' }] }, content: () => `<div class="p-3"><div class="mb-3"><label for="profileName" class="form-label">Name</label><input type="text" class="form-control" id="profileName" value="${AppState.get('userProfile.name')}"></div><div class="mb-3"><label for="profileEmail" class="form-label">Email address</label><input type="email" class="form-control" id="profileEmail" value="${AppState.get('userProfile.email')}"></div><div class="btn-group w-100"><button type="button" class="btn btn-outline-secondary" data-action="back">Cancel</button><button type="button" class="btn btn-primary" data-action="saveProfile">Save Changes</button></div></div>` }, 'profile/settings': { title: 'Account Settings', type: 'menu', items: [{ label: 'Dark Mode', icon: 'fa-solid fa-moon', toggle: true, key: 'userProfile.darkMode' }, { label: 'Auto-Save', icon: 'fa-solid fa-save', toggle: true, key: 'settings.autoSave' }, { label: 'Debug Mode', icon: 'fa-solid fa-bug', toggle: true, key: 'settings.debugMode' }] }, 'profile/security': { title: 'Security', type: 'menu', items: [{ label: 'Enable Two-Factor Auth', icon: 'fa-solid fa-key', toggle: true, key: 'userProfile.twoFactor' }, { label: 'Email Notifications', icon: 'fa-solid fa-envelope', toggle: true, key: 'notifications.email' }, { label: 'Push Notifications', icon: 'fa-solid fa-mobile-alt', toggle: true, key: 'notifications.push' }] }, about: { title: 'About Us', type: 'info', content: `<div class="text-center p-4"><i class="fa-solid fa-circle-info text-primary" style="font-size: 3rem;"></i><h3 class="mt-2">Our Company</h3><p class="text-muted">Welcome to our innovative platform that brings cutting-edge technology to your fingertips.</p></div>` } }, offcanvas: { settings: { title: 'Settings', type: 'menu', items: [{ label: 'General', icon: 'fa-solid fa-sliders', route: 'settings/general' }, { label: 'Privacy Controls', icon: 'fa-solid fa-user-shield', route: 'settings/privacy', description: 'Manage location and ad settings' }] }, 'settings/general': { title: 'General Settings', type: 'menu', items: [{ label: 'General Notices', icon: 'fa-solid fa-bell', toggle: true, key: 'settings.generalNotices' }, { label: 'Promotional Emails', icon: 'fa-solid fa-envelope-open-text', toggle: true, key: 'settings.promoEmails' }] }, 'settings/privacy': { title: 'Privacy Controls', type: 'menu', items: [{ label: 'Location Services', icon: 'fa-solid fa-location-dot', toggle: true, key: 'privacy.locationServices' }, { label: 'Personalized Ads', icon: 'fa-solid fa-ad', toggle: true, key: 'privacy.personalizedAds' }] } } };
const ActionHandlers = { 'signOut': () => alert('Signed out!'), 'openHelp': () => alert('Help Center opened!'), 'showBillingInfo': () => alert('Billing information is handled externally.'), 'showNotifications': () => AppState.showStateUpdate('Profile notifications overview'), 'viewProfileBadge': () => AppState.showStateUpdate(`Viewing profile for: ${AppState.get('userProfile.name') || 'User'}`) };
class MenuRenderer {
constructor(containerId, backButtonId, headerActionContainerIds, type, bsComponent) {
this.container = document.getElementById(containerId);
this.backButton = document.getElementById(backButtonId);
this.titleElement = bsComponent._element.querySelector('.modal-title, .offcanvas-title');
this.headerActionContainers = { center: document.getElementById(headerActionContainerIds.center), right: document.getElementById(headerActionContainerIds.right) };
this.type = type;
this.bsComponent = bsComponent;
this.previousPath = '';
this.currentPosition = null;
this.container.addEventListener('click', this._handleContainerClick.bind(this));
this.backButton.onclick = () => Router.back();
// When a menu is closed via UI, reset the route to sync state.
const onHidden = () => {
this.reset();
if (Router.getFragment().startsWith(this.type)) {
Router.navigate('');
}
};
bsComponent._element.addEventListener('hidden.bs.modal', onHidden);
bsComponent._element.addEventListener('hidden.bs.offcanvas', onHidden);
}
reset() {
this.container.innerHTML = '';
this.previousPath = '';
this.titleElement.textContent = 'Menu';
this.backButton.style.display = 'none';
Object.values(this.headerActionContainers).forEach(c => { if (c) c.innerHTML = ''; });
}
_handleContainerClick(event) {
const actionableEl = event.target.closest('[data-action]');
if (!actionableEl) return;
event.preventDefault();
event.stopPropagation();
const { action, value } = actionableEl.dataset;
switch (action) {
case 'navigate': Router.navigate(`${this.type}/${value}`); break;
case 'toggle': AppState.toggle(value); this.render(this.previousPath, false); break;
case 'custom': if (ActionHandlers[value]) ActionHandlers[value](); break;
case 'saveProfile':
const form = actionableEl.closest('.p-3');
if (form) { AppState.set('userProfile.name', form.querySelector('#profileName').value); AppState.set('userProfile.email', form.querySelector('#profileEmail').value); Router.back(); }
break;
case 'back': Router.back(); break;
}
}
render(path, animate = true) {
let config = MenuConfig[this.type][path];
if (!config) { this.close(); Router.navigate(''); return; }
this.updateHeader(config, path);
const newLevel = document.createElement('div');
newLevel.className = 'menu-level';
let contentHtml = '';
if (config.type === 'info') { contentHtml = `<div class="info-content p-3">${config.content}</div>`; }
else if (config.type === 'form') { contentHtml = config.content(); }
else {
const itemsHtml = (config.items || []).map(item =>
item.items ? `<div class="menu-group-title">${item.title}</div>${item.items.map(gi => this.createMenuItemHtml(gi)).join('')}` : this.createMenuItemHtml(item)
).join('');
// Ensure menu content scrolls if it exceeds height, excluding footer height
const itemsContainer = `<div style="height: ${config.footerActions ? 'calc(100% - 60px)' : '100%'}; overflow-y: auto;">${itemsHtml}</div>`;
let footerHtml = '';
if (config.footerActions) {
footerHtml += '<div class="menu-footer">';
for (const groupKey in config.footerActions) {
footerHtml += `<div class="footer-action-group footer-group-${groupKey}">${config.footerActions[groupKey].map(action => this._createActionHtml(action, 'button')).join('')}</div>`;
}
footerHtml += '</div>';
}
contentHtml = itemsContainer + footerHtml;
}
newLevel.innerHTML = contentHtml;
if (animate) this.animateLevel(newLevel, path);
else { this.container.innerHTML = ''; this.container.appendChild(newLevel); newLevel.classList.add('active'); }
this.previousPath = path;
}
updateHeader(config, currentFullPath) {
this.titleElement.textContent = config.title;
this.backButton.style.display = currentFullPath.includes('/') ? 'flex' : 'none';
Object.values(this.headerActionContainers).forEach(c => { if (c) c.innerHTML = ''; });
if (config.headerActions) {
for (const groupKey in config.headerActions) {
const container = this.headerActionContainers[groupKey];
if (container) { container.innerHTML = config.headerActions[groupKey].map(item => this._createHeaderActionHtml(item)).join(''); }
}
}
}
_createHeaderActionHtml(item) {
const dataAttrs = item.action ? `data-action="custom" data-value="${item.action.id}"` : '';
const label = typeof item.label === 'function' ? item.label() : (item.label || '');
const icon = item.icon ? `<i class="${item.icon}${label ? ' me-1' : ''}"></i>` : '';
const inner = item.type === 'badge' ? `<span class="badge rounded-pill ${item.class || ''}">${icon}${label}</span>` : `${icon}${label}`;
return `<div class="header-action-item" ${dataAttrs}>${inner}</div>`;
}
createMenuItemHtml(item) {
const dataAttrs = item.route ? `data-action="navigate" data-value="${item.route}"` : item.toggle ? `data-action="toggle" data-value="${item.key}"` : (item.action ? `data-action="custom" data-value="${item.action.id}"` : '');
const label = typeof item.label === 'function' ? item.label() : item.label;
const icon = item.icon ? `<i class="${item.icon} fa-fw"></i>` : '';
const desc = item.description ? `<small class="d-block menu-item-description">${item.description}</small>` : '';
const content = `<div class="menu-item-content"><div>${icon}<span>${label}</span>${desc}</div></div>`;
if (item.toggle) { return `<div class="menu-item" role="menuitem" ${dataAttrs}>${content}<div class="form-check form-switch"><input class="form-check-input" type="checkbox" ${AppState.get(item.key) ? 'checked' : ''} style="pointer-events: none;"></div></div>`; }
const arrow = item.route ? '<i class="fa-solid fa-chevron-right text-muted"></i>' : (item.action ? '<i class="fa-solid fa-arrow-up-right-from-square text-muted"></i>' : '');
return `<div class="menu-item" role="menuitem" ${dataAttrs}>${content}${arrow}</div>`;
}
_createActionHtml(action, type) {
const dataAttrs = action.route ? `data-action="navigate" data-value="${action.route}"` : (action.action ? `data-action="custom" data-value="${action.action.id}"` : '');
const icon = action.icon ? `<i class="${action.icon} me-1"></i>` : '';
return `<${type} class="${action.class || 'btn btn-sm'}" ${dataAttrs}>${icon}${action.label}</${type}>`;
}
animateLevel(newLevel, path) {
// Robustly calculate the direction of navigation for animations.
const getDepth = p => p ? p.split('/').length : 0;
const isForward = getDepth(path) > getDepth(this.previousPath);
const currentActiveLevel = this.container.querySelector('.menu-level.active');
if (currentActiveLevel) {
// Add initial slide class to the new level
newLevel.classList.add(isForward ? 'slide-right' : 'slide-left');
this.container.appendChild(newLevel);
// Force reflow to ensure initial state is applied before transition
newLevel.offsetWidth; // Trigger reflow
requestAnimationFrame(() => {
// Animate current level out
currentActiveLevel.classList.remove('active');
currentActiveLevel.classList.add(isForward ? 'slide-left' : 'slide-right');
// Animate new level in
newLevel.classList.add('active');
newLevel.classList.remove(isForward ? 'slide-right' : 'slide-left');
// Clean up old level after transition
currentActiveLevel.addEventListener('transitionend', () => currentActiveLevel.remove(), { once: true });
});
} else {
this.container.appendChild(newLevel);
newLevel.classList.add('active');
}
}
close() { if (this.bsComponent && this.bsComponent._isShown) this.bsComponent.hide(); }
}
// --- App Initialization ---
const menuModalEl = document.getElementById('menuModal');
const menuOffcanvasEl = document.getElementById('menuOffcanvas');
const modal = new bootstrap.Modal(menuModalEl);
const offcanvas = new bootstrap.Offcanvas(menuOffcanvasEl);
const modalRenderer = new MenuRenderer('modalMenuContainer', 'modalBackButton', {center: 'modalHeaderActionsCenter', right: 'modalHeaderActionsRight'}, 'modal', modal);
const offcanvasRenderer = new MenuRenderer('offcanvasMenuContainer', 'offcanvasBackButton', {center: 'offcanvasHeaderActionsCenter', right: 'offcanvasHeaderActionsRight'}, 'offcanvas', offcanvas);
const routeHandler = (params, renderer, defaultPosition, bsInstance, type) => {
const fullPath = params.path || '';
if (!fullPath || !MenuConfig[type][fullPath]) {
if (bsInstance._isShown) bsInstance.hide();
return;
}
const position = Router.__currentNavigationOptions?.position || renderer.currentPosition || defaultPosition;
renderer.currentPosition = position;
if (type === 'modal') {
const modalDialog = menuModalEl.querySelector('.modal-dialog');
modalDialog.className = 'modal-dialog'; // Reset classes
if (position === 'fullscreen') modalDialog.classList.add('modal-fullscreen');
else if (['top', 'bottom'].includes(position)) modalDialog.classList.add(`modal-${position}`);
else modalDialog.classList.add('modal-dialog-centered');
} else {
const positionClasses = ['offcanvas-top', 'offcanvas-bottom', 'offcanvas-start', 'offcanvas-end', 'offcanvas-fullscreen', 'offcanvas-slide-up', 'offcanvas-slide-right'];
menuOffcanvasEl.classList.remove(...positionClasses);
if (position.startsWith('fullscreen')) {
menuOffcanvasEl.classList.add('offcanvas-fullscreen', `offcanvas-slide-${position.split('-')[1] || 'up'}`);
} else {
menuOffcanvasEl.classList.add(`offcanvas-${position}`);
}
}
renderer.render(fullPath);
if (!bsInstance._isShown) bsInstance.show();
document.getElementById('currentRoute').textContent = `#${type}/${fullPath}`;
};
Router.add('modal/:path*', (params) => routeHandler(params, modalRenderer, 'center', modal, 'modal'));
Router.add('offcanvas/:path*', (params) => routeHandler(params, offcanvasRenderer, 'end', offcanvas, 'offcanvas'));
Router.add('', () => {
if (modal._isShown) modal.hide();
if (offcanvas._isShown) offcanvas.hide();
document.getElementById('currentRoute').textContent = '#';
});
let lastScrollY = 0;
const onShow = () => { lastScrollY = window.scrollY; };
const onHide = () => { setTimeout(() => window.scrollTo(0, lastScrollY), 0); };
menuModalEl.addEventListener('show.bs.modal', onShow);
menuModalEl.addEventListener('hidden.bs.modal', onHide);
menuOffcanvasEl.addEventListener('show.bs.offcanvas', onShow);
menuOffcanvasEl.addEventListener('hidden.bs.offcanvas', onHide);
document.addEventListener('DOMContentLoaded', () => Router.start());
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment