Created
July 20, 2025 21:48
-
-
Save zilveer/c4cacf3084fee104d56d1ca68984132c to your computer and use it in GitHub Desktop.
Modal offvanvas event 1.59
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: 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; | |
border-radius: 0.75rem; /* Rounded corners for modals */ | |
} | |
/* Fullscreen modal should not be rounded */ | |
.modal-fullscreen .modal-content { | |
border-radius: 0; | |
} | |
/* 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%; | |
} | |
/* --- Offcanvas Fullscreen Custom Slide Animations --- */ | |
/* Override Bootstrap's default offcanvas-fullscreen behavior */ | |
.offcanvas.offcanvas-fullscreen { | |
transition: transform .3s ease-in-out; /* Apply transition to the offcanvas element itself */ | |
} | |
.offcanvas.offcanvas-fullscreen.offcanvas-slide-up { | |
transform: translateY(100%); | |
left: 0; | |
top: 0; | |
width: 100%; | |
height: 100%; | |
bottom: auto; /* Reset bottom */ | |
right: auto; /* Reset right */ | |
} | |
.offcanvas.offcanvas-fullscreen.offcanvas-slide-up.show { | |
transform: translateY(0%); | |
} | |
.offcanvas.offcanvas-fullscreen.offcanvas-slide-down { | |
transform: translateY(-100%); | |
left: 0; | |
top: 0; | |
width: 100%; | |
height: 100%; | |
bottom: auto; | |
right: auto; | |
} | |
.offcanvas.offcanvas-fullscreen.offcanvas-slide-down.show { | |
transform: translateY(0%); | |
} | |
.offcanvas.offcanvas-fullscreen.offcanvas-slide-left { | |
transform: translateX(100%); | |
left: 0; | |
top: 0; | |
width: 100%; | |
height: 100%; | |
bottom: auto; | |
right: auto; | |
} | |
.offcanvas.offcanvas-fullscreen.offcanvas-slide-left.show { | |
transform: translateX(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%); | |
} | |
/* Ensure that the modal/offcanvas backdrop fades in correctly */ | |
.modal.fade .modal-dialog, .offcanvas.fade { | |
transition: transform .3s ease-out; | |
} | |
</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" data-menu-navigate="modal/profile">Profile Modal (Default)</button> | |
<button class="btn btn-secondary" data-menu-navigate="offcanvas/settings">Settings Offcanvas (Default)</button> | |
<button class="btn btn-info text-white" data-menu-navigate="modal/about">About Us Modal (Default)</button> | |
<button class="btn btn-success" data-menu-navigate="offcanvas/account">Account Offcanvas (Default)</button> | |
<hr class="w-100 my-2"> | |
<h5>Modal Position Examples:</h5> | |
<button class="btn btn-primary" data-menu-navigate="modal/profile" data-menu-position="top">Modal: Top</button> | |
<button class="btn btn-primary" data-menu-navigate="modal/profile" data-menu-position="bottom">Modal: Bottom</button> | |
<button class="btn btn-primary" data-menu-navigate="modal/profile" data-menu-position="center">Modal: Center</button> | |
<button class="btn btn-primary" data-menu-navigate="modal/profile" data-menu-position="fullscreen">Modal: Fullscreen</button> | |
<hr class="w-100 my-2"> | |
<h5>Offcanvas Position Examples:</h5> | |
<button class="btn btn-secondary" data-menu-navigate="offcanvas/settings" data-menu-position="top">Offcanvas: Top</button> | |
<button class="btn btn-secondary" data-menu-navigate="offcanvas/settings" data-menu-position="bottom">Offcanvas: Bottom</button> | |
<button class="btn btn-secondary" data-menu-navigate="offcanvas/settings" data-menu-position="start">Offcanvas: Left</button> | |
<button class="btn btn-secondary" data-menu-navigate="offcanvas/settings" data-menu-position="end">Offcanvas: Right</button> | |
<button class="btn btn-secondary" data-menu-navigate="offcanvas/settings" data-menu-position="fullscreen">Offcanvas: Fullscreen (Slide Up)</button> | |
<button class="btn btn-secondary" data-menu-navigate="offcanvas/settings" data-menu-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" data-menu-navigate="modal/profile/edit" data-menu-position="top">Modal: Edit Profile (Top)</button> | |
<button class="btn btn-success" data-menu-navigate="offcanvas/settings/privacy" data-menu-position="bottom">Offcanvas: Privacy (Bottom)</button> | |
<button class="btn btn-warning text-white" data-menu-navigate="modal/profile/security">Modal: Security (Default)</button> | |
<button class="btn btn-dark" data-menu-navigate="offcanvas/account/security-offcanvas" data-menu-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" data-menu-action="saveAppState">Save State</button> | |
<button class="btn btn-outline-secondary btn-sm me-2" data-menu-action="loadAppState">Load State</button> | |
<button class="btn btn-outline-danger btn-sm" data-menu-action="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" data-menu-action="routerBack"></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" data-menu-action="routerBack"></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) (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; // 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; | |
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; | |
}); | |
// Deep Merge Utility for AppState.load function (unchanged) | |
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 (minor adjustment for showStateUpdate, no functional change) | |
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) (unchanged) | |
const MenuConfig = { | |
modal: { | |
profile: { | |
title: 'User Profile', | |
type: 'menu', | |
action: () => { | |
const userName = AppState.get('userProfile.name') || 'User'; | |
return `<span class="badge bg-primary" data-menu-action="showProfileInfo" data-info-value="${userName}"><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: 'alertBillingInfo' } | |
], | |
footer: '<button class="btn btn-sm btn-outline-danger" data-menu-action="signOut"><i class="fa-solid fa-right-from-bracket me-1"></i>Sign Out</button>' | |
}, | |
'profile/edit': { | |
title: 'Edit Profile', | |
type: 'form', | |
action: '<i class="fa-solid fa-pencil text-secondary" data-menu-action="showEditingProfileInfo"></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" data-menu-action="routerBack">Cancel</button> | |
<button type="button" class="btn btn-primary" id="saveProfileBtn" data-menu-action="saveProfile">Save Changes</button> | |
</div> | |
</div> | |
`, | |
onRender: () => { | |
// Logic for form submission handled by event delegation on saveProfileBtn now | |
} | |
}, | |
'profile/settings': { | |
title: 'Account Settings', | |
type: 'menu', | |
action: '<span class="badge bg-info" data-menu-action="showAccountSettingsInfo"><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: 'alertContactPreferences' } | |
] } | |
] | |
}, | |
'profile/settings/notifications': { | |
title: 'Notifications', | |
type: 'menu', | |
action: '<i class="fa-solid fa-envelope-open text-muted" data-menu-action="showNotificationStatus"></i>', | |
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" data-menu-action="showSecurityStatus"><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: 'alertChangePassword' } | |
] }, | |
{ type: 'group', title: 'Activity', items: [ | |
{ label: 'Login History', icon: 'fa-solid fa-history', action: 'alertLoginHistory' }, | |
{ label: 'Active Sessions', icon: 'fa-solid fa-desktop', action: 'alertActiveSessions' } | |
] } | |
] | |
}, | |
about: { | |
title: 'About Us', | |
type: 'info', | |
action: '<i class="fa-solid fa-info-circle text-primary" data-menu-action="showAboutAppInfo"></i>', | |
content: ` | |
<div class="text-center mb-4"> | |
<i class="fa-solid fa-circle-info text-primary" style="font-size: 3rem;"></i> | |
<h3 class="mt-2">Our Company</h3> | |
</div> | |
<p>Welcome to our innovative platform that brings cutting-edge technology to your fingertips.</p> | |
<h5>Our Mission</h5> | |
<p>We strive to create exceptional user experiences through intuitive design and robust functionality. Our team is dedicated to pushing the boundaries of what's possible in web applications.</p> | |
<h5>Features</h5> | |
<ul> | |
<li><i class="fa-solid fa-route me-2 text-info"></i>Advanced routing capabilities</li> | |
<li><i class="fa-solid fa-database me-2 text-info"></i>State management</li> | |
<li><i class="fa-solid fa-mobile-alt me-2 text-info"></i>Responsive design</li> | |
<li><i class="fa-brands fa-bootstrap me-2 text-info"></i>Bootstrap integration</li> | |
</ul> | |
<h5>Contact Information</h5> | |
<div class="row"> | |
<div class="col-md-6 mb-3 mb-md-0"> | |
<strong><i class="fa-solid fa-envelope me-2"></i>Email:</strong><br> | |
<a href="mailto:[email protected]">[email protected]</a> | |
</div> | |
<div class="col-md-6"> | |
<strong><i class="fa-solid fa-phone me-2"></i>Phone:</strong><br> | |
+1 (555) 123-4567 | |
</div> | |
</div> | |
<div class="mt-4 p-3 bg-light rounded text-center"> | |
<small class="text-muted"> Version 2.1.0 • Last updated: July 2025<br> © 2025 Our Company. All rights reserved. </small> | |
</div> | |
` | |
} | |
}, | |
offcanvas: { | |
settings: { | |
title: 'Settings', | |
type: 'menu', | |
action: '<i class="fa-solid fa-gear text-muted me-1" data-menu-action="showBetaSettingsInfo"></i><span class="badge bg-warning text-dark">Beta</span>', | |
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" data-menu-action="showGeneralAppSettings"></i>', | |
items: [ | |
{ label: () => `Language: ${AppState.get('settings.language').toUpperCase()}`, icon: 'fa-solid fa-language', action: 'promptLanguage' }, | |
{ label: () => `Timezone: ${AppState.get('settings.timezone')}`, icon: 'fa-solid fa-earth-americas', action: 'promptTimezone' }, | |
{ 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" data-menu-action="showAppearanceInfo"></i>', | |
items: [ | |
{ label: 'Dark Theme', icon: 'fa-solid fa-moon', toggle: true, key: 'userProfile.darkMode' }, | |
{ label: 'Font Size', icon: 'fa-solid fa-font', action: 'alertFontSize' }, | |
{ label: 'Color Scheme', icon: 'fa-solid fa-fill-drip', action: 'alertColorScheme' } | |
] | |
}, | |
'settings/privacy': { | |
title: 'Privacy Controls', | |
type: 'menu', | |
action: '<i class="fa-solid fa-lock text-success" data-menu-action="showPrivacySettingsInfo"></i>', | |
items: [ | |
{ label: 'Cookie Preferences', icon: 'fa-solid fa-cookie-bite', action: 'alertCookieConsent' }, | |
{ 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" data-menu-action="showDataManagementInfo"></i>', | |
items: [ | |
{ label: 'Export Data', icon: 'fa-solid fa-download', action: 'saveAppState' }, | |
{ label: 'Import Data', icon: 'fa-solid fa-upload', action: 'loadAppState' }, | |
{ label: 'Reset All Data', icon: 'fa-solid fa-eraser', action: 'confirmClearAppState' }, | |
] | |
}, | |
'settings/advanced': { | |
title: 'Advanced Settings', | |
type: 'menu', | |
action: '<i class="fa-solid fa-microchip text-danger" data-menu-action="showAdvancedSettingsWarning"></i>', | |
items: [ | |
{ label: 'Debug Mode', icon: 'fa-solid fa-bug', toggle: true, key: 'settings.debugMode' }, | |
{ label: 'Cache Settings', icon: 'fa-solid fa-hdd', action: 'alertCacheSettings' }, | |
{ label: 'API Keys', icon: 'fa-solid fa-key', action: 'alertApiKeys' } | |
] | |
}, | |
account: { | |
title: 'Account Management', | |
type: 'menu', | |
action: '<i class="fa-solid fa-user-gear text-primary" data-menu-action="showAccountManagementInfo"></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: 'alertPaymentMethods' } | |
] }, | |
{ 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" data-menu-action="confirmDeactivateAccount"><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" data-menu-action="showProfileDetailsInfo"></i>', | |
items: [ | |
{ label: () => `Display Name: ${AppState.get('userProfile.name')}`, icon: 'fa-solid fa-user', action: 'promptDisplayName' }, | |
{ label: () => `Contact Email: ${AppState.get('userProfile.email')}`, icon: 'fa-solid fa-at', action: 'promptContactEmail' }, | |
{ label: 'Profile Picture', icon: 'fa-solid fa-camera', action: 'alertProfilePicture' } | |
] | |
}, | |
'account/security-offcanvas': { | |
title: 'Account Security', | |
type: 'menu', | |
action: '<i class="fa-solid fa-fingerprint text-danger" data-menu-action="showAccountSecurityInfo"></i>', | |
items: [ | |
{ label: 'Change Password', icon: 'fa-solid fa-lock', action: 'alertChangePasswordDialog' }, | |
{ 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: 'alertLoginHistory' } | |
] | |
}, | |
'account/billing': { | |
title: 'Subscription & Billing', | |
type: 'menu', | |
action: '<span class="badge bg-success" data-menu-action="showSubscriptionActiveInfo"><i class="fa-solid fa-check me-1"></i>Active</span>', | |
items: [ | |
{ label: 'Current Plan: Pro', icon: 'fa-solid fa-gem', action: 'alertPlanDetails' }, | |
{ label: 'Payment Method', icon: 'fa-solid fa-credit-card', action: 'alertPaymentMethodManagement' }, | |
{ label: 'Billing History', icon: 'fa-solid fa-receipt', action: 'alertDownloadBillingHistory' } | |
] | |
}, | |
'account/privacy': { | |
title: 'Data & Privacy', | |
type: 'menu', | |
action: '<i class="fa-solid fa-user-secret text-secondary" data-menu-action="showDataPrivacyInfo"></i>', | |
items: [ | |
{ label: 'Data Export', icon: 'fa-solid fa-file-export', action: 'saveAppState' }, | |
{ label: 'Privacy Settings', icon: 'fa-solid fa-eye-slash', action: 'alertPrivacyControls' }, | |
{ label: 'Delete All Data', icon: 'fa-solid fa-trash', action: 'confirmDeleteAllData' } | |
] | |
} | |
} | |
}; | |
// Menu Renderer Class (Minor adjustment to createMenuItem for data attributes) | |
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 = ''; | |
// Back button click handled by event delegation now (removed direct onclick) | |
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); | |
return; | |
} | |
} | |
this.close(); | |
Router.navigate(''); | |
return; | |
} | |
let isForward; | |
const currentFragment = Router.getFragment(); | |
if (this.previousPath === '' || currentFragment === this.previousPath) { | |
isForward = true; | |
} else { | |
isForward = currentFragment.split('/').length > this.previousPath.split('/').length; | |
} | |
this.updateHeader(config, path); | |
if (config.type === 'info') { | |
this.renderInfoContent(config, isForward); | |
} else if (config.type === 'form') { | |
this.renderFormContent(config, isForward); | |
} 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'; | |
} else { | |
this.backButton.style.display = 'none'; | |
} | |
} | |
if (this.headerAction) { | |
if (config.action) { | |
this.headerAction.innerHTML = typeof config.action === 'function' ? config.action() : config.action; | |
// Ensure the header action area is clickable if it contains data-menu-action | |
const actionElement = this.headerAction.querySelector('[data-menu-action]'); | |
if (actionElement) { | |
this.headerAction.style.cursor = 'pointer'; // Indicate clickability | |
} else { | |
this.headerAction.style.cursor = 'default'; | |
} | |
} else { | |
this.headerAction.innerHTML = ''; | |
this.headerAction.style.cursor = 'default'; | |
} | |
} | |
} | |
renderInfoContent(config, isForward) { | |
if (!this.container) return; | |
const infoLevel = document.createElement('div'); | |
infoLevel.className = 'menu-level'; | |
infoLevel.innerHTML = `<div class="info-content">${config.content}</div>`; | |
this.container.appendChild(infoLevel); | |
this.animateLevel(infoLevel, isForward); | |
} | |
renderFormContent(config, isForward) { | |
if (!this.container) return; | |
const formLevel = document.createElement('div'); | |
formLevel.className = 'menu-level'; | |
formLevel.innerHTML = typeof config.content === 'function' ? config.content() : config.content; | |
this.container.appendChild(formLevel); | |
if (typeof config.onRender === 'function') { | |
config.onRender(); | |
} | |
this.animateLevel(formLevel, isForward); | |
} | |
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'; | |
const labelSpan = contentDiv.querySelector('span'); | |
if (labelSpan) { | |
labelSpan.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.setAttribute('data-menu-navigate', `${this.type}/${item.route}`); | |
itemEl.setAttribute('data-menu-type', this.type); // Add type for proper routing | |
} 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}" data-menu-action="toggleSetting" data-menu-toggle-key="${toggleKey}"> | |
`; | |
itemEl.appendChild(toggleSwitchDiv); | |
itemEl.setAttribute('data-menu-action', 'toggleSettingClick'); // For clicking outside the input | |
itemEl.setAttribute('data-menu-toggle-key', toggleKey); // For clicking outside the input | |
} else if (item.action) { | |
itemEl.innerHTML += '<i class="fa-solid fa-arrow-up-right-from-square text-muted" aria-hidden="true"></i>'; | |
itemEl.setAttribute('data-menu-action', typeof item.action === 'function' ? 'customAction' : item.action); | |
if (typeof item.action === 'function') { | |
// Store the function reference if it's a custom action | |
// This is a simplified approach; in a larger app, you'd map functions to string keys | |
itemEl._customAction = item.action; | |
} | |
} | |
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; // Force 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 (unchanged) | |
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', | |
'#offcanvasOffcanvasLabel', // Corrected selector for offcanvas title | |
'offcanvasBackButton', | |
'#offcanvasHeaderAction', | |
'offcanvas', | |
currentOffcanvasInstance | |
); | |
// Define default positions | |
const defaultModalPosition = 'center'; | |
const defaultOffcanvasPosition = 'end'; | |
// Router Setup (unchanged in logic, only `Router.__currentNavigationOptions` usage) | |
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; | |
} | |
const position = Router.__currentNavigationOptions?.position || defaultModalPosition; | |
Router.__currentNavigationOptions = {}; | |
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') { | |
modalDialog.classList.add('modal-top'); | |
} else if (position === 'bottom') { | |
modalDialog.classList.add('modal-bottom'); | |
} else { | |
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; | |
} | |
const position = Router.__currentNavigationOptions?.position || defaultOffcanvasPosition; | |
Router.__currentNavigationOptions = {}; | |
menuOffcanvasEl.classList.remove('offcanvas-top', 'offcanvas-bottom', 'offcanvas-start', 'offcanvas-end', 'offcanvas-fullscreen', 'offcanvas-slide-up', 'offcanvas-slide-down', 'offcanvas-slide-left', 'offcanvas-slide-right'); | |
if (position === 'fullscreen') { | |
menuOffcanvasEl.classList.add('offcanvas-fullscreen', 'offcanvas-slide-up'); | |
} else if (position === 'fullscreen-right') { | |
menuOffcanvasEl.classList.add('offcanvas-fullscreen', 'offcanvas-slide-right'); | |
} else { | |
menuOffcanvasEl.classList.add(`offcanvas-${position}`); | |
} | |
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 = '#'; | |
}); | |
// Global Action Handlers (moved to a single object for organization) | |
const MenuActions = { | |
saveAppState: () => AppState.save(), | |
loadAppState: () => AppState.load(), | |
clearAppState: () => AppState.clear(), | |
routerBack: () => Router.back(), | |
signOut: () => alert('Signed out!'), | |
alertBillingInfo: () => alert('Billing information is handled externally.'), | |
saveProfile: () => { | |
const newName = document.getElementById('profileName').value; | |
const newEmail = document.getElementById('profileEmail').value; | |
AppState.set('userProfile.name', newName); | |
AppState.set('userProfile.email', newEmail); | |
Router.back(); | |
}, | |
toggleSetting: (element) => { // Called when the input itself is clicked | |
const key = element.dataset.menuToggleKey; | |
if (key) { | |
AppState.toggle(key); | |
// Re-render the current menu to update the label if it's dynamic | |
const currentFragment = Router.getFragment(); | |
const [type, ...pathParts] = currentFragment.split('/'); | |
const currentPath = pathParts.join('/'); | |
if (type === 'modal') { | |
modalMenuRenderer.render(currentPath); | |
} else if (type === 'offcanvas') { | |
offcanvasMenuRenderer.render(currentPath); | |
} | |
} | |
}, | |
toggleSettingClick: (element) => { // Called when menu-item (not the input) is clicked | |
const toggleInput = element.querySelector('input[type="checkbox"]'); | |
if (toggleInput) { | |
toggleInput.checked = !toggleInput.checked; | |
MenuActions.toggleSetting(toggleInput); | |
} | |
}, | |
alertContactPreferences: () => alert('Contact preferences editor would open here.'), | |
alertChangePassword: () => alert('Password change dialog would open here.'), | |
alertLoginHistory: () => alert('Login history would be displayed here.'), | |
alertActiveSessions: () => alert('Active sessions management.'), | |
alertFontSize: () => alert('Font size selector would open here.'), | |
alertColorScheme: () => alert('Color scheme picker would open here.'), | |
alertCookieConsent: () => alert('Cookie consent management.'), | |
confirmClearAppState: () => { | |
if (confirm('Are you sure you want to reset all application data to defaults?')) { | |
AppState.clear(); | |
Router.back(); | |
} | |
}, | |
alertCacheSettings: () => alert('Cache management options.'), | |
alertApiKeys: () => alert('Manage API integration keys.'), | |
alertPaymentMethods: () => alert('Manage stored payment methods.'), | |
confirmDeactivateAccount: () => alert('Account deactivation process would begin.'), | |
promptDisplayName: () => { | |
const name = prompt('Update display name:', AppState.get('userProfile.name')); | |
if (name) { | |
AppState.set('userProfile.name', name); | |
Router.apply(Router.getFragment()); | |
} | |
}, | |
promptContactEmail: () => { | |
const email = prompt('Update email:', AppState.get('userProfile.email')); | |
if (email) { | |
AppState.set('userProfile.email', email); | |
Router.apply(Router.getFragment()); | |
} | |
}, | |
alertProfilePicture: () => alert('Profile picture upload would open here.'), | |
alertChangePasswordDialog: () => alert('Change password dialog.'), | |
alertLoginHistory: () => alert('View recent logins.'), // Duplicate, keep for clarity | |
alertPlanDetails: () => alert('Plan details and upgrade options.'), | |
alertPaymentMethodManagement: () => alert('Payment method management.'), | |
alertDownloadBillingHistory: () => alert('Download billing history.'), | |
alertPrivacyControls: () => alert('Privacy controls would open here.'), | |
confirmDeleteAllData: () => { | |
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(''); | |
} | |
}, | |
// Dynamic header actions | |
showProfileInfo: (element) => AppState.showStateUpdate(`Viewing profile for: ${element.dataset.infoValue || 'User'}`), | |
showEditingProfileInfo: () => AppState.showStateUpdate('Editing profile details...'), | |
showAccountSettingsInfo: () => AppState.showStateUpdate('Account settings information'), | |
showNotificationStatus: () => AppState.showStateUpdate('Notification status'), | |
showSecurityStatus: () => AppState.showStateUpdate('Security status: All good!'), | |
showAboutAppInfo: () => AppState.showStateUpdate('About this application'), | |
showBetaSettingsInfo: () => AppState.showStateUpdate('Settings are in Beta!'), | |
showGeneralAppSettings: () => AppState.showStateUpdate('General app settings'), | |
promptLanguage: (element) => { | |
const lang = prompt('Enter language code (en/es/fr):', AppState.get('settings.language')); | |
if (lang) { | |
AppState.set('settings.language', lang); | |
Router.apply(Router.getFragment()); | |
} | |
}, | |
promptTimezone: (element) => { | |
const tz = prompt('Enter timezone:', AppState.get('settings.timezone')); | |
if (tz) { | |
AppState.set('settings.timezone', tz); | |
Router.apply(Router.getFragment()); | |
} | |
}, | |
showAppearanceInfo: () => AppState.showStateUpdate('Customizing appearance'), | |
showPrivacySettingsInfo: () => AppState.showStateUpdate('Managing privacy settings'), | |
showDataManagementInfo: () => AppState.showStateUpdate('Data management options'), | |
showAdvancedSettingsWarning: () => AppState.showStateUpdate('Advanced settings are for power users!'), | |
showAccountManagementInfo: () => AppState.showStateUpdate('Managing your account'), | |
showProfileDetailsInfo: () => AppState.showStateUpdate('Your profile details'), | |
showAccountSecurityInfo: () => AppState.showStateUpdate('Reviewing account security'), | |
showSubscriptionActiveInfo: () => AppState.showStateUpdate('Subscription is active'), | |
showDataPrivacyInfo: () => AppState.showStateUpdate('Your data privacy settings'), | |
customAction: (element) => { | |
// This handles functions passed directly in the config, retrieved via `_customAction` | |
if (element && element._customAction && typeof element._customAction === 'function') { | |
element._customAction(); | |
} else { | |
console.warn('Custom action not found or not a function for element:', element); | |
} | |
} | |
}; | |
// Centralized Event Listener for Event Delegation | |
document.addEventListener('click', (event) => { | |
let targetElement = event.target; | |
// Traverse up the DOM to find the closest element with a data-menu-action or data-menu-navigate attribute | |
while (targetElement && targetElement !== document.body) { | |
// Handle Navigation | |
if (targetElement.hasAttribute('data-menu-navigate')) { | |
event.preventDefault(); // Prevent default link behavior if it were an <a> | |
if (targetElement.classList.contains('disabled')) return; // Skip if disabled | |
const path = targetElement.getAttribute('data-menu-navigate'); | |
const position = targetElement.getAttribute('data-menu-position'); | |
const options = {}; | |
if (position) { | |
options.position = position; | |
} | |
Router.navigate(path, options); | |
break; | |
} | |
// Handle Actions | |
if (targetElement.hasAttribute('data-menu-action')) { | |
event.preventDefault(); // Prevent default action for buttons/links if any | |
if (targetElement.classList.contains('disabled')) return; // Skip if disabled | |
const actionName = targetElement.getAttribute('data-menu-action'); | |
if (actionName && typeof MenuActions[actionName] === 'function') { | |
// Pass the clicked element to the action handler, useful for toggles/dynamic info | |
MenuActions[actionName](targetElement); | |
// For toggle switches, re-render the menu to update dynamic labels | |
if (actionName === 'toggleSettingClick' || actionName === 'toggleSetting') { | |
const currentFragment = Router.getFragment(); | |
const [type, ...pathParts] = currentFragment.split('/'); | |
const currentPath = pathParts.join('/'); | |
if (type === 'modal') { | |
modalMenuRenderer.render(currentPath); | |
} else if (type === 'offcanvas') { | |
offcanvasMenuRenderer.render(currentPath); | |
} | |
} | |
} else { | |
console.warn(`No action handler found for: ${actionName}`); | |
} | |
break; // Stop looking for actions | |
} | |
// For toggle inputs, specifically handle their change event rather than click on parent | |
if (targetElement.type === 'checkbox' && targetElement.hasAttribute('data-menu-action') && targetElement.getAttribute('data-menu-action') === 'toggleSetting') { | |
// The change event will handle this, not the click handler on parent. | |
// This break prevents the parent .menu-item click handler from firing if the checkbox itself was clicked. | |
break; | |
} | |
targetElement = targetElement.parentElement; | |
} | |
}); | |
// Start the router | |
document.addEventListener('DOMContentLoaded', () => { | |
Router.start(); | |
document.getElementById('currentRoute').textContent = Router.getFragment() || '#'; | |
Router.apply(); | |
}); | |
// Ensure closing modal/offcanvas also updates hash (makes URL consistent) (unchanged) | |
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