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