// /core/js/overlay.js
(function(){
const SWIPE_THRESHOLD = 50; // px horizontal
const VERTICAL_LIMIT = 40; // px vertical
// --- Config (persisted) ---
const LS_KEY = 'AppOverlayConfig';
const defaultConfig = { showArrows: true, enableSwipe: true };
function loadConfig(){
try { return Object.assign({}, defaultConfig, JSON.parse(localStorage.getItem(LS_KEY) || '{}')); }
catch { return { ...defaultConfig }; }
}
function saveConfig(cfg){
try { localStorage.setItem(LS_KEY, JSON.stringify(cfg)); } catch {}
}
let config = loadConfig();
// --- Global menu items array ---
window.AppOverlayMenuItems = window.AppOverlayMenuItems || [];
// --- DOM creation (once) ---
function createOverlayDOM(){
const overlay = document.createElement('div');
overlay.className = 'app-overlay';
overlay.setAttribute('role','dialog');
overlay.setAttribute('aria-modal','true');
overlay.setAttribute('aria-hidden','true');
overlay.innerHTML = `
<section class="app-dialog" aria-labelledby="appDialogTitle">
<header class="app-dialog__header">
<div class="app-dialog__menu" data-menu style="display:none;">☰</div>
<div class="app-dialog__title" id="appDialogTitle">Title</div>
<button class="app-dialog__close" type="button" aria-label="Close overlay">✕</button>
</header>
<div class="app-dialog__body"></div>
<footer class="app-dialog__footer" data-footer>
<button class="app-navbtn" data-prev type="button">← Prev</button>
<div class="app-index" data-index>1 / 1</div>
<button class="app-navbtn" data-next type="button">Next →</button>
</footer>
</section>
`;
document.body.appendChild(overlay);
return overlay;
}
const overlay = createOverlayDOM();
const titleEl = overlay.querySelector('#appDialogTitle');
const bodyEl = overlay.querySelector('.app-dialog__body');
const closeEl = overlay.querySelector('.app-dialog__close');
const prevEl = overlay.querySelector('[data-prev]');
const nextEl = overlay.querySelector('[data-next]');
const indexEl = overlay.querySelector('[data-index]');
const footerEl= overlay.querySelector('[data-footer]');
const menuEl = overlay.querySelector('[data-menu]');
let slides = [];
let slideEls = [];
let current = 0;
let opener = null;
// --- Menu toggle ---
let menuOpen = false;
menuEl.addEventListener('click', (e) => {
e.stopPropagation();
menuOpen = !menuOpen;
if (menuOpen) showMenu();
else hideMenu();
});
let currentMenuStack = []; // Track menu hierarchy
function showMenu(items = window.AppOverlayMenuItems, parentDropdown = null) {
const rect = menuEl.getBoundingClientRect();
// Create dropdown
let dropdown = document.createElement('div');
dropdown.className = 'app-menu-dropdown';
dropdown.style.position = 'fixed';
dropdown.style.background = '#fff';
dropdown.style.border = '1px solid #ddd';
dropdown.style.borderRadius = '4px';
dropdown.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
dropdown.style.minWidth = '180px';
dropdown.style.zIndex = '2147483647';
dropdown.style.transition = 'all 0.2s ease';
dropdown.style.overflow = 'hidden';
// Start position (main vs submenu)
if (!parentDropdown) {
dropdown.style.top = (rect.bottom + 4) + 'px';
dropdown.style.left = rect.left + 'px';
currentMenuStack = []; // Reset stack for root menu
} else {
const r = parentDropdown.getBoundingClientRect();
dropdown.style.top = r.top + 'px';
dropdown.style.left = (r.right - 2) + 'px';
// AUTO-ADD BACK BUTTON FOR SUBMENUS
items = [{ label: "◀ Back", type: "back" }, ...items];
}
// Build buttons
items.forEach(item => {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.className = 'app-menu-item';
btn.style.display = 'flex';
btn.style.alignItems = 'center';
btn.style.justifyContent = 'space-between';
btn.style.width = '100%';
btn.style.padding = '8px 12px';
btn.style.border = 'none';
btn.style.background = 'transparent';
btn.style.cursor = 'pointer';
btn.style.fontSize = '14px';
btn.style.color = '#333';
btn.onmouseenter = () => btn.style.background = '#f0f0f0';
btn.onmouseleave = () => btn.style.background = 'transparent';
if (item.type === 'toggle') {
const toggle = document.createElement('span');
toggle.textContent = item.state ? '✅' : '⬜';
btn.appendChild(toggle);
btn.onclick = (e) => {
e.stopPropagation(); // Prevent menu close
item.state = !item.state;
toggle.textContent = item.state ? '✅' : '⬜';
if (item.onToggle) item.onToggle(item.state);
hideMenu();
};
}
else if (item.submenu) {
const arrow = document.createElement('span');
arrow.textContent = '▶';
btn.appendChild(arrow);
btn.onclick = (e) => {
e.stopPropagation(); // Prevent menu close
// Save current menu to stack
currentMenuStack.push(dropdown);
// Hide current dropdown
dropdown.style.display = 'none';
// Show submenu
const sub = showMenu(item.submenu, dropdown);
document.body.appendChild(sub);
};
}
else if (item.type === 'back') {
btn.textContent = '◀ Back';
btn.onclick = (e) => {
e.stopPropagation(); // Prevent menu close
// Remove current dropdown
dropdown.remove();
// Show previous menu from stack
if (currentMenuStack.length > 0) {
const prevMenu = currentMenuStack.pop();
prevMenu.style.display = 'block';
}
};
}
else {
btn.onclick = (e) => {
e.stopPropagation(); // Prevent menu close
if (item.action) item.action();
hideMenu();
};
}
dropdown.appendChild(btn);
});
// Handle root menu
if (!parentDropdown) {
hideMenu();
document.body.appendChild(dropdown);
// CLOSE MENU WHEN CLICKING OUTSIDE
setTimeout(() => {
const closeOnOutsideClick = (e) => {
// Check if click is on ANY menu dropdown or the menu button
const clickedOnMenu = e.target.closest('.app-menu-dropdown') || e.target === menuEl || e.target.closest('.app-menu-trigger');
if (!clickedOnMenu) {
hideMenu();
document.removeEventListener('click', closeOnOutsideClick);
document.removeEventListener('touchstart', closeOnOutsideClick);
}
};
document.addEventListener('click', closeOnOutsideClick);
document.addEventListener('touchstart', closeOnOutsideClick);
}, 10);
}
dropdown.style.display = 'block';
return dropdown;
}
function hideMenu() {
// Remove all menu dropdowns
document.querySelectorAll('.app-menu-dropdown').forEach(m => m.remove());
currentMenuStack = [];
}
// --- Render / Update ---
function renderSlides(){
bodyEl.innerHTML = '';
slideEls = slides.map(s => {
const el = document.createElement('article');
el.className = 'app-slide';
el.innerHTML = s.html || '';
bodyEl.appendChild(el);
// Call onRender callback if provided
if (typeof s.onRender === 'function') {
requestAnimationFrame(() => s.onRender(el));
}
return el;
});
}
function applyNavVisibility(){
const single = slides.length <= 1;
// Hide arrows when: (a) single item OR (b) user turned them off
const showArrows = !single && !!config.showArrows;
prevEl.style.display = showArrows ? '' : 'none';
nextEl.style.display = showArrows ? '' : 'none';
// Show menu only if items exist
menuEl.style.display = window.AppOverlayMenuItems.length > 0 ? 'flex' : 'none';
}
function update(){
titleEl.textContent = slides[current]?.title || '';
slideEls.forEach((el,i)=>el.classList.toggle('is-active', i===current));
indexEl.textContent = `${current+1} / ${slides.length}`;
applyNavVisibility();
}
function mountOnTop(){
if (overlay.parentElement !== document.body || document.body.lastElementChild !== overlay) {
document.body.appendChild(overlay);
}
}
// --- Controls ---
function open(items, startIndex=0, openerEl=null){
slides = Array.isArray(items) ? items : [];
if (slides.length === 0) return;
opener = openerEl || document.activeElement;
renderSlides();
current = Math.max(0, Math.min(startIndex, slides.length - 1));
mountOnTop();
overlay.classList.add('open');
overlay.setAttribute('aria-hidden','false');
document.body.classList.add('body-lock'); // block background scroll/P2R
update();
closeEl.focus();
}
function close(){
overlay.classList.remove('open');
overlay.setAttribute('aria-hidden','true');
document.body.classList.remove('body-lock');
hideMenu();
if (opener && typeof opener.focus === 'function') opener.focus();
}
function next(){ if (slides.length > 1) { current = (current + 1) % slides.length; update(); } }
function prev(){ if (slides.length > 1) { current = (current - 1 + slides.length) % slides.length; update(); } }
// --- Events ---
closeEl.addEventListener('click', close);
nextEl.addEventListener('click', next);
prevEl.addEventListener('click', prev);
overlay.addEventListener('click', (e)=>{
if (e.target === overlay) close();
// Close menu if clicking outside
if (menuOpen && !menuEl.contains(e.target) && !e.target.closest('.app-menu-dropdown')) {
hideMenu();
}
});
window.addEventListener('keydown', (e)=>{
if (!overlay.classList.contains('open')) return;
if (e.key === 'Escape') { e.preventDefault(); if (menuOpen) hideMenu(); else close(); }
if (e.key === 'ArrowRight') { e.preventDefault(); next(); }
if (e.key === 'ArrowLeft') { e.preventDefault(); prev(); }
});
// --- Swipe (obeys enableSwipe and slide count) ---
let startX = 0, startY = 0, tracking = false;
bodyEl.addEventListener('touchstart', (e)=>{
if (!overlay.classList.contains('open')) return;
if (!config.enableSwipe || slides.length <= 1) return;
if (e.touches.length !== 1) return;
tracking = true;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
}, { passive: true });
bodyEl.addEventListener('touchend', (e)=>{
if (!tracking) return; tracking = false;
if (!config.enableSwipe || slides.length <= 1) return;
const endX = e.changedTouches[0].clientX;
const endY = e.changedTouches[0].clientY;
const dx = endX - startX;
const dy = endY - startY;
if (Math.abs(dx) >= SWIPE_THRESHOLD && Math.abs(dy) <= VERTICAL_LIMIT) {
if (dx > 0) prev(); else next();
}
}, { passive: true });
// --- Public config API ---
function configure(partial){
if (!partial || typeof partial !== 'object') return;
config = Object.assign({}, config, partial);
saveConfig(config);
// Re-apply current UI state without closing overlay
applyNavVisibility();
}
function getConfig(){ return Object.assign({}, config); }
// --- Export API ---
window.AppOverlay = { open, close, next, prev, configure, getConfig };
})();