Skip to content

Instantly share code, notes, and snippets.

@zilveer
Created July 20, 2025 23:23
Show Gist options
  • Save zilveer/0e0ab4402e3a5cd6ff8d216540a829ce to your computer and use it in GitHub Desktop.
Save zilveer/0e0ab4402e3a5cd6ff8d216540a829ce to your computer and use it in GitHub Desktop.
ModalOffcanvas1.65 event
<!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; /* Important for containing sliding menu levels */
width: 100%;
height: 100%;
}
.menu-level {
position: absolute;
width: 100%;
height: 100%;
transition: transform 0.3s ease-in-out;
background: white;
opacity: 1;
box-sizing: border-box;
padding-bottom: 60px; /* Space for footer */
}
.menu-level.slide-right { transform: translateX(100%); }
.menu-level.slide-left { transform: translateX(-100%); }
.menu-level.active { transform: translateX(0); opacity: 1; }
.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.disabled { opacity: 0.5; cursor: not-allowed; background: #f8f9fa; transform: none; }
.menu-item.disabled:hover { background: #f8f9fa; }
.menu-item i { margin-right: 12px; color: var(--primary-color); min-width: 20px; text-align: center; }
.menu-item-content { flex-grow: 1; }
.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; }
.modal-content { height: 550px; border-radius: 0.75rem; }
.modal-fullscreen .modal-content { border-radius: 0; }
.offcanvas-body, .modal-body { flex-grow: 1; padding: 0; position: relative; overflow: hidden; }
/* FIXED: Header layout changed to a flexbox for proper alignment */
.modal-header, .offcanvas-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 65px;
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; }
.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); }
.btn-close { font-size: 0.9rem; }
/* Unchanged styles... */
.info-content { padding: 1.5rem; line-height: 1.7; height: 100%; overflow-y: auto; color: #343a40; }
.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); }
.state-indicator.alert-danger { background-color: rgba(220, 53, 69, 0.15); border-color: #dc3545; color: #dc3545; }
.state-indicator.alert-info { background-color: rgba(13, 202, 240, 0.15); border-color: #0dcaf0; color: #087990; }
.form-check.form-switch { display: flex; align-items: center; justify-content: flex-end; margin-left: auto; }
.form-check-input { margin-left: 0 !important; cursor: pointer; }
.profile-edit-form { padding: 1.5rem; height: 100%; overflow-y: auto; }
.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.fade .modal-dialog { transition: transform .3s ease-out, opacity .3s ease-out; }
.modal-fullscreen .modal-content { height: 100%; }
.offcanvas.offcanvas-fullscreen { transition: transform .3s ease-in-out; }
.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>
<button class="btn btn-success" onclick="Router.navigate('offcanvas/account', {position: 'end'})">Account Offcanvas (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>
<hr class="w-100 my-2">
<h5>Direct Submenu & Position Examples:</h5>
<button class="btn btn-info" onclick="Router.navigate('modal/profile/edit', {position: 'top'})">Modal: Edit Profile (Top)</button>
<button class="btn btn-success" onclick="Router.navigate('offcanvas/settings/privacy', {position: 'bottom'})">Offcanvas: Privacy (Bottom)</button>
<button class="btn btn-warning text-white" onclick="Router.navigate('modal/profile/security', {position: 'center'})">Modal: Security (Default)</button>
<button class="btn btn-dark" onclick="Router.navigate('offcanvas/account/security-offcanvas', {position: 'start'})">Offcanvas: Account Security (Left)</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>
<p class="text-muted small">Click a button to open a menu. Navigate inside the menu. The current route will update accordingly. You can also save/load/clear the application state. New buttons demonstrate explicit modal/offcanvas positioning and direct submenu access.</p>
</div>
<div id="stateIndicator" class="state-indicator" role="status" aria-live="polite" style="display: none;"></div>
<div class="modal fade" 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 src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/js/all.min.js" defer></script>
<script>
// Router Library (embedded - 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: [], run_before: null, run_after: null, }; const normalizeHash = (hash) => hash.replace(new RegExp(HASH_PREFIX), '').replace(SLASH_SUFFIX, ''); const isFunction = (fn) => typeof fn === 'function'; const runHook = (hook, route) => isFunction(hook) && hook.call(null, route); const router = { getFragment: () => normalizeHash(window.location.hash), parseRoute: (route) => { const paramNames = []; let regexString = route.replace(/:([a-zA-Z0-9_]+)(\*)?/g, (match, p1, p2) => { paramNames.push(p1); if (p2 === '*') { return '(.*?)'; } return '([^/]+)'; }); regexString = `^${regexString}$`; return { regex: new RegExp(regexString), paramNames }; }, add: (route, handler, run_before, run_after) => { const { regex, paramNames } = router.parseRoute(route); internal.routes.push({ originalRoute: route, handler, route: regex, paramNames, run_before, run_after }); return router; }, apply: (frg) => { const fragment = frg || router.getFragment(); if (internal.current === fragment && !frg) return router; for (const routeObj of internal.routes) { const matches = fragment.match(routeObj.route); if (matches) { matches.shift(); const params = {}; routeObj.paramNames.forEach((name, i) => { let value = matches[i]; if (routeObj.originalRoute.includes(`:${name}*`) && typeof value === 'string') { params[name] = value.split('/').filter(s => s !== ''); } else { params[name] = value; } }); internal.current = fragment; if (internal.history[internal.history.length - 1] !== fragment) { internal.history.push(fragment); } runHook(internal.run_before, routeObj); routeObj.handler.call({}, matches, params, routeObj.originalRoute); runHook(internal.run_after, routeObj); return router; } } internal.current = null; return router; }, start: () => { const handleHashChange = () => { const fragment = router.getFragment(); if (internal.current !== fragment) { router.apply(fragment); } }; window.addEventListener('hashchange', handleHashChange); if (!internal.current) router.apply(); return router; }, navigate: (path, options = {}) => { document.title = options.title || document.title; router.__currentNavigationOptions = options; window.location.hash = path ? `#${path.replace(/##/g, '#')}` : ''; return router; }, back: () => { if (internal.history.length > 0 && internal.history[internal.history.length - 1] === router.getFragment()) { internal.history.pop(); } const previousPath = internal.history.pop(); if (previousPath !== undefined) { window.location.hash = previousPath ? `#${previousPath}` : ''; } else { window.location.hash = ''; } router.__currentNavigationOptions = {}; return router; }, }; return router; });
// State Management and Utils (UNCHANGED)
const deepMerge = (target, source) => { for (const key in source) { if (source.hasOwnProperty(key)) { if (source[key] instanceof Object && !Array.isArray(source[key])) { if (!target[key]) Object.assign(target, { [key]: {} }); deepMerge(target[key], source[key]); } else { Object.assign(target, { [key]: source[key] }); } } } return target; };
const AppState = { data: { userProfile: { name: 'John Doe', email: '[email protected]', darkMode: false, notifications: true, twoFactor: true }, settings: { language: 'en', timezone: 'UTC', autoSave: true, debugMode: 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)); }, save() { try { const json = JSON.stringify(this.data, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'app_state.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); this.showStateUpdate('State saved to app_state.json'); } catch (e) { console.error('Failed to save state:', e); this.showStateUpdate('Failed to save state', true); } }, load() { const input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json'; input.onchange = e => { const file = e.target.files[0]; if (!file) return; try { const reader = new FileReader(); reader.onload = (event) => { try { const loadedData = JSON.parse(event.target.result); this.data = deepMerge(this.data, loadedData); this.showStateUpdate('State loaded successfully'); Router.apply(Router.getFragment()); } catch (parseError) { console.error('Error parsing JSON:', parseError); this.showStateUpdate('Error loading state: Invalid JSON', true); } }; reader.readAsText(file); } catch (readError) { console.error('Error reading file:', readError); this.showStateUpdate('Error loading state: File read error', true); } }; input.click(); }, clear() { this.data = { userProfile: { name: 'John Doe', email: '[email protected]', darkMode: false, notifications: true, twoFactor: true }, settings: { language: 'en', timezone: 'UTC', autoSave: true, debugMode: false }, notifications: { email: true, sms: false, push: true, mentions: true }, privacy: { locationServices: true, personalizedAds: false } }; this.showStateUpdate('State cleared to defaults'); Router.apply(Router.getFragment()); }, 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); } };
// Menu Configuration (All actions are now objects of arrays)
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: () => AppState.showStateUpdate(`Viewing profile for: ${AppState.get('userProfile.name') || 'User'}`) }, { type: 'icon', icon: 'fa-solid fa-bell', class: 'text-info', action: () => AppState.showStateUpdate('Profile notifications overview') }] },
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: () => alert('Billing information is handled externally.') }],
footerActions: { left: [{ label: 'Sign Out', icon: 'fa-solid fa-right-from-bracket', class: 'btn btn-sm btn-outline-danger', action: () => alert('Signed out!') }], right: [{ label: 'Help', icon: 'fa-solid fa-question-circle', class: 'btn btn-sm btn-outline-secondary', action: () => alert('Help Center opened!') }] }
},
'profile/edit': { title: 'Edit Profile', type: 'form', headerActions: { right: [{ type: 'icon', icon: 'fa-solid fa-pencil', class: 'text-secondary', action: () => AppState.showStateUpdate('Editing profile details...') }] }, content: () => `<div class="profile-edit-form"><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"><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', headerActions: { right: [{ type: 'badge', label: 'Info', icon: 'fa-solid fa-info-circle', class: 'bg-info', action: () => AppState.showStateUpdate('Account settings information') }, { type: 'icon', icon: 'fa-solid fa-question-circle', class: 'text-warning', action: () => alert('Need help with settings?') }] }, items: [{ type: 'group', title: 'General Options', items: [{ label: 'Dark Mode', icon: 'fa-solid fa-moon', toggle: true, key: 'userProfile.darkMode' }, { label: 'Auto Save', icon: 'fa-solid fa-cloud-arrow-up', toggle: true, key: 'settings.autoSave' }] }, { type: 'group', title: 'Communication', items: [{ label: 'Notifications', icon: 'fa-solid fa-bell', route: 'profile/settings/notifications', description: 'Manage notification preferences' }, { label: 'Contact Preferences', icon: 'fa-solid fa-address-book', action: () => alert('Contact preferences editor would open here.') }] }] },
'profile/settings/notifications': { title: 'Notifications', type: 'menu', headerActions: { right: [{ type: 'icon', icon: 'fa-solid fa-envelope-open', class: 'text-muted', action: () => AppState.showStateUpdate('Notification status') }] }, items: [{ label: 'Email Notifications', icon: 'fa-solid fa-envelope', toggle: true, key: 'notifications.email' }, { label: 'SMS Alerts', icon: 'fa-solid fa-mobile-alt', toggle: true, key: 'notifications.sms' }, { label: 'Push Notifications', icon: 'fa-solid fa-bell-slash', toggle: true, key: 'notifications.push' }, { label: 'Mentions & Replies', icon: 'fa-solid fa-at', toggle: true, key: 'notifications.mentions' }] },
'profile/security': { title: 'Security Settings', type: 'menu', headerActions: { right: [{ type: 'badge', label: 'Secure', icon: 'fa-solid fa-check-circle', class: 'bg-success', action: () => AppState.showStateUpdate('Security status: All good!') }] }, items: [{ type: 'group', title: 'Authentication', items: [{ label: 'Two-Factor Authentication', icon: 'fa-solid fa-shield-alt', toggle: true, key: 'userProfile.twoFactor' }, { label: 'Change Password', icon: 'fa-solid fa-lock', action: () => alert('Password change dialog would open here.') }] }, { type: 'group', title: 'Activity', items: [{ label: 'Login History', icon: 'fa-solid fa-history', action: () => alert('Login history would be displayed here.') }, { label: 'Active Sessions', icon: 'fa-solid fa-desktop', action: () => alert('Active sessions management.') }] }] },
about: { title: 'About Us', type: 'info', headerActions: { center: [{ type: 'icon', icon: 'fa-solid fa-info-circle', class: 'text-primary', action: () => AppState.showStateUpdate('About this application') }] }, content: `<div class="text-center mb-4"><i class="fa-solid fa-circle-info text-primary" style="font-size: 3rem;"></i><h3 class="mt-2">Our Company</h3></div><p>Welcome to our innovative platform that brings cutting-edge technology to your fingertips.</p>` }
},
offcanvas: {
settings: { title: 'Settings', type: 'menu', headerActions: { right: [{ type: 'icon', icon: 'fa-solid fa-gear', class: 'text-muted', action: () => AppState.showStateUpdate('Settings are in Beta!') }, { type: 'badge', label: 'Beta', class: 'bg-warning text-dark', action: () => AppState.showStateUpdate('This is a beta feature!') }] }, items: [{ type: 'group', title: 'General', items: [{ label: 'General Settings', icon: 'fa-solid fa-sliders', route: 'settings/general', description: 'Basic application settings' }, { label: 'Appearance', icon: 'fa-solid fa-palette', route: 'settings/appearance', description: 'Customize the look and feel' }] }, { type: 'group', title: 'Privacy & Data', items: [{ label: 'Privacy Controls', icon: 'fa-solid fa-user-shield', route: 'settings/privacy', description: 'Privacy controls and data management' }, { label: 'Data Export/Import', icon: 'fa-solid fa-database', route: 'settings/data-management', description: 'Manage your application data' }] }, { type: 'group', title: 'Advanced', items: [{ label: 'Advanced Settings', icon: 'fa-solid fa-gear-code', route: 'settings/advanced', description: 'Advanced configuration options' }] }] },
'settings/general': { title: 'General Settings', type: 'menu', headerActions: { right: [{ type: 'icon', icon: 'fa-solid fa-cog', class: 'text-primary', action: () => AppState.showStateUpdate('General app settings') }] }, items: [{ label: () => `Language: ${AppState.get('settings.language').toUpperCase()}`, icon: 'fa-solid fa-language', action: () => { const lang = prompt('Enter language code (en/es/fr):', AppState.get('settings.language')); if (lang) AppState.set('settings.language', lang); } }, { label: () => `Timezone: ${AppState.get('settings.timezone')}`, icon: 'fa-solid fa-earth-americas', action: () => { const tz = prompt('Enter timezone:', AppState.get('settings.timezone')); if (tz) AppState.set('settings.timezone', tz); } }, { label: 'Auto Save', icon: 'fa-solid fa-cloud-arrow-up', toggle: true, key: 'settings.autoSave' }] },
'settings/appearance': { title: 'Appearance', type: 'menu', headerActions: { right: [{ type: 'icon', icon: 'fa-solid fa-brush', class: 'text-info', action: () => AppState.showStateUpdate('Customizing appearance') }] }, items: [{ label: 'Dark Theme', icon: 'fa-solid fa-moon', toggle: true, key: 'userProfile.darkMode' }, { label: 'Font Size', icon: 'fa-solid fa-font', action: () => alert('Font size selector would open here.') }, { label: 'Color Scheme', icon: 'fa-solid fa-fill-drip', action: () => alert('Color scheme picker would open here.') }] },
'settings/privacy': { title: 'Privacy Controls', type: 'menu', headerActions: { right: [{ type: 'icon', icon: 'fa-solid fa-lock', class: 'text-success', action: () => AppState.showStateUpdate('Managing privacy settings') }] }, items: [{ label: 'Cookie Preferences', icon: 'fa-solid fa-cookie-bite', action: () => alert('Cookie consent management.') }, { 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' }] },
'settings/data-management': { title: 'Data Management', type: 'menu', headerActions: { right: [{ type: 'icon', icon: 'fa-solid fa-hdd', class: 'text-secondary', action: () => AppState.showStateUpdate('Data management options') }] }, items: [{ label: 'Export Data', icon: 'fa-solid fa-download', action: () => AppState.save() }, { label: 'Import Data', icon: 'fa-solid fa-upload', action: () => AppState.load() }, { label: 'Reset All Data', icon: 'fa-solid fa-eraser', action: () => { if (confirm('Are you sure you want to reset all application data to defaults?')) { AppState.clear(); Router.back(); } } }] },
'settings/advanced': { title: 'Advanced Settings', type: 'menu', headerActions: { right: [{ type: 'icon', icon: 'fa-solid fa-microchip', class: 'text-danger', action: () => AppState.showStateUpdate('Advanced settings are for power users!') }] }, items: [{ label: 'Debug Mode', icon: 'fa-solid fa-bug', toggle: true, key: 'settings.debugMode' }, { label: 'Cache Settings', icon: 'fa-solid fa-hdd', action: () => alert('Cache management options.') }, { label: 'API Keys', icon: 'fa-solid fa-key', action: () => alert('Manage API integration keys.') }] },
account: { title: 'Account Management', type: 'menu', headerActions: { right: [{ type: 'icon', icon: 'fa-solid fa-user-gear', class: 'text-primary', action: () => AppState.showStateUpdate('Managing your account') }] }, items: [{ type: 'group', title: 'Profile', items: [{ label: 'Profile Overview', icon: 'fa-solid fa-user-check', route: 'account/profile', description: 'View and edit profile details' }, { label: 'Account Security', icon: 'fa-solid fa-shield-alt', route: 'account/security-offcanvas', description: 'Security and login settings' }] }, { type: 'group', title: 'Financial', items: [{ label: 'Subscription', icon: 'fa-solid fa-credit-card', route: 'account/billing', description: 'Manage your subscription' }, { label: 'Payment Methods', icon: 'fa-solid fa-wallet', action: () => alert('Manage stored payment methods.') }] }, { type: 'group', title: 'Data', items: [{ label: 'Data & Privacy', icon: 'fa-solid fa-shield-alt', route: 'account/privacy', description: 'Privacy and data settings' }] }], footerActions: { main: [{ label: 'Deactivate Account', icon: 'fa-solid fa-triangle-exclamation', class: 'btn btn-sm btn-warning', action: () => alert('Account deactivation process would begin.') }] } },
'account/profile': { title: 'Profile Overview', type: 'menu', headerActions: { right: [{ type: 'icon', icon: 'fa-solid fa-id-card', class: 'text-success', action: () => AppState.showStateUpdate('Your profile details') }] }, items: [{ label: () => `Display Name: ${AppState.get('userProfile.name')}`, icon: 'fa-solid fa-user', action: () => { const name = prompt('Update display name:', AppState.get('userProfile.name')); if (name) AppState.set('userProfile.name', name); } }, { label: () => `Contact Email: ${AppState.get('userProfile.email')}`, icon: 'fa-solid fa-at', action: () => { const email = prompt('Update email:', AppState.get('userProfile.email')); if (email) AppState.set('userProfile.email', email); } }, { label: 'Profile Picture', icon: 'fa-solid fa-camera', action: () => alert('Profile picture upload would open here.') }] },
'account/security-offcanvas': { title: 'Account Security', type: 'menu', headerActions: { right: [{ type: 'icon', icon: 'fa-solid fa-fingerprint', class: 'text-danger', action: () => AppState.showStateUpdate('Reviewing account security') }] }, items: [{ label: 'Change Password', icon: 'fa-solid fa-lock', action: () => alert('Change password dialog.') }, { label: 'Two-Factor Authentication', icon: 'fa-solid fa-shield-alt', toggle: true, key: 'userProfile.twoFactor' }, { label: 'View Login History', icon: 'fa-solid fa-history', action: () => alert('View recent logins.') }] },
'account/billing': { title: 'Subscription & Billing', type: 'menu', headerActions: { right: [{ type: 'badge', label: 'Active', icon: 'fa-solid fa-check', class: 'bg-success', action: () => AppState.showStateUpdate('Subscription is active') }] }, items: [{ label: 'Current Plan: Pro', icon: 'fa-solid fa-gem', action: () => alert('Plan details and upgrade options.') }, { label: 'Payment Method', icon: 'fa-solid fa-credit-card', action: () => alert('Payment method management.') }, { label: 'Billing History', icon: 'fa-solid fa-receipt', action: () => alert('Download billing history.') }] },
'account/privacy': { title: 'Data & Privacy', type: 'menu', headerActions: { right: [{ type: 'icon', icon: 'fa-solid fa-user-secret', class: 'text-secondary', action: () => AppState.showStateUpdate('Your data privacy settings') }] }, items: [{ label: 'Data Export', icon: 'fa-solid fa-file-export', action: () => AppState.save() }, { label: 'Privacy Settings', icon: 'fa-solid fa-eye-slash', action: () => alert('Privacy controls would open here.') }, { label: 'Delete All Data', icon: 'fa-solid fa-trash', action: () => { if (confirm('Are you sure you want to delete all data? This action cannot be undone.')) { AppState.clear(); if (currentModalInstance) currentModalInstance.hide(); if (currentOffcanvasInstance) currentOffcanvasInstance.hide(); Router.navigate(''); } } }] }
}
};
class MenuRenderer {
constructor(containerId, headerTitleSelector, backButtonId, headerActionContainerIds, type, bsComponent) {
this.container = document.getElementById(containerId);
this.titleElement = document.querySelector(headerTitleSelector);
this.backButton = document.getElementById(backButtonId);
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));
if (this.backButton) { this.backButton.onclick = () => Router.back(); }
if (bsComponent && bsComponent._element) {
bsComponent._element.addEventListener('hidden.bs.modal', () => this.reset());
bsComponent._element.addEventListener('hidden.bs.offcanvas', () => this.reset());
}
}
reset() {
if (this.container) this.container.innerHTML = '';
this.previousPath = '';
// FIXED: Do not reset currentPosition, to allow browser 'back' to work
if (this.titleElement) this.titleElement.textContent = 'Menu';
if (this.backButton) 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();
const action = actionableEl.dataset.action;
const value = actionableEl.dataset.value;
switch (action) {
case 'navigate': Router.navigate(`${this.type}/${value}`); break;
case 'toggle': AppState.toggle(value); this.render(this.previousPath, false); break;
case 'custom':
const configItem = this._findActionConfig(this.previousPath, { actionName: value });
if (configItem && typeof configItem.action === 'function') { configItem.action(); this.render(this.previousPath, false); }
break;
case 'saveProfile':
const form = actionableEl.closest('.profile-edit-form');
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;
}
}
_findActionConfig(path, { actionName }) {
const config = MenuConfig[this.type][path];
if (!config) return null;
const searchIn = (items) => {
for (const item of items) {
if (actionName && item.action && item.action.name === actionName) return item;
if (item.type === 'group' && item.items) { const found = searchIn(item.items); if (found) return found; }
}
return null;
};
const sources = [config.items || [], ...Object.values(config.footerActions || {}), ...Object.values(config.headerActions || {})];
for (const source of sources) { const found = searchIn(source); if (found) return found; }
return null;
}
render(path, animate = true) {
let config = MenuConfig[this.type][path];
if (!config) {
let pathParts = path.split('/');
while (pathParts.length > 1) {
pathParts.pop();
const parentPath = pathParts.join('/');
if (MenuConfig[this.type][parentPath]) {
console.warn(`Menu for '${path}' not found. Navigating to parent '${parentPath}'.`);
Router.navigate(`${this.type}/${parentPath}`);
return;
}
}
console.error(`Menu config for path '${this.type}/${path}' and its parents not found. Closing.`);
this.close();
Router.navigate('');
return;
}
const isForward = animate && (Router.getFragment().split('/').length > this.previousPath.split('/').length || this.previousPath === '');
this.updateHeader(config, path);
const newLevel = document.createElement('div');
newLevel.className = 'menu-level';
if (config.type === 'info') { newLevel.innerHTML = `<div class="info-content">${config.content}</div>`; }
else if (config.type === 'form') { newLevel.innerHTML = typeof config.content === 'function' ? config.content() : config.content; }
else {
const itemsContainer = document.createElement('div');
itemsContainer.style.cssText = `height: ${config.footerActions ? 'calc(100% - 60px)' : '100%'}; overflow-y: auto;`;
(config.items || []).forEach(item => {
if (item.type === 'group') {
itemsContainer.innerHTML += `<div class="menu-group-title">${item.title}</div>`;
item.items.forEach(groupItem => itemsContainer.innerHTML += this.createMenuItemHtml(groupItem));
} else { itemsContainer.innerHTML += this.createMenuItemHtml(item); }
});
newLevel.appendChild(itemsContainer);
if (config.footerActions && Object.keys(config.footerActions).length > 0) {
const footerContainer = document.createElement('div');
footerContainer.className = 'menu-footer';
for (const groupKey in config.footerActions) {
const groupDiv = document.createElement('div');
groupDiv.className = `footer-action-group footer-group-${groupKey}`;
config.footerActions[groupKey].forEach(action => groupDiv.innerHTML += this._createActionHtml(action, 'button'));
footerContainer.appendChild(groupDiv);
}
newLevel.appendChild(footerContainer);
}
}
if (animate) { this.container.appendChild(newLevel); this.animateLevel(newLevel, isForward); }
else { this.container.innerHTML = ''; this.container.appendChild(newLevel); newLevel.classList.add('active'); }
this.previousPath = path;
}
updateHeader(config, currentFullPath) {
if (this.titleElement) this.titleElement.textContent = config.title;
if (this.backButton) this.backButton.style.display = currentFullPath.split('/').length > 1 ? 'flex' : 'none';
// FIXED: Properly render header actions into their respective containers
Object.values(this.headerActionContainers).forEach(c => { if(c) c.innerHTML = ''; });
if (config.headerActions) {
for (const groupKey in config.headerActions) { // 'center', 'right'
const container = this.headerActionContainers[groupKey];
if (container) {
config.headerActions[groupKey].forEach(actionItem => {
container.innerHTML += this._createHeaderActionHtml(actionItem);
});
}
}
}
}
_createHeaderActionHtml(actionItem) {
const actionName = actionItem.action ? actionItem.action.name : '';
const dataAttrs = actionItem.action ? `data-action="custom" data-value="${actionName}"` : '';
const labelContent = typeof actionItem.label === 'function' ? actionItem.label() : (actionItem.label || '');
const iconHtml = actionItem.icon ? `<i class="${actionItem.icon}${labelContent ? ' me-1' : ''}"></i>` : '';
const innerHtml = actionItem.type === 'badge' ? `<span class="badge rounded-pill ${actionItem.class || ''}">${iconHtml}${labelContent}</span>` : `${iconHtml}${labelContent}`;
return `<div class="header-action-item" ${dataAttrs}>${innerHtml}</div>`;
}
createMenuItemHtml(item) {
const labelText = typeof item.label === 'function' ? item.label() : item.label;
const iconHtml = item.icon ? `<i class="${item.icon} fa-fw" aria-hidden="true"></i>` : '';
const descriptionHtml = item.description ? `<small class="d-block menu-item-description">${item.description}</small>` : '';
let dataAttrs = '';
if (item.route) dataAttrs = `data-action="navigate" data-value="${item.route}"`;
else if (item.toggle) dataAttrs = `data-action="toggle" data-value="${item.key}"`;
else if (item.action) dataAttrs = `data-action="custom" data-value="${item.action.name}"`;
const content = `<div class="menu-item-content">${iconHtml}<span>${labelText}${descriptionHtml}</span></div>`;
if (item.toggle) { return `<div class="menu-item ${item.disabled ? 'disabled' : ''}" role="menuitem" ${dataAttrs}>${content}<div class="form-check form-switch"><input class="form-check-input" type="checkbox" role="switch" ${AppState.get(item.key) ? 'checked' : ''} style="pointer-events: none;"></div></div>`; }
return `<div class="menu-item ${item.disabled ? 'disabled' : ''}" role="menuitem" ${dataAttrs}>${content}${item.route ? '<i class="fa-solid fa-chevron-right text-muted"></i>' : ''}${(item.action && !item.toggle) ? '<i class="fa-solid fa-arrow-up-right-from-square text-muted"></i>' : ''}</div>`;
}
_createActionHtml(action, elementType = 'button') {
let dataAttrs = '';
if (action.route) dataAttrs = `data-action="navigate" data-value="${action.route}"`;
else if (action.action) dataAttrs = `data-action="custom" data-value="${action.action.name}"`;
if (elementType === 'button') { return `<button type="button" class="${action.class || 'btn btn-sm'}" ${dataAttrs}>${action.icon ? `<i class="${action.icon} me-1"></i>` : ''}${action.label}</button>`; }
return '';
}
animateLevel(newLevel, isForward) {
const currentActiveLevel = this.container.querySelector('.menu-level.active');
if (currentActiveLevel && currentActiveLevel !== newLevel) {
newLevel.classList.add(isForward ? 'slide-right' : 'slide-left');
requestAnimationFrame(() => {
currentActiveLevel.classList.remove('active');
currentActiveLevel.classList.add(isForward ? 'slide-left' : 'slide-right');
newLevel.classList.add('active');
newLevel.classList.remove(isForward ? 'slide-right' : 'slide-left');
currentActiveLevel.addEventListener('transitionend', () => currentActiveLevel.remove(), { once: true });
});
} else { newLevel.classList.add('active'); }
}
close() { if (this.bsComponent) this.bsComponent.hide(); }
}
// --- App Initialization ---
const menuModalEl = document.getElementById('menuModal');
const menuOffcanvasEl = document.getElementById('menuOffcanvas');
let currentModalInstance = new bootstrap.Modal(menuModalEl);
let currentOffcanvasInstance = new bootstrap.Offcanvas(menuOffcanvasEl);
const modalMenuRenderer = new MenuRenderer('modalMenuContainer', '#menuModalLabel', 'modalBackButton', {center: 'modalHeaderActionsCenter', right: 'modalHeaderActionsRight'}, 'modal', currentModalInstance);
const offcanvasMenuRenderer = new MenuRenderer('offcanvasMenuContainer', '#offcanvasMenuLabel', 'offcanvasBackButton', {center: 'offcanvasHeaderActionsCenter', right: 'offcanvasHeaderActionsRight'}, 'offcanvas', currentOffcanvasInstance);
const defaultModalPosition = 'center';
const defaultOffcanvasPosition = 'end';
Router.add('modal/:path*', (matches, params) => {
const fullPath = (Array.isArray(params.path) ? params.path.join('/') : params.path) || '';
const position = Router.__currentNavigationOptions?.position || modalMenuRenderer.currentPosition || defaultModalPosition;
modalMenuRenderer.currentPosition = position;
const modalDialog = menuModalEl.querySelector('.modal-dialog');
modalDialog.classList.remove('modal-dialog-centered', 'modal-fullscreen', 'modal-top', 'modal-bottom');
if (position === 'fullscreen') modalDialog.classList.add('modal-fullscreen');
else if (position === 'top' || position === 'bottom') modalDialog.classList.add(`modal-${position}`);
else modalDialog.classList.add('modal-dialog-centered');
modalMenuRenderer.render(fullPath);
if (!currentModalInstance._isShown) currentModalInstance.show();
document.getElementById('currentRoute').textContent = `#modal/${fullPath}`;
});
Router.add('offcanvas/:path*', (matches, params) => {
const fullPath = (Array.isArray(params.path) ? params.path.join('/') : params.path) || '';
const position = Router.__currentNavigationOptions?.position || offcanvasMenuRenderer.currentPosition || defaultOffcanvasPosition;
offcanvasMenuRenderer.currentPosition = position;
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')) {
const direction = position.split('-')[1] || 'up';
menuOffcanvasEl.classList.add('offcanvas-fullscreen', `offcanvas-slide-${direction}`);
} else {
menuOffcanvasEl.classList.add(`offcanvas-${position}`);
}
offcanvasMenuRenderer.render(fullPath);
if (!currentOffcanvasInstance._isShown) currentOffcanvasInstance.show();
document.getElementById('currentRoute').textContent = `#offcanvas/${fullPath}`;
});
Router.add('', () => {
if (currentModalInstance && currentModalInstance._isShown) currentModalInstance.hide();
if (currentOffcanvasInstance && currentOffcanvasInstance._isShown) currentOffcanvasInstance.hide();
document.getElementById('currentRoute').textContent = '#';
});
document.addEventListener('DOMContentLoaded', () => { Router.start(); Router.apply(); });
function saveAppState() { AppState.save(); }
function loadAppState() { AppState.load(); }
function clearAppState() { AppState.clear(); }
menuModalEl.addEventListener('hidden.bs.modal', () => { if (Router.getFragment().startsWith('modal/')) Router.navigate(''); });
menuOffcanvasEl.addEventListener('hidden.bs.offcanvas', () => { if (Router.getFragment().startsWith('offcanvas/')) Router.navigate(''); });
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment