Created
July 20, 2025 20:41
-
-
Save zilveer/006901e75df11d9376364461106d4662 to your computer and use it in GitHub Desktop.
Modal offcanvas 5.1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> | |
body { | |
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
background-color: #f4f7f6; | |
color: #333; | |
} | |
.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: #007bff; | |
margin-bottom: 25px; | |
font-weight: 600; | |
} | |
.menu-container { | |
position: relative; | |
overflow: hidden; | |
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; /* Ensure padding/border is included in width/height */ | |
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-level.fade-out { opacity: 0; } | |
.menu-item { | |
padding: 0.9rem 1.2rem; /* Increased padding */ | |
border-bottom: 1px solid #e9ecef; /* Lighter border */ | |
cursor: pointer; | |
display: flex; | |
align-items: center; | |
justify-content: space-between; | |
transition: background-color 0.2s, transform 0.1s ease-out; /* Added transform for subtle hover */ | |
font-size: 1.05rem; /* Slightly larger font */ | |
color: #495057; | |
} | |
.menu-item:hover { | |
background: #e9f5ff; /* Lighter blue on hover */ | |
transform: translateX(3px); /* Subtle slide effect */ | |
} | |
.menu-item.disabled { | |
opacity: 0.5; | |
cursor: not-allowed; | |
background: #f8f9fa; /* Slightly greyed out for disabled */ | |
transform: none; | |
} | |
.menu-item.disabled:hover { background: #f8f9fa; } | |
.menu-item i { | |
margin-right: 12px; /* Spacing for icons */ | |
color: #007bff; /* Primary color for icons */ | |
min-width: 20px; /* Ensure consistent alignment */ | |
text-align: center; | |
} | |
.menu-item-content { | |
flex-grow: 1; | |
} | |
.menu-item-description { | |
font-size: 0.85rem; | |
color: #6c757d; | |
margin-top: 2px; | |
} | |
.menu-footer { | |
position: absolute; | |
bottom: 0; | |
left: 0; | |
right: 0; | |
border-top: 1px solid #dee2e6; | |
padding: 0.8rem 1rem; /* Adjusted padding */ | |
background: #f8f9fa; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
min-height: 60px; /* Ensure minimum height */ | |
} | |
/* Adjust modal/offcanvas heights for better display */ | |
.modal-content { height: 550px; display: flex; flex-direction: column; } | |
.modal-body { flex-grow: 1; padding: 0; position: relative; } /* Ensure flex-grow and position for absolute children */ | |
.offcanvas-body { flex-grow: 1; padding: 0; position: relative; } | |
.modal-header, .offcanvas-header { | |
position: relative; | |
min-height: 65px; /* Slightly taller header */ | |
border-bottom: 1px solid #dee2e6; | |
padding: 1rem; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
background-color: #f8f9fa; /* Light background for header */ | |
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); | |
} | |
.modal-header h5, .offcanvas-header h5 { | |
margin: 0; | |
font-weight: 700; /* Bolder title */ | |
color: #343a40; | |
font-size: 1.25rem; | |
} | |
.header-back { | |
background: none; | |
border: none; | |
color: #0d6efd; | |
cursor: pointer; | |
padding: 0.25rem; | |
font-size: 1.25rem; /* Larger icon */ | |
position: absolute; | |
left: 1.2rem; /* Adjusted position */ | |
top: 50%; | |
transform: translateY(-50%); | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
transition: color 0.2s; | |
} | |
.header-back:hover { | |
color: #0a58ca; | |
} | |
.header-action { | |
position: absolute; | |
right: 3.5rem; /* Adjusted to accommodate close button */ | |
top: 50%; | |
transform: translateY(-50%); | |
display: flex; | |
align-items: center; | |
color: #6c757d; | |
} | |
.header-action .badge { | |
font-size: 0.8rem; /* Smaller badge font */ | |
padding: 0.4em 0.7em; | |
} | |
.btn-close { | |
position: absolute !important; | |
top: 50% !important; | |
right: 1.2rem !important; /* Adjusted position */ | |
transform: translateY(-50%) !important; | |
z-index: 1070 !important; | |
font-size: 0.9rem; /* Smaller close button */ | |
} | |
.info-content { | |
padding: 1.5rem; | |
line-height: 1.7; /* Increased line height for readability */ | |
height: 100%; | |
overflow-y: auto; | |
color: #343a40; | |
} | |
.info-content h3, .info-content h5 { | |
color: #007bff; | |
margin-top: 1.5rem; | |
margin-bottom: 0.8rem; | |
font-weight: 600; | |
} | |
.info-content ul { | |
padding-left: 20px; | |
margin-bottom: 1rem; | |
} | |
.info-content ul li { | |
margin-bottom: 0.5rem; | |
} | |
.info-content a { | |
color: #007bff; | |
text-decoration: none; | |
} | |
.info-content a:hover { | |
text-decoration: underline; | |
} | |
.state-indicator { | |
position: fixed; | |
top: 20px; | |
right: 20px; | |
z-index: 1050; | |
background: rgba(0, 123, 255, 0.15); | |
border: 1px solid #007bff; | |
border-radius: 6px; | |
padding: 0.7rem 1.2rem; | |
font-size: 0.9rem; | |
max-width: 350px; | |
color: #0056b3; | |
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); | |
} | |
/* Specific styles for toggle switches */ | |
.form-check.form-switch { | |
display: flex; | |
align-items: center; | |
justify-content: flex-end; /* Push switch to the right */ | |
margin-left: auto; /* Push to the right */ | |
} | |
.form-check-input { | |
margin-left: 0 !important; /* Reset default margin */ | |
cursor: pointer; | |
} | |
/* Styles for the profile edit form */ | |
.profile-edit-form { | |
padding: 1.5rem; | |
height: 100%; | |
overflow-y: auto; | |
} | |
.profile-edit-form .form-label { | |
font-weight: 500; | |
color: #343a40; | |
margin-bottom: 0.3rem; | |
} | |
.profile-edit-form .form-control { | |
margin-bottom: 1rem; | |
} | |
.profile-edit-form .btn-group { | |
margin-top: 1.5rem; | |
display: flex; | |
gap: 0.5rem; | |
justify-content: flex-end; /* Align buttons to the right */ | |
} | |
/* Styles for menu groups */ | |
.menu-group-title { | |
padding: 0.5rem 1.2rem; | |
font-size: 0.85rem; | |
font-weight: 600; | |
color: #6c757d; | |
background-color: #f8f9fa; | |
border-bottom: 1px solid #e9ecef; | |
margin-top: 0.5rem; | |
text-transform: uppercase; | |
} | |
.menu-group-title:first-child { | |
margin-top: 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')">User Profile Modal</button> | |
<button class="btn btn-secondary" onclick="Router.navigate('offcanvas/settings')">Settings Offcanvas</button> | |
<button class="btn btn-info text-white" onclick="Router.navigate('modal/about')">About Us Modal</button> | |
<button class="btn btn-success" onclick="Router.navigate('offcanvas/account')">Account Offcanvas</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.</p> | |
</div> | |
<div id="stateIndicator" class="state-indicator" 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"> | |
<button id="modalBackButton" class="header-back" style="display: none;" aria-label="Go Back"> | |
<i class="fa-solid fa-chevron-left"></i> | |
</button> | |
<h5 class="modal-title" id="menuModalLabel">Menu</h5> | |
<div id="modalHeaderAction" class="header-action"></div> | |
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | |
</div> | |
<div class="modal-body"> | |
<div id="modalMenuContainer" class="menu-container"></div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div class="offcanvas offcanvas-end" id="menuOffcanvas" tabindex="-1" aria-labelledby="menuOffcanvasLabel"> | |
<div class="offcanvas-header"> | |
<button id="offcanvasBackButton" class="header-back" style="display: none;" aria-label="Go Back"> | |
<i class="fa-solid fa-chevron-left"></i> | |
</button> | |
<h5 class="offcanvas-title" id="menuOffcanvasLabel">Menu</h5> | |
<div id="offcanvasHeaderAction" class="header-action"></div> | |
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button> | |
</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) | |
(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: [], // Stores fragments in order of navigation for back button | |
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 '(.*?)'; // Non-greedy match for the rest of the path | |
} | |
return '([^/]+)'; // Match a single segment | |
}); | |
regexString = `^${regexString}$`; // Anchor the regex for exact match | |
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(); | |
// Do not re-apply if fragment is the same and not explicitly forced by external call to apply(fragment) | |
if (internal.current === fragment && !frg) return router; | |
for (const routeObj of internal.routes) { | |
const matches = fragment.match(routeObj.route); | |
if (matches) { | |
matches.shift(); // Remove the full match | |
const params = {}; | |
routeObj.paramNames.forEach((name, i) => { | |
let value = matches[i]; | |
if (routeObj.originalRoute.includes(`:${name}*`) && typeof value === 'string') { | |
// For path*, split by '/' to get an array of segments | |
// Handle cases like "path/" resulting in an empty last element | |
params[name] = value.split('/').filter(s => s !== ''); | |
} else { | |
params[name] = value; | |
} | |
}); | |
// Update internal.current for change detection | |
internal.current = fragment; | |
// Add to history only if it's a new unique fragment or navigating forward | |
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; | |
} | |
} | |
// If no route matches, clear current fragment to allow re-application if hash changes to a known route later | |
internal.current = null; | |
return router; | |
}, | |
start: () => { | |
const handleHashChange = () => { | |
const fragment = router.getFragment(); | |
// Only apply if the fragment has genuinely changed or it's the initial load | |
if (internal.current !== fragment) { | |
router.apply(fragment); | |
} | |
}; | |
window.addEventListener('hashchange', handleHashChange); | |
// Initial application on page load | |
if (!internal.current) router.apply(); | |
return router; | |
}, | |
navigate: (path, title) => { | |
document.title = title || document.title; | |
// Ensure path starts with # if not empty | |
window.location.hash = path ? `#${path.replace(/##/g, '#')}` : ''; | |
return router; | |
}, | |
back: () => { | |
// Pop current state if it's the top of the history | |
// This ensures that hitting back multiple times doesn't get stuck on the same hash | |
if (internal.history.length > 0 && internal.history[internal.history.length - 1] === router.getFragment()) { | |
internal.history.pop(); | |
} | |
const previousPath = internal.history.pop(); // Get the actual previous state | |
if (previousPath !== undefined) { | |
window.location.hash = previousPath ? `#${previousPath}` : ''; | |
} else { | |
// If history is empty, clear hash or navigate to default | |
window.location.hash = ''; | |
} | |
return router; | |
}, | |
}; | |
return router; | |
}); | |
// Deep Merge Utility for AppState.load | |
function 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; | |
} | |
// State Management | |
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: { // New state for deeper nesting | |
locationServices: true, | |
personalizedAds: false | |
} | |
}, | |
get(key) { | |
const keys = key.split('.'); | |
let value = this.data; | |
for (const k of keys) { | |
if (value && typeof value === 'object' && value.hasOwnProperty(k)) { | |
value = value[k]; | |
} else { | |
return undefined; | |
} | |
} | |
return value; | |
}, | |
// Modified set method: do NOT automatically trigger router.apply() | |
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)}`); | |
// **DO NOT call Router.apply() here** | |
}, | |
toggle(key) { | |
const current = this.get(key); | |
this.set(key, !current); // The set method no longer calls Router.apply() | |
return !current; | |
}, | |
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); // Required for Firefox | |
a.click(); | |
document.body.removeChild(a); // Clean up | |
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 = async (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); | |
// Deep merge to preserve structure and add new defaults | |
this.data = deepMerge(this.data, loadedData); | |
this.showStateUpdate('State loaded successfully'); | |
// **Explicitly re-apply router after loading state if a menu is open** | |
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'); | |
// **Explicitly re-apply router after clearing state if a menu is open** | |
Router.apply(Router.getFragment()); // Re-render to reflect cleared state | |
}, | |
showStateUpdate(message, isError = false) { | |
const indicator = document.getElementById('stateIndicator'); | |
indicator.textContent = message; | |
indicator.classList.remove('alert-danger', 'alert-info', 'alert-success'); | |
indicator.classList.add(isError ? 'alert-danger' : 'alert-info'); // Use Bootstrap alert classes if you want | |
indicator.style.display = 'block'; | |
indicator.classList.add('show'); | |
setTimeout(() => { | |
indicator.classList.remove('show'); | |
setTimeout(() => { | |
indicator.style.display = 'none'; | |
}, 300); // Wait for fade out transition | |
}, 2000); | |
} | |
}; | |
// Menu Configuration with hierarchical structure (Font Awesome Icons) | |
// Note: The 'parent' property is still used here for *logic* within MenuRenderer, | |
// but its value is simplified to the immediate parent path segment. | |
// The router's 'back' method already handles history. | |
const MenuConfig = { | |
modal: { | |
profile: { | |
title: 'User Profile', | |
type: 'menu', | |
action: '<span class="badge bg-primary"><i class="fa-solid fa-star"></i> Pro</span>', | |
items: [ | |
{ | |
label: () => AppState.get('userProfile.name') || 'John Doe', | |
icon: 'fa-solid fa-user-circle', | |
route: 'profile/edit', // Relative path for nested menu | |
description: 'Edit your profile information' | |
}, | |
{ | |
label: 'Account Settings', | |
icon: 'fa-solid fa-gear', | |
route: 'profile/settings', // Relative path | |
description: 'Manage account preferences' | |
}, | |
{ | |
label: 'Security', | |
icon: 'fa-solid fa-shield-halved', | |
route: 'profile/security', // Relative path | |
description: 'Security and privacy settings' | |
}, | |
{ | |
label: 'Billing', | |
icon: 'fa-solid fa-credit-card', | |
action: () => alert('Billing information') | |
} | |
], | |
footer: '<button class="btn btn-sm btn-outline-danger" onclick="alert(\'Signed out!\')"><i class="fa-solid fa-right-from-bracket me-1"></i>Sign Out</button>' | |
}, | |
'profile/edit': { | |
title: 'Edit Profile', | |
type: 'form', // New type 'form' | |
// parent: 'modal/profile', // Removed explicit parent for back button (handled by Router.back) | |
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" onclick="Router.back()">Cancel</button> | |
<button type="button" class="btn btn-primary" id="saveProfileBtn">Save Changes</button> | |
</div> | |
</div> | |
`, | |
// Lifecycle hook for form setup | |
onRender: () => { | |
const saveBtn = document.getElementById('saveProfileBtn'); | |
if (saveBtn) { | |
saveBtn.onclick = () => { | |
const newName = document.getElementById('profileName').value; | |
const newEmail = document.getElementById('profileEmail').value; | |
AppState.set('userProfile.name', newName); // This won't trigger re-render | |
AppState.set('userProfile.email', newEmail); // This won't trigger re-render | |
Router.back(); // Go back after saving | |
}; | |
} | |
} | |
}, | |
'profile/settings': { | |
title: 'Account Settings', | |
type: 'menu', | |
// parent: 'modal/profile', | |
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', // Deeper nesting! | |
description: 'Manage notification preferences' | |
}, | |
{ | |
label: 'Contact Preferences', | |
icon: 'fa-solid fa-address-book', | |
action: () => alert('Contact preferences editor') | |
} | |
]} | |
] | |
}, | |
'profile/settings/notifications': { // New deeper menu | |
title: 'Notifications', | |
type: 'menu', | |
// parent: 'modal/profile/settings', | |
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', | |
// parent: 'modal/profile', | |
action: '<span class="badge bg-success"><i class="fa-solid fa-check-circle"></i> Secure</span>', | |
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', | |
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> | |
<h5>Our Mission</h5> | |
<p>We strive to create exceptional user experiences through intuitive design and robust functionality. Our team is dedicated to pushing the boundaries of what's possible in web applications.</p> | |
<h5>Features</h5> | |
<ul> | |
<li><i class="fa-solid fa-route me-2 text-info"></i>Advanced routing capabilities</li> | |
<li><i class="fa-solid fa-database me-2 text-info"></i>State management</li> | |
<li><i class="fa-solid fa-mobile-alt me-2 text-info"></i>Responsive design</li> | |
<li><i class="fa-brands fa-bootstrap me-2 text-info"></i>Bootstrap integration</li> | |
</ul> | |
<h5>Contact Information</h5> | |
<div class="row"> | |
<div class="col-md-6 mb-3 mb-md-0"> | |
<strong><i class="fa-solid fa-envelope me-2"></i>Email:</strong><br> | |
<a href="mailto:[email protected]">[email protected]</a> | |
</div> | |
<div class="col-md-6"> | |
<strong><i class="fa-solid fa-phone me-2"></i>Phone:</strong><br> | |
+1 (555) 123-4567 | |
</div> | |
</div> | |
<div class="mt-4 p-3 bg-light rounded text-center"> | |
<small class="text-muted"> | |
Version 2.1.0 • Last updated: July 2025<br> | |
© 2025 Our Company. All rights reserved. | |
</small> | |
</div> | |
` | |
} | |
}, | |
offcanvas: { | |
settings: { | |
title: 'Settings', | |
type: 'menu', | |
action: '<i class="fa-solid fa-gear text-muted"></i>', | |
items: [ | |
{ type: 'group', title: 'General', items: [ | |
{ | |
label: 'General Settings', | |
icon: 'fa-solid fa-sliders', | |
route: 'settings/general', // Relative path | |
description: 'Basic application settings' | |
}, | |
{ | |
label: 'Appearance', | |
icon: 'fa-solid fa-palette', | |
route: 'settings/appearance', // Relative path | |
description: 'Customize the look and feel' | |
} | |
]}, | |
{ type: 'group', title: 'Privacy & Data', items: [ | |
{ | |
label: 'Privacy Controls', // New nested menu item | |
icon: 'fa-solid fa-user-shield', | |
route: 'settings/privacy', // Deeper nesting! | |
description: 'Privacy controls and data management' | |
}, | |
{ | |
label: 'Data Export/Import', | |
icon: 'fa-solid fa-database', | |
route: 'settings/data-management', // Even deeper! | |
description: 'Manage your application data' | |
} | |
]}, | |
{ type: 'group', title: 'Advanced', items: [ | |
{ | |
label: 'Advanced Settings', | |
icon: 'fa-solid fa-gear-code', | |
route: 'settings/advanced', // Relative path | |
description: 'Advanced configuration options' | |
} | |
]} | |
] | |
}, | |
'settings/general': { | |
title: 'General Settings', | |
type: 'menu', | |
// parent: 'offcanvas/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); | |
// Manually update the label after state change if needed | |
// This is an alternative to re-rendering the entire menu | |
// For simplicity, we are still re-rendering the whole menu below | |
Router.apply(Router.getFragment()); | |
} | |
} | |
}, | |
{ | |
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); | |
Router.apply(Router.getFragment()); | |
} | |
} | |
}, | |
{ | |
label: 'Auto Save', | |
icon: 'fa-solid fa-cloud-arrow-up', | |
toggle: true, | |
key: 'settings.autoSave' | |
} | |
] | |
}, | |
'settings/appearance': { | |
title: 'Appearance', | |
type: 'menu', | |
// parent: 'offcanvas/settings', | |
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': { // New deeper menu for Offcanvas | |
title: 'Privacy Controls', | |
type: 'menu', | |
// parent: 'offcanvas/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': { // Even deeper menu for Offcanvas | |
title: 'Data Management', | |
type: 'menu', | |
// parent: 'offcanvas/settings', | |
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(); // Go back after clearing | |
} | |
}}, | |
] | |
}, | |
'settings/advanced': { | |
title: 'Advanced Settings', | |
type: 'menu', | |
// parent: 'offcanvas/settings', | |
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', | |
action: '<i class="fa-solid fa-user-gear text-muted"></i>', | |
items: [ | |
{ type: 'group', title: 'Profile', items: [ | |
{ | |
label: 'Profile Overview', | |
icon: 'fa-solid fa-user-check', | |
route: 'account/profile', // Relative path | |
description: 'View and edit profile details' | |
}, | |
{ | |
label: 'Account Security', | |
icon: 'fa-solid fa-shield-alt', | |
route: 'account/security-offcanvas', // Deeper nesting (new route) | |
description: 'Security and login settings' | |
} | |
]}, | |
{ type: 'group', title: 'Financial', items: [ | |
{ | |
label: 'Subscription', | |
icon: 'fa-solid fa-credit-card', | |
route: 'account/billing', // Relative path | |
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', // Relative path | |
description: 'Privacy and data settings' | |
} | |
]} | |
], | |
footer: '<button class="btn btn-sm btn-warning" onclick="alert(\'Account deactivation process\')"><i class="fa-solid fa-triangle-exclamation me-1"></i>Deactivate Account</button>' | |
}, | |
'account/profile': { | |
title: 'Profile Overview', | |
type: 'menu', | |
// parent: 'offcanvas/account', | |
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); | |
Router.apply(Router.getFragment()); | |
} | |
} | |
}, | |
{ | |
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); | |
Router.apply(Router.getFragment()); | |
} | |
} | |
}, | |
{ | |
label: 'Profile Picture', | |
icon: 'fa-solid fa-camera', | |
action: () => alert('Profile picture upload would open here') | |
} | |
] | |
}, | |
'account/security-offcanvas': { // New security menu for Offcanvas | |
title: 'Account Security', | |
type: 'menu', | |
// parent: 'offcanvas/account', | |
items: [ | |
{ label: 'Change Password', icon: 'fa-solid fa-lock', action: () => alert('Change password') }, | |
{ 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', | |
// parent: 'offcanvas/account', | |
action: '<span class="badge bg-success"><i class="fa-solid fa-check"></i> Active</span>', | |
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', | |
// parent: 'offcanvas/account', | |
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(); | |
// Optionally close the menu after clearing data | |
if (currentModalInstance) currentModalInstance.hide(); | |
if (currentOffcanvasInstance) currentOffcanvasInstance.hide(); | |
Router.navigate(''); // Go back to root hash | |
} | |
} | |
} | |
] | |
} | |
} | |
}; | |
// Menu Renderer Class | |
class MenuRenderer { | |
constructor(containerId, headerTitleSelector, headerBackSelector, headerActionSelector, type, bsComponent) { | |
this.container = document.getElementById(containerId); | |
this.titleElement = document.querySelector(headerTitleSelector); | |
this.backButton = document.getElementById(headerBackSelector); | |
this.headerAction = document.getElementById(headerActionSelector); | |
this.type = type; // 'modal' or 'offcanvas' | |
this.bsComponent = bsComponent; // The Bootstrap Modal or Offcanvas instance | |
this.previousPath = ''; // Track the previously rendered path for animation | |
// Ensure elements are available before setting properties or event listeners | |
if (this.backButton) { | |
this.backButton.onclick = () => { | |
Router.back(); | |
}; | |
} | |
// Event listener to reset menu when Bootstrap component is hidden | |
if (bsComponent && bsComponent._element) { | |
bsComponent._element.addEventListener('hidden.bs.modal', () => this.reset()); | |
bsComponent._element.addEventListener('hidden.bs.offcanvas', () => this.reset()); | |
} | |
} | |
// Resets the menu state when the modal/offcanvas is closed | |
reset() { | |
if (this.container) this.container.innerHTML = ''; // Clear content | |
this.previousPath = ''; // Reset previous path | |
if (this.titleElement) this.titleElement.textContent = 'Menu'; // Reset title | |
if (this.backButton) this.backButton.style.display = 'none'; // Hide back button | |
if (this.headerAction) this.headerAction.innerHTML = ''; // Clear header action | |
} | |
render(path) { | |
const config = MenuConfig[this.type][path]; | |
if (!config) { | |
console.warn(`Menu config not found for path: ${this.type}/${path}. Attempting to navigate back.`); | |
if (path.includes('/')) { | |
const pathParts = path.split('/'); | |
pathParts.pop(); // Remove last segment to get parent path | |
const parentPath = pathParts.join('/'); | |
if (MenuConfig[this.type][parentPath]) { | |
Router.navigate(`${this.type}/${parentPath}`); // Go to parent if valid | |
return; | |
} | |
} | |
this.close(); // Close if the top-level path isn't found | |
Router.navigate(''); | |
return; | |
} | |
// Determine animation direction | |
let isForward; | |
if (!this.previousPath) { | |
isForward = true; // First render | |
} else if (path.startsWith(this.previousPath + '/')) { | |
isForward = true; // Navigating deeper | |
} else if (this.previousPath.startsWith(path + '/') && this.previousPath.split('/').length === path.split('/').length + 1) { | |
// This is a more robust check for "backwards" animation | |
// It ensures we're going up one level from a direct child | |
isForward = false; | |
} else { | |
isForward = true; // Lateral move or new root, treat as forward for simplicity | |
} | |
this.updateHeader(config, path); // Pass current path to updateHeader | |
if (config.type === 'info') { | |
this.renderInfoContent(config); | |
} else if (config.type === 'form') { // Handle new 'form' type | |
this.renderFormContent(config); | |
} | |
else { | |
this.renderMenu(path, config, isForward); | |
} | |
this.previousPath = path; // Update previousPath *after* rendering | |
} | |
updateHeader(config, currentFullPath) { // currentFullPath is like "profile/settings/notifications" | |
if (this.titleElement) { | |
this.titleElement.textContent = config.title; | |
} | |
if (this.backButton) { | |
// Dynamically determine if there's a parent path segment | |
const pathParts = currentFullPath.split('/'); | |
if (pathParts.length > 1) { // If there's more than one segment, there's a parent | |
this.backButton.style.display = 'flex'; | |
// The Router.back() method handles navigating to the actual previous history entry, | |
// so we just rely on that. No need to calculate parent route explicitly here for 'onclick'. | |
this.backButton.onclick = () => { | |
Router.back(); | |
}; | |
} else { | |
this.backButton.style.display = 'none'; | |
this.backButton.onclick = null; // Clear handler | |
} | |
} | |
if (this.headerAction) { | |
if (config.action) { | |
this.headerAction.innerHTML = typeof config.action === 'function' ? config.action() : config.action; | |
this.headerAction.querySelectorAll('*').forEach(el => { | |
el.style.cursor = 'pointer'; | |
el.onclick = (e) => { | |
e.stopPropagation(); | |
if (typeof config.action === 'function') { | |
config.action(); | |
} else { | |
alert(`Header action clicked for: ${config.title}`); | |
} | |
}; | |
}); | |
} else { | |
this.headerAction.innerHTML = ''; | |
} | |
} | |
} | |
renderInfoContent(config) { | |
if (!this.container) return; | |
const infoLevel = document.createElement('div'); | |
infoLevel.className = 'menu-level active'; | |
infoLevel.innerHTML = `<div class="info-content">${config.content}</div>`; | |
this.container.innerHTML = ''; // Clear previous menu level | |
this.container.appendChild(infoLevel); | |
} | |
renderFormContent(config) { // New render method for forms | |
if (!this.container) return; | |
const formLevel = document.createElement('div'); | |
formLevel.className = 'menu-level active'; | |
formLevel.innerHTML = typeof config.content === 'function' ? config.content() : config.content; | |
this.container.innerHTML = ''; | |
this.container.appendChild(formLevel); | |
// Call onRender lifecycle hook if available for form specific setup | |
if (typeof config.onRender === 'function') { | |
config.onRender(); | |
} | |
} | |
renderMenu(path, config, isForward) { | |
if (!this.container) return; | |
const newLevel = document.createElement('div'); | |
newLevel.className = 'menu-level'; | |
const itemsContainer = document.createElement('div'); | |
// Calculate height dynamically for scrolling, considering header and footer | |
itemsContainer.style.cssText = `height: ${config.footer ? 'calc(100% - 60px)' : '100%'}; overflow-y: auto;`; | |
config.items.forEach(item => { | |
if (item.type === 'group') { | |
const groupTitleEl = document.createElement('div'); | |
groupTitleEl.className = 'menu-group-title'; | |
groupTitleEl.textContent = item.title; | |
itemsContainer.appendChild(groupTitleEl); | |
item.items.forEach(groupItem => { | |
const itemEl = this.createMenuItem(path, groupItem); | |
itemsContainer.appendChild(itemEl); | |
}); | |
} else { | |
const itemEl = this.createMenuItem(path, item); | |
itemsContainer.appendChild(itemEl); | |
} | |
}); | |
newLevel.appendChild(itemsContainer); | |
if (config.footer) { | |
const footerEl = document.createElement('div'); | |
footerEl.className = 'menu-footer'; | |
footerEl.innerHTML = config.footer; | |
newLevel.appendChild(footerEl); | |
} | |
// Append new level, then animate | |
this.container.appendChild(newLevel); | |
this.animateLevel(newLevel, isForward); | |
} | |
createMenuItem(currentMenuPath, item) { | |
const itemEl = document.createElement('div'); | |
itemEl.className = 'menu-item'; | |
if (item.disabled) itemEl.classList.add('disabled'); | |
let labelText = typeof item.label === 'function' ? item.label() : item.label; | |
let iconHtml = item.icon ? `<i class="${item.icon} fa-fw"></i>` : ''; // fa-fw for fixed width icons | |
const contentDiv = document.createElement('div'); | |
contentDiv.className = 'menu-item-content'; | |
contentDiv.innerHTML = `${iconHtml}<span>${labelText}</span>`; | |
if (item.description) { | |
const descriptionSpan = document.createElement('small'); | |
descriptionSpan.className = 'd-block menu-item-description'; | |
contentDiv.querySelector('span').appendChild(descriptionSpan); // Append description below label | |
descriptionSpan.textContent = item.description; | |
} | |
itemEl.appendChild(contentDiv); | |
if (item.route) { | |
itemEl.innerHTML += '<i class="fa-solid fa-chevron-right text-muted"></i>'; | |
itemEl.onclick = () => { | |
if (!item.disabled) { | |
// Construct the full route from the component type and the item's relative route | |
Router.navigate(`${this.type}/${item.route}`); | |
} | |
}; | |
} else if (item.toggle) { | |
const toggleKey = item.key; | |
if (!toggleKey) { | |
console.error('Toggle item missing key:', item); | |
return itemEl; | |
} | |
const toggleSwitchDiv = document.createElement('div'); | |
toggleSwitchDiv.className = 'form-check form-switch'; | |
toggleSwitchDiv.innerHTML = ` | |
<input class="form-check-input" type="checkbox" role="switch" id="${toggleKey.replace(/\./g, '-')}" ${AppState.get(toggleKey) ? 'checked' : ''}> | |
`; | |
const toggleInput = toggleSwitchDiv.querySelector('input'); | |
toggleInput.onchange = (e) => { | |
e.stopPropagation(); // Prevent the parent div's click from firing | |
AppState.toggle(toggleKey); | |
// Manually update the label if it's dynamic | |
if (typeof item.label === 'function') { | |
contentDiv.querySelector('span').firstChild.nodeValue = item.label(); | |
} | |
}; | |
itemEl.appendChild(toggleSwitchDiv); | |
// Handle clicks on the entire item, toggling the switch | |
itemEl.onclick = (e) => { | |
if (!item.disabled && e.target !== toggleInput) { // Only trigger if click isn't directly on switch | |
toggleInput.checked = !toggleInput.checked; | |
AppState.toggle(toggleKey); | |
if (typeof item.label === 'function') { | |
contentDiv.querySelector('span').firstChild.nodeValue = item.label(); | |
} | |
} | |
}; | |
} else if (item.action) { | |
itemEl.innerHTML += '<i class="fa-solid fa-arrow-up-right-from-square text-muted"></i>'; // External link icon or action icon | |
itemEl.onclick = () => { | |
if (!item.disabled) { | |
if (typeof item.action === 'function') { | |
item.action(); | |
} else { | |
alert(`Action for: ${item.label}`); | |
} | |
} | |
}; | |
} | |
// If an item has an action or toggle, remove the default chevron if it somehow got added | |
if (item.action || item.toggle) { | |
const chevron = itemEl.querySelector('.fa-chevron-right'); | |
if (chevron) chevron.remove(); | |
} | |
return itemEl; | |
} | |
animateLevel(newLevel, isForward) { | |
if (!this.container) return; | |
const currentActiveLevel = this.container.querySelector('.menu-level.active'); | |
if (currentActiveLevel && currentActiveLevel !== newLevel) { // Ensure we're not animating the same level | |
// Start new level off-screen based on direction | |
newLevel.classList.add(isForward ? 'slide-right' : 'slide-left'); | |
newLevel.style.zIndex = 2; // Bring new level to front | |
// Trigger reflow to ensure transform is applied before transition | |
newLevel.offsetWidth; | |
// Animate current level off-screen | |
currentActiveLevel.classList.remove('active'); | |
currentActiveLevel.classList.add(isForward ? 'slide-left' : 'slide-right'); | |
currentActiveLevel.style.zIndex = 1; | |
// Animate new level into view | |
newLevel.classList.add('active'); | |
newLevel.classList.remove(isForward ? 'slide-right' : 'slide-left'); | |
const onTransitionEnd = () => { | |
currentActiveLevel.removeEventListener('transitionend', onTransitionEnd); | |
currentActiveLevel.remove(); // Remove old level after transition | |
}; | |
currentActiveLevel.addEventListener('transitionend', onTransitionEnd, { once: true }); // Use once: true for cleaner cleanup | |
} else { | |
// No active level, just make this one active | |
newLevel.classList.add('active'); | |
} | |
} | |
// Public method to close the associated Bootstrap component | |
close() { | |
if (this.bsComponent) { | |
this.bsComponent.hide(); | |
} | |
} | |
} | |
// Initialize Bootstrap Modals/Offcanvas | |
const menuModalEl = document.getElementById('menuModal'); | |
const menuOffcanvasEl = document.getElementById('menuOffcanvas'); | |
let currentModalInstance = null; | |
let currentOffcanvasInstance = null; | |
// Initialize Bootstrap components only if their elements exist | |
if (menuModalEl) { | |
currentModalInstance = new bootstrap.Modal(menuModalEl); | |
} | |
if (menuOffcanvasEl) { | |
currentOffcanvasInstance = new bootstrap.Offcanvas(menuOffcanvasEl); | |
} | |
// Initialize Menu Renderers. Pass valid instances or null if not found. | |
const modalMenuRenderer = new MenuRenderer( | |
'modalMenuContainer', | |
'#menuModalLabel', | |
'modalBackButton', | |
'#modalHeaderAction', // Correct selector for header action | |
'modal', | |
currentModalInstance | |
); | |
const offcanvasMenuRenderer = new MenuRenderer( | |
'offcanvasMenuContainer', | |
'#offcanvasMenuLabel', | |
'offcanvasBackButton', | |
'#offcanvasHeaderAction', // Correct selector for header action | |
'offcanvas', | |
currentOffcanvasInstance | |
); | |
// Router Setup | |
// Modified routes to capture the full path as a single 'path' parameter | |
Router.add('modal/:path*', (matches, params) => { | |
// params.path will be an array like ['profile', 'edit'] or a single string 'about' | |
const fullPath = (Array.isArray(params.path) ? params.path.join('/') : params.path) || ''; | |
// Check if the exact fullPath exists in MenuConfig.modal | |
if (!MenuConfig.modal[fullPath]) { | |
// If a path is provided but not found in config, try to fall back or close | |
if (fullPath.includes('/')) { | |
const pathParts = fullPath.split('/'); | |
pathParts.pop(); // Remove last segment to get parent path | |
const parentPath = pathParts.join('/'); | |
if (MenuConfig.modal[parentPath]) { // Check if parent path exists | |
Router.navigate(`modal/${parentPath}`); // Go to parent if valid | |
return; | |
} | |
} | |
modalMenuRenderer.close(); // Close if no valid path or parent path is found | |
Router.navigate(''); // Reset hash | |
return; | |
} | |
modalMenuRenderer.render(fullPath); | |
if (currentModalInstance) 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) || ''; | |
if (!MenuConfig.offcanvas[fullPath]) { | |
if (fullPath.includes('/')) { | |
const pathParts = fullPath.split('/'); | |
pathParts.pop(); // Remove last segment to get parent path | |
const parentPath = pathParts.join('/'); | |
if (MenuConfig.offcanvas[parentPath]) { // Check if parent path exists | |
Router.navigate(`offcanvas/${parentPath}`); | |
return; | |
} | |
} | |
offcanvasMenuRenderer.close(); | |
Router.navigate(''); | |
return; | |
} | |
offcanvasMenuRenderer.render(fullPath); | |
if (currentOffcanvasInstance) currentOffcanvasInstance.show(); | |
document.getElementById('currentRoute').textContent = `#offcanvas/${fullPath}`; | |
}); | |
// Route for handling direct hash change to root or empty hash | |
Router.add('', () => { | |
if (currentModalInstance) currentModalInstance.hide(); | |
if (currentOffcanvasInstance) currentOffcanvasInstance.hide(); | |
document.getElementById('currentRoute').textContent = '#'; | |
}); | |
// Start the router | |
Router.start(); | |
// Global functions for state management (for button clicks) | |
function saveAppState() { | |
AppState.save(); | |
} | |
function loadAppState() { | |
AppState.load(); | |
} | |
function clearAppState() { | |
AppState.clear(); | |
} | |
// Ensure closing modal/offcanvas also updates hash (makes URL consistent) | |
if (menuModalEl) { | |
menuModalEl.addEventListener('hidden.bs.modal', () => { | |
// Only clear hash if the modal was truly controlling the current route | |
if (Router.getFragment().startsWith('modal/')) { | |
Router.navigate(''); | |
} | |
}); | |
} | |
if (menuOffcanvasEl) { | |
menuOffcanvasEl.addEventListener('hidden.bs.offcanvas', () => { | |
// Only clear hash if the offcanvas was truly controlling the current route | |
if (Router.getFragment().startsWith('offcanvas/')) { | |
Router.navigate(''); | |
} | |
}); | |
} | |
// Initial update of current route text | |
document.addEventListener('DOMContentLoaded', () => { | |
document.getElementById('currentRoute').textContent = Router.getFragment() || '#'; | |
// If page loads with a hash, ensure the corresponding modal/offcanvas is shown | |
Router.apply(); | |
}); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment