Files
tinker_tickets/assets/js/base.js
T
jared cfdc9e0f37 Sync TDS v1.2 additions: scanlines, cursor, radar, display-field, VT323
- Sync base.css + base.js from web_template (adds lt-scanlines,
  lt-cursor, lt-radar, lt-display-field, --font-crt/VT323 token)
- Add VT323 to Google Fonts link in layout_header.php
- Add lt-scanlines to <body> — CRT scanline overlay, light-mode suppressed
- Replace custom .editable-metadata:disabled CSS override in ticket.css
  with the canonical .lt-display-field class from base.css
- Switch Priority/Category/Type/Visibility selects and visibility-group
  checkboxes in TicketView.php from disabled attribute to lt-display-field
- Update toggleEditMode() in ticket.js to add/remove lt-display-field
  instead of toggling the disabled attribute

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 16:55:12 -04:00

2948 lines
127 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) { if (_toastQueue.length < 12) _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.type = 'button';
closeEl.className = 'lt-toast-close';
closeEl.textContent = '✕';
closeEl.setAttribute('aria-label', 'Dismiss');
closeEl.addEventListener('click', () => _dismissToast(toast));
const progressEl = document.createElement('div');
progressEl.className = 'lt-toast-progress';
progressEl.style.animationDuration = duration + 'ms';
toast.appendChild(iconEl);
toast.appendChild(msgEl);
toast.appendChild(closeEl);
toast.appendChild(progressEl);
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.removeAttribute('aria-hidden'); /* removing is correct; setting 'false' is an anti-pattern */
_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 (only if no other modal remains open)
const trigger = _modalTriggers.get(el);
if (trigger) {
_modalTriggers.delete(el);
if (!document.querySelector('.lt-modal-overlay.is-open') && document.contains(trigger)) {
trigger.focus();
}
}
}
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[data-tab]').forEach(t => {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
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'); btn.setAttribute('aria-selected', 'true'); }
if (panel) panel.classList.add('active');
try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {}
}
let _tabsInitialized = false;
function initTabs() {
if (_tabsInitialized) return; _tabsInitialized = true;
try {
const saved = localStorage.getItem('lt_activeTab_' + location.pathname);
if (saved && document.getElementById(saved)) { switchTab(saved); }
} catch (_) {}
document.querySelectorAll('[role="tablist"]').forEach(tablist => {
const btns = Array.from(tablist.querySelectorAll('.lt-tab[data-tab]'));
btns.forEach((btn, i) => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
btn.addEventListener('keydown', e => {
let idx = -1;
if (e.key === 'ArrowRight') idx = (i + 1) % btns.length;
else if (e.key === 'ArrowLeft') idx = (i - 1 + btns.length) % btns.length;
else if (e.key === 'Home') idx = 0;
else if (e.key === 'End') idx = btns.length - 1;
if (idx >= 0) { e.preventDefault(); btns[idx].focus(); switchTab(btns[idx].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
---------------------------------------------------------------- */
let _bootFired = false; // in-memory guard: survives within a JS context, resets on true page reload
function runBoot(appName, force) {
if (!force && _bootFired) return; // Fastest guard — blocks any same-page double-call
const storageKey = 'lt_booted_' + (appName || 'app');
if (!force && sessionStorage.getItem(storageKey)) return;
_bootFired = true;
sessionStorage.setItem(storageKey, '1');
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);
}
}, 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()
---------------------------------------------------------------- */
let _sidebarInitialized = false;
function initSidebar() {
if (_sidebarInitialized) return; _sidebarInitialized = true;
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 hasBody = body !== undefined;
const headers = Object.assign({}, csrfHeaders());
if (hasBody) headers['Content-Type'] = 'application/json'; // Only set on requests with a body
const opts = { method, headers };
if (hasBody) 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.setAttribute('aria-sort', 'none');
th.setAttribute('tabindex', '0');
const _sort = () => {
ths.forEach(h => { h.removeAttribute('data-sort'); h.setAttribute('aria-sort', 'none'); });
th.setAttribute('data-sort', dir);
th.setAttribute('aria-sort', dir === 'asc' ? 'ascending' : 'descending');
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';
};
th.addEventListener('click', _sort);
th.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _sort(); } });
});
}
const sortTable = { init: initSortTable };
/* ----------------------------------------------------------------
15. STATS WIDGET FILTERING
----------------------------------------------------------------
lt.statsFilter.init()
---------------------------------------------------------------- */
function initStatsFilter() {
document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => {
const _activate = () => {
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);
};
card.addEventListener('click', _activate);
card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _activate(); } });
});
}
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');
});
}
let _accordionInitialized = false;
function initAccordion() {
if (_accordionInitialized) return; _accordionInitialized = true;
// 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;
}
const maxLeft = (global.scrollX || 0) + global.innerWidth - tr.width - 4;
const maxTop = (global.scrollY || 0) + global.innerHeight - tr.height - 4;
left = Math.max(4 + (global.scrollX || 0), Math.min(maxLeft, left));
top = Math.max(4 + (global.scrollY || 0), Math.min(maxTop, top));
tip.style.cssText = 'position:absolute;top:' + top + 'px;left:' + left + 'px';
requestAnimationFrame(() => tip.classList.add('is-visible'));
}
function _tooltipHide() {
if (_tooltipEl) { _tooltipEl.remove(); _tooltipEl = null; }
}
let _tooltipInitialized = false;
function initTooltips() {
if (_tooltipInitialized) return;
_tooltipInitialized = true;
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; }
}
let _copyInitialized = false;
function initCopyButtons() {
if (_copyInitialized) return; _copyInitialized = true;
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(() => { if (document.contains(btn)) { 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);
}));
}
let _alertsInitialized = false;
function initAlerts() {
if (_alertsInitialized) return; _alertsInitialized = true;
document.addEventListener('click', function (e) {
const btn = e.target.closest('.lt-alert-close, .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, _cpTrigger = null;
const _cpRecentKey = 'lt_cmd_recent';
function _cmdPaletteOpen() {
const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return;
if (_mnOpen) _mnSetOpen(false);
_cpTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null;
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();
const inp = document.querySelector('#lt-cmd-palette .lt-cmd-input');
if (inp) inp.removeAttribute('aria-activedescendant');
if (_cpTrigger) { if (document.contains(_cpTrigger)) _cpTrigger.focus(); _cpTrigger = null; }
}
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' : '') + '" id="lt-cmd-item-' + idx + '" 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;
const pal = document.getElementById('lt-cmd-palette');
const inp = pal && pal.querySelector('.lt-cmd-input');
const allItems = Array.from(results.querySelectorAll('.lt-cmd-item'));
if (inp && allItems[0]) inp.setAttribute('aria-activedescendant', allItems[0].id);
allItems.forEach((item, i) => {
item.addEventListener('mouseenter', () => {
allItems.forEach(x => x.classList.remove('is-selected'));
item.classList.add('is-selected'); _cpSelected = i;
if (inp) inp.setAttribute('aria-activedescendant', item.id);
});
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' });
const inp = ov.querySelector('.lt-cmd-input');
if (inp && items[_cpSelected]) inp.setAttribute('aria-activedescendant', items[_cpSelected].id);
}
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 ((type === 'checkbox' || type === 'radio') && el.required && !el.checked) return { valid: false, message: 'This field is required' };
if (el.tagName === 'SELECT' && el.multiple && el.required && el.selectedOptions.length === 0) return { valid: false, message: 'Please select at least one option' };
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';
err.id = (el.id || ('lt-field-' + Math.random().toString(36).slice(2))) + '-err';
if (el.parentElement) el.parentElement.appendChild(err);
}
err.textContent = msg;
err.setAttribute('role', 'alert');
el.setAttribute('aria-describedby', err.id);
el.setAttribute('aria-invalid', 'true');
}
function _clearError(el) {
el.classList.remove('is-invalid'); el.classList.add('is-valid');
el.removeAttribute('aria-invalid');
el.removeAttribute('aria-describedby');
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.length) 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();
initSidebarSubmenus();
/* 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, _mnTrigger = null;
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) {
_mnTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null;
drawer.classList.add('open');
drawer.removeAttribute('aria-hidden');
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
if (!drawer._mnTrapHandler) {
drawer._mnTrapHandler = e => { if (e.key === 'Tab') _trapFocus(drawer, e); };
drawer.addEventListener('keydown', drawer._mnTrapHandler);
}
const first = drawer.querySelector('button, a, [tabindex]');
if (first) setTimeout(() => { if (document.contains(first)) 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 = '';
if (drawer._mnTrapHandler) { drawer.removeEventListener('keydown', drawer._mnTrapHandler); delete drawer._mnTrapHandler; }
if (_mnTrigger && document.contains(_mnTrigger)) { _mnTrigger.focus(); }
_mnTrigger = null;
}
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);
document.documentElement.style.colorScheme = t;
try { localStorage.setItem(_themeKey, t); } catch(_) {}
// Sync theme-color meta for browser chrome
const metaTheme = document.querySelector('meta[name="theme-color"]');
if (metaTheme) metaTheme.setAttribute('content', t === 'light' ? '#edf0f5' : '#030508');
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');
});
// Sync theme across tabs via storage event
window.addEventListener('storage', e => {
if (e.key === _themeKey && e.newValue) _applyTheme(e.newValue);
});
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.removeAttribute('aria-hidden');
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 + Tab trap
drawer._rdKeyHandler = e => { if (e.key === 'Escape') _rdClose(drawer); };
document.addEventListener('keydown', drawer._rdKeyHandler);
drawer._rdTrapHandler = e => { if (e.key === 'Tab') _trapFocus(drawer, e); };
drawer.addEventListener('keydown', drawer._rdTrapHandler);
// 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; } }
drawer.querySelectorAll('[data-drawer-close]').forEach(btn => {
if (btn._rdHandler) { btn.removeEventListener('click', btn._rdHandler); delete btn._rdHandler; }
});
_unlockScroll();
if (drawer._rdKeyHandler) { document.removeEventListener('keydown', drawer._rdKeyHandler); delete drawer._rdKeyHandler; }
if (drawer._rdTrapHandler) { drawer.removeEventListener('keydown', drawer._rdTrapHandler); delete drawer._rdTrapHandler; }
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, _ctxTrigger = null;
const _ctxItems = {};
function _ctxShow(x, y, items, trigger) {
_ctxTrigger = trigger || (document.activeElement !== document.body ? document.activeElement : null);
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 ? `<span class="icon">${escHtml(item.icon)}</span>` : '<span class="icon"></span>'}<span>${escHtml(item.label || '')}</span>${item.kbd ? `<kbd>${escHtml(item.kbd)}</kbd>` : ''}`;
el.addEventListener('click', () => { _ctxHide(); if (item.action) item.action(); });
el.addEventListener('keydown', e => {
const items = Array.from(_ctxMenu.querySelectorAll('[role="menuitem"]'));
const idx = items.indexOf(e.currentTarget);
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); }
else if (e.key === 'ArrowDown') { e.preventDefault(); (items[idx + 1] || items[0]).focus(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); (items[idx - 1] || items[items.length - 1]).focus(); }
else if (e.key === 'Home') { e.preventDefault(); items[0].focus(); }
else if (e.key === 'End') { e.preventDefault(); items[items.length - 1].focus(); }
});
_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.max(8, 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');
if (_ctxTrigger && document.contains(_ctxTrigger)) { _ctxTrigger.focus(); }
_ctxTrigger = null;
}
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, target);
});
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 = '<span class="lt-dot lt-dot--red"></span> 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];
// ARIA combobox wiring
const dropId = dropdown.id || ('lt-cb-drop-' + Math.random().toString(36).slice(2));
dropdown.id = dropId;
dropdown.setAttribute('role', 'listbox');
inputEl.setAttribute('role', 'combobox');
inputEl.setAttribute('aria-expanded', 'false');
inputEl.setAttribute('aria-controls', dropId);
inputEl.setAttribute('aria-autocomplete', 'list');
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)}<button type="button" class="lt-combobox-tag-remove" data-value="${escHtml(v)}" aria-label="Remove ${escHtml(opt.label)}">✕</button>`;
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 = `<div class="lt-combobox-empty">${q ? 'No matches' : 'All selected'}</div>`;
return;
}
filtered.forEach((opt, i) => {
const el = document.createElement('div');
el.id = dropId + '-opt-' + i;
el.className = 'lt-combobox-option' + (selected.includes(opt.value) ? ' is-selected' : '');
el.setAttribute('role', 'option');
el.setAttribute('data-value', opt.value);
const safeLabel = escHtml(opt.label);
const hl = q ? safeLabel.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>') : safeLabel;
el.innerHTML = `${opt.icon ? `<span class="icon">${escHtml(opt.icon)}</span>` : ''}<span>${hl}</span>`;
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 = Array.from(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.setAttribute('aria-activedescendant', items[focusedIdx].id);
}
function _setOpen(open) {
dropdown.classList.toggle('is-open', open);
inputEl.setAttribute('aria-expanded', open ? 'true' : 'false');
if (!open) { inputEl.removeAttribute('aria-activedescendant'); focusedIdx = -1; }
}
inputEl.addEventListener('input', () => { _setOpen(true); _renderDropdown(inputEl.value); });
inputEl.addEventListener('focus', () => { _setOpen(true); _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') { _setOpen(false); }
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)) _setOpen(false); });
_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 = `<div class="lt-typeahead-empty">[ NO RESULTS ]</div>`;
return;
}
const q = query.toLowerCase();
_items.forEach((item, i) => {
const el = document.createElement('div');
el.id = (inputEl.id || 'lt-ta') + '-item-' + i;
el.className = 'lt-typeahead-item';
el.setAttribute('role', 'option');
const safeItemLabel = escHtml(item.label);
const hl = safeItemLabel.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>');
el.innerHTML = `${item.icon ? `<span class="icon">${escHtml(item.icon)}</span>` : ''}<span>${hl}</span>${item.meta ? `<span style="margin-left:auto;color:var(--text-muted);font-size:0.68rem">${escHtml(item.meta)}</span>` : ''}`;
el.addEventListener('mousedown', e => { e.preventDefault(); _select(item); });
dropdown.appendChild(el);
});
_focusedIdx = -1;
}
async function _search(query) {
dropdown.innerHTML = '<div class="lt-typeahead-loading">Searching…</div>';
dropdown.classList.add('is-open');
inputEl.setAttribute('aria-busy', 'true');
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 = '<div class="lt-typeahead-empty">Error loading results</div>';
} finally {
inputEl.setAttribute('aria-busy', 'false');
}
}
function _select(item) {
inputEl.value = item.label;
inputEl.removeAttribute('aria-activedescendant');
dropdown.classList.remove('is-open');
if (onSelect) onSelect(item);
bus.emit('typeahead:select', { item });
}
function _moveFocus(dir) {
const els = Array.from(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.setAttribute('aria-activedescendant', els[_focusedIdx].id);
}
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'); });
// Keyboard resize support
divider.setAttribute('tabindex', '0');
divider.setAttribute('role', 'separator');
divider.setAttribute('aria-label', 'Resize panes');
divider.addEventListener('keydown', e => {
const step = 0.05;
const total = vertical ? container.clientHeight : container.clientWidth;
const divSize = vertical ? divider.offsetHeight : divider.offsetWidth;
const available = total - divSize;
const currentSize = vertical ? panes[0].offsetHeight : panes[0].offsetWidth;
const currentRatio = currentSize / available;
if ((e.key === 'ArrowRight' && !vertical) || (e.key === 'ArrowDown' && vertical)) {
e.preventDefault(); _setRatio(Math.min(1, currentRatio + step));
} else if ((e.key === 'ArrowLeft' && !vertical) || (e.key === 'ArrowUp' && vertical)) {
e.preventDefault(); _setRatio(Math.max(0, currentRatio - step));
} else if (e.key === 'Home') { e.preventDefault(); _setRatio(0); }
else if (e.key === 'End') { e.preventDefault(); _setRatio(1); }
});
_setRatio(initial);
return { setRatio: _setRatio };
},
};
/* ================================================================
MODULE 48 — SIDEBAR SUBMENUS
Auto-inits .lt-sidebar-group elements.
Click label → toggle .is-open + animate submenu.
================================================================ */
function initSidebarSubmenus(root) {
const container = root || document;
container.querySelectorAll('.lt-sidebar-group').forEach(group => {
if (group._sbInit) return;
group._sbInit = true;
const label = group.querySelector('.lt-sidebar-group-label');
if (!label) return;
label.setAttribute('tabindex', '0');
label.setAttribute('role', 'button');
const chevron = label.querySelector('.chevron, .lt-sidebar-chevron');
if (chevron) chevron.setAttribute('aria-hidden', 'true');
const _toggle = () => {
group.classList.toggle('is-open');
label.setAttribute('aria-expanded', group.classList.contains('is-open') ? 'true' : 'false');
};
label.setAttribute('aria-expanded', group.classList.contains('is-open') ? 'true' : 'false');
label.addEventListener('click', _toggle);
label.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _toggle(); } });
// Open group if it contains the active link
if (group.querySelector('.lt-sidebar-sub-link.active, .lt-sidebar-sub-link[aria-current="page"]')) {
group.classList.add('is-open');
label.setAttribute('aria-expanded', 'true');
}
});
}
/* ================================================================
MODULE 49 — INFINITE SCROLL
lt.infiniteScroll.init(containerEl, loadFn, opts)
loadFn: async fn() → { items: [], done: bool }
opts: { threshold (px from bottom), loadingClass, sentinelClass }
================================================================ */
const infiniteScroll = {
init(container, loadFn, opts = {}) {
const { threshold = 200, onEmpty = null } = opts;
let _loading = false;
let _done = false;
// Sentinel element at the bottom of the container
const sentinel = document.createElement('div');
sentinel.className = 'lt-infinite-sentinel';
sentinel.setAttribute('aria-hidden', 'true');
container.appendChild(sentinel);
// Loading indicator
const loadingEl = document.createElement('div');
loadingEl.className = 'lt-infinite-loading lt-loading lt-hidden';
loadingEl.setAttribute('aria-live', 'polite');
loadingEl.setAttribute('aria-label', 'Loading more items');
container.appendChild(loadingEl);
async function _load() {
if (_loading || _done) return;
_loading = true;
loadingEl.classList.remove('lt-hidden');
try {
const result = await loadFn();
if (result && result.done) {
_done = true;
sentinel.remove();
loadingEl.remove();
if (onEmpty) onEmpty();
}
} catch (e) {
console.error('[lt.infiniteScroll]', e);
} finally {
_loading = false;
loadingEl.classList.add('lt-hidden');
}
}
// Use IntersectionObserver if available, else scroll listener
if (global.IntersectionObserver) {
const io = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) _load();
}, { rootMargin: `0px 0px ${threshold}px 0px` });
io.observe(sentinel);
return { reset() { _done = false; _loading = false; }, stop() { io.disconnect(); } };
} else {
const scrollRoot = container === document.body ? window : container;
function _onScroll() {
const el = container === document.body ? document.documentElement : container;
const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
if (dist < threshold) _load();
}
const _onScrollThrottled = throttle(_onScroll, 150);
scrollRoot.addEventListener('scroll', _onScrollThrottled, { passive: true });
return { reset() { _done = false; _loading = false; }, stop() { scrollRoot.removeEventListener('scroll', _onScrollThrottled); } };
}
},
};
/* ================================================================
MODULE 49 — WIZARD / MULTI-STEP FORM
lt.wizard.init(containerEl, opts)
opts: { onStep(n, total), onComplete(data), validate(n) }
HTML: [data-wizard-step="1"] ... [data-wizard-nav]
================================================================ */
const wizard = {
init(container, opts = {}) {
const { onStep = null, onComplete = null, validate = null } = opts;
const steps = Array.from(container.querySelectorAll('[data-wizard-step]'));
const total = steps.length;
let current = 0;
const formData = {};
function _getStepData(idx) {
const step = steps[idx];
const data = {};
step.querySelectorAll('input, select, textarea').forEach(el => {
if (el.name) data[el.name] = el.type === 'checkbox' ? el.checked : el.value;
});
return data;
}
function _show(idx) {
steps.forEach((s, i) => {
s.classList.toggle('is-active', i === idx);
if (i !== idx) s.setAttribute('aria-hidden', 'true'); else s.removeAttribute('aria-hidden');
});
// Update step indicators
container.querySelectorAll('[data-wizard-indicator]').forEach((ind, i) => {
ind.classList.toggle('is-active', i === idx);
ind.classList.toggle('is-complete', i < idx);
ind.classList.remove('is-error');
});
// Update nav buttons
const prevBtn = container.querySelector('[data-wizard-prev]');
const nextBtn = container.querySelector('[data-wizard-next]');
const doneBtn = container.querySelector('[data-wizard-done]');
if (prevBtn) prevBtn.disabled = idx === 0;
if (nextBtn) nextBtn.style.display = idx < total - 1 ? '' : 'none';
if (doneBtn) doneBtn.style.display = idx === total - 1 ? '' : 'none';
// Update step counter
container.querySelectorAll('[data-wizard-current]').forEach(el => el.textContent = idx + 1);
container.querySelectorAll('[data-wizard-total]').forEach(el => el.textContent = total);
if (onStep) onStep(idx + 1, total, formData);
// Focus first input in step
const first = steps[idx].querySelector('input, select, textarea, button');
if (first) setTimeout(() => first.focus(), 60);
}
let _wizBusy = false;
async function _next() {
if (_wizBusy) return;
_wizBusy = true;
try {
if (validate) {
const ok = await validate(current + 1, _getStepData(current));
if (!ok) {
container.querySelectorAll('[data-wizard-indicator]')[current]?.classList.add('is-error');
return;
}
}
Object.assign(formData, _getStepData(current));
if (current < total - 1) { current++; _show(current); }
} finally {
_wizBusy = false;
}
}
function _prev() {
if (current > 0) { current--; _show(current); }
}
function _done() {
Object.assign(formData, _getStepData(current));
if (onComplete) onComplete({ ...formData });
}
function _goTo(n) { // 1-based
const idx = Math.max(0, Math.min(n - 1, total - 1));
current = idx; _show(current);
}
// Wire up nav buttons
container.querySelector('[data-wizard-next]')?.addEventListener('click', _next);
container.querySelector('[data-wizard-prev]')?.addEventListener('click', _prev);
container.querySelector('[data-wizard-done]')?.addEventListener('click', _done);
_show(0);
return { next: _next, prev: _prev, goTo: _goTo, getData: () => ({ ...formData }), total };
},
};
/* ================================================================
MODULE 50 — SORTABLE (drag-to-reorder lists/kanban)
lt.sortable.init(listEl, opts)
opts: { handle (selector), onSort(newOrder, movedEl), group }
Supports cross-list dragging when group matches.
Returns { refresh(), getOrder() }; emits 'sortable:change' on bus
================================================================ */
// Shared cross-instance drag state (enables group/cross-column dragging)
let _srtDragging = null, _srtPlaceholder = null, _srtSrcList = null;
const sortable = {
init(list, opts = {}) {
const { handle = null, onSort = null, group = null } = opts;
list.setAttribute('data-sortable-group', group || '');
function _mark(child) {
child.setAttribute('data-sortable-item', '');
child.setAttribute('draggable', handle ? 'false' : 'true');
if (handle) {
const h = child.querySelector(handle);
if (h) { h.setAttribute('draggable', 'true'); h.style.cursor = 'grab'; }
} else { child.style.cursor = 'grab'; }
}
function _getItems() { return Array.from(list.querySelectorAll(':scope > [data-sortable-item]')); }
function _makePlaceholder(el) {
const ph = document.createElement(el.tagName);
ph.className = 'lt-sortable-placeholder';
ph.style.height = el.offsetHeight + 'px';
ph.style.width = '100%';
return ph;
}
function _sameGroup(otherList) {
if (!group) return false;
return otherList.getAttribute('data-sortable-group') === group;
}
// Mark all current children
Array.from(list.children).forEach(_mark);
list.addEventListener('dragstart', e => {
const item = e.target.closest('[data-sortable-item]');
if (!item || !list.contains(item)) return;
_srtDragging = item;
_srtSrcList = list;
_srtPlaceholder = _makePlaceholder(item);
requestAnimationFrame(() => { item.classList.add('is-dragging'); });
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
});
list.addEventListener('dragend', () => {
if (!_srtDragging) return;
_srtDragging.classList.remove('is-dragging');
if (_srtPlaceholder && _srtPlaceholder.parentNode) {
_srtPlaceholder.parentNode.insertBefore(_srtDragging, _srtPlaceholder);
_srtPlaceholder.remove();
}
if (onSort) onSort(_getItems(), _srtDragging);
bus.emit('sortable:change', { list, items: _getItems(), moved: _srtDragging });
_srtDragging = null; _srtPlaceholder = null; _srtSrcList = null;
});
list.addEventListener('dragover', e => {
if (!_srtDragging) return;
// Allow drop only within same list or same group
if (list !== _srtSrcList && !_sameGroup(_srtSrcList)) return;
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
if (!_srtPlaceholder) _srtPlaceholder = _makePlaceholder(_srtDragging);
const over = e.target.closest('[data-sortable-item]');
if (over && over !== _srtDragging && list.contains(over)) {
const rect = over.getBoundingClientRect();
list.insertBefore(_srtPlaceholder, e.clientY < rect.top + rect.height / 2 ? over : over.nextSibling);
} else if (!list.contains(_srtPlaceholder)) {
list.appendChild(_srtPlaceholder);
}
});
list.addEventListener('drop', e => { e.preventDefault(); });
return {
refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); },
getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()),
};
},
};
/* ================================================================
MODULE 51 — COUNTDOWN / TIMER
lt.timer.countdown(el, targetDate, opts)
lt.timer.stopwatch(el, opts)
el = DOM element or selector; updates .textContent
opts: { onExpire, format, urgent (seconds), urgentClass }
================================================================ */
const timer = {
countdown(el, target, opts = {}) {
const dom = typeof el === 'string' ? document.querySelector(el) : el;
if (!dom) return;
const { onExpire = null, urgent = 300, urgentClass = 'lt-text-red' } = opts;
const end = target instanceof Date ? target : new Date(target);
function _tick() {
const diff = Math.floor((end - Date.now()) / 1000);
if (diff <= 0) {
dom.textContent = '00:00:00';
dom.classList.add(urgentClass);
if (onExpire) onExpire();
clearInterval(handle);
return;
}
if (diff <= urgent) urgentClass.split(/\s+/).filter(Boolean).forEach(c => dom.classList.add(c));
const h = Math.floor(diff / 3600), m = Math.floor((diff % 3600) / 60), s = diff % 60;
dom.textContent = [h, m, s].map(n => String(n).padStart(2, '0')).join(':');
}
_tick();
const handle = setInterval(_tick, 1000);
return { stop: () => clearInterval(handle) };
},
stopwatch(el, opts = {}) {
const dom = typeof el === 'string' ? document.querySelector(el) : el;
if (!dom) return;
const { onTick = null } = opts;
let start = Date.now(), paused = false, offset = 0;
function _tick() {
if (paused) return;
const elapsed = Math.floor((Date.now() - start + offset) / 1000);
const h = Math.floor(elapsed / 3600), m = Math.floor((elapsed % 3600) / 60), s = elapsed % 60;
dom.textContent = [h, m, s].map(n => String(n).padStart(2, '0')).join(':');
if (onTick) onTick(elapsed);
}
const handle = setInterval(_tick, 1000);
_tick();
return {
pause() { paused = true; offset += Date.now() - start; },
resume() { paused = false; start = Date.now(); },
reset() { offset = 0; start = Date.now(); _tick(); },
stop() { clearInterval(handle); },
elapsed: () => Math.floor((Date.now() - start + offset) / 1000),
};
},
};
/* ================================================================
MODULE 52 — IMAGE LIGHTBOX
lt.lightbox.init(selector, opts)
Clicking any matched image opens a full-screen overlay with
prev/next, keyboard nav, zoom.
opts: { caption (fn|'alt'|'title'), loop }
================================================================ */
const lightbox = {
init(selector, opts = {}) {
const { caption = 'alt', loop = true } = opts;
let _images = [], _current = 0, _overlay = null, _lbKeyBound = null, _lbTrigger = null;
function _getCaption(img) {
if (typeof caption === 'function') return caption(img);
return img.getAttribute(caption) || '';
}
function _buildOverlay() {
if (_overlay) return;
_overlay = document.createElement('div');
_overlay.className = 'lt-lightbox-overlay';
_overlay.setAttribute('role', 'dialog');
_overlay.setAttribute('aria-modal', 'true');
_overlay.setAttribute('aria-label', 'Image viewer');
_overlay.innerHTML = `
<button type="button" class="lt-lightbox-close" aria-label="Close">&times;</button>
<button type="button" class="lt-lightbox-prev" aria-label="Previous">&#8249;</button>
<button type="button" class="lt-lightbox-next" aria-label="Next">&#8250;</button>
<div class="lt-lightbox-img-wrap">
<img class="lt-lightbox-img" src="" alt="">
</div>
<div class="lt-lightbox-caption"></div>
<div class="lt-lightbox-counter"></div>
`;
document.body.appendChild(_overlay);
_overlay.querySelector('.lt-lightbox-close').addEventListener('click', lightbox.close);
_overlay.querySelector('.lt-lightbox-prev').addEventListener('click', () => lightbox.prev());
_overlay.querySelector('.lt-lightbox-next').addEventListener('click', () => lightbox.next());
_overlay.addEventListener('click', e => { if (e.target === _overlay) lightbox.close(); });
_lbKeyBound = _lbKey.bind(null);
document.addEventListener('keydown', _lbKeyBound);
}
function _lbKey(e) {
if (!_overlay || !_overlay.classList.contains('is-open')) return;
if (e.key === 'Escape') lightbox.close();
if (e.key === 'ArrowLeft') lightbox.prev();
if (e.key === 'ArrowRight') lightbox.next();
}
function _show(idx) {
if (!_overlay) _buildOverlay();
if (!_overlay.classList.contains('is-open')) {
_lbTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null;
}
_current = idx;
const img = _images[idx];
const el = _overlay.querySelector('.lt-lightbox-img');
el.src = img.src; el.alt = img.alt || '';
_overlay.querySelector('.lt-lightbox-caption').textContent = _getCaption(img);
_overlay.querySelector('.lt-lightbox-counter').textContent = `${idx + 1} / ${_images.length}`;
// Hide prev/next when single image or at edges
_overlay.querySelector('.lt-lightbox-prev').style.display = (loop || idx > 0) && _images.length > 1 ? '' : 'none';
_overlay.querySelector('.lt-lightbox-next').style.display = (loop || idx < _images.length - 1) && _images.length > 1 ? '' : 'none';
_overlay.classList.add('is-open');
_lockScroll();
setTimeout(() => { if (document.contains(el)) el.focus?.(); }, 50);
}
function _collect() {
_images = Array.from(document.querySelectorAll(selector));
_images.forEach((img, i) => {
img.style.cursor = 'zoom-in';
img.setAttribute('tabindex', '0');
img.removeEventListener('click', img._lbHandler);
img.removeEventListener('keydown', img._lbKeyHandler);
img._lbHandler = () => _show(i);
img._lbKeyHandler = e => { if (e.key === 'Enter' || e.key === ' ') _show(i); };
img.addEventListener('click', img._lbHandler);
img.addEventListener('keydown', img._lbKeyHandler);
});
}
_collect();
return Object.assign(lightbox, {
open: idx => _show(idx),
close() {
if (!_overlay) return;
_overlay.classList.remove('is-open');
_unlockScroll();
if (_lbKeyBound) { document.removeEventListener('keydown', _lbKeyBound); _lbKeyBound = null; }
if (_lbTrigger && document.contains(_lbTrigger)) { _lbTrigger.focus(); }
_lbTrigger = null;
},
prev() { _show(loop ? (_current - 1 + _images.length) % _images.length : Math.max(0, _current - 1)); },
next() { _show(loop ? (_current + 1) % _images.length : Math.min(_images.length - 1, _current + 1)); },
refresh: _collect,
});
},
};
/* ================================================================
MODULE 53 — AUTH / JWT HELPERS
Extends lt.api with token refresh support.
lt.auth.setToken(accessToken, refreshToken, expiresIn)
lt.auth.getToken()
lt.auth.refresh() — explicit refresh
lt.auth.onExpire(fn) — callback when token expires & refresh fails
lt.auth.clear()
Auto-intercepts lt.api calls to inject Bearer header
and silently refreshes when token is within 60s of expiry.
================================================================ */
let _authAccess = null;
let _authRefresh = null;
let _authExpiry = 0; // epoch ms
let _authRefreshUrl = null;
let _authRefreshing = null; // in-flight promise
const _authExpireHandlers = [];
const auth = {
setToken(access, refresh, expiresIn, refreshUrl) {
_authAccess = access;
_authRefresh = refresh;
_authExpiry = expiresIn ? Date.now() + expiresIn * 1000 : 0;
if (refreshUrl) _authRefreshUrl = refreshUrl;
try { sessionStorage.setItem('lt_auth_access', access); } catch(_) {}
},
getToken: () => _authAccess,
clear() {
_authAccess = _authRefresh = null; _authExpiry = 0;
try { sessionStorage.removeItem('lt_auth_access'); } catch(_) {}
bus.emit('auth:logout');
},
onExpire: fn => _authExpireHandlers.push(fn),
async refresh() {
if (!_authRefreshUrl || !_authRefresh) return false;
if (_authRefreshing) return _authRefreshing;
_authRefreshing = fetch(_authRefreshUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: _authRefresh }),
})
.then(r => r.json())
.then(data => {
if (data.access_token) {
_authAccess = data.access_token;
_authExpiry = data.expires_in ? Date.now() + data.expires_in * 1000 : 0;
bus.emit('auth:refreshed');
return true;
}
throw new Error('Refresh failed');
})
.catch(e => {
console.error('[lt.auth]', e);
_authExpireHandlers.forEach(fn => fn());
bus.emit('auth:expired');
return false;
})
.finally(() => { _authRefreshing = null; });
return _authRefreshing;
},
isExpired: () => _authExpiry > 0 && Date.now() >= _authExpiry,
isExpiringSoon: (secs = 60) => _authExpiry > 0 && Date.now() >= _authExpiry - secs * 1000,
};
// Patch lt.api — auth-aware wrapper (renamed to avoid strict-mode duplicate declaration)
async function _apiFetchAuth(method, url, body) {
if (_authAccess && auth.isExpiringSoon()) await auth.refresh();
const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) };
if (_authAccess) opts.headers['Authorization'] = 'Bearer ' + _authAccess;
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); }
// Auto-retry once on 401 after silent token refresh
if (resp.status === 401 && _authRefresh) {
const ok = await auth.refresh();
if (ok) {
opts.headers['Authorization'] = 'Bearer ' + _authAccess;
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;
}
api.get = url => _apiFetchAuth('GET', url);
api.post = (u, b) => _apiFetchAuth('POST', u, b);
api.put = (u, b) => _apiFetchAuth('PUT', u, b);
api.patch = (u, b) => _apiFetchAuth('PATCH', u, b);
api.delete = (u, b) => _apiFetchAuth('DELETE', u, b);
/* ================================================================
MODULE 54 — MARKDOWN RENDERER
lt.markdown.render(mdString) → HTML string (sanitized)
lt.markdown.init(selector) → renders all matching el's .textContent
Uses a built-in micro-renderer (no deps) for common syntax.
For full GFM, swap in marked.js: window.marked && marked.parse()
================================================================ */
const markdown = {
render(md) {
// Delegate to window.marked if available
if (global.marked) return global.marked.parse(md);
if (global.markdownit) return global.markdownit().render(md);
// Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr
let html = escHtml(md)
// Fenced code blocks
.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => `<pre class="lt-code-block"><code class="lt-tok tok-${lang || 'plain'}">${code.trim()}</code></pre>`)
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Headings
.replace(/^######\s(.+)$/gm, '<h6>$1</h6>')
.replace(/^#####\s(.+)$/gm, '<h5>$1</h5>')
.replace(/^####\s(.+)$/gm, '<h4>$1</h4>')
.replace(/^###\s(.+)$/gm, '<h3>$1</h3>')
.replace(/^##\s(.+)$/gm, '<h2>$1</h2>')
.replace(/^#\s(.+)$/gm, '<h1>$1</h1>')
// Bold / italic
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/_(.+?)_/g, '<em>$1</em>')
// Links — block javascript: and data: URIs
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
const safeUrl = /^(https?:\/\/|\/|#|\.\.?\/)/i.test(url) ? url : '#';
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${escHtml(text)}</a>`;
})
// Images — block javascript: and data: URIs
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
const safeSrc = /^(https?:\/\/|\/|\.\.?\/)/i.test(src) ? src : '';
return `<img src="${safeSrc}" alt="${escHtml(alt)}" style="max-width:100%">`;
})
// Blockquote
.replace(/^&gt;\s(.+)$/gm, '<blockquote>$1</blockquote>')
// Horizontal rule
.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '<hr>')
// Unordered list items
.replace(/^[-*+]\s(.+)$/gm, '<li>$1</li>')
.replace(/(<li>[\s\S]+?<\/li>\n?)+/g, m => `<ul>${m}</ul>`)
// Ordered list items
.replace(/^\d+\.\s(.+)$/gm, '<li>$1</li>')
// Paragraphs (double newline)
.replace(/\n{2,}/g, '</p><p>')
.replace(/\n/g, '<br>');
return `<p>${html}</p>`
.replace(/<p>(<(?:pre|ul|ol|h[1-6]|blockquote|hr)[^>]*>)/g, '$1')
.replace(/(<\/(?:pre|ul|ol|h[1-6]|blockquote|hr)>)<\/p>/g, '$1');
},
init(selector) {
document.querySelectorAll(selector).forEach(el => {
const raw = el.getAttribute('data-markdown') || el.textContent;
el.innerHTML = markdown.render(raw);
el.classList.add('lt-markdown');
});
},
};
/* ================================================================
MODULE 55 — PAGINATION
lt.pagination.init(navEl, opts)
opts: { total, perPage, page, onChange(page) }
Renders page buttons inside navEl; re-renders on page change.
================================================================ */
const pagination = {
init(nav, opts = {}) {
if (typeof nav === 'string') nav = document.querySelector(nav);
if (!nav) return null;
let { total = 0, perPage = 10, page = 1, onChange = null, maxBtns = 7 } = opts;
function _pages() { return Math.max(1, Math.ceil(total / perPage)); }
function render() {
const pages = _pages();
let html = '';
// Prev
html += `<button type="button" class="lt-page-btn" ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}" aria-label="Previous page">&laquo;</button>`;
// Page buttons with ellipsis
const half = Math.floor((maxBtns - 2) / 2);
let start = Math.max(2, page - half);
let end = Math.min(pages - 1, page + half);
if (end - start < maxBtns - 3) {
if (start === 2) end = Math.min(pages - 1, start + maxBtns - 3);
else start = Math.max(2, end - maxBtns + 3);
}
html += `<button type="button" class="lt-page-btn${page === 1 ? ' active' : ''}" data-page="1"${page === 1 ? ' aria-current="page"' : ''} aria-label="Page 1">1</button>`;
if (start > 2) html += `<button type="button" class="lt-page-btn" disabled aria-hidden="true">…</button>`;
for (let i = start; i <= end; i++) {
html += `<button type="button" class="lt-page-btn${page === i ? ' active' : ''}" data-page="${i}"${page === i ? ' aria-current="page"' : ''} aria-label="Page ${i}">${i}</button>`;
}
if (end < pages - 1) html += `<button type="button" class="lt-page-btn" disabled aria-hidden="true">…</button>`;
if (pages > 1) html += `<button type="button" class="lt-page-btn${page === pages ? ' active' : ''}" data-page="${pages}"${page === pages ? ' aria-current="page"' : ''} aria-label="Page ${pages}">${pages}</button>`;
// Next
html += `<button type="button" class="lt-page-btn" ${page >= pages ? 'disabled' : ''} data-page="${page + 1}" aria-label="Next page">&raquo;</button>`;
if (!nav.getAttribute('role')) nav.setAttribute('role', 'navigation');
if (!nav.getAttribute('aria-label')) nav.setAttribute('aria-label', 'Pagination');
nav.innerHTML = html;
}
nav.addEventListener('click', e => {
const btn = e.target.closest('.lt-page-btn');
if (!btn || btn.disabled || btn.classList.contains('active')) return;
const p = parseInt(btn.dataset.page, 10);
if (!p || p < 1 || p > _pages()) return;
page = p;
render();
if (onChange) onChange(page);
});
render();
return {
setTotal(n) { total = n; page = 1; render(); },
setPage(p) { page = Math.max(1, Math.min(_pages(), p)); render(); },
getPage() { return page; },
getPages() { return _pages(); },
};
},
};
/* ================================================================
MASTER INIT
lt.init(opts?)
Call once after DOM ready. Runs all standard auto-init modules.
Opts: { boot: bool, bootName: str, tooltip: bool, accordion: bool,
alerts: bool, clipboard: bool, sidebar: bool, submenus: bool }
Individual modules can still be called manually.
================================================================ */
let _ltInitialized = false;
function ltInit(opts) {
if (_ltInitialized) return; // Guard: safe to call multiple times
_ltInitialized = true;
const o = Object.assign({
boot: true,
bootName: null,
tooltip: true,
accordion: true,
alerts: true,
clipboard: true,
sidebar: true,
submenus: true,
}, opts || {});
if (o.accordion) accordion.init();
if (o.tooltip) tooltip.init();
if (o.alerts) alerts.init();
if (o.clipboard) initCopyButtons();
if (o.sidebar) initSidebar();
if (o.submenus) initSidebarSubmenus();
if (o.boot) {
const bootEl = document.getElementById('lt-boot');
if (bootEl) {
const name = o.bootName || bootEl.dataset.appName || document.title.split('—')[0].trim();
boot.run(name);
}
}
}
/* ================================================================
PUBLIC API
---------------------------------------------------------------- */
global.lt = {
/* Master initializer */
init: ltInit,
/* 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,
infiniteScroll,
wizard,
sortable,
timer,
lightbox,
auth,
markdown,
pagination,
sidebarSubmenus: { init: initSidebarSubmenus },
};
}(window));