Files
web_template/base.js
T
jared db67f0c92b v1.2 → v1.3: responsive overhaul, mobile nav, accessibility & bug fixes
- 8-breakpoint responsive system (xs→4k) replacing 3-breakpoint stub
- Off-canvas mobile nav drawer with swipe gestures and focus trap
- iOS-safe scroll lock for modals (position:fixed pattern + ref count)
- Modal focus trap with Tab cycling and return-focus to trigger
- Z-index stack overhaul: modal > nav drawer, toast above scanlines
- Fixed --border-dim CSS variable undefined across all v1.2 components
- Fixed badge absolute positioning clipping in Component Reference
- Fixed accordion CSS class mismatch (open → is-open)
- Fixed command palette selector/class mismatches
- Select dark mode: color-scheme:dark + option background
- 4K scaling: rem-based overrides for all hardcoded px elements
- Safe area insets for iPhone notch/home bar (viewport-fit:cover)
- Touch targets: 44px min on all interactive elements (pointer:coarse)
- Disabled glitch/pulse animations on coarse/hover:none devices
- Table responsive card mode with data-label attributes
- viewport.is() validation, debounce caching, 350ms orientation debounce
- initMobileNav() guard prevents duplicate listener registration
- cmd palette: input.select() on open, consistent scroll lock

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:47:26 -04:00

1497 lines
62 KiB
JavaScript

/**
* LOTUSGUILD TERMINAL DESIGN SYSTEM v1.2 — base.js
* Core JavaScript utilities shared across all LotusGuild applications
*
* Apps: Tinker Tickets (PHP), PULSE (Node.js), GANDALF (Flask)
* Namespace: window.lt
*
* CONTENTS
* 1. HTML Escape
* 2. Toast Notifications
* 3. Terminal Audio (beep)
* 4. Modal Management
* 5. Tab Management
* 6. Boot Sequence Animation
* 7. Keyboard Shortcuts
* 8. Sidebar Collapse
* 9. CSRF Token Helpers
* 10. Fetch Helpers (JSON API wrapper)
* 11. Time Formatting
* 12. Bytes Formatting
* 13. Table Keyboard Navigation
* 14. Sortable Table Headers
* 15. Stats Widget Filtering
* 16. Auto-refresh Manager
* --- v1.2 additions ---
* 17. Accordion
* 18. Tooltip System
* 19. Clipboard Copy
* 20. Alert / Banner Dismiss
* 21. Progress Bar
* 22. Command Palette
* 23. Form Validation
* 24. Debounce & Throttle
* 25. Event Bus (pub/sub)
* 26. Storage Helpers
* 27. URL / Query String Helpers
* 28. Number Formatter
* 29. DOM Helpers
* 30. Table Column Visibility
* 31. Polling & Async Retry
* 32. Drag & Drop Upload
* 33. Intersection Observer
* 34. Full Initialisation
*/
(function (global) {
'use strict';
/* ----------------------------------------------------------------
1. HTML ESCAPE
---------------------------------------------------------------- */
function escHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/* ----------------------------------------------------------------
2. TOAST NOTIFICATIONS
----------------------------------------------------------------
lt.toast.success(msg, duration?)
lt.toast.error(msg, duration?)
lt.toast.warning(msg, duration?)
lt.toast.info(msg, duration?)
---------------------------------------------------------------- */
const _toastQueue = [];
let _toastActive = false;
function showToast(message, type, duration) {
type = type || 'info';
duration = duration || 3500;
if (_toastActive) { _toastQueue.push({ message, type, duration }); return; }
_displayToast(message, type, duration);
}
function _displayToast(message, type, duration) {
_toastActive = true;
let container = document.getElementById('lt-toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'lt-toast-container';
document.body.appendChild(container);
}
const icons = { success: '✓', error: '✗', warning: '!', info: 'i' };
const toast = document.createElement('div');
toast.className = 'lt-toast lt-toast-' + type;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'polite');
const iconEl = document.createElement('span');
iconEl.className = 'lt-toast-icon';
iconEl.textContent = '[' + (icons[type] || 'i') + ']';
const msgEl = document.createElement('span');
msgEl.className = 'lt-toast-msg';
msgEl.textContent = message;
const closeEl = document.createElement('button');
closeEl.className = 'lt-toast-close';
closeEl.textContent = '✕';
closeEl.setAttribute('aria-label', 'Dismiss');
closeEl.addEventListener('click', () => _dismissToast(toast));
toast.appendChild(iconEl);
toast.appendChild(msgEl);
toast.appendChild(closeEl);
container.appendChild(toast);
const timer = setTimeout(() => _dismissToast(toast), duration);
toast._lt_timer = timer;
_beep(type);
}
function _dismissToast(toast) {
if (!toast || !toast.parentNode) return;
clearTimeout(toast._lt_timer);
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s ease';
setTimeout(() => {
if (toast.parentNode) toast.parentNode.removeChild(toast);
_toastActive = false;
if (_toastQueue.length) {
const next = _toastQueue.shift();
_displayToast(next.message, next.type, next.duration);
}
}, 320);
}
const toast = {
success: (msg, dur) => showToast(msg, 'success', dur),
error: (msg, dur) => showToast(msg, 'error', dur),
warning: (msg, dur) => showToast(msg, 'warning', dur),
info: (msg, dur) => showToast(msg, 'info', dur),
};
/* ----------------------------------------------------------------
3. TERMINAL AUDIO
---------------------------------------------------------------- */
function _beep(type) {
try {
const ctx = new (global.AudioContext || global.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = type === 'success' ? 880 : type === 'error' ? 220 : 440;
osc.type = 'sine';
gain.gain.setValueAtTime(0.06, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.1);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.1);
} catch (_) {}
}
/* ----------------------------------------------------------------
4. MODAL MANAGEMENT
----------------------------------------------------------------
lt.modal.open('modal-id')
lt.modal.close('modal-id')
lt.modal.closeAll()
---------------------------------------------------------------- */
// iOS-safe scroll lock: position:fixed preserves scroll position on iOS
let _scrollLockCount = 0;
let _scrollLockY = 0;
function _lockScroll() {
if (_scrollLockCount === 0) {
_scrollLockY = window.scrollY;
document.body.style.position = 'fixed';
document.body.style.top = `-${_scrollLockY}px`;
document.body.style.width = '100%';
}
_scrollLockCount++;
}
function _unlockScroll() {
_scrollLockCount = Math.max(0, _scrollLockCount - 1);
if (_scrollLockCount === 0) {
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
window.scrollTo(0, _scrollLockY);
}
}
// Focus-trap helpers
const _FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function _trapFocus(el, e) {
const nodes = Array.from(el.querySelectorAll(_FOCUSABLE)).filter(n => !n.closest('[aria-hidden="true"]'));
if (!nodes.length) return;
const first = nodes[0], last = nodes[nodes.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
// Map modal → trigger element so focus returns after close
const _modalTriggers = new WeakMap();
function openModal(id) {
const el = typeof id === 'string' ? document.getElementById(id) : id;
if (!el) return;
// Close mobile nav before opening modal (avoids z-index overlap)
if (_mnOpen) _mnSetOpen(false);
// Remember what triggered the open for return-focus
if (document.activeElement && document.activeElement !== document.body) {
_modalTriggers.set(el, document.activeElement);
}
el.classList.add('is-open');
el.setAttribute('aria-hidden', 'false');
_lockScroll();
// Focus first focusable element
const first = el.querySelector(_FOCUSABLE);
if (first) setTimeout(() => first.focus(), 50);
// Tab focus trap
el._ltTrapHandler = e => { if (e.key === 'Tab') _trapFocus(el, e); };
el.addEventListener('keydown', el._ltTrapHandler);
}
function closeModal(id) {
const el = typeof id === 'string' ? document.getElementById(id) : id;
if (!el || !el.classList.contains('is-open')) return;
el.classList.remove('is-open');
el.setAttribute('aria-hidden', 'true');
_unlockScroll();
// Remove trap handler
if (el._ltTrapHandler) { el.removeEventListener('keydown', el._ltTrapHandler); delete el._ltTrapHandler; }
// Return focus to trigger
const trigger = _modalTriggers.get(el);
if (trigger) { trigger.focus(); _modalTriggers.delete(el); }
}
function closeAllModals() {
document.querySelectorAll('.lt-modal-overlay.is-open').forEach(closeModal);
const ov = document.getElementById('lt-cmd-overlay');
if (ov && ov.classList.contains('is-open')) {
ov.classList.remove('is-open');
_unlockScroll();
}
}
document.addEventListener('click', function (e) {
if (e.target.classList.contains('lt-modal-overlay')) { closeModal(e.target); return; }
const closeBtn = e.target.closest('[data-modal-close]');
if (closeBtn) { const overlay = closeBtn.closest('.lt-modal-overlay'); if (overlay) closeModal(overlay); }
const openBtn = e.target.closest('[data-modal-open]');
if (openBtn) openModal(openBtn.dataset.modalOpen);
});
const modal = { open: openModal, close: closeModal, closeAll: closeAllModals };
/* ----------------------------------------------------------------
5. TAB MANAGEMENT
----------------------------------------------------------------
lt.tabs.init()
lt.tabs.switch('panel-id')
---------------------------------------------------------------- */
function switchTab(panelId) {
document.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.lt-tab-panel').forEach(p => p.classList.remove('active'));
const btn = document.querySelector('.lt-tab[data-tab="' + panelId + '"]');
const panel = document.getElementById(panelId);
if (btn) btn.classList.add('active');
if (panel) panel.classList.add('active');
try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {}
}
function initTabs() {
try {
const saved = localStorage.getItem('lt_activeTab_' + location.pathname);
if (saved && document.getElementById(saved)) { switchTab(saved); }
} catch (_) {}
document.querySelectorAll('.lt-tab[data-tab]').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
}
const tabs = { init: initTabs, switch: switchTab };
/* ----------------------------------------------------------------
6. BOOT SEQUENCE ANIMATION
----------------------------------------------------------------
lt.boot.run('APP NAME')
lt.boot.run('APP NAME', true) // force replay
---------------------------------------------------------------- */
function runBoot(appName, force) {
const storageKey = 'lt_booted_' + (appName || 'app');
if (!force && sessionStorage.getItem(storageKey)) return;
const overlay = document.getElementById('lt-boot');
const pre = document.getElementById('lt-boot-text');
if (!overlay || !pre) return;
overlay.style.display = 'flex';
overlay.style.opacity = '1';
const name = (appName || 'TERMINAL').toUpperCase();
const titleStr = name + ' v1.2';
const inner = 50;
const lp = Math.max(0, Math.floor((inner - titleStr.length) / 2));
const rp = Math.max(0, inner - titleStr.length - lp);
const messages = [
'╔════════════════════════════════════════════════════╗',
'║' + ' '.repeat(lp) + titleStr + ' '.repeat(rp) + '║',
'║ LOTUSGUILD INFRASTRUCTURE PLATFORM ║',
'╚════════════════════════════════════════════════════╝',
'',
'[ OK ] Kernel modules loaded',
'[ OK ] Filesystem mounted read-write',
'[ OK ] Network interfaces configured',
'[ OK ] Database connection pool initialized',
'[ OK ] Authentication service started',
'[ OK ] Security headers applied',
'[ OK ] API gateway bound',
'[ OK ] Scheduled tasks registered',
'[ OK ] Terminal interface rendered',
'',
'> ALL SYSTEMS NOMINAL — ' + name,
'',
];
let i = 0;
pre.textContent = '';
const interval = setInterval(() => {
if (i < messages.length) {
pre.textContent += messages[i] + '\n';
i++;
} else {
clearInterval(interval);
setTimeout(() => {
overlay.style.transition = 'opacity 0.5s ease';
overlay.style.opacity = '0';
setTimeout(() => { overlay.style.display = 'none'; overlay.style.opacity = ''; overlay.style.transition = ''; }, 520);
}, 500);
sessionStorage.setItem(storageKey, '1');
}
}, 65);
}
const boot = { run: runBoot };
/* ----------------------------------------------------------------
7. KEYBOARD SHORTCUTS
----------------------------------------------------------------
lt.keys.on('ctrl+k', fn)
lt.keys.off('ctrl+k')
lt.keys.initDefaults()
---------------------------------------------------------------- */
const _keyHandlers = {};
function normalizeKey(combo) {
return combo.replace(/ctrl\+/i, 'ctrl+').replace(/cmd\+/i, 'ctrl+').replace(/meta\+/i, 'ctrl+').toLowerCase();
}
function registerKey(combo, handler) { _keyHandlers[normalizeKey(combo)] = handler; }
function unregisterKey(combo) { delete _keyHandlers[normalizeKey(combo)]; }
document.addEventListener('keydown', function (e) {
const inInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName) || e.target.isContentEditable;
let combo = '';
if (e.ctrlKey || e.metaKey) combo += 'ctrl+';
if (e.altKey) combo += 'alt+';
if (e.shiftKey) combo += 'shift+';
combo += e.key.toLowerCase();
const alwaysFire = e.key === 'Escape' || e.ctrlKey || e.metaKey;
if (inInput && !alwaysFire) return;
const handler = _keyHandlers[combo];
if (handler) { e.preventDefault(); handler(e); }
});
function initDefaultKeys() {
registerKey('escape', closeAllModals);
registerKey('?', () => { const h = document.getElementById('lt-keys-help'); if (h) openModal(h); });
registerKey('ctrl+k', () => {
const ov = document.getElementById('lt-cmd-overlay');
if (ov) { _cmdPaletteOpen(); return; }
const s = document.querySelector('.lt-search-input');
if (s) { s.focus(); s.select(); }
});
}
const keys = { on: registerKey, off: unregisterKey, initDefaults: initDefaultKeys };
/* ----------------------------------------------------------------
8. SIDEBAR COLLAPSE
----------------------------------------------------------------
lt.sidebar.init()
---------------------------------------------------------------- */
function initSidebar() {
document.querySelectorAll('[data-sidebar-toggle]').forEach(btn => {
const sidebar = document.getElementById(btn.dataset.sidebarToggle);
if (!sidebar) return;
const collapsed = sessionStorage.getItem('lt_sidebar_' + btn.dataset.sidebarToggle) === '1';
if (collapsed) { sidebar.classList.add('collapsed'); btn.textContent = '▶'; }
btn.addEventListener('click', () => {
sidebar.classList.toggle('collapsed');
const c = sidebar.classList.contains('collapsed');
btn.textContent = c ? '▶' : '◀';
try { sessionStorage.setItem('lt_sidebar_' + btn.dataset.sidebarToggle, c ? '1' : '0'); } catch (_) {}
});
});
}
const sidebar = { init: initSidebar };
/* ----------------------------------------------------------------
9. CSRF TOKEN HELPERS
----------------------------------------------------------------
lt.csrf.headers()
---------------------------------------------------------------- */
function csrfHeaders() {
const token = global.CSRF_TOKEN || '';
return token ? { 'X-CSRF-Token': token } : {};
}
const csrf = { headers: csrfHeaders };
/* ----------------------------------------------------------------
10. FETCH HELPERS
----------------------------------------------------------------
lt.api.get(url)
lt.api.post(url, body)
lt.api.put / patch / delete
---------------------------------------------------------------- */
async function apiFetch(method, url, body) {
const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) };
if (body !== undefined) opts.body = JSON.stringify(body);
let resp;
try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); }
let data;
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status);
return data;
}
const api = {
get: url => apiFetch('GET', url),
post: (url, b) => apiFetch('POST', url, b),
put: (url, b) => apiFetch('PUT', url, b),
patch: (url, b) => apiFetch('PATCH', url, b),
delete: (url, b) => apiFetch('DELETE', url, b),
};
/* ----------------------------------------------------------------
11. TIME FORMATTING
----------------------------------------------------------------
lt.time.ago(value)
lt.time.uptime(secs)
lt.time.format(value)
---------------------------------------------------------------- */
function timeAgo(value) {
const date = value instanceof Date ? value : new Date(value);
if (isNaN(date)) return '—';
const diff = Math.floor((Date.now() - date.getTime()) / 1000);
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
function formatUptime(secs) {
secs = Math.floor(secs);
const d = Math.floor(secs / 86400), h = Math.floor((secs % 86400) / 3600),
m = Math.floor((secs % 3600) / 60), s = secs % 60;
const p = [];
if (d) p.push(d + 'd'); if (h) p.push(h + 'h'); if (m) p.push(m + 'm'); if (!d) p.push(s + 's');
return p.join(' ') || '0s';
}
function formatDate(value) {
const date = value instanceof Date ? value : new Date(value);
if (isNaN(date)) return '—';
try {
return date.toLocaleString(undefined, {
timeZone: global.APP_TIMEZONE || undefined,
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
});
} catch (_) { return date.toLocaleString(); }
}
const time = { ago: timeAgo, uptime: formatUptime, format: formatDate };
/* ----------------------------------------------------------------
12. BYTES FORMATTING
----------------------------------------------------------------
lt.bytes.format(n)
---------------------------------------------------------------- */
function formatBytes(bytes) {
if (bytes == null) return '—';
const units = ['B','KB','MB','GB','TB']; let i = 0;
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
return bytes.toFixed(i === 0 ? 0 : 2) + ' ' + units[i];
}
/* ----------------------------------------------------------------
13. TABLE KEYBOARD NAVIGATION
----------------------------------------------------------------
lt.tableNav.init('table-id')
---------------------------------------------------------------- */
function initTableNav(tableId) {
const table = tableId ? document.getElementById(tableId) : document.querySelector('.lt-table');
if (!table) return;
function rows() { return Array.from(table.querySelectorAll('tbody tr')); }
function selected() { return table.querySelector('tbody tr.lt-row-selected'); }
function move(dir) {
const all = rows(); if (!all.length) return;
const cur = selected(), idx = cur ? all.indexOf(cur) : -1;
const next = dir === 'down' ? all[idx < all.length - 1 ? idx + 1 : 0] : all[idx > 0 ? idx - 1 : all.length - 1];
if (cur) cur.classList.remove('lt-row-selected');
next.classList.add('lt-row-selected');
next.scrollIntoView({ block: 'nearest' });
}
keys.on('j', () => move('down'));
keys.on('arrowdown', () => move('down'));
keys.on('k', () => move('up'));
keys.on('arrowup', () => move('up'));
keys.on('enter', () => { const row = selected(); if (row) { const l = row.querySelector('a[href]'); if (l) global.location.href = l.href; } });
}
const tableNav = { init: initTableNav };
/* ----------------------------------------------------------------
14. SORTABLE TABLE HEADERS
----------------------------------------------------------------
lt.sortTable.init('table-id')
---------------------------------------------------------------- */
function initSortTable(tableId) {
const table = document.getElementById(tableId);
if (!table) return;
const ths = Array.from(table.querySelectorAll('th[data-sort-key]'));
ths.forEach((th, colIdx) => {
let dir = 'asc';
th.addEventListener('click', () => {
ths.forEach(h => h.removeAttribute('data-sort'));
th.setAttribute('data-sort', dir);
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
const aT = (a.cells[colIdx] || {}).textContent || '';
const bT = (b.cells[colIdx] || {}).textContent || '';
const n = !isNaN(parseFloat(aT)) && !isNaN(parseFloat(bT));
const cmp = n ? parseFloat(aT) - parseFloat(bT) : aT.localeCompare(bT);
return dir === 'asc' ? cmp : -cmp;
});
rows.forEach(r => tbody.appendChild(r));
dir = dir === 'asc' ? 'desc' : 'asc';
});
});
}
const sortTable = { init: initSortTable };
/* ----------------------------------------------------------------
15. STATS WIDGET FILTERING
----------------------------------------------------------------
lt.statsFilter.init()
---------------------------------------------------------------- */
function initStatsFilter() {
document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => {
card.addEventListener('click', () => {
const key = card.dataset.filterKey, val = card.dataset.filterVal;
const wasActive = card.classList.contains('active');
document.querySelectorAll('.lt-stat-card').forEach(c => c.classList.remove('active'));
if (!wasActive) card.classList.add('active');
if (typeof global.lt_onStatFilter === 'function')
global.lt_onStatFilter(wasActive ? null : key, wasActive ? null : val);
});
});
}
const statsFilter = { init: initStatsFilter };
/* ----------------------------------------------------------------
16. AUTO-REFRESH MANAGER
----------------------------------------------------------------
lt.autoRefresh.start(fn, ms)
lt.autoRefresh.stop()
lt.autoRefresh.now()
---------------------------------------------------------------- */
let _arTimer = null, _arFn = null, _arInterval = 30000;
function arStart(fn, ms) { arStop(); _arFn = fn; _arInterval = ms || 30000; _arTimer = setInterval(_arFn, _arInterval); }
function arStop() { if (_arTimer) { clearInterval(_arTimer); _arTimer = null; } }
function arNow() { arStop(); if (_arFn) { _arFn(); _arTimer = setInterval(_arFn, _arInterval); } }
const autoRefresh = { start: arStart, stop: arStop, now: arNow };
/* ================================================================
v1.2 NEW MODULES
================================================================ */
/* ----------------------------------------------------------------
17. ACCORDION
----------------------------------------------------------------
lt.accordion.init()
lt.accordion.open(triggerEl)
lt.accordion.close(triggerEl)
---------------------------------------------------------------- */
function _accordionOpen(trigger) {
const body = trigger.nextElementSibling;
if (!body || !body.classList.contains('lt-accordion-body')) return;
trigger.setAttribute('aria-expanded', 'true');
body.style.height = body.scrollHeight + 'px';
body.classList.add('is-open');
body.addEventListener('transitionend', function once() {
if (body.classList.contains('is-open')) body.style.height = 'auto';
body.removeEventListener('transitionend', once);
});
}
function _accordionClose(trigger) {
const body = trigger.nextElementSibling;
if (!body || !body.classList.contains('lt-accordion-body')) return;
body.style.height = body.scrollHeight + 'px';
requestAnimationFrame(() => {
body.style.height = '0';
body.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
});
}
function initAccordion() {
// Support both data-accordion attribute (HTML) and .lt-accordion-trigger class
document.querySelectorAll('[data-accordion], .lt-accordion-trigger').forEach(trigger => {
if (trigger.getAttribute('aria-expanded') === 'true') {
const body = trigger.nextElementSibling;
if (body) { body.style.height = 'auto'; body.classList.add('is-open'); }
}
trigger.addEventListener('click', () => {
const isOpen = trigger.getAttribute('aria-expanded') === 'true';
const acc = trigger.closest('[data-accordion-single]');
if (acc) {
acc.querySelectorAll('[data-accordion], .lt-accordion-trigger').forEach(t => {
if (t !== trigger && t.getAttribute('aria-expanded') === 'true') _accordionClose(t);
});
}
isOpen ? _accordionClose(trigger) : _accordionOpen(trigger);
});
});
}
const accordion = { init: initAccordion, open: _accordionOpen, close: _accordionClose };
/* ----------------------------------------------------------------
18. TOOLTIP SYSTEM
----------------------------------------------------------------
lt.tooltip.init()
lt.tooltip.show(anchorEl)
lt.tooltip.hide()
HTML: <any data-tooltip="Text" [data-tooltip-pos="top|bottom|left|right"]>
---------------------------------------------------------------- */
let _tooltipEl = null;
function _tooltipShow(anchor) {
_tooltipHide();
const text = anchor.dataset.tooltip;
if (!text) return;
const pos = anchor.dataset.tooltipPos || 'top';
const tip = document.createElement('div');
tip.className = 'lt-tooltip-bubble';
tip.textContent = text;
tip.setAttribute('role', 'tooltip');
document.body.appendChild(tip);
_tooltipEl = tip;
const r = anchor.getBoundingClientRect();
const tr = tip.getBoundingClientRect();
const sx = global.scrollX || 0, sy = global.scrollY || 0;
let top, left;
switch (pos) {
case 'bottom': top = r.bottom + sy + 8; left = r.left + sx + r.width / 2 - tr.width / 2; break;
case 'left': top = r.top + sy + r.height / 2 - tr.height / 2; left = r.left + sx - tr.width - 8; break;
case 'right': top = r.top + sy + r.height / 2 - tr.height / 2; left = r.right + sx + 8; break;
default: top = r.top + sy - tr.height - 8; left = r.left + sx + r.width / 2 - tr.width / 2;
}
tip.style.cssText = 'position:absolute;top:' + Math.max(4, top) + 'px;left:' + Math.max(4, left) + 'px;z-index:9000';
requestAnimationFrame(() => tip.classList.add('is-visible'));
}
function _tooltipHide() {
if (_tooltipEl) { _tooltipEl.remove(); _tooltipEl = null; }
}
function initTooltips() {
document.addEventListener('mouseover', e => { const a = e.target.closest('[data-tooltip]'); if (a) _tooltipShow(a); });
document.addEventListener('mouseout', e => { if (!e.target.closest('[data-tooltip]')) return; if (!e.relatedTarget || !e.relatedTarget.closest('[data-tooltip]')) _tooltipHide(); });
document.addEventListener('focusin', e => { const a = e.target.closest('[data-tooltip]'); if (a) _tooltipShow(a); });
document.addEventListener('focusout', _tooltipHide);
document.addEventListener('scroll', _tooltipHide, { passive: true });
global.addEventListener('resize', _tooltipHide, { passive: true });
}
const tooltip = { init: initTooltips, show: _tooltipShow, hide: _tooltipHide };
/* ----------------------------------------------------------------
19. CLIPBOARD COPY
----------------------------------------------------------------
lt.clipboard.copy(text) → Promise<boolean>
lt.clipboard.initCopyButtons()
HTML: <button data-copy="text" [data-copy-toast]>COPY</button>
---------------------------------------------------------------- */
async function clipboardCopy(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return true; }
const ta = document.createElement('textarea');
ta.value = text; ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px';
document.body.appendChild(ta); ta.focus(); ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta); return ok;
} catch (_) { return false; }
}
function initCopyButtons() {
document.addEventListener('click', async function (e) {
const btn = e.target.closest('[data-copy]'); if (!btn) return;
const orig = btn.textContent;
const ok = await clipboardCopy(btn.dataset.copy);
if (ok) {
btn.textContent = 'COPIED ✓'; btn.disabled = true;
if (btn.hasAttribute('data-copy-toast')) toast.success('Copied to clipboard');
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 1500);
} else { toast.error('Copy failed'); }
});
}
const clipboard = { copy: clipboardCopy, initCopyButtons };
/* ----------------------------------------------------------------
20. ALERT / BANNER DISMISS
----------------------------------------------------------------
lt.alerts.init()
lt.alerts.dismiss(el)
---------------------------------------------------------------- */
function dismissAlert(el) {
el.style.transition = 'opacity 0.3s ease, max-height 0.35s ease, padding 0.35s ease, margin 0.35s ease';
el.style.overflow = 'hidden';
el.style.opacity = '0';
el.style.maxHeight = el.offsetHeight + 'px';
requestAnimationFrame(() => requestAnimationFrame(() => {
el.style.maxHeight = '0'; el.style.margin = '0'; el.style.padding = '0';
setTimeout(() => el.remove(), 360);
}));
}
function initAlerts() {
document.addEventListener('click', function (e) {
const btn = e.target.closest('.lt-alert-dismiss'); if (!btn) return;
const al = btn.closest('.lt-alert'); if (al) dismissAlert(al);
});
document.querySelectorAll('.lt-alert[data-alert-auto-dismiss]').forEach(el => {
const ms = parseInt(el.dataset.alertAutoDismiss, 10);
if (ms > 0) setTimeout(() => dismissAlert(el), ms);
});
}
const alerts = { init: initAlerts, dismiss: dismissAlert };
/* ----------------------------------------------------------------
21. PROGRESS BAR
----------------------------------------------------------------
lt.progress.set(el, value, max?)
lt.progress.animate(el, from, to, durationMs?)
---------------------------------------------------------------- */
function _pEl(el) { return typeof el === 'string' ? document.querySelector(el) : el; }
function progressSet(el, value, max) {
el = _pEl(el); if (!el) return;
const pct = Math.min(100, Math.max(0, (value / (max || 100)) * 100));
const bar = el.querySelector('.lt-progress-bar');
const valEl = el.parentElement && el.parentElement.querySelector('.lt-progress-value');
if (bar) bar.style.width = pct + '%';
if (valEl) valEl.textContent = Math.round(pct) + '%';
el.setAttribute('aria-valuenow', Math.round(pct));
}
function progressAnimate(el, from, to, dur) {
el = _pEl(el); if (!el) return;
dur = dur || 600;
const start = performance.now();
function step(now) {
const t = Math.min(1, (now - start) / dur);
progressSet(el, from + (to - from) * (1 - Math.pow(1 - t, 3)));
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
const progress = { set: progressSet, animate: progressAnimate };
/* ----------------------------------------------------------------
22. COMMAND PALETTE
----------------------------------------------------------------
lt.cmdPalette.init(commands)
lt.cmdPalette.open()
lt.cmdPalette.close()
lt.cmdPalette.register(cmd)
Command: { id, label, icon?, description?, kbd?, group?, tags?, action }
---------------------------------------------------------------- */
let _cpCommands = [], _cpSelected = 0;
const _cpRecentKey = 'lt_cmd_recent';
function _cmdPaletteOpen() {
const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return;
if (_mnOpen) _mnSetOpen(false);
ov.classList.add('is-open');
_lockScroll();
const palette = document.getElementById('lt-cmd-palette');
const inp = palette && palette.querySelector('.lt-cmd-input');
if (inp) { inp.value = ''; inp.focus(); inp.select(); }
_cpRender('');
}
function _cmdPaletteClose() {
const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return;
ov.classList.remove('is-open');
_unlockScroll();
}
function _cpHighlight(text, q) {
if (!q) return escHtml(text);
const i = text.toLowerCase().indexOf(q.toLowerCase());
if (i < 0) return escHtml(text);
return escHtml(text.slice(0, i)) +
'<mark style="background:rgba(255,107,0,0.35);color:inherit">' + escHtml(text.slice(i, i + q.length)) + '</mark>' +
escHtml(text.slice(i + q.length));
}
function _cpRender(query) {
const ov = document.getElementById('lt-cmd-palette'); if (!ov) return;
const results = ov.querySelector('.lt-cmd-results'); if (!results) return;
query = query.trim().toLowerCase();
let recents = [];
try { recents = JSON.parse(localStorage.getItem(_cpRecentKey) || '[]'); } catch (_) {}
const filtered = _cpCommands.filter(cmd =>
!query || [cmd.label, cmd.description || '', ...(cmd.tags || [])].join(' ').toLowerCase().includes(query)
);
_cpSelected = 0;
if (!filtered.length) { results.innerHTML = '<div class="lt-cmd-empty">No results for &ldquo;' + escHtml(query) + '&rdquo;</div>'; return; }
const groups = {}, order = [];
if (!query && recents.length) {
const rec = recents.map(id => _cpCommands.find(c => c.id === id)).filter(Boolean);
if (rec.length) { groups['Recent'] = rec; order.push('Recent'); }
}
filtered.forEach(cmd => {
const g = cmd.group || 'Other';
if (!groups[g]) { groups[g] = []; if (!order.includes(g)) order.push(g); }
groups[g].push(cmd);
});
let html = '', idx = 0;
order.forEach(g => {
if (!groups[g] || !groups[g].length) return;
html += '<div class="lt-cmd-section-label">' + escHtml(g) + '</div>';
groups[g].forEach(cmd => {
html += '<div class="lt-cmd-item' + (idx === 0 ? ' is-selected' : '') + '" data-cmd-id="' + escHtml(cmd.id) + '">' +
'<span class="lt-cmd-item-icon">' + escHtml(cmd.icon || '◦') + '</span>' +
'<span class="lt-cmd-item-label">' + _cpHighlight(cmd.label, query) + '</span>' +
(cmd.kbd ? '<span class="lt-cmd-item-kbd">' + escHtml(cmd.kbd) + '</span>' : '') +
'</div>';
idx++;
});
});
results.innerHTML = html;
results.querySelectorAll('.lt-cmd-item').forEach((item, i) => {
item.addEventListener('mouseenter', () => {
results.querySelectorAll('.lt-cmd-item').forEach(x => x.classList.remove('is-selected'));
item.classList.add('is-selected'); _cpSelected = i;
});
item.addEventListener('click', () => _cpExec(item.dataset.cmdId));
});
}
function _cpExec(id) {
const cmd = _cpCommands.find(c => c.id === id);
if (!cmd || typeof cmd.action !== 'function') return;
_cmdPaletteClose();
try {
let r = JSON.parse(localStorage.getItem(_cpRecentKey) || '[]');
r = [id, ...r.filter(x => x !== id)].slice(0, 5);
localStorage.setItem(_cpRecentKey, JSON.stringify(r));
} catch (_) {}
cmd.action();
}
function _cpMove(dir) {
const ov = document.getElementById('lt-cmd-palette'); if (!ov) return;
const items = Array.from(ov.querySelectorAll('.lt-cmd-item')); if (!items.length) return;
items[_cpSelected] && items[_cpSelected].classList.remove('is-selected');
_cpSelected = (_cpSelected + dir + items.length) % items.length;
items[_cpSelected] && items[_cpSelected].classList.add('is-selected');
items[_cpSelected] && items[_cpSelected].scrollIntoView({ block: 'nearest' });
}
function initCmdPalette(commands) {
_cpCommands = commands || [];
const ov = document.getElementById('lt-cmd-palette'); if (!ov) return;
const inp = ov.querySelector('.lt-cmd-input');
if (inp) {
inp.addEventListener('input', () => _cpRender(inp.value));
inp.addEventListener('keydown', e => {
if (e.key === 'ArrowDown') { e.preventDefault(); _cpMove(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); _cpMove(-1); }
if (e.key === 'Enter') { e.preventDefault(); const s = ov.querySelector('.lt-cmd-item.is-selected'); if (s) _cpExec(s.dataset.cmdId); }
if (e.key === 'Escape') { e.preventDefault(); _cmdPaletteClose(); }
});
}
const overlay = document.getElementById('lt-cmd-overlay');
if (overlay) overlay.addEventListener('click', e => { if (e.target === overlay) _cmdPaletteClose(); });
}
const cmdPalette = {
init: initCmdPalette,
open: _cmdPaletteOpen,
close: _cmdPaletteClose,
register: cmd => { const i = _cpCommands.findIndex(c => c.id === cmd.id); if (i >= 0) _cpCommands[i] = cmd; else _cpCommands.push(cmd); },
};
/* ----------------------------------------------------------------
23. FORM VALIDATION
----------------------------------------------------------------
lt.validate.field(el) → { valid, message }
lt.validate.form(formEl) → { valid, errors }
lt.validate.showError(el, msg)
lt.validate.clearError(el)
lt.validate.init(formEl, onSubmit)
lt.validate.custom = {}
---------------------------------------------------------------- */
const _validateCustom = {};
function _validateField(el) {
const val = el.value || '', type = (el.type || '').toLowerCase();
if (el.required && !val.trim()) return { valid: false, message: 'This field is required' };
if (el.minLength > 0 && val.length < el.minLength) return { valid: false, message: 'Minimum ' + el.minLength + ' characters' };
if (el.maxLength > 0 && val.length > el.maxLength) return { valid: false, message: 'Maximum ' + el.maxLength + ' characters' };
if (val && type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) return { valid: false, message: 'Enter a valid email address' };
if (val && type === 'url') { try { new URL(val); } catch (_) { return { valid: false, message: 'Enter a valid URL' }; } }
if (val && type === 'number' && isNaN(Number(val))) return { valid: false, message: 'Enter a valid number' };
if (val && el.pattern && !new RegExp('^(?:' + el.pattern + ')$').test(val)) return { valid: false, message: el.dataset.validateMsg || 'Invalid format' };
if (el.dataset.validate) {
const fn = _validateCustom[el.dataset.validate];
if (typeof fn === 'function') { const r = fn(val, el); if (r !== true) return { valid: false, message: r || 'Invalid value' }; }
}
return { valid: true, message: '' };
}
function _showError(el, msg) {
el.classList.add('is-invalid'); el.classList.remove('is-valid');
let err = el.parentElement && el.parentElement.querySelector('.lt-field-error');
if (!err) { err = document.createElement('span'); err.className = 'lt-field-error'; if (el.parentElement) el.parentElement.appendChild(err); }
err.textContent = msg;
}
function _clearError(el) {
el.classList.remove('is-invalid'); el.classList.add('is-valid');
const err = el.parentElement && el.parentElement.querySelector('.lt-field-error');
if (err) err.remove();
}
function _validateForm(formEl) {
const errors = []; let valid = true;
Array.from(formEl.querySelectorAll('input, select, textarea')).forEach(f => {
if (f.disabled || f.readOnly) return;
const r = _validateField(f);
if (!r.valid) { valid = false; _showError(f, r.message); errors.push({ el: f, message: r.message }); }
else _clearError(f);
});
return { valid, errors };
}
function initFormValidation(formEl, onSubmit) {
if (!formEl) return;
formEl.querySelectorAll('input, select, textarea').forEach(f => {
f.addEventListener('blur', () => { const r = _validateField(f); r.valid ? _clearError(f) : _showError(f, r.message); });
});
formEl.addEventListener('submit', e => {
e.preventDefault();
const r = _validateForm(formEl);
if (r.valid && typeof onSubmit === 'function') onSubmit(formEl, e);
else if (!r.valid) r.errors[0].el.focus();
});
}
const validate = { field: _validateField, form: _validateForm, showError: _showError, clearError: _clearError, init: initFormValidation, custom: _validateCustom };
/* ----------------------------------------------------------------
24. DEBOUNCE & THROTTLE
----------------------------------------------------------------
lt.debounce(fn, wait) → fn with .cancel()
lt.throttle(fn, wait) → throttled fn
---------------------------------------------------------------- */
function debounce(fn, wait) {
let t;
function d() { const a = arguments, c = this; clearTimeout(t); t = setTimeout(() => fn.apply(c, a), wait); }
d.cancel = () => clearTimeout(t);
return d;
}
function throttle(fn, wait) {
let last = 0;
return function () { const now = Date.now(); if (now - last >= wait) { last = now; fn.apply(this, arguments); } };
}
/* ----------------------------------------------------------------
25. EVENT BUS
----------------------------------------------------------------
lt.bus.on / off / emit / once
---------------------------------------------------------------- */
const _busH = new Map();
function busOn(e, h) { if (!_busH.has(e)) _busH.set(e, []); _busH.get(e).push(h); }
function busOff(e, h) { const hs = _busH.get(e); if (hs) _busH.set(e, hs.filter(x => x !== h)); }
function busEmit(e, d) { (_busH.get(e) || []).slice().forEach(h => { try { h(d); } catch (err) { console.error('lt.bus:', err); } }); }
function busOnce(e, h) { function w(d) { h(d); busOff(e, w); } busOn(e, w); }
const bus = { on: busOn, off: busOff, emit: busEmit, once: busOnce };
/* ----------------------------------------------------------------
26. STORAGE HELPERS
----------------------------------------------------------------
lt.store.set / get / remove / clear
lt.store.session.*
---------------------------------------------------------------- */
function _mkStore(s) {
return {
set(k, v) { try { s.setItem('lt_' + k, JSON.stringify(v)); } catch (_) {} },
get(k, fb) { try { const r = s.getItem('lt_' + k); return r !== null ? JSON.parse(r) : (fb !== undefined ? fb : null); } catch (_) { return fb !== undefined ? fb : null; } },
remove(k) { try { s.removeItem('lt_' + k); } catch (_) {} },
clear() { try { Object.keys(s).filter(k => k.startsWith('lt_')).forEach(k => s.removeItem(k)); } catch (_) {} },
};
}
const store = Object.assign(_mkStore(localStorage), { session: _mkStore(sessionStorage) });
/* ----------------------------------------------------------------
27. URL HELPERS
----------------------------------------------------------------
lt.url.params / get / set / remove / setMultiple
---------------------------------------------------------------- */
const url = {
params() { return new URLSearchParams(global.location.search); },
get(k) { return this.params().get(k); },
set(k, v) { const p = this.params(); p.set(k, v); global.history.pushState({}, '', global.location.pathname + '?' + p); },
remove(k) { const p = this.params(); p.delete(k); const q = p.toString(); global.history.pushState({}, '', global.location.pathname + (q ? '?' + q : '')); },
setMultiple(obj) { const p = this.params(); Object.entries(obj).forEach(([k, v]) => v == null ? p.delete(k) : p.set(k, v)); global.history.pushState({}, '', global.location.pathname + '?' + p); },
};
/* ----------------------------------------------------------------
28. NUMBER FORMATTER
----------------------------------------------------------------
lt.num.format / compact / percent / pad / clamp / lerp
---------------------------------------------------------------- */
const num = {
format(n, d) { if (n == null || isNaN(n)) return '—'; return Number(n).toLocaleString(undefined, { minimumFractionDigits: d || 0, maximumFractionDigits: d != null ? d : 2 }); },
compact(n) { if (Math.abs(n) >= 1e9) return (n/1e9).toFixed(1)+'B'; if (Math.abs(n) >= 1e6) return (n/1e6).toFixed(1)+'M'; if (Math.abs(n) >= 1e3) return (n/1e3).toFixed(1)+'K'; return String(n); },
percent(n, total) { return total ? ((n/total)*100).toFixed(1)+'%' : '—'; },
pad(n, w) { return String(Math.floor(n)).padStart(w, '0'); },
clamp(n, a, b) { return Math.min(b, Math.max(a, n)); },
lerp(a, b, t) { return a + (b - a) * t; },
};
/* ----------------------------------------------------------------
29. DOM HELPERS
----------------------------------------------------------------
lt.dom.el / qs / qsa / on / off / show / hide / toggle /
empty / append / closest / ready
---------------------------------------------------------------- */
const dom = {
el(tag, attrs) {
const el = document.createElement(tag);
if (attrs) Object.entries(attrs).forEach(([k, v]) => {
if (k === 'class') el.className = v;
else if (k === 'text') el.textContent = v;
else if (k === 'html') el.innerHTML = v;
else el.setAttribute(k, v);
});
for (let i = 2; i < arguments.length; i++) {
const c = arguments[i];
el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
return el;
},
qs(sel, ctx) { return (ctx || document).querySelector(sel); },
qsa(sel, ctx) { return Array.from((ctx || document).querySelectorAll(sel)); },
on(el, ev, fn, o) { el.addEventListener(ev, fn, o); return () => el.removeEventListener(ev, fn, o); },
off(el, ev, fn) { el.removeEventListener(ev, fn); },
show(el) { if (el) el.classList.remove('lt-hidden'); },
hide(el) { if (el) el.classList.add('lt-hidden'); },
toggle(el, force) { if (el) el.classList.toggle('lt-hidden', force === undefined ? undefined : !force); },
empty(el) { while (el && el.firstChild) el.removeChild(el.firstChild); },
append(p) { for (let i = 1; i < arguments.length; i++) if (arguments[i]) p.appendChild(arguments[i]); },
closest(el, sel) { return el ? el.closest(sel) : null; },
ready(fn) { document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', fn) : fn(); },
};
/* ----------------------------------------------------------------
30. TABLE COLUMN VISIBILITY
----------------------------------------------------------------
lt.tableColumns.init / show / hide / toggle
---------------------------------------------------------------- */
function _cells(t, i) { const c = []; t.querySelectorAll('tr').forEach(r => { if (r.cells[i]) c.push(r.cells[i]); }); return c; }
function _saveCols(id, i, v) { try { const k = 'lt_cols_' + id, s = JSON.parse(localStorage.getItem(k) || '{}'); s[i] = v; localStorage.setItem(k, JSON.stringify(s)); } catch (_) {} }
function _colShow(id, i) { const t = document.getElementById(id); if (!t) return; _cells(t, i).forEach(c => c.style.display = ''); _saveCols(id, i, true); }
function _colHide(id, i) { const t = document.getElementById(id); if (!t) return; _cells(t, i).forEach(c => c.style.display = 'none'); _saveCols(id, i, false); }
function _colToggle(id, i) { const t = document.getElementById(id); if (!t) return; const c0 = _cells(t, i)[0]; (c0 && c0.style.display === 'none') ? _colShow(id, i) : _colHide(id, i); }
function initTableColumns(tableId) {
try { const s = JSON.parse(localStorage.getItem('lt_cols_' + tableId) || '{}'); Object.entries(s).forEach(([i, v]) => { if (!v) _colHide(tableId, +i); }); } catch (_) {}
document.querySelectorAll('[data-toggle-col]').forEach(btn => {
btn.addEventListener('click', () => _colToggle(btn.dataset.tableId || tableId, +btn.dataset.toggleCol));
});
}
const tableColumns = { init: initTableColumns, show: _colShow, hide: _colHide, toggle: _colToggle };
/* ----------------------------------------------------------------
31. POLLING & ASYNC RETRY
----------------------------------------------------------------
lt.poll(fn, ms, opts?) → { stop }
lt.retry(fn, opts?) → Promise
---------------------------------------------------------------- */
function poll(fn, ms, opts) {
opts = opts || {}; let stopped = false, timer = null;
async function tick() {
if (stopped) return;
try { await fn(); } catch (e) { if (typeof opts.onError === 'function') opts.onError(e); }
if (!stopped) timer = setTimeout(tick, ms);
}
opts.immediate ? tick() : (timer = setTimeout(tick, ms));
return { stop() { stopped = true; clearTimeout(timer); } };
}
async function retry(fn, opts) {
opts = opts || {};
const retries = opts.retries || 3, delay = opts.delay || 1000, backoff = opts.backoff || 1;
let lastErr;
for (let i = 0; i <= retries; i++) {
try { return await fn(i); } catch (e) {
lastErr = e;
if (i < retries) {
if (typeof opts.onRetry === 'function') opts.onRetry(e, i + 1);
await new Promise(r => setTimeout(r, delay * Math.pow(backoff, i)));
}
}
}
throw lastErr;
}
/* ----------------------------------------------------------------
32. DRAG & DROP UPLOAD
----------------------------------------------------------------
lt.dropzone.init(el, opts)
opts: { onFiles, accept, maxSize, multiple }
---------------------------------------------------------------- */
function initDropzone(el, opts) {
if (typeof el === 'string') el = document.querySelector(el);
if (!el) return;
opts = opts || {};
const maxSize = opts.maxSize || 10 * 1024 * 1024;
function validate(files) {
const valid = [], invalid = [];
Array.from(files).forEach(f => {
if (f.size > maxSize) { invalid.push(f.name + ' (too large)'); return; }
if (opts.accept && opts.accept.length) {
const ok = opts.accept.some(a => a.startsWith('.') ? f.name.toLowerCase().endsWith(a) : f.type.match(new RegExp(a.replace('*', '.*'))));
if (!ok) { invalid.push(f.name + ' (not allowed)'); return; }
}
valid.push(f);
});
if (invalid.length) toast.error('Rejected: ' + invalid.join(', '));
return valid;
}
function deliver(files) {
const v = validate(files);
if (v.length) { el.classList.add('has-file'); if (typeof opts.onFiles === 'function') opts.onFiles(opts.multiple !== false ? v : [v[0]]); }
}
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('is-dragging'); });
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('is-dragging'); });
el.addEventListener('drop', e => { e.preventDefault(); el.classList.remove('is-dragging'); deliver(e.dataTransfer.files); });
el.addEventListener('click', () => {
const inp = document.createElement('input');
inp.type = 'file'; inp.multiple = opts.multiple !== false;
if (opts.accept) inp.accept = opts.accept.join(',');
inp.addEventListener('change', () => deliver(inp.files));
inp.click();
});
}
function _initAllDropzones() {
document.querySelectorAll('[data-dropzone]').forEach(el => {
initDropzone(el, { onFiles: files => toast.info(Array.from(files).length + ' file(s) selected: ' + Array.from(files).map(f => f.name).join(', ')) });
});
}
const dropzone = { init: initDropzone };
/* ----------------------------------------------------------------
33. INTERSECTION OBSERVER
----------------------------------------------------------------
lt.observe.lazy(selector, opts?) → adds .is-visible on enter
lt.observe.sentinel(el, callback) → fires when el enters viewport
---------------------------------------------------------------- */
function observeLazy(selector, opts) {
if (!global.IntersectionObserver) {
document.querySelectorAll(selector).forEach(el => el.classList.add('is-visible'));
return;
}
opts = opts || {};
const obs = new IntersectionObserver((entries, o) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
if (opts.once !== false) o.unobserve(entry.target);
}
});
}, { threshold: opts.threshold || 0.1, rootMargin: opts.rootMargin || '0px' });
document.querySelectorAll(selector).forEach(el => obs.observe(el));
return obs;
}
function observeSentinel(el, cb) {
if (!el || !global.IntersectionObserver) { if (el) cb(); return; }
const obs = new IntersectionObserver(entries => { if (entries[0].isIntersecting) cb(); }, { threshold: 0.1 });
obs.observe(el);
return { stop: () => obs.disconnect() };
}
const observe = { lazy: observeLazy, sentinel: observeSentinel };
/* ----------------------------------------------------------------
34. FULL INITIALISATION
---------------------------------------------------------------- */
function init() {
/* Core */
initTabs();
initSidebar();
initDefaultKeys();
initStatsFilter();
/* v1.2 */
initAccordion();
initTooltips();
initCopyButtons();
initAlerts();
_initAllDropzones();
observeLazy('[data-lazy]');
/* v1.3 */
initMobileNav();
/* Boot */
const bootEl = document.getElementById('lt-boot');
if (bootEl) runBoot(bootEl.dataset.appName || document.title);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
/* ================================================================
MODULE 35 — VIEWPORT
Tracks current breakpoint, fires callbacks on change.
lt.viewport.bp() → 'xs'|'sm'|'md'|'lg'|'xl'|'2xl'|'3xl'|'4k'
lt.viewport.is('md') → true if current bp >= md
lt.viewport.on(cb) → subscribe (cb receives {bp, w, h, prev})
lt.viewport.off(cb) → unsubscribe
lt.viewport.touch() → true if primary pointer is coarse
lt.viewport.landscape() → true if width > height
================================================================ */
const _bpOrder = ['xs','sm','md','lg','xl','2xl','3xl','4k'];
const _bpMin = { xs: 0, sm: 480, md: 768, lg: 1024, xl: 1280, '2xl': 1536, '3xl': 1920, '4k': 2560 };
let _vpListeners = [];
let _vpCurrent = null;
function _getBp(w) {
if (w >= 2560) return '4k';
if (w >= 1920) return '3xl';
if (w >= 1536) return '2xl';
if (w >= 1280) return 'xl';
if (w >= 1024) return 'lg';
if (w >= 768) return 'md';
if (w >= 480) return 'sm';
return 'xs';
}
function _vpFire() {
const w = window.innerWidth;
const h = window.innerHeight;
const bp = _getBp(w);
const prev = _vpCurrent;
_vpCurrent = bp;
if (bp !== prev) {
const evt = { bp, w, h, prev };
_vpListeners.forEach(cb => { try { cb(evt); } catch (_) {} });
bus.emit('viewport:change', evt);
}
}
const _vpResizeHandler = debounce(_vpFire, 120);
const _vpOrientationHandler = debounce(_vpFire, 350); // 350ms for iOS layout settle
window.addEventListener('resize', _vpResizeHandler);
window.addEventListener('orientationchange', _vpOrientationHandler);
_vpFire(); // set initial value
const viewport = {
bp: () => _vpCurrent || _getBp(window.innerWidth),
is: name => {
if (!_bpOrder.includes(name)) { console.warn('[lt.viewport] Unknown breakpoint:', name); return false; }
return _bpOrder.indexOf(_vpCurrent || _getBp(window.innerWidth)) >= _bpOrder.indexOf(name);
},
width: () => window.innerWidth,
height: () => window.innerHeight,
touch: () => window.matchMedia('(pointer: coarse)').matches,
landscape: () => window.innerWidth > window.innerHeight,
on: cb => { if (!_vpListeners.includes(cb)) _vpListeners.push(cb); },
off: cb => { _vpListeners = _vpListeners.filter(l => l !== cb); },
breakpoints: _bpMin,
};
/* ================================================================
MODULE 36 — MOBILE NAV
Hamburger-driven off-canvas navigation drawer with swipe support.
lt.mobileNav.open() / .close() / .toggle()
Auto-inits on [id="lt-menu-btn"] + [id="lt-nav-drawer"]
Swipe right from left edge (≤ 20px) opens; swipe left closes.
================================================================ */
let _mnOpen = false;
function _mnSetOpen(open) {
_mnOpen = open;
const btn = document.getElementById('lt-menu-btn');
const drawer = document.getElementById('lt-nav-drawer');
const overlay = document.getElementById('lt-nav-overlay');
if (!drawer) return;
if (open) {
drawer.classList.add('open');
drawer.setAttribute('aria-hidden', 'false');
if (overlay) overlay.classList.add('open');
if (btn) { btn.classList.add('open'); btn.setAttribute('aria-expanded', 'true'); }
document.body.style.overflow = 'hidden';
// Trap focus inside drawer
const first = drawer.querySelector('button, a, [tabindex]');
if (first) setTimeout(() => first.focus(), 50);
} else {
drawer.classList.remove('open');
drawer.setAttribute('aria-hidden', 'true');
if (overlay) overlay.classList.remove('open');
if (btn) { btn.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); }
document.body.style.overflow = '';
}
bus.emit('mobileNav:' + (open ? 'open' : 'close'));
}
let _mnInitialized = false;
function initMobileNav() {
if (_mnInitialized) return; // prevent duplicate init / memory leaks
_mnInitialized = true;
const btn = document.getElementById('lt-menu-btn');
const overlay = document.getElementById('lt-nav-overlay');
const closeBtn = document.getElementById('lt-nav-drawer-close');
if (btn) btn.addEventListener('click', () => _mnSetOpen(!_mnOpen));
if (overlay) overlay.addEventListener('click', () => _mnSetOpen(false));
if (closeBtn) closeBtn.addEventListener('click', () => _mnSetOpen(false));
// Close on Escape
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && _mnOpen) _mnSetOpen(false);
});
// Close when nav link is clicked (navigates away)
const drawer = document.getElementById('lt-nav-drawer');
if (drawer) {
drawer.querySelectorAll('a').forEach(a => {
a.addEventListener('click', () => _mnSetOpen(false));
});
}
// Swipe gesture — open from left edge, close by swiping left
let _touchStartX = 0;
let _touchStartY = 0;
let _isSwiping = false;
document.addEventListener('touchstart', e => {
_touchStartX = e.touches[0].clientX;
_touchStartY = e.touches[0].clientY;
// Only initiate open-swipe from left 20px edge
_isSwiping = !_mnOpen && _touchStartX <= 20;
}, { passive: true });
document.addEventListener('touchmove', e => {
if (!_isSwiping && !_mnOpen) return;
const dx = e.touches[0].clientX - _touchStartX;
const dy = e.touches[0].clientY - _touchStartY;
// Ignore mostly-vertical scrolls
if (Math.abs(dy) > Math.abs(dx) * 1.5) { _isSwiping = false; return; }
// Live drag feedback on the drawer
const drawerEl = document.getElementById('lt-nav-drawer');
if (!drawerEl) return;
if (!_mnOpen && _isSwiping && dx > 0) {
const pct = Math.min(dx / 280, 1);
drawerEl.style.transform = `translateX(${-100 + pct * 100}%)`;
drawerEl.style.transition = 'none';
} else if (_mnOpen && dx < 0) {
const pct = Math.max(0, 1 + dx / 280);
drawerEl.style.transform = `translateX(${-(1 - pct) * 100}%)`;
drawerEl.style.transition = 'none';
}
}, { passive: true });
document.addEventListener('touchend', e => {
const drawerEl = document.getElementById('lt-nav-drawer');
if (!drawerEl) return;
drawerEl.style.transform = '';
drawerEl.style.transition = '';
const dx = e.changedTouches[0].clientX - _touchStartX;
if (!_mnOpen && _isSwiping && dx > 60) _mnSetOpen(true);
if (_mnOpen && dx < -60) _mnSetOpen(false);
_isSwiping = false;
}, { passive: true });
// Auto-close when viewport becomes desktop width
viewport.on(({ bp }) => {
if (['lg', 'xl', '2xl', '3xl', '4k'].includes(bp) && _mnOpen) _mnSetOpen(false);
});
}
const mobileNav = {
open: () => _mnSetOpen(true),
close: () => _mnSetOpen(false),
toggle: () => _mnSetOpen(!_mnOpen),
isOpen: () => _mnOpen,
};
/* ----------------------------------------------------------------
PUBLIC API
---------------------------------------------------------------- */
global.lt = {
/* Core */
escHtml,
toast,
beep: _beep,
modal,
tabs,
boot,
keys,
sidebar,
csrf,
api,
time,
bytes: { format: formatBytes },
tableNav,
sortTable,
statsFilter,
autoRefresh,
/* v1.2 */
accordion,
tooltip,
clipboard,
alerts,
progress,
cmdPalette,
validate,
debounce,
throttle,
bus,
store,
url,
num,
dom,
tableColumns,
poll,
retry,
dropzone,
observe,
/* v1.3 responsive */
viewport,
mobileNav,
};
}(window));