Skip to content

Instantly share code, notes, and snippets.

@zilveer
Created July 20, 2025 21:02
Show Gist options
  • Save zilveer/ebe5175dda554120b44a334299843fd7 to your computer and use it in GitHub Desktop.
Save zilveer/ebe5175dda554120b44a334299843fd7 to your computer and use it in GitHub Desktop.
Modal offvanvas router 1.58
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Advanced Routable Bootstrap Menus (Font Awesome)</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet">
<style>
:root {
--primary-color: #007bff;
--primary-hover-color: #0056b3;
--light-blue-hover: #e9f5ff;
--text-color: #333;
--secondary-text-color: #6c757d;
--border-color: #e9ecef;
--header-bg-color: #f8f9fa;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f4f7f6;
color: var(--text-color);
}
.container {
background-color: #ffffff;
padding: 30px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
margin-top: 50px;
}
h2 {
color: var(--primary-color);
margin-bottom: 25px;
font-weight: 600;
}
.menu-container {
position: relative;
overflow: hidden; /* Important for containing sliding menu levels */
width: 100%;
height: 100%;
}
.menu-level {
position: absolute;
width: 100%;
height: 100%;
transition: transform 0.3s ease-in-out;
background: white;
opacity: 1;
box-sizing: border-box;
padding-bottom: 60px; /* Space for footer */
}
.menu-level.slide-right {
transform: translateX(100%);
}
.menu-level.slide-left {
transform: translateX(-100%);
}
.menu-level.active {
transform: translateX(0);
opacity: 1;
}
.menu-item {
padding: 0.9rem 1.2rem;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.2s, transform 0.1s ease-out;
font-size: 1.05rem;
color: #495057;
}
.menu-item:hover {
background: var(--light-blue-hover);
transform: translateX(3px);
}
.menu-item.disabled {
opacity: 0.5;
cursor: not-allowed;
background: #f8f9fa;
transform: none;
}
.menu-item.disabled:hover {
background: #f8f9fa;
}
.menu-item i {
margin-right: 12px;
color: var(--primary-color);
min-width: 20px;
text-align: center;
}
.menu-item-content {
flex-grow: 1;
}
.menu-item-description {
font-size: 0.85rem;
color: var(--secondary-text-color);
margin-top: 2px;
}
.menu-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
border-top: 1px solid #dee2e6;
padding: 0.8rem 1rem;
background: var(--header-bg-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 60px;
}
/* Adjust modal/offcanvas heights for better display */
.modal-content {
height: 550px; /* Fixed height for demo */
display: flex;
flex-direction: column;
}
/* Make offcanvas-body take full height and be scrollable if needed */
.offcanvas-body {
flex-grow: 1;
padding: 0;
position: relative; /* Crucial for absolute positioning of .menu-level children */
overflow: hidden; /* Hide overflow from sliding menus inside */
}
.modal-body {
flex-grow: 1;
padding: 0;
position: relative; /* Crucial for absolute positioning of .menu-level children */
overflow: hidden; /* Hide overflow from sliding menus inside */
}
.modal-header, .offcanvas-header {
position: sticky; /* Make header sticky when content scrolls */
top: 0;
z-index: 10; /* Ensure it's above scrolling content */
min-height: 65px;
border-bottom: 1px solid #dee2e6;
padding: 1rem;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--header-bg-color);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
flex-shrink: 0; /* Prevent header from shrinking */
}
.modal-header h5, .offcanvas-header h5 {
margin: 0;
font-weight: 700;
color: #343a40;
font-size: 1.25rem;
}
.header-back {
background: none;
border: none;
color: var(--primary-color);
cursor: pointer;
padding: 0.25rem;
font-size: 1.25rem;
position: absolute;
left: 1.2rem;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.header-back:hover {
color: var(--primary-hover-color);
}
.header-action {
position: absolute;
right: 3.5rem;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
color: var(--secondary-text-color);
cursor: default; /* Default cursor for action area unless inner elements are clickable */
}
.header-action > * { /* Make elements inside header-action clickable if they have a click handler */
cursor: pointer;
}
.header-action .badge {
font-size: 0.8rem;
padding: 0.4em 0.7em;
}
.btn-close {
position: absolute !important;
top: 50% !important;
right: 1.2rem !important;
transform: translateY(-50%) !important;
z-index: 1070 !important;
font-size: 0.9rem;
}
.info-content {
padding: 1.5rem;
line-height: 1.7;
height: 100%;
overflow-y: auto; /* Allow info content to scroll */
color: #343a40;
}
.info-content h3, .info-content h5 {
color: var(--primary-color);
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: var(--primary-color);
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 var(--primary-color);
border-radius: 6px;
padding: 0.7rem 1.2rem;
font-size: 0.9rem;
max-width: 350px;
color: var(--primary-hover-color);
box-shadow: 0 2px 10px rgba(0, 123, 255, 0.2);
opacity: 0;
transform: translateY(-20px);
transition: opacity 0.3s ease-out, transform 0.3s ease-out, background-color 0.2s, border-color 0.2s;
}
.state-indicator.show {
opacity: 1;
transform: translateY(0);
}
.state-indicator.alert-danger {
background-color: rgba(220, 53, 69, 0.15);
border-color: #dc3545;
color: #dc3545;
}
.state-indicator.alert-info {
background-color: rgba(13, 202, 240, 0.15);
border-color: #0dcaf0;
color: #087990;
}
.form-check.form-switch {
display: flex;
align-items: center;
justify-content: flex-end;
margin-left: auto;
}
.form-check-input {
margin-left: 0 !important;
cursor: pointer;
}
.profile-edit-form {
padding: 1.5rem;
height: 100%;
overflow-y: auto; /* Allow form content to scroll */
}
.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;
}
.menu-group-title {
padding: 0.5rem 1.2rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--secondary-text-color);
background-color: var(--header-bg-color);
border-bottom: 1px solid var(--border-color);
margin-top: 0.5rem;
text-transform: uppercase;
}
.menu-group-title:first-child {
margin-top: 0;
}
/* --- Modal Positioning Overrides --- */
/* Bootstrap 5 provides .modal-dialog-centered and .modal-fullscreen. We add top/bottom */
.modal.modal-top .modal-dialog {
top: 0;
margin-top: 20px; /* Small margin from top */
transform: translate(0, 0) !important; /* Override default Bootstrap center transform */
}
.modal.modal-bottom .modal-dialog {
bottom: 0;
margin-bottom: 20px; /* Small margin from bottom */
top: auto; /* Ensure it's not trying to position from top */
transform: translate(0, 0) !important; /* Override default Bootstrap center transform */
}
/* Ensure fade animation still works with custom positioning for modal-dialog */
.modal.fade .modal-dialog {
transition: transform .3s ease-out, opacity .3s ease-out; /* Keep fade/scale transitions */
}
/* No specific 'slide-right' or 'slide-left' for the .modal-dialog itself, rely on Bootstrap's default */
/* Ensure .modal-fullscreen takes full height */
.modal-fullscreen .modal-content {
height: 100%;
}
</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')">Profile Modal (Default)</button>
<button class="btn btn-secondary" onclick="Router.navigate('offcanvas/settings')">Settings Offcanvas (Default)</button>
<button class="btn btn-info text-white" onclick="Router.navigate('modal/about')">About Us Modal (Default)</button>
<button class="btn btn-success" onclick="Router.navigate('offcanvas/account')">Account Offcanvas (Default)</button>
<hr class="w-100 my-2"> <h5>Modal Position Examples:</h5>
<button class="btn btn-primary" onclick="Router.navigate('modal/profile', {position: 'top'})">Modal: Top</button>
<button class="btn btn-primary" onclick="Router.navigate('modal/profile', {position: 'bottom'})">Modal: Bottom</button>
<button class="btn btn-primary" onclick="Router.navigate('modal/profile', {position: 'center'})">Modal: Center</button>
<button class="btn btn-primary" onclick="Router.navigate('modal/profile', {position: 'fullscreen'})">Modal: Fullscreen</button>
<h5>Offcanvas Position Examples:</h5>
<button class="btn btn-secondary" onclick="Router.navigate('offcanvas/settings', {position: 'top'})">Offcanvas: Top</button>
<button class="btn btn-secondary" onclick="Router.navigate('offcanvas/settings', {position: 'bottom'})">Offcanvas: Bottom</button>
<button class="btn btn-secondary" onclick="Router.navigate('offcanvas/settings', {position: 'start'})">Offcanvas: Left</button>
<button class="btn btn-secondary" onclick="Router.navigate('offcanvas/settings', {position: 'end'})">Offcanvas: Right</button>
<button class="btn btn-secondary" onclick="Router.navigate('offcanvas/settings', {position: 'fullscreen'})">Offcanvas: Fullscreen</button>
<hr class="w-100 my-2"> <h5>Direct Submenu & Position Examples:</h5>
<button class="btn btn-info" onclick="Router.navigate('modal/profile/edit', {position: 'top'})">Modal: Edit Profile (Top)</button>
<button class="btn btn-success" onclick="Router.navigate('offcanvas/settings/privacy', {position: 'bottom'})">Offcanvas: Privacy (Bottom)</button>
<button class="btn btn-warning text-white" onclick="Router.navigate('modal/profile/security')">Modal: Security (Default)</button>
<button class="btn btn-dark" onclick="Router.navigate('offcanvas/account/security-offcanvas', {position: 'start'})">Offcanvas: Account Security (Left)</button>
</div>
<div class="mb-4 d-flex align-items-center">
<button class="btn btn-outline-primary btn-sm me-2" onclick="saveAppState()">Save State</button>
<button class="btn btn-outline-secondary btn-sm me-2" onclick="loadAppState()">Load State</button>
<button class="btn btn-outline-danger btn-sm" onclick="clearAppState()">Clear State</button>
<span class="ms-auto text-muted">Current route: <span id="currentRoute" class="fw-bold text-primary">#</span></span>
</div>
<p class="text-muted small">Click a button to open a menu. Navigate inside the menu. The current route will update accordingly. You can also save/load/clear the application state. New buttons demonstrate explicit modal/offcanvas positioning and direct submenu access.</p>
</div>
<div id="stateIndicator" class="state-indicator" role="status" aria-live="polite" style="display: none;"></div>
<div class="modal fade" id="menuModal" tabindex="-1" aria-labelledby="menuModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<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" 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: [],
run_before: null,
run_after: null,
};
const normalizeHash = (hash) => hash.replace(new RegExp(HASH_PREFIX), '').replace(SLASH_SUFFIX, '');
const isFunction = (fn) => typeof fn === 'function';
const runHook = (hook, route) => isFunction(hook) && hook.call(null, route);
const router = {
getFragment: () => normalizeHash(window.location.hash),
parseRoute: (route) => {
const paramNames = [];
let regexString = route.replace(/:([a-zA-Z0-9_]+)(\*)?/g, (match, p1, p2) => {
paramNames.push(p1);
if (p2 === '*') {
return '(.*?)';
}
return '([^/]+)';
});
regexString = `^${regexString}$`;
return { regex: new RegExp(regexString), paramNames };
},
add: (route, handler, run_before, run_after) => {
const { regex, paramNames } = router.parseRoute(route);
internal.routes.push({ originalRoute: route, handler, route: regex, paramNames, run_before, run_after });
return router;
},
apply: (frg) => {
const fragment = frg || router.getFragment();
if (internal.current === fragment && !frg) return router; // Do not re-apply if fragment is the same and not explicitly forced
for (const routeObj of internal.routes) {
const matches = fragment.match(routeObj.route);
if (matches) {
matches.shift();
const params = {};
routeObj.paramNames.forEach((name, i) => {
let value = matches[i];
if (routeObj.originalRoute.includes(`:${name}*`) && typeof value === 'string') {
params[name] = value.split('/').filter(s => s !== '');
} else {
params[name] = value;
}
});
internal.current = fragment;
if (internal.history[internal.history.length - 1] !== fragment) {
internal.history.push(fragment);
}
runHook(internal.run_before, routeObj);
routeObj.handler.call({}, matches, params, routeObj.originalRoute);
runHook(internal.run_after, routeObj);
return router;
}
}
internal.current = null;
return router;
},
start: () => {
const handleHashChange = () => {
const fragment = router.getFragment();
if (internal.current !== fragment) {
router.apply(fragment);
}
};
window.addEventListener('hashchange', handleHashChange);
if (!internal.current) router.apply();
return router;
},
// MODIFIED: router.navigate now accepts an options object
navigate: (path, options = {}) => {
document.title = options.title || document.title;
// Temporarily store options for the route handler to pick up.
// A more robust solution for complex apps might involve a global event bus
// or storing options in a dedicated state object. For this demo, this is sufficient.
router.__currentNavigationOptions = options;
window.location.hash = path ? `#${path.replace(/##/g, '#')}` : '';
return router;
},
back: () => {
if (internal.history.length > 0 && internal.history[internal.history.length - 1] === router.getFragment()) {
internal.history.pop();
}
const previousPath = internal.history.pop();
if (previousPath !== undefined) {
window.location.hash = previousPath ? `#${previousPath}` : '';
} else {
window.location.hash = '';
}
// When going back, reset any temporary navigation options
router.__currentNavigationOptions = {};
return router;
},
};
return router;
});
// Deep Merge Utility for AppState.load function
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: {
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;
},
set(key, value) {
const keys = key.split('.');
const lastKey = keys.pop();
let obj = this.data;
for (const k of keys) {
if (!obj[k] || typeof obj[k] !== 'object') {
obj[k] = {};
}
obj = obj[k];
}
obj[lastKey] = value;
this.showStateUpdate(`Updated ${key}: ${JSON.stringify(value)}`);
},
toggle(key) {
const current = this.get(key);
this.set(key, !current);
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);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.showStateUpdate('State saved to app_state.json');
} catch (e) {
console.error('Failed to save state:', e);
this.showStateUpdate('Failed to save state', true);
}
},
load() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = 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);
this.data = deepMerge(this.data, loadedData);
this.showStateUpdate('State loaded successfully');
Router.apply(Router.getFragment());
} catch (parseError) {
console.error('Error parsing JSON:', parseError);
this.showStateUpdate('Error loading state: Invalid JSON', true);
}
};
reader.readAsText(file);
} catch (readError) {
console.error('Error reading file:', readError);
this.showStateUpdate('Error loading state: File read error', true);
}
};
input.click();
},
clear() {
this.data = {
userProfile: { name: 'John Doe', email: '[email protected]', darkMode: false, notifications: true, twoFactor: true },
settings: { language: 'en', timezone: 'UTC', autoSave: true, debugMode: false },
notifications: { email: true, sms: false, push: true, mentions: true },
privacy: { locationServices: true, personalizedAds: false }
};
this.showStateUpdate('State cleared to defaults');
Router.apply(Router.getFragment());
},
showStateUpdate(message, isError = false) {
const indicator = document.getElementById('stateIndicator');
indicator.textContent = message;
indicator.classList.remove('alert-danger', 'alert-info');
indicator.classList.add(isError ? 'alert-danger' : 'alert-info');
indicator.style.display = 'block';
indicator.classList.add('show');
setTimeout(() => {
indicator.classList.remove('show');
setTimeout(() => { indicator.style.display = 'none'; }, 300);
}, 2000);
}
};
// Menu Configuration with hierarchical structure (Font Awesome Icons)
const MenuConfig = {
modal: {
profile: {
title: 'User Profile',
type: 'menu',
// Example of a dynamic action for the header badge
action: () => {
const userName = AppState.get('userProfile.name') || 'User';
return `<span class="badge bg-primary"><i class="fa-solid fa-user-circle me-1"></i>${userName}</span>`;
},
items: [
{ label: () => AppState.get('userProfile.name') || 'John Doe', icon: 'fa-solid fa-user-circle', route: 'profile/edit', description: 'Edit your profile information' },
{ label: 'Account Settings', icon: 'fa-solid fa-gear', route: 'profile/settings', description: 'Manage account preferences' },
{ label: 'Security', icon: 'fa-solid fa-shield-halved', route: 'profile/security', description: 'Security and privacy settings' },
{ label: 'Billing', icon: 'fa-solid fa-credit-card', action: () => alert('Billing information is handled externally.') }
],
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',
// Header action for the edit form
action: '<i class="fa-solid fa-pencil text-secondary"></i>',
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>
`,
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);
AppState.set('userProfile.email', newEmail);
Router.back();
};
}
}
},
'profile/settings': {
title: 'Account Settings',
type: 'menu',
action: '<span class="badge bg-info"><i class="fa-solid fa-info-circle me-1"></i>Info</span>',
items: [
{ type: 'group', title: 'General Options', items: [
{ label: 'Dark Mode', icon: 'fa-solid fa-moon', toggle: true, key: 'userProfile.darkMode' },
{ label: 'Auto Save', icon: 'fa-solid fa-cloud-arrow-up', toggle: true, key: 'settings.autoSave' }
]},
{ type: 'group', title: 'Communication', items: [
{ label: 'Notifications', icon: 'fa-solid fa-bell', route: 'profile/settings/notifications', description: 'Manage notification preferences' },
{ label: 'Contact Preferences', icon: 'fa-solid fa-address-book', action: () => alert('Contact preferences editor would open here.') }
]}
]
},
'profile/settings/notifications': {
title: 'Notifications',
type: 'menu',
action: '<i class="fa-solid fa-envelope-open text-muted"></i>', // Simple icon action
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',
action: '<span class="badge bg-success"><i class="fa-solid fa-check-circle me-1"></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',
action: '<i class="fa-solid fa-info-circle text-primary"></i>', // Action for About Us
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 &bull; Last updated: July 2025<br> &copy; 2025 Our Company. All rights reserved. </small>
</div>
`
}
},
offcanvas: {
settings: {
title: 'Settings',
type: 'menu',
action: '<i class="fa-solid fa-gear text-muted me-1"></i><span class="badge bg-warning text-dark">Beta</span>', // Example with badge and icon
items: [
{ type: 'group', title: 'General', items: [
{ label: 'General Settings', icon: 'fa-solid fa-sliders', route: 'settings/general', description: 'Basic application settings' },
{ label: 'Appearance', icon: 'fa-solid fa-palette', route: 'settings/appearance', description: 'Customize the look and feel' }
]},
{ type: 'group', title: 'Privacy & Data', items: [
{ label: 'Privacy Controls', icon: 'fa-solid fa-user-shield', route: 'settings/privacy', description: 'Privacy controls and data management' },
{ label: 'Data Export/Import', icon: 'fa-solid fa-database', route: 'settings/data-management', description: 'Manage your application data' }
]},
{ type: 'group', title: 'Advanced', items: [
{ label: 'Advanced Settings', icon: 'fa-solid fa-gear-code', route: 'settings/advanced', description: 'Advanced configuration options' }
]}
]
},
'settings/general': {
title: 'General Settings',
type: 'menu',
action: '<i class="fa-solid fa-cog text-primary"></i>', // Simple icon for general 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); 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',
action: '<i class="fa-solid fa-brush text-info"></i>',
items: [
{ label: 'Dark Theme', icon: 'fa-solid fa-moon', toggle: true, key: 'userProfile.darkMode' },
{ label: 'Font Size', icon: 'fa-solid fa-font', action: () => alert('Font size selector would open here.') },
{ label: 'Color Scheme', icon: 'fa-solid fa-fill-drip', action: () => alert('Color scheme picker would open here.') }
]
},
'settings/privacy': {
title: 'Privacy Controls',
type: 'menu',
action: '<i class="fa-solid fa-lock text-success"></i>', // Green lock icon
items: [
{ label: 'Cookie Preferences', icon: 'fa-solid fa-cookie-bite', action: () => alert('Cookie consent management.') },
{ label: 'Location Services', icon: 'fa-solid fa-location-dot', toggle: true, key: 'privacy.locationServices' },
{ label: 'Personalized Ads', icon: 'fa-solid fa-ad', toggle: true, key: 'privacy.personalizedAds' }
]
},
'settings/data-management': {
title: 'Data Management',
type: 'menu',
action: '<i class="fa-solid fa-hdd text-secondary"></i>',
items: [
{ label: 'Export Data', icon: 'fa-solid fa-download', action: () => AppState.save() },
{ label: 'Import Data', icon: 'fa-solid fa-upload', action: () => AppState.load() },
{ label: 'Reset All Data', icon: 'fa-solid fa-eraser', action: () => {
if (confirm('Are you sure you want to reset all application data to defaults?')) {
AppState.clear();
Router.back();
}
}},
]
},
'settings/advanced': {
title: 'Advanced Settings',
type: 'menu',
action: '<i class="fa-solid fa-microchip text-danger"></i>',
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-primary"></i>',
items: [
{ type: 'group', title: 'Profile', items: [
{ label: 'Profile Overview', icon: 'fa-solid fa-user-check', route: 'account/profile', description: 'View and edit profile details' },
{ label: 'Account Security', icon: 'fa-solid fa-shield-alt', route: 'account/security-offcanvas', description: 'Security and login settings' }
]},
{ type: 'group', title: 'Financial', items: [
{ label: 'Subscription', icon: 'fa-solid fa-credit-card', route: 'account/billing', description: 'Manage your subscription' },
{ label: 'Payment Methods', icon: 'fa-solid fa-wallet', action: () => alert('Manage stored payment methods.') }
]},
{ type: 'group', title: 'Data', items: [
{ label: 'Data & Privacy', icon: 'fa-solid fa-shield-alt', route: 'account/privacy', description: 'Privacy and data settings' }
]}
],
footer: '<button class="btn btn-sm btn-warning" onclick="alert(\'Account deactivation process would begin.\')"><i class="fa-solid fa-triangle-exclamation me-1"></i>Deactivate Account</button>'
},
'account/profile': {
title: 'Profile Overview',
type: 'menu',
action: '<i class="fa-solid fa-id-card text-success"></i>',
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': {
title: 'Account Security',
type: 'menu',
action: '<i class="fa-solid fa-fingerprint text-danger"></i>',
items: [
{ label: 'Change Password', icon: 'fa-solid fa-lock', action: () => alert('Change password dialog.') },
{ label: 'Two-Factor Authentication', icon: 'fa-solid fa-shield-alt', toggle: true, key: 'userProfile.twoFactor' },
{ label: 'View Login History', icon: 'fa-solid fa-history', action: () => alert('View recent logins.') }
]
},
'account/billing': {
title: 'Subscription & Billing',
type: 'menu',
action: '<span class="badge bg-success"><i class="fa-solid fa-check me-1"></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',
action: '<i class="fa-solid fa-user-secret text-secondary"></i>',
items: [
{ label: 'Data Export', icon: 'fa-solid fa-file-export', action: () => AppState.save() },
{ label: 'Privacy Settings', icon: 'fa-solid fa-eye-slash', action: () => alert('Privacy controls would open here.') },
{ label: 'Delete All Data', icon: 'fa-solid fa-trash', action: () => {
if (confirm('Are you sure you want to delete all data? This action cannot be undone.')) {
AppState.clear();
if (currentModalInstance) currentModalInstance.hide();
if (currentOffcanvasInstance) currentOffcanvasInstance.hide();
Router.navigate('');
}
}}
]
}
}
};
// Menu Renderer Class (No major changes needed here for component positioning, as it's handled by Router)
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;
this.bsComponent = bsComponent;
this.previousPath = '';
if (this.backButton) {
this.backButton.onclick = () => { Router.back(); };
}
if (bsComponent && bsComponent._element) {
bsComponent._element.addEventListener('hidden.bs.modal', () => this.reset());
bsComponent._element.addEventListener('hidden.bs.offcanvas', () => this.reset());
}
}
reset() {
if (this.container) this.container.innerHTML = '';
this.previousPath = '';
if (this.titleElement) this.titleElement.textContent = 'Menu';
if (this.backButton) this.backButton.style.display = 'none';
if (this.headerAction) this.headerAction.innerHTML = '';
}
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();
const parentPath = pathParts.join('/');
if (MenuConfig[this.type][parentPath]) {
Router.navigate(`${this.type}/${parentPath}`, Router.__currentNavigationOptions); // Pass options on fallback
return;
}
}
this.close();
Router.navigate('');
return;
}
let isForward;
if (!this.previousPath) {
isForward = true;
} else if (path.startsWith(this.previousPath + '/')) {
isForward = true;
} else if (this.previousPath.startsWith(path + '/') && this.previousPath.split('/').length === path.split('/').length + 1) {
isForward = false;
} else {
isForward = true;
}
this.updateHeader(config, path);
if (config.type === 'info') {
this.renderInfoContent(config);
} else if (config.type === 'form') {
this.renderFormContent(config);
} else {
this.renderMenu(path, config, isForward);
}
this.previousPath = path;
}
updateHeader(config, currentFullPath) {
if (this.titleElement) {
this.titleElement.textContent = config.title;
}
if (this.backButton) {
const pathParts = currentFullPath.split('/');
if (pathParts.length > 1) {
this.backButton.style.display = 'flex';
this.backButton.onclick = () => { Router.back(); };
} else {
this.backButton.style.display = 'none';
this.backButton.onclick = null;
}
}
if (this.headerAction) {
if (config.action) {
this.headerAction.innerHTML = typeof config.action === 'function' ? config.action() : config.action;
// Add click handler to action if it's dynamic, but only if it's a function.
// For simple HTML, we don't need a wrapper click.
if (typeof config.action === 'function') {
this.headerAction.querySelectorAll('*').forEach(el => {
el.style.cursor = 'pointer'; // Indicate clickability
el.onclick = (e) => {
e.stopPropagation();
config.action(); // Execute the action function if it's a function
};
});
}
} 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 = '';
this.container.appendChild(infoLevel);
}
renderFormContent(config) {
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);
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');
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);
}
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');
itemEl.setAttribute('role', 'menuitem');
let labelText = typeof item.label === 'function' ? item.label() : item.label;
let iconHtml = item.icon ? `<i class="${item.icon} fa-fw" aria-hidden="true"></i>` : '';
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);
descriptionSpan.textContent = item.description;
}
itemEl.appendChild(contentDiv);
if (item.route) {
itemEl.innerHTML += '<i class="fa-solid fa-chevron-right text-muted" aria-hidden="true"></i>';
itemEl.onclick = () => {
if (!item.disabled) {
Router.navigate(`${this.type}/${item.route}`, Router.__currentNavigationOptions); // Pass options to submenu navigation
}
};
} else if (item.toggle) {
const toggleKey = item.key;
if (!toggleKey) { console.error('Toggle item missing key:', item); return itemEl; }
const toggleId = toggleKey.replace(/\./g, '-');
const toggleSwitchDiv = document.createElement('div');
toggleSwitchDiv.className = 'form-check form-switch';
toggleSwitchDiv.innerHTML = `
<input class="form-check-input" type="checkbox" role="switch" id="${toggleId}" ${AppState.get(toggleKey) ? 'checked' : ''} aria-label="Toggle ${labelText}">
`;
const toggleInput = toggleSwitchDiv.querySelector('input');
toggleInput.onchange = (e) => {
e.stopPropagation();
AppState.toggle(toggleKey);
if (typeof item.label === 'function') { contentDiv.querySelector('span').firstChild.nodeValue = item.label(); }
};
itemEl.appendChild(toggleSwitchDiv);
itemEl.onclick = (e) => {
if (!item.disabled && e.target !== toggleInput) {
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" aria-hidden="true"></i>';
itemEl.onclick = () => {
if (!item.disabled) {
if (typeof item.action === 'function') {
item.action();
} else {
alert(`Action for: ${item.label}`);
}
}
};
}
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) {
newLevel.classList.add(isForward ? 'slide-right' : 'slide-left');
newLevel.style.zIndex = 2;
newLevel.offsetWidth; // Trigger reflow
currentActiveLevel.classList.remove('active');
currentActiveLevel.classList.add(isForward ? 'slide-left' : 'slide-right');
currentActiveLevel.style.zIndex = 1;
newLevel.classList.add('active');
newLevel.classList.remove(isForward ? 'slide-right' : 'slide-left');
const onTransitionEnd = () => {
currentActiveLevel.removeEventListener('transitionend', onTransitionEnd);
currentActiveLevel.remove();
};
currentActiveLevel.addEventListener('transitionend', onTransitionEnd, { once: true });
} else {
newLevel.classList.add('active');
}
}
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;
if (menuModalEl) { currentModalInstance = new bootstrap.Modal(menuModalEl); }
if (menuOffcanvasEl) { currentOffcanvasInstance = new bootstrap.Offcanvas(menuOffcanvasEl); }
// Initialize Menu Renderers.
const modalMenuRenderer = new MenuRenderer(
'modalMenuContainer',
'#menuModalLabel',
'modalBackButton',
'#modalHeaderAction',
'modal', currentModalInstance
);
const offcanvasMenuRenderer = new MenuRenderer(
'offcanvasMenuContainer',
'#offcanvasMenuLabel',
'offcanvasBackButton',
'#offcanvasHeaderAction',
'offcanvas', currentOffcanvasInstance
);
// Define default positions
const defaultModalPosition = 'center'; // 'top', 'bottom', 'center', 'fullscreen'
const defaultOffcanvasPosition = 'end'; // 'top', 'bottom', 'start', 'end', 'fullscreen'
// Router Setup (Modified to handle positioning)
Router.add('modal/:path*', (matches, params) => {
const fullPath = (Array.isArray(params.path) ? params.path.join('/') : params.path) || '';
const config = MenuConfig.modal[fullPath];
if (!config) {
if (fullPath.includes('/')) {
const pathParts = fullPath.split('/');
pathParts.pop();
const parentPath = pathParts.join('/');
if (MenuConfig.modal[parentPath]) {
Router.navigate(`modal/${parentPath}`, { position: Router.__currentNavigationOptions?.position || defaultModalPosition });
return;
}
}
modalMenuRenderer.close();
Router.navigate('');
return;
}
// Get position from options or use default
const position = Router.__currentNavigationOptions?.position || defaultModalPosition;
Router.__currentNavigationOptions = {}; // Clear options after use
// Apply Bootstrap modal position classes
const modalDialog = menuModalEl.querySelector('.modal-dialog');
// Remove all possible positioning classes before adding the new one
modalDialog.classList.remove('modal-dialog-centered', 'modal-fullscreen', 'modal-top', 'modal-bottom');
// Apply the chosen position class
if (position === 'fullscreen') {
modalDialog.classList.add('modal-fullscreen');
} else if (position === 'top') {
modalDialog.classList.add('modal-top');
} else if (position === 'bottom') {
modalDialog.classList.add('modal-bottom');
} else { // 'center' or any unrecognized position will default to 'modal-dialog-centered'
modalDialog.classList.add('modal-dialog-centered');
}
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) || '';
const config = MenuConfig.offcanvas[fullPath];
if (!config) {
if (fullPath.includes('/')) {
const pathParts = fullPath.split('/');
pathParts.pop();
const parentPath = pathParts.join('/');
if (MenuConfig.offcanvas[parentPath]) {
Router.navigate(`offcanvas/${parentPath}`, { position: Router.__currentNavigationOptions?.position || defaultOffcanvasPosition });
return;
}
}
offcanvasMenuRenderer.close();
Router.navigate('');
return;
}
// Get position from options or use default
const position = Router.__currentNavigationOptions?.position || defaultOffcanvasPosition;
Router.__currentNavigationOptions = {}; // Clear options after use
// Apply Bootstrap offcanvas position classes
// Remove all possible positioning classes before adding the new one
menuOffcanvasEl.classList.remove('offcanvas-top', 'offcanvas-bottom', 'offcanvas-start', 'offcanvas-end', 'offcanvas-fullscreen');
menuOffcanvasEl.classList.add(`offcanvas-${position}`); // Bootstrap uses these classes for animation
offcanvasMenuRenderer.render(fullPath);
if (currentOffcanvasInstance) currentOffcanvasInstance.show();
document.getElementById('currentRoute').textContent = `#offcanvas/${fullPath}`;
});
Router.add('', () => {
if (currentModalInstance) currentModalInstance.hide();
if (currentOffcanvasInstance) currentOffcanvasInstance.hide();
document.getElementById('currentRoute').textContent = '#';
});
// Start the router
document.addEventListener('DOMContentLoaded', () => {
Router.start();
document.getElementById('currentRoute').textContent = Router.getFragment() || '#';
Router.apply();
});
// 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', () => {
if (Router.getFragment().startsWith('modal/')) {
Router.navigate('');
}
});
}
if (menuOffcanvasEl) {
menuOffcanvasEl.addEventListener('hidden.bs.offcanvas', () => {
if (Router.getFragment().startsWith('offcanvas/')) {
Router.navigate('');
}
});
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment