/** * 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /* ---------------------------------------------------------------- 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: ---------------------------------------------------------------- */ 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 lt.clipboard.initCopyButtons() HTML: ---------------------------------------------------------------- */ 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)) + '' + escHtml(text.slice(i, i + q.length)) + '' + 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 = '
No results for “' + escHtml(query) + '”
'; 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 += ''; groups[g].forEach(cmd => { html += '
' + '' + escHtml(cmd.icon || '◦') + '' + '' + _cpHighlight(cmd.label, query) + '' + (cmd.kbd ? '' + escHtml(cmd.kbd) + '' : '') + '
'; 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, }; /* ================================================================ MODULE 37 — THEME TOGGLE lt.theme.toggle() lt.theme.set('light'|'dark') lt.theme.get() ================================================================ */ const _themeKey = 'lt_theme'; function _applyTheme(t) { document.documentElement.setAttribute('data-theme', t); try { localStorage.setItem(_themeKey, t); } catch(_) {} document.querySelectorAll('.lt-theme-btn').forEach(btn => { btn.textContent = t === 'light' ? '◐' : '☀'; btn.setAttribute('aria-label', t === 'light' ? 'Switch to dark mode' : 'Switch to light mode'); btn.setAttribute('title', t === 'light' ? 'Switch to dark mode' : 'Switch to light mode'); }); bus.emit('theme:change', { theme: t }); } const _initTheme = (function() { let saved; try { saved = localStorage.getItem(_themeKey); } catch(_) {} return saved || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); })(); _applyTheme(_initTheme); window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', e => { let saved; try { saved = localStorage.getItem(_themeKey); } catch(_) {} if (!saved) _applyTheme(e.matches ? 'light' : 'dark'); }); const theme = { toggle: () => _applyTheme(document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light'), set: t => _applyTheme(t), get: () => document.documentElement.getAttribute('data-theme') || 'dark', }; /* ================================================================ MODULE 38 — NOTIFICATION BADGE lt.notif.set(el, count) lt.notif.inc(el) lt.notif.clear(el) el = CSS selector string or DOM element ================================================================ */ function _notifEl(el) { return typeof el === 'string' ? document.querySelector(el) : el; } function _notifBadge(el) { const wrap = _notifEl(el); if (!wrap) return null; let b = wrap.querySelector(':scope > .lt-notif-badge'); if (!b) { b = document.createElement('span'); b.className = 'lt-notif-badge'; b.setAttribute('aria-live', 'polite'); b.setAttribute('role', 'status'); wrap.classList.add('lt-notif-wrap'); wrap.appendChild(b); } return b; } const notif = { set(el, n) { const b = _notifBadge(el); if (!b) return; const label = n > 99 ? '99+' : n > 0 ? String(n) : ''; b.textContent = label; b.setAttribute('data-count', n); b.setAttribute('aria-label', n > 0 ? `${n} notification${n !== 1 ? 's' : ''}` : ''); }, inc(el) { const b = _notifBadge(el); if (!b) return; const cur = parseInt(b.getAttribute('data-count') || '0', 10); notif.set(el, cur + 1); }, clear(el) { notif.set(el, 0); }, }; /* ================================================================ MODULE 39 — RIGHT-SIDE DRAWER lt.rightDrawer.open(id) lt.rightDrawer.close(id) lt.rightDrawer.toggle(id) ================================================================ */ function _rdOpen(id, triggerEl) { const drawer = typeof id === 'string' ? document.getElementById(id) : id; if (!drawer) return; const ovId = drawer.dataset.overlay || id + '-overlay'; const ov = document.getElementById(ovId); if (_mnOpen) _mnSetOpen(false); drawer.classList.add('is-open'); drawer.setAttribute('aria-hidden', 'false'); if (ov) ov.classList.add('is-open'); _lockScroll(); if (triggerEl) _modalTriggers.set(drawer, triggerEl); const first = drawer.querySelector(_FOCUSABLE); if (first) setTimeout(() => first.focus(), 50); // ESC to close drawer._rdKeyHandler = e => { if (e.key === 'Escape') _rdClose(drawer); }; document.addEventListener('keydown', drawer._rdKeyHandler); // Overlay click if (ov) ov._rdClick = () => _rdClose(drawer); if (ov) ov.addEventListener('click', ov._rdClick); // Close button drawer.querySelectorAll('[data-drawer-close]').forEach(btn => { btn._rdHandler = () => _rdClose(drawer); btn.addEventListener('click', btn._rdHandler); }); } function _rdClose(id) { const drawer = typeof id === 'string' ? document.getElementById(id) : id; if (!drawer || !drawer.classList.contains('is-open')) return; const ovId = drawer.dataset.overlay || (drawer.id ? drawer.id + '-overlay' : null); const ov = ovId ? document.getElementById(ovId) : null; drawer.classList.remove('is-open'); drawer.setAttribute('aria-hidden', 'true'); if (ov) { ov.classList.remove('is-open'); if (ov._rdClick) { ov.removeEventListener('click', ov._rdClick); delete ov._rdClick; } } _unlockScroll(); if (drawer._rdKeyHandler) { document.removeEventListener('keydown', drawer._rdKeyHandler); delete drawer._rdKeyHandler; } const trigger = _modalTriggers.get(drawer); if (trigger) { trigger.focus(); _modalTriggers.delete(drawer); } } const rightDrawer = { open: (id) => _rdOpen(id, document.activeElement !== document.body ? document.activeElement : null), close: (id) => _rdClose(id), toggle: (id) => { const el = typeof id === 'string' ? document.getElementById(id) : id; if (el && el.classList.contains('is-open')) _rdClose(el); else _rdOpen(id); }, }; // data-drawer-open="drawer-id" trigger wiring document.addEventListener('click', e => { const btn = e.target.closest('[data-drawer-open]'); if (btn) { e.preventDefault(); _rdOpen(btn.dataset.drawerOpen, btn); } }); /* ================================================================ MODULE 40 — CONTEXT MENU lt.contextMenu.register(selector, items) items = [{ label, icon, kbd, danger, divider, action }] ================================================================ */ let _ctxMenu = null; let _ctxItems = []; function _ctxShow(x, y, items) { if (!_ctxMenu) { _ctxMenu = document.createElement('div'); _ctxMenu.className = 'lt-context-menu'; _ctxMenu.setAttribute('role', 'menu'); document.body.appendChild(_ctxMenu); } _ctxMenu.innerHTML = ''; items.forEach(item => { if (item.divider) { const d = document.createElement('div'); d.className = 'lt-context-menu-divider'; _ctxMenu.appendChild(d); return; } if (item.label && !item.action) { const l = document.createElement('div'); l.className = 'lt-context-menu-label'; l.textContent = item.label; _ctxMenu.appendChild(l); return; } const el = document.createElement('div'); el.className = 'lt-context-menu-item' + (item.danger ? ' is-danger' : ''); el.setAttribute('role', 'menuitem'); el.setAttribute('tabindex', '0'); el.innerHTML = `${item.icon ? `${escHtml(item.icon)}` : ''}${escHtml(item.label || '')}${item.kbd ? `${escHtml(item.kbd)}` : ''}`; el.addEventListener('click', () => { _ctxHide(); if (item.action) item.action(); }); el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); } }); _ctxMenu.appendChild(el); }); _ctxMenu.classList.add('is-open'); // Position — keep on screen const vw = window.innerWidth, vh = window.innerHeight; const mw = _ctxMenu.offsetWidth || 180, mh = _ctxMenu.offsetHeight || 200; _ctxMenu.style.left = Math.min(x, vw - mw - 8) + 'px'; _ctxMenu.style.top = Math.min(y, vh - mh - 8) + 'px'; // Focus first item const first = _ctxMenu.querySelector('[role="menuitem"]'); if (first) setTimeout(() => first.focus(), 20); } function _ctxHide() { if (_ctxMenu) _ctxMenu.classList.remove('is-open'); } document.addEventListener('click', () => _ctxHide()); document.addEventListener('contextmenu', e => { const target = e.target.closest('[data-context-menu]'); if (!target) { _ctxHide(); return; } e.preventDefault(); const menuId = target.dataset.contextMenu; const items = _ctxItems[menuId]; if (items) _ctxShow(e.clientX, e.clientY, items); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') _ctxHide(); }); const contextMenu = { register(id, items) { _ctxItems[id] = items; }, show: (x, y, items) => _ctxShow(x, y, items), hide: () => _ctxHide(), }; /* ================================================================ MODULE 41 — OFFLINE DETECTION lt.offline.onOnline(fn) lt.offline.onOffline(fn) lt.offline.isOnline() ================================================================ */ let _offlineBanner = null; function _offlineGetBanner() { if (!_offlineBanner) { _offlineBanner = document.getElementById('lt-offline-banner'); if (!_offlineBanner) { _offlineBanner = document.createElement('div'); _offlineBanner.id = 'lt-offline-banner'; _offlineBanner.className = 'lt-offline-banner'; _offlineBanner.setAttribute('role', 'alert'); _offlineBanner.setAttribute('aria-live', 'assertive'); _offlineBanner.innerHTML = ' NO NETWORK CONNECTION — RECONNECTING...'; document.body.appendChild(_offlineBanner); } } return _offlineBanner; } const _offlineHandlers = []; const _onlineHandlers = []; window.addEventListener('offline', () => { document.body.classList.add('lt-is-offline'); _offlineGetBanner().classList.add('is-visible'); toast.warning('Connection lost — working offline'); _offlineHandlers.forEach(fn => fn()); }); window.addEventListener('online', () => { document.body.classList.remove('lt-is-offline'); _offlineGetBanner().classList.remove('is-visible'); toast.success('Connection restored'); _onlineHandlers.forEach(fn => fn()); }); const offline = { isOnline: () => navigator.onLine, onOffline: fn => _offlineHandlers.push(fn), onOnline: fn => _onlineHandlers.push(fn), }; /* ================================================================ MODULE 42 — WEBSOCKET MANAGER lt.ws.connect(url, opts) opts: { protocols, onOpen, onMessage, onClose, onError, reconnect: true, reconnectDelay: 2000, maxRetries: 10 } Returns a handle: { send, close, status, on, off } ================================================================ */ const ws = { connect(url, opts = {}) { const { protocols = [], onOpen = null, onMessage = null, onClose = null, onError = null, reconnect = true, reconnectDelay = 2000, maxRetries = 10, } = opts; let _sock = null; let _retries = 0; let _closed = false; let _statusEl = opts.statusEl ? (typeof opts.statusEl === 'string' ? document.querySelector(opts.statusEl) : opts.statusEl) : null; const _handlers = {}; function _setStatus(state) { if (_statusEl) { _statusEl.setAttribute('data-state', state); _statusEl.querySelector('.lt-dot'); // force repaint const labels = { connected: 'Connected', connecting: 'Connecting…', disconnected: 'Disconnected' }; const dot = _statusEl.querySelector('.lt-dot'); const span = _statusEl.querySelector('span:last-child'); if (span) span.textContent = labels[state] || state; } bus.emit('ws:status', { url, state }); } function _connect() { _setStatus('connecting'); try { _sock = protocols.length ? new WebSocket(url, protocols) : new WebSocket(url); } catch(e) { console.error('[lt.ws] Failed to create WebSocket:', e); return; } _sock.addEventListener('open', e => { _retries = 0; _setStatus('connected'); if (onOpen) onOpen(e); (_handlers['open'] || []).forEach(fn => fn(e)); }); _sock.addEventListener('message', e => { let data = e.data; try { data = JSON.parse(e.data); } catch(_) {} if (onMessage) onMessage(data, e); (_handlers['message'] || []).forEach(fn => fn(data, e)); }); _sock.addEventListener('close', e => { _setStatus('disconnected'); if (onClose) onClose(e); (_handlers['close'] || []).forEach(fn => fn(e)); if (reconnect && !_closed && _retries < maxRetries) { _retries++; const delay = Math.min(reconnectDelay * Math.pow(1.5, _retries - 1), 30000); setTimeout(_connect, delay); } }); _sock.addEventListener('error', e => { if (onError) onError(e); (_handlers['error'] || []).forEach(fn => fn(e)); }); } _connect(); return { send(data) { if (!_sock || _sock.readyState !== WebSocket.OPEN) { console.warn('[lt.ws] Not connected'); return false; } _sock.send(typeof data === 'string' ? data : JSON.stringify(data)); return true; }, close() { _closed = true; if (_sock) _sock.close(); _setStatus('disconnected'); }, get status() { return _sock ? ['connecting','open','closing','closed'][_sock.readyState] : 'disconnected'; }, on(event, fn) { if (!_handlers[event]) _handlers[event] = []; _handlers[event].push(fn); return this; }, off(event, fn) { if (_handlers[event]) _handlers[event] = _handlers[event].filter(f => f !== fn); return this; }, }; }, }; /* ================================================================ MODULE 43 — MULTI-SELECT COMBOBOX lt.combobox.init(inputEl, options, opts) options = [{ value, label, icon? }] opts: { max, placeholder, onChange } ================================================================ */ const combobox = { init(inputEl, options = [], opts = {}) { const wrap = inputEl.closest('.lt-combobox') || inputEl.parentElement; const inputWrap = wrap.querySelector('.lt-combobox-input-wrap') || wrap; const dropdown = wrap.querySelector('.lt-combobox-dropdown'); if (!dropdown) return; const { max = Infinity, placeholder = 'Search…', onChange = null } = opts; let selected = []; let focusedIdx = -1; let filtered = [...options]; function _renderTags() { wrap.querySelectorAll('.lt-combobox-tag').forEach(t => t.remove()); selected.forEach(v => { const opt = options.find(o => o.value === v); if (!opt) return; const tag = document.createElement('span'); tag.className = 'lt-combobox-tag'; tag.innerHTML = `${escHtml(opt.label)}`; inputWrap.insertBefore(tag, inputEl); }); } function _renderDropdown(query) { const q = query.toLowerCase().trim(); filtered = options.filter(o => !selected.includes(o.value) && (!q || o.label.toLowerCase().includes(q))); dropdown.innerHTML = ''; if (!filtered.length) { dropdown.innerHTML = `
${q ? 'No matches' : 'All selected'}
`; return; } filtered.forEach((opt, i) => { const el = document.createElement('div'); el.className = 'lt-combobox-option' + (selected.includes(opt.value) ? ' is-selected' : ''); el.setAttribute('role', 'option'); el.setAttribute('data-value', opt.value); const hl = q ? opt.label.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '$1') : escHtml(opt.label); el.innerHTML = `${opt.icon ? `${escHtml(opt.icon)}` : ''}${hl}`; el.addEventListener('mousedown', e => { e.preventDefault(); _toggle(opt.value); }); dropdown.appendChild(el); }); focusedIdx = -1; } function _toggle(value) { const idx = selected.indexOf(value); if (idx >= 0) { selected.splice(idx, 1); } else if (selected.length < max) { selected.push(value); } _renderTags(); _renderDropdown(inputEl.value); inputEl.value = ''; inputEl.focus(); if (onChange) onChange([...selected]); } function _moveFocus(dir) { const items = dropdown.querySelectorAll('.lt-combobox-option'); if (!items.length) return; focusedIdx = Math.max(0, Math.min(focusedIdx + dir, items.length - 1)); items.forEach((el, i) => el.classList.toggle('is-focused', i === focusedIdx)); items[focusedIdx].scrollIntoView({ block: 'nearest' }); } inputEl.addEventListener('input', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); }); inputEl.addEventListener('focus', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); }); inputEl.addEventListener('keydown', e => { if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); } if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); } if (e.key === 'Enter') { e.preventDefault(); if (focusedIdx >= 0 && filtered[focusedIdx]) _toggle(filtered[focusedIdx].value); } if (e.key === 'Escape') { dropdown.classList.remove('is-open'); } if (e.key === 'Backspace' && !inputEl.value && selected.length) { _toggle(selected[selected.length - 1]); } }); inputWrap.addEventListener('mousedown', e => { const rmBtn = e.target.closest('.lt-combobox-tag-remove'); if (rmBtn) { e.preventDefault(); _toggle(rmBtn.dataset.value); return; } inputEl.focus(); }); document.addEventListener('click', e => { if (!wrap.contains(e.target)) dropdown.classList.remove('is-open'); }); _renderTags(); _renderDropdown(''); return { getValue: () => [...selected], setValue: vals => { selected = [...vals]; _renderTags(); _renderDropdown(''); }, clear: () => { selected = []; _renderTags(); _renderDropdown(''); }, }; }, }; /* ================================================================ MODULE 44 — AUTOCOMPLETE / TYPEAHEAD lt.typeahead.init(inputEl, source, opts) source: array or async fn(query) => [{value, label, icon?, meta?}] opts: { minChars, debounceMs, onSelect, maxResults } ================================================================ */ const typeahead = { init(inputEl, source, opts = {}) { const wrap = inputEl.closest('.lt-typeahead') || inputEl.parentElement; const dropdown = wrap.querySelector('.lt-typeahead-dropdown'); if (!dropdown) return; const { minChars = 1, debounceMs = 150, onSelect = null, maxResults = 10 } = opts; let _focusedIdx = -1; let _items = []; let _debTimer = null; function _render(items, query) { _items = items.slice(0, maxResults); dropdown.innerHTML = ''; if (!_items.length) { dropdown.innerHTML = `
[ NO RESULTS ]
`; return; } const q = query.toLowerCase(); _items.forEach((item, i) => { const el = document.createElement('div'); el.className = 'lt-typeahead-item'; el.setAttribute('role', 'option'); const hl = item.label.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '$1'); el.innerHTML = `${item.icon ? `${escHtml(item.icon)}` : ''}${hl}${item.meta ? `${escHtml(item.meta)}` : ''}`; el.addEventListener('mousedown', e => { e.preventDefault(); _select(item); }); dropdown.appendChild(el); }); _focusedIdx = -1; } async function _search(query) { dropdown.innerHTML = '
Searching…
'; dropdown.classList.add('is-open'); try { const results = typeof source === 'function' ? await source(query) : source.filter(i => i.label.toLowerCase().includes(query.toLowerCase())); _render(results, query); } catch(e) { dropdown.innerHTML = '
Error loading results
'; } } function _select(item) { inputEl.value = item.label; dropdown.classList.remove('is-open'); if (onSelect) onSelect(item); bus.emit('typeahead:select', { item }); } function _moveFocus(dir) { const els = dropdown.querySelectorAll('.lt-typeahead-item'); if (!els.length) return; _focusedIdx = Math.max(0, Math.min(_focusedIdx + dir, els.length - 1)); els.forEach((el, i) => el.classList.toggle('is-focused', i === _focusedIdx)); els[_focusedIdx].scrollIntoView({ block: 'nearest' }); } inputEl.addEventListener('input', () => { clearTimeout(_debTimer); const q = inputEl.value.trim(); if (q.length < minChars) { dropdown.classList.remove('is-open'); return; } _debTimer = setTimeout(() => _search(q), debounceMs); }); inputEl.addEventListener('keydown', e => { if (!dropdown.classList.contains('is-open')) return; if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); } if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); } if (e.key === 'Enter') { e.preventDefault(); if (_focusedIdx >= 0 && _items[_focusedIdx]) _select(_items[_focusedIdx]); } if (e.key === 'Escape') { dropdown.classList.remove('is-open'); } if (e.key === 'Tab') { dropdown.classList.remove('is-open'); } }); inputEl.addEventListener('blur', () => setTimeout(() => dropdown.classList.remove('is-open'), 150)); document.addEventListener('click', e => { if (!wrap.contains(e.target)) dropdown.classList.remove('is-open'); }); return { search: q => _search(q) }; }, }; /* ================================================================ MODULE 45 — COOKIE UTILITY lt.cookie.set(name, value, days?, opts?) lt.cookie.get(name) lt.cookie.del(name) ================================================================ */ const cookie = { set(name, value, days = 0, opts = {}) { let str = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`; if (days) { const d = new Date(); d.setDate(d.getDate() + days); str += `; expires=${d.toUTCString()}`; } str += `; path=${opts.path || '/'}`; if (opts.sameSite) str += `; SameSite=${opts.sameSite}`; if (opts.secure || location.protocol === 'https:') str += '; Secure'; document.cookie = str; }, get(name) { const key = encodeURIComponent(name) + '='; const found = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith(key)); return found ? decodeURIComponent(found.slice(key.length)) : null; }, del(name, opts = {}) { cookie.set(name, '', -1, opts); }, getAll() { const out = {}; document.cookie.split(';').forEach(c => { const [k, ...v] = c.trim().split('='); if (k) out[decodeURIComponent(k)] = decodeURIComponent(v.join('=')); }); return out; }, }; /* ================================================================ MODULE 46 — SPLIT PANE lt.splitPane.init(containerEl, opts) opts: { minA, minB, initial (0-1), vertical, onResize } ================================================================ */ const splitPane = { init(container, opts = {}) { const { minA = 80, minB = 80, initial = 0.5, vertical = false, onResize = null } = opts; const panes = container.querySelectorAll('.lt-split-pane'); const divider = container.querySelector('.lt-split-divider'); if (!panes[0] || !panes[1] || !divider) return; const dim = vertical ? 'height' : 'width'; const client = vertical ? 'clientY' : 'clientX'; let dragging = false; let startPos, startSizeA; function _setRatio(ratio) { const total = vertical ? container.clientHeight : container.clientWidth; const divSize = vertical ? divider.offsetHeight : divider.offsetWidth; const available = total - divSize; const sizeA = Math.max(minA, Math.min(available - minB, ratio * available)); panes[0].style[dim] = sizeA + 'px'; panes[0].style.flex = 'none'; panes[1].style.flex = '1'; if (onResize) onResize(sizeA, available - sizeA); } // Pointer events (handles both mouse and touch) divider.addEventListener('pointerdown', e => { e.preventDefault(); dragging = true; divider.setPointerCapture(e.pointerId); divider.classList.add('is-dragging'); startPos = e[client]; startSizeA = vertical ? panes[0].offsetHeight : panes[0].offsetWidth; }); divider.addEventListener('pointermove', e => { if (!dragging) return; const delta = e[client] - startPos; const total = vertical ? container.clientHeight : container.clientWidth; const divSize = vertical ? divider.offsetHeight : divider.offsetWidth; const newSize = Math.max(minA, Math.min(total - divSize - minB, startSizeA + delta)); panes[0].style[dim] = newSize + 'px'; panes[0].style.flex = 'none'; panes[1].style.flex = '1'; if (onResize) onResize(newSize, total - divSize - newSize); }); divider.addEventListener('pointerup', () => { dragging = false; divider.classList.remove('is-dragging'); }); _setRatio(initial); return { setRatio: _setRatio }; }, }; /* ================================================================ MODULE 47 — TOAST QUEUE (enhanced dispatch) Wraps existing toast module with max-stack + progress bars ================================================================ */ const _toastMaxStack = 5; const _toastQueue = []; let _toastActive = 0; const _origToast = Object.assign({}, toast); function _toastEnqueue(type, msg, dur = 4000) { if (_toastActive >= _toastMaxStack) { _toastQueue.push({ type, msg, dur }); return; } _toastActive++; const id = _origToast[type] ? _origToast[type](msg, dur) : _origToast.info(msg, dur); // Add progress bar to the toast element setTimeout(() => { const toastEl = document.querySelector(`#lt-toast-container .lt-toast:last-child`); if (toastEl && !toastEl.querySelector('.lt-toast-progress')) { const bar = document.createElement('div'); bar.className = 'lt-toast-progress'; bar.style.animationDuration = dur + 'ms'; toastEl.appendChild(bar); } }, 20); setTimeout(() => { _toastActive = Math.max(0, _toastActive - 1); if (_toastQueue.length) { const next = _toastQueue.shift(); _toastEnqueue(next.type, next.msg, next.dur); } }, dur + 400); return id; } // Override toast methods to use queue ['success','error','warning','info'].forEach(t => { if (toast[t]) { const orig = toast[t].bind(toast); toast[t] = (msg, dur) => _toastEnqueue(t, msg, dur || 4000); } }); /* ================================================================ 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, /* v1.1 new features */ theme, notif, rightDrawer, contextMenu, offline, ws, combobox, typeahead, cookie, splitPane, }; }(window));