(function(){
const SWIPE_THRESHOLD = 50;
const VERTICAL_LIMIT = 40;
// --- Config ---
const LS_KEY = 'AppOverlayConfig';
const defaultConfig = { showArrows: false, enableSwipe: false };
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 ---
window.AppOverlayMenuItems = window.AppOverlayMenuItems || [];
// --- Track all overlay instances ---
const overlayInstances = [];
let zIndexCounter = 2147483647;
// --- Layout Templates ---
const layouts = {
grid: {
container: 'display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; padding: 20px;',
button: 'width: 100%; padding: 20px; font-size: 18px;'
},
list: {
container: 'display: flex; flex-direction: column; gap: 10px; padding: 20px; max-width: 600px; margin: 0 auto;',
button: 'width: 100%; padding: 16px 24px; font-size: 16px; text-align: left;'
},
compact: {
container: 'display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; padding: 20px;',
button: 'width: 100%; padding: 12px; font-size: 14px;'
},
hero: {
container: 'display: flex; flex-direction: column; gap: 20px; padding: 40px; align-items: center; justify-content: center;',
button: 'min-width: 300px; padding: 24px 48px; font-size: 20px;'
},
sidebar: {
container: 'display: flex; flex-direction: column; gap: 8px; padding: 20px; max-width: 300px;',
button: 'width: 100%; padding: 14px 20px; font-size: 15px; text-align: left;'
},
tiles: {
container: 'display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 20px; padding: 20px;',
button: 'width: 100%; aspect-ratio: 1; padding: 20px; font-size: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center;'
},
topbar: {
container: 'display: flex; flex-direction: row; gap: 10px; padding: 12px 15px; overflow-x: auto; flex-wrap: nowrap; align-items: center; background: #0a0a0a; border-bottom: 1px solid #1a1a1a; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; scrollbar-width: none;',
button: 'padding: 10px 20px; font-size: 14px; white-space: nowrap; flex-shrink: 0; scroll-snap-align: start;'
},
bottombar: {
container: 'display: flex; flex-direction: row; gap: 10px; padding: 12px 15px; overflow-x: auto; flex-wrap: nowrap; align-items: center; background: #0a0a0a; border-top: 1px solid #1a1a1a; position: sticky; bottom: 0; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; scrollbar-width: none;',
button: 'padding: 10px 20px; font-size: 14px; white-space: nowrap; flex-shrink: 0; scroll-snap-align: start;'
}
};
// --- Button Renderer ---
function renderButtons(buttons, layout = 'grid', buttonCSS = {}, containerCSS = {}) {
const layoutStyle = layouts[layout] || layouts.grid;
const container = document.createElement('div');
container.style.cssText = layoutStyle.container;
container.className = 'button-container';
// Apply container-level CSS overrides (spacing, padding, etc.)
Object.entries(containerCSS).forEach(([prop, value]) => {
container.style[prop] = value;
});
buttons.forEach(btn => {
const button = document.createElement('button');
button.textContent = btn.label || 'Button';
// Apply layout-specific button styles first
button.style.cssText = layoutStyle.button;
// Apply global buttonCSS overrides (from slide config)
Object.entries(buttonCSS).forEach(([prop, value]) => {
button.style[prop] = value;
});
// Apply individual button styles (highest priority)
const btnStyles = {
background: btn.background || '#1a1a1a',
color: btn.color || '#ffffff',
border: btn.border || '1px solid #2a2a2a',
borderRadius: btn.borderRadius || '0',
cursor: 'pointer',
transition: 'all 0.2s',
fontWeight: btn.fontWeight || '600'
};
Object.entries(btnStyles).forEach(([prop, value]) => {
button.style[prop] = value;
});
// Apply any additional custom CSS from button object
if (btn.css) {
Object.entries(btn.css).forEach(([prop, value]) => {
button.style[prop] = value;
});
}
if (btn.icon) {
const icon = document.createElement('span');
icon.textContent = btn.icon + ' ';
icon.style.marginRight = '8px';
button.insertBefore(icon, button.firstChild);
}
button.onmouseenter = () => {
button.style.transform = btn.hoverTransform || 'scale(1.05)';
button.style.opacity = btn.hoverOpacity || '0.9';
};
button.onmouseleave = () => {
button.style.transform = 'scale(1)';
button.style.opacity = '1';
};
// Handle onclick - can be function or overlay config
if (typeof btn.onClick === 'function') {
button.onclick = btn.onClick;
} else if (btn.onClick && typeof btn.onClick === 'object') {
// If onClick is an object, treat it as overlay config
button.onclick = () => {
AppOverlay.open(btn.onClick.slides || [btn.onClick], btn.onClick.startIndex);
};
}
container.appendChild(button);
});
return container;
}
// --- DOM creation ---
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');
// Add cache-busting data attribute
overlay.setAttribute('data-overlay-id', Date.now() + '-' + Math.random());
// Add styles for hiding scrollbars
const style = document.createElement('style');
style.textContent = `
.button-container::-webkit-scrollbar {
display: none;
}
.button-container {
-ms-overflow-style: none;
scrollbar-width: none;
}
`;
document.head.appendChild(style);
overlay.innerHTML = `
<section class="app-dialog" aria-labelledby="appDialogTitle">
<header class="app-dialog__header" style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:#0a0a0a;border-bottom:1px solid #1a1a1a;">
<div style="display:flex;align-items:center;gap:10px;">
<div class="app-dialog__menu" data-menu style="font-size:20px;cursor:pointer;color:#e0e0e0;">☰</div>
<div class="app-dialog__title" id="appDialogTitle" style="color:#e0e0e0;font-size:16px;font-weight:500;">Title</div>
</div>
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0;">
<button class="app-navbtn" data-prev type="button" style="display:none;background:#3a3a3a;color:#e0e0e0;border:none;padding:6px 12px;border-radius:4px;cursor:pointer;">←</button>
<button class="app-navbtn" data-next type="button" style="display:none;background:#3a3a3a;color:#e0e0e0;border:none;padding:6px 12px;border-radius:4px;cursor:pointer;">→</button>
<button class="app-dialog__close" type="button" aria-label="Close overlay" style="background:#3a3a3a;color:#e0e0e0;border:none;padding:6px 12px;border-radius:4px;cursor:pointer;">✕</button>
</div>
</header>
<div class="app-dialog__body" style="background:#000;padding:0;min-height:100%;overflow:auto;color:#e0e0e0;"></div>
</section>
`;
// --- Overlay Styles (full screen, no padding)
overlay.style.cssText = `
position: fixed;
inset: 0;
display: none;
flex-direction: column;
align-items: center;
justify-content: flex-start;
background: rgba(0,0,0,0.8);
z-index: ${zIndexCounter++};
overflow-y: auto;
padding: 0;
-webkit-overflow-scrolling: touch;
`;
// --- Dialog container adjustments (full screen)
const dialog = overlay.querySelector('.app-dialog');
dialog.style.cssText = `
background: #000;
border: none;
border-radius: 0;
width: 100%;
max-width: 100%;
height: 100vh;
max-height: 100vh;
display: flex;
flex-direction: column;
box-shadow: none;
overflow: hidden;
position: relative;
margin: 0;
`;
const bodyEl = overlay.querySelector('.app-dialog__body');
bodyEl.style.overflowY = 'auto';
bodyEl.style.maxHeight = 'calc(100vh - 60px)';
bodyEl.style.flex = '1';
document.body.appendChild(overlay);
return overlay;
}
// --- Create Overlay Instance ---
function createOverlayInstance() {
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 menuEl = overlay.querySelector('[data-menu]');
let slides = [];
let slideEls = [];
let current = 0;
let opener = null;
// --- Menu toggle ---
let menuOpen = false;
let currentMenuStack = [];
menuEl.addEventListener('click', (e) => {
e.stopPropagation();
menuOpen = !menuOpen;
if (menuOpen) showMenu();
else hideMenu();
});
function showMenu(items = window.AppOverlayMenuItems, parentDropdown = null) {
const rect = menuEl.getBoundingClientRect();
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 = (parseInt(overlay.style.zIndex) + 1).toString();
dropdown.style.transition = 'all 0.2s ease';
dropdown.style.overflow = 'hidden';
if (!parentDropdown) {
dropdown.style.top = (rect.bottom + 4) + 'px';
dropdown.style.left = rect.left + 'px';
currentMenuStack = [];
} else {
const r = parentDropdown.getBoundingClientRect();
dropdown.style.top = r.top + 'px';
dropdown.style.left = (r.right - 2) + 'px';
items = [{ label: "◀ Back", type: "back" }, ...items];
}
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();
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();
currentMenuStack.push(dropdown);
dropdown.style.display = 'none';
const sub = showMenu(item.submenu, dropdown);
document.body.appendChild(sub);
};
} else if (item.type === 'back') {
btn.textContent = '◀ Back';
btn.onclick = (e) => {
e.stopPropagation();
dropdown.remove();
if (currentMenuStack.length > 0) {
const prevMenu = currentMenuStack.pop();
prevMenu.style.display = 'block';
}
};
} else {
btn.onclick = (e) => {
e.stopPropagation();
if (item.action) item.action();
hideMenu();
};
}
dropdown.appendChild(btn);
});
if (!parentDropdown) {
hideMenu();
document.body.appendChild(dropdown);
setTimeout(() => {
const closeOnOutsideClick = (e) => {
const clickedOnMenu = e.target.closest('.app-menu-dropdown') || e.target === menuEl;
if (!clickedOnMenu) {
hideMenu();
document.removeEventListener('click', closeOnOutsideClick);
}
};
document.addEventListener('click', closeOnOutsideClick);
}, 10);
}
dropdown.style.display = 'block';
return dropdown;
}
function hideMenu() {
document.querySelectorAll('.app-menu-dropdown').forEach(m => m.remove());
currentMenuStack = [];
menuOpen = false;
}
// --- Render / Update ---
function renderSlides(){
bodyEl.innerHTML = '';
slideEls = slides.map(s => {
const el = document.createElement('article');
el.className = 'app-slide';
el.style.height = '100%';
// If slide has buttons, render them with slide-level buttonCSS and containerCSS
if (s.buttons && Array.isArray(s.buttons)) {
const buttonContainer = renderButtons(
s.buttons,
s.layout || 'grid',
s.buttonCSS || {},
s.containerCSS || {}
);
el.appendChild(buttonContainer);
}
// If slide has HTML, add it
if (s.html) {
const htmlContainer = document.createElement('div');
htmlContainer.innerHTML = s.html;
el.appendChild(htmlContainer);
}
bodyEl.appendChild(el);
if (typeof s.onRender === 'function') requestAnimationFrame(() => s.onRender(el));
return el;
});
}
function update(){
titleEl.textContent = slides[current]?.title || '';
slideEls.forEach((el,i)=>el.classList.toggle('is-active', i===current));
const showArrows = config.showArrows && slides.length > 1;
prevEl.style.display = showArrows ? '' : 'none';
nextEl.style.display = showArrows ? '' : 'none';
menuEl.style.display = window.AppOverlayMenuItems.length > 0 ? 'block' : 'none';
}
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;
// Apply overlay-level styling if present
const firstSlide = slides[0];
if (firstSlide.overlayCSS) {
const allowedOverlayProps = [
'background', 'backgroundColor', 'backdropFilter',
'border', 'borderRadius', 'outline', 'boxShadow',
'opacity'
];
Object.entries(firstSlide.overlayCSS).forEach(([prop, value]) => {
if (allowedOverlayProps.includes(prop)) {
overlay.style[prop] = value;
}
});
}
// Apply dialog-level styling if present
const dialog = overlay.querySelector('.app-dialog');
if (firstSlide.dialogCSS) {
const allowedDialogProps = [
'background', 'backgroundColor', 'backdropFilter',
'border', 'borderColor', 'borderWidth', 'borderStyle',
'outline', 'boxShadow', 'color'
];
Object.entries(firstSlide.dialogCSS).forEach(([prop, value]) => {
if (allowedDialogProps.includes(prop)) {
dialog.style[prop] = value;
}
});
}
renderSlides();
current = Math.max(0, Math.min(startIndex, slides.length - 1));
mountOnTop();
overlay.classList.add('open');
overlay.setAttribute('aria-hidden','false');
overlay.style.display = 'flex';
update();
closeEl.focus();
}
function close(){
overlay.classList.remove('open');
overlay.setAttribute('aria-hidden','true');
overlay.style.display = 'none';
hideMenu();
// Remove from instances array
const idx = overlayInstances.indexOf(instance);
if (idx > -1) overlayInstances.splice(idx, 1);
// Remove from DOM
overlay.remove();
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();
if (menuOpen && !menuEl.contains(e.target) && !e.target.closest('.app-menu-dropdown')) {
hideMenu();
}
});
const keyHandler = (e) => {
if (!overlay.classList.contains('open')) return;
// Only handle keys for the topmost overlay
if (overlayInstances[overlayInstances.length - 1] !== instance) 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(); }
};
window.addEventListener('keydown', keyHandler);
const instance = {
open,
close,
next,
prev,
overlay,
keyHandler
};
overlayInstances.push(instance);
return instance;
}
// --- Public API ---
function configure(partial){
if (!partial || typeof partial !== 'object') return;
config = Object.assign({}, config, partial);
saveConfig(config);
}
function getConfig(){
return Object.assign({}, config);
}
// Main API - creates new overlay instances
function openNew(items, startIndex=0, openerEl=null) {
const instance = createOverlayInstance();
instance.open(items, startIndex, openerEl);
return instance;
}
function closeAll() {
[...overlayInstances].forEach(inst => inst.close());
}
window.AppOverlay = {
open: openNew,
closeAll,
configure,
getConfig,
instances: overlayInstances,
layouts: Object.keys(layouts) // Expose available layouts
};
})();