Skip to content

Instantly share code, notes, and snippets.

@zilveer
Created July 20, 2025 20:41
Show Gist options
  • Save zilveer/006901e75df11d9376364461106d4662 to your computer and use it in GitHub Desktop.
Save zilveer/006901e75df11d9376364461106d4662 to your computer and use it in GitHub Desktop.
Modal offcanvas 5.1
<!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