Created
July 21, 2025 00:38
-
-
Save zilveer/23999b0b33ce5b223dd1e3c7f1cf45f5 to your computer and use it in GitHub Desktop.
Menu better nodaloffcsnvas
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; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .menu-level { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| /* Adjusted transition for smoother slide */ | |
| transition: transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); | |
| background: white; | |
| box-sizing: border-box; | |
| padding-bottom: 60px; | |
| overflow-x: hidden; | |
| overflow-y: auto; /* Allow vertical scrolling within menu levels */ | |
| } | |
| .menu-level.slide-right { transform: translateX(100%); } | |
| .menu-level.slide-left { transform: translateX(-100%); } | |
| .menu-level.active { transform: translateX(0); } | |
| .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 i { margin-right: 12px; color: var(--primary-color); min-width: 20px; text-align: center; } | |
| .menu-item-content { flex-grow: 1; overflow: hidden; } | |
| .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; } | |
| /* FIX: Remove fixed height for modal content, it will adjust to content */ | |
| .modal-content { | |
| /* height: 550px; <-- Removed */ | |
| border-radius: 0.75rem; | |
| overflow: hidden; | |
| } | |
| /* Ensure fullscreen modal content is always 100% height */ | |
| .modal-fullscreen .modal-content { | |
| height: 100%; | |
| border-radius: 0; | |
| } | |
| .offcanvas-body, .modal-body { flex-grow: 1; padding: 0; position: relative; overflow: hidden; } | |
| /* FIX: Fixed height for header and title to the left */ | |
| .modal-header, .offcanvas-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| min-height: 65px; /* Fixed height */ | |
| 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; | |
| align-items: flex-start; /* Align title and center actions to the left */ | |
| text-align: left; /* Ensure text within is also left-aligned */ | |
| } | |
| .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); } | |
| .modal.modal-zoom .modal-dialog { | |
| transform: scale(0.9); | |
| opacity: 0; | |
| transition: transform 0.25s ease-out, opacity 0.25s ease-out; | |
| } | |
| .modal.modal-zoom.show .modal-dialog { | |
| transform: scale(1); | |
| opacity: 1; | |
| } | |
| .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); } | |
| .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-fullscreen .modal-content { height: 100%; } <-- Moved up */ | |
| .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> | |
| <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> | |
| </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> | |
| </div> | |
| <div id="stateIndicator" class="state-indicator" role="status" aria-live="polite" style="display: none;"></div> | |
| <div class="modal modal-zoom" 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> | |
| // Router Library (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: [], }; const normalizeHash = (hash) => hash.replace(new RegExp(`^${HASH_PREFIX}`), '').replace(SLASH_SUFFIX, ''); const router = { getFragment: () => normalizeHash(window.location.hash), add: (route, handler) => { const paramNames = []; const regexString = `^${route.replace(/:([a-zA-Z0-9_]+)(\*)?/g, (match, paramName, isGreedy) => { paramNames.push(paramName); return isGreedy ? '(.+)' : '([^/]+)'; })}$`; internal.routes.push({ handler, route: new RegExp(regexString), paramNames }); return router; }, apply: (frg) => { const fragment = frg || router.getFragment(); for (const routeObj of internal.routes) { const matches = fragment.match(routeObj.route); if (matches) { matches.shift(); const params = {}; routeObj.paramNames.forEach((name, i) => { params[name] = matches[i]; }); internal.current = fragment; if (internal.history[internal.history.length - 1] !== fragment) { internal.history.push(fragment); } routeObj.handler.call({}, params); return router; } } if (fragment === '') { const emptyRoute = internal.routes.find(r => r.route.source === '^$'); if (emptyRoute) emptyRoute.handler.call({}); internal.current = ''; internal.history = []; } return router; }, start: () => { const handleHashChange = () => { if (internal.current !== router.getFragment()) router.apply(); }; window.addEventListener('hashchange', handleHashChange); router.apply(); return router; }, navigate: (path, options = {}) => { router.__currentNavigationOptions = options; const newFragment = path || ''; if (router.getFragment() === newFragment) { router.apply(newFragment); } else { window.location.hash = newFragment; } return router; }, back: () => { if (internal.history.length > 1) { internal.history.pop(); window.location.hash = `${HASH_PREFIX}${internal.history.pop()}`; } else { internal.history = []; window.location.hash = ''; } router.__currentNavigationOptions = {}; return router; }, }; return router; }); | |
| // State Management and Utils (UNCHANGED) | |
| const AppState = { data: { userProfile: { name: 'John Doe', email: 'john@example.com', darkMode: false, notifications: true, twoFactor: true }, settings: { language: 'en', timezone: 'UTC', autoSave: true, debugMode: false, generalNotices: true, promoEmails: 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)); }, 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); } }; | |
| const saveAppState = () => { try { const json = JSON.stringify(AppState.data, null, 2); const blob = new Blob([json], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'app_state.json'; a.click(); URL.revokeObjectURL(a.href); } catch (e) { AppState.showStateUpdate('Failed to save state', true); } }; | |
| const loadAppState = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json'; input.onchange = e => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { try { const loadedData = JSON.parse(event.target.result); AppState.data = loadedData; AppState.showStateUpdate('State loaded successfully'); Router.apply(); } catch (err) { AppState.showStateUpdate('Error loading state: Invalid JSON', true); } }; reader.readAsText(file); }; input.click(); }; | |
| const clearAppState = () => { AppState.data = { userProfile: { name: 'John Doe', email: 'john@example.com', darkMode: false, notifications: true, twoFactor: true }, settings: { language: 'en', timezone: 'UTC', autoSave: true, debugMode: false, generalNotices: true, promoEmails: false }, notifications: { email: true, sms: false, push: true, mentions: true }, privacy: { locationServices: true, personalizedAds: false } }; AppState.showStateUpdate('State cleared to defaults'); Router.apply(); }; | |
| // Menu Configuration (UNCHANGED) | |
| 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: {id: 'viewProfileBadge'} }, { type: 'icon', icon: 'fa-solid fa-bell', class: 'text-info', action: { id: 'showNotifications' } }] }, 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: { id: 'showBillingInfo' } }], footerActions: { left: [{ label: 'Sign Out', icon: 'fa-solid fa-right-from-bracket', class: 'btn btn-sm btn-outline-danger', action: { id: 'signOut' } }], right: [{ label: 'Help', icon: 'fa-solid fa-question-circle', class: 'btn btn-sm btn-outline-secondary', action: { id: 'openHelp' } }] } }, 'profile/edit': { title: 'Edit Profile', type: 'form', headerActions: { right: [{ type: 'icon', icon: 'fa-solid fa-pencil', class: 'text-secondary' }] }, content: () => `<div class="p-3"><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 w-100"><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', items: [{ label: 'Dark Mode', icon: 'fa-solid fa-moon', toggle: true, key: 'userProfile.darkMode' }, { label: 'Auto-Save', icon: 'fa-solid fa-save', toggle: true, key: 'settings.autoSave' }, { label: 'Debug Mode', icon: 'fa-solid fa-bug', toggle: true, key: 'settings.debugMode' }] }, 'profile/security': { title: 'Security', type: 'menu', items: [{ label: 'Enable Two-Factor Auth', icon: 'fa-solid fa-key', toggle: true, key: 'userProfile.twoFactor' }, { label: 'Email Notifications', icon: 'fa-solid fa-envelope', toggle: true, key: 'notifications.email' }, { label: 'Push Notifications', icon: 'fa-solid fa-mobile-alt', toggle: true, key: 'notifications.push' }] }, about: { title: 'About Us', type: 'info', content: `<div class="text-center p-4"><i class="fa-solid fa-circle-info text-primary" style="font-size: 3rem;"></i><h3 class="mt-2">Our Company</h3><p class="text-muted">Welcome to our innovative platform that brings cutting-edge technology to your fingertips.</p></div>` } }, offcanvas: { settings: { title: 'Settings', type: 'menu', items: [{ label: 'General', icon: 'fa-solid fa-sliders', route: 'settings/general' }, { label: 'Privacy Controls', icon: 'fa-solid fa-user-shield', route: 'settings/privacy', description: 'Manage location and ad settings' }] }, 'settings/general': { title: 'General Settings', type: 'menu', items: [{ label: 'General Notices', icon: 'fa-solid fa-bell', toggle: true, key: 'settings.generalNotices' }, { label: 'Promotional Emails', icon: 'fa-solid fa-envelope-open-text', toggle: true, key: 'settings.promoEmails' }] }, 'settings/privacy': { title: 'Privacy Controls', type: 'menu', items: [{ 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' }] } } }; | |
| const ActionHandlers = { 'signOut': () => alert('Signed out!'), 'openHelp': () => alert('Help Center opened!'), 'showBillingInfo': () => alert('Billing information is handled externally.'), 'showNotifications': () => AppState.showStateUpdate('Profile notifications overview'), 'viewProfileBadge': () => AppState.showStateUpdate(`Viewing profile for: ${AppState.get('userProfile.name') || 'User'}`) }; | |
| class MenuRenderer { | |
| constructor(containerId, backButtonId, headerActionContainerIds, type, bsComponent) { | |
| this.container = document.getElementById(containerId); | |
| this.backButton = document.getElementById(backButtonId); | |
| this.titleElement = bsComponent._element.querySelector('.modal-title, .offcanvas-title'); | |
| 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)); | |
| this.backButton.onclick = () => Router.back(); | |
| // When a menu is closed via UI, reset the route to sync state. | |
| const onHidden = () => { | |
| this.reset(); | |
| if (Router.getFragment().startsWith(this.type)) { | |
| Router.navigate(''); | |
| } | |
| }; | |
| bsComponent._element.addEventListener('hidden.bs.modal', onHidden); | |
| bsComponent._element.addEventListener('hidden.bs.offcanvas', onHidden); | |
| } | |
| reset() { | |
| this.container.innerHTML = ''; | |
| this.previousPath = ''; | |
| this.titleElement.textContent = 'Menu'; | |
| 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(); | |
| event.stopPropagation(); | |
| const { action, value } = actionableEl.dataset; | |
| switch (action) { | |
| case 'navigate': Router.navigate(`${this.type}/${value}`); break; | |
| case 'toggle': AppState.toggle(value); this.render(this.previousPath, false); break; | |
| case 'custom': if (ActionHandlers[value]) ActionHandlers[value](); break; | |
| case 'saveProfile': | |
| const form = actionableEl.closest('.p-3'); | |
| 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; | |
| } | |
| } | |
| render(path, animate = true) { | |
| let config = MenuConfig[this.type][path]; | |
| if (!config) { this.close(); Router.navigate(''); return; } | |
| this.updateHeader(config, path); | |
| const newLevel = document.createElement('div'); | |
| newLevel.className = 'menu-level'; | |
| let contentHtml = ''; | |
| if (config.type === 'info') { contentHtml = `<div class="info-content p-3">${config.content}</div>`; } | |
| else if (config.type === 'form') { contentHtml = config.content(); } | |
| else { | |
| const itemsHtml = (config.items || []).map(item => | |
| item.items ? `<div class="menu-group-title">${item.title}</div>${item.items.map(gi => this.createMenuItemHtml(gi)).join('')}` : this.createMenuItemHtml(item) | |
| ).join(''); | |
| // Ensure menu content scrolls if it exceeds height, excluding footer height | |
| const itemsContainer = `<div style="height: ${config.footerActions ? 'calc(100% - 60px)' : '100%'}; overflow-y: auto;">${itemsHtml}</div>`; | |
| let footerHtml = ''; | |
| if (config.footerActions) { | |
| footerHtml += '<div class="menu-footer">'; | |
| for (const groupKey in config.footerActions) { | |
| footerHtml += `<div class="footer-action-group footer-group-${groupKey}">${config.footerActions[groupKey].map(action => this._createActionHtml(action, 'button')).join('')}</div>`; | |
| } | |
| footerHtml += '</div>'; | |
| } | |
| contentHtml = itemsContainer + footerHtml; | |
| } | |
| newLevel.innerHTML = contentHtml; | |
| if (animate) this.animateLevel(newLevel, path); | |
| else { this.container.innerHTML = ''; this.container.appendChild(newLevel); newLevel.classList.add('active'); } | |
| this.previousPath = path; | |
| } | |
| updateHeader(config, currentFullPath) { | |
| this.titleElement.textContent = config.title; | |
| this.backButton.style.display = currentFullPath.includes('/') ? 'flex' : 'none'; | |
| Object.values(this.headerActionContainers).forEach(c => { if (c) c.innerHTML = ''; }); | |
| if (config.headerActions) { | |
| for (const groupKey in config.headerActions) { | |
| const container = this.headerActionContainers[groupKey]; | |
| if (container) { container.innerHTML = config.headerActions[groupKey].map(item => this._createHeaderActionHtml(item)).join(''); } | |
| } | |
| } | |
| } | |
| _createHeaderActionHtml(item) { | |
| const dataAttrs = item.action ? `data-action="custom" data-value="${item.action.id}"` : ''; | |
| const label = typeof item.label === 'function' ? item.label() : (item.label || ''); | |
| const icon = item.icon ? `<i class="${item.icon}${label ? ' me-1' : ''}"></i>` : ''; | |
| const inner = item.type === 'badge' ? `<span class="badge rounded-pill ${item.class || ''}">${icon}${label}</span>` : `${icon}${label}`; | |
| return `<div class="header-action-item" ${dataAttrs}>${inner}</div>`; | |
| } | |
| createMenuItemHtml(item) { | |
| const dataAttrs = item.route ? `data-action="navigate" data-value="${item.route}"` : item.toggle ? `data-action="toggle" data-value="${item.key}"` : (item.action ? `data-action="custom" data-value="${item.action.id}"` : ''); | |
| const label = typeof item.label === 'function' ? item.label() : item.label; | |
| const icon = item.icon ? `<i class="${item.icon} fa-fw"></i>` : ''; | |
| const desc = item.description ? `<small class="d-block menu-item-description">${item.description}</small>` : ''; | |
| const content = `<div class="menu-item-content"><div>${icon}<span>${label}</span>${desc}</div></div>`; | |
| if (item.toggle) { return `<div class="menu-item" role="menuitem" ${dataAttrs}>${content}<div class="form-check form-switch"><input class="form-check-input" type="checkbox" ${AppState.get(item.key) ? 'checked' : ''} style="pointer-events: none;"></div></div>`; } | |
| const arrow = item.route ? '<i class="fa-solid fa-chevron-right text-muted"></i>' : (item.action ? '<i class="fa-solid fa-arrow-up-right-from-square text-muted"></i>' : ''); | |
| return `<div class="menu-item" role="menuitem" ${dataAttrs}>${content}${arrow}</div>`; | |
| } | |
| _createActionHtml(action, type) { | |
| const dataAttrs = action.route ? `data-action="navigate" data-value="${action.route}"` : (action.action ? `data-action="custom" data-value="${action.action.id}"` : ''); | |
| const icon = action.icon ? `<i class="${action.icon} me-1"></i>` : ''; | |
| return `<${type} class="${action.class || 'btn btn-sm'}" ${dataAttrs}>${icon}${action.label}</${type}>`; | |
| } | |
| animateLevel(newLevel, path) { | |
| // Robustly calculate the direction of navigation for animations. | |
| const getDepth = p => p ? p.split('/').length : 0; | |
| const isForward = getDepth(path) > getDepth(this.previousPath); | |
| const currentActiveLevel = this.container.querySelector('.menu-level.active'); | |
| if (currentActiveLevel) { | |
| // Add initial slide class to the new level | |
| newLevel.classList.add(isForward ? 'slide-right' : 'slide-left'); | |
| this.container.appendChild(newLevel); | |
| // Force reflow to ensure initial state is applied before transition | |
| newLevel.offsetWidth; // Trigger reflow | |
| requestAnimationFrame(() => { | |
| // Animate current level out | |
| currentActiveLevel.classList.remove('active'); | |
| currentActiveLevel.classList.add(isForward ? 'slide-left' : 'slide-right'); | |
| // Animate new level in | |
| newLevel.classList.add('active'); | |
| newLevel.classList.remove(isForward ? 'slide-right' : 'slide-left'); | |
| // Clean up old level after transition | |
| currentActiveLevel.addEventListener('transitionend', () => currentActiveLevel.remove(), { once: true }); | |
| }); | |
| } else { | |
| this.container.appendChild(newLevel); | |
| newLevel.classList.add('active'); | |
| } | |
| } | |
| close() { if (this.bsComponent && this.bsComponent._isShown) this.bsComponent.hide(); } | |
| } | |
| // --- App Initialization --- | |
| const menuModalEl = document.getElementById('menuModal'); | |
| const menuOffcanvasEl = document.getElementById('menuOffcanvas'); | |
| const modal = new bootstrap.Modal(menuModalEl); | |
| const offcanvas = new bootstrap.Offcanvas(menuOffcanvasEl); | |
| const modalRenderer = new MenuRenderer('modalMenuContainer', 'modalBackButton', {center: 'modalHeaderActionsCenter', right: 'modalHeaderActionsRight'}, 'modal', modal); | |
| const offcanvasRenderer = new MenuRenderer('offcanvasMenuContainer', 'offcanvasBackButton', {center: 'offcanvasHeaderActionsCenter', right: 'offcanvasHeaderActionsRight'}, 'offcanvas', offcanvas); | |
| const routeHandler = (params, renderer, defaultPosition, bsInstance, type) => { | |
| const fullPath = params.path || ''; | |
| if (!fullPath || !MenuConfig[type][fullPath]) { | |
| if (bsInstance._isShown) bsInstance.hide(); | |
| return; | |
| } | |
| const position = Router.__currentNavigationOptions?.position || renderer.currentPosition || defaultPosition; | |
| renderer.currentPosition = position; | |
| if (type === 'modal') { | |
| const modalDialog = menuModalEl.querySelector('.modal-dialog'); | |
| modalDialog.className = 'modal-dialog'; // Reset classes | |
| if (position === 'fullscreen') modalDialog.classList.add('modal-fullscreen'); | |
| else if (['top', 'bottom'].includes(position)) modalDialog.classList.add(`modal-${position}`); | |
| else modalDialog.classList.add('modal-dialog-centered'); | |
| } else { | |
| 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')) { | |
| menuOffcanvasEl.classList.add('offcanvas-fullscreen', `offcanvas-slide-${position.split('-')[1] || 'up'}`); | |
| } else { | |
| menuOffcanvasEl.classList.add(`offcanvas-${position}`); | |
| } | |
| } | |
| renderer.render(fullPath); | |
| if (!bsInstance._isShown) bsInstance.show(); | |
| document.getElementById('currentRoute').textContent = `#${type}/${fullPath}`; | |
| }; | |
| Router.add('modal/:path*', (params) => routeHandler(params, modalRenderer, 'center', modal, 'modal')); | |
| Router.add('offcanvas/:path*', (params) => routeHandler(params, offcanvasRenderer, 'end', offcanvas, 'offcanvas')); | |
| Router.add('', () => { | |
| if (modal._isShown) modal.hide(); | |
| if (offcanvas._isShown) offcanvas.hide(); | |
| document.getElementById('currentRoute').textContent = '#'; | |
| }); | |
| let lastScrollY = 0; | |
| const onShow = () => { lastScrollY = window.scrollY; }; | |
| const onHide = () => { setTimeout(() => window.scrollTo(0, lastScrollY), 0); }; | |
| menuModalEl.addEventListener('show.bs.modal', onShow); | |
| menuModalEl.addEventListener('hidden.bs.modal', onHide); | |
| menuOffcanvasEl.addEventListener('show.bs.offcanvas', onShow); | |
| menuOffcanvasEl.addEventListener('hidden.bs.offcanvas', onHide); | |
| document.addEventListener('DOMContentLoaded', () => Router.start()); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment