2026-03-14 21:08:57 -04:00
|
|
|
/**
|
2026-03-25 18:47:26 -04:00
|
|
|
* LOTUSGUILD TERMINAL DESIGN SYSTEM v1.2 — base.js
|
2026-03-14 21:08:57 -04:00
|
|
|
* 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
|
2026-03-25 18:47:26 -04:00
|
|
|
* --- 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
|
2026-03-14 21:08:57 -04:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
(function (global) {
|
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
1. HTML ESCAPE
|
|
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
function escHtml(str) {
|
|
|
|
|
if (str === null || str === undefined) return '';
|
|
|
|
|
return String(str)
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
.replace(/'/g, ''');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
2. TOAST NOTIFICATIONS
|
|
|
|
|
----------------------------------------------------------------
|
2026-03-25 18:47:26 -04:00
|
|
|
lt.toast.success(msg, duration?)
|
|
|
|
|
lt.toast.error(msg, duration?)
|
|
|
|
|
lt.toast.warning(msg, duration?)
|
|
|
|
|
lt.toast.info(msg, duration?)
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
const _toastQueue = [];
|
|
|
|
|
let _toastActive = false;
|
|
|
|
|
|
|
|
|
|
function showToast(message, type, duration) {
|
|
|
|
|
type = type || 'info';
|
|
|
|
|
duration = duration || 3500;
|
2026-03-26 18:22:53 -04:00
|
|
|
if (_toastActive) { if (_toastQueue.length < 12) _toastQueue.push({ message, type, duration }); return; }
|
2026-03-14 21:08:57 -04:00
|
|
|
_displayToast(message, type, duration);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _displayToast(message, type, duration) {
|
|
|
|
|
_toastActive = true;
|
2026-03-25 18:47:26 -04:00
|
|
|
let container = document.getElementById('lt-toast-container');
|
2026-03-14 21:08:57 -04:00
|
|
|
if (!container) {
|
|
|
|
|
container = document.createElement('div');
|
2026-03-25 18:47:26 -04:00
|
|
|
container.id = 'lt-toast-container';
|
2026-03-14 21:08:57 -04:00
|
|
|
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');
|
|
|
|
|
|
2026-03-25 18:47:26 -04:00
|
|
|
const iconEl = document.createElement('span');
|
2026-03-14 21:08:57 -04:00
|
|
|
iconEl.className = 'lt-toast-icon';
|
|
|
|
|
iconEl.textContent = '[' + (icons[type] || 'i') + ']';
|
|
|
|
|
|
2026-03-25 18:47:26 -04:00
|
|
|
const msgEl = document.createElement('span');
|
2026-03-14 21:08:57 -04:00
|
|
|
msgEl.className = 'lt-toast-msg';
|
|
|
|
|
msgEl.textContent = message;
|
|
|
|
|
|
|
|
|
|
const closeEl = document.createElement('button');
|
|
|
|
|
closeEl.className = 'lt-toast-close';
|
|
|
|
|
closeEl.textContent = '✕';
|
|
|
|
|
closeEl.setAttribute('aria-label', 'Dismiss');
|
|
|
|
|
closeEl.addEventListener('click', () => _dismissToast(toast));
|
|
|
|
|
|
2026-03-25 23:36:29 -04:00
|
|
|
const progressEl = document.createElement('div');
|
|
|
|
|
progressEl.className = 'lt-toast-progress';
|
|
|
|
|
progressEl.style.animationDuration = duration + 'ms';
|
|
|
|
|
|
2026-03-14 21:08:57 -04:00
|
|
|
toast.appendChild(iconEl);
|
|
|
|
|
toast.appendChild(msgEl);
|
|
|
|
|
toast.appendChild(closeEl);
|
2026-03-25 23:36:29 -04:00
|
|
|
toast.appendChild(progressEl);
|
2026-03-14 21:08:57 -04:00
|
|
|
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);
|
2026-03-25 18:47:26 -04:00
|
|
|
osc.frequency.value = type === 'success' ? 880 : type === 'error' ? 220 : 440;
|
2026-03-14 21:08:57 -04:00
|
|
|
osc.type = 'sine';
|
2026-03-25 18:47:26 -04:00
|
|
|
gain.gain.setValueAtTime(0.06, ctx.currentTime);
|
|
|
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.1);
|
2026-03-14 21:08:57 -04:00
|
|
|
osc.start(ctx.currentTime);
|
2026-03-25 18:47:26 -04:00
|
|
|
osc.stop(ctx.currentTime + 0.1);
|
|
|
|
|
} catch (_) {}
|
2026-03-14 21:08:57 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
4. MODAL MANAGEMENT
|
|
|
|
|
----------------------------------------------------------------
|
2026-03-25 18:47:26 -04:00
|
|
|
lt.modal.open('modal-id')
|
|
|
|
|
lt.modal.close('modal-id')
|
|
|
|
|
lt.modal.closeAll()
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
2026-03-25 18:47:26 -04:00
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
2026-03-14 21:08:57 -04:00
|
|
|
function openModal(id) {
|
|
|
|
|
const el = typeof id === 'string' ? document.getElementById(id) : id;
|
|
|
|
|
if (!el) return;
|
2026-03-25 18:47:26 -04:00
|
|
|
// 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');
|
2026-03-26 18:22:53 -04:00
|
|
|
el.removeAttribute('aria-hidden'); /* removing is correct; setting 'false' is an anti-pattern */
|
2026-03-25 18:47:26 -04:00
|
|
|
_lockScroll();
|
|
|
|
|
// Focus first focusable element
|
|
|
|
|
const first = el.querySelector(_FOCUSABLE);
|
2026-03-14 21:08:57 -04:00
|
|
|
if (first) setTimeout(() => first.focus(), 50);
|
2026-03-25 18:47:26 -04:00
|
|
|
// Tab focus trap
|
|
|
|
|
el._ltTrapHandler = e => { if (e.key === 'Tab') _trapFocus(el, e); };
|
|
|
|
|
el.addEventListener('keydown', el._ltTrapHandler);
|
2026-03-14 21:08:57 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeModal(id) {
|
|
|
|
|
const el = typeof id === 'string' ? document.getElementById(id) : id;
|
2026-03-25 18:47:26 -04:00
|
|
|
if (!el || !el.classList.contains('is-open')) return;
|
|
|
|
|
el.classList.remove('is-open');
|
2026-03-14 21:08:57 -04:00
|
|
|
el.setAttribute('aria-hidden', 'true');
|
2026-03-25 18:47:26 -04:00
|
|
|
_unlockScroll();
|
|
|
|
|
// Remove trap handler
|
|
|
|
|
if (el._ltTrapHandler) { el.removeEventListener('keydown', el._ltTrapHandler); delete el._ltTrapHandler; }
|
2026-03-26 20:46:31 -04:00
|
|
|
// Return focus to trigger (only if no other modal remains open)
|
2026-03-25 18:47:26 -04:00
|
|
|
const trigger = _modalTriggers.get(el);
|
2026-03-26 20:46:31 -04:00
|
|
|
if (trigger) {
|
|
|
|
|
_modalTriggers.delete(el);
|
|
|
|
|
if (!document.querySelector('.lt-modal-overlay.is-open') && document.contains(trigger)) {
|
|
|
|
|
trigger.focus();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-14 21:08:57 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeAllModals() {
|
2026-03-25 18:47:26 -04:00
|
|
|
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();
|
|
|
|
|
}
|
2026-03-14 21:08:57 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.addEventListener('click', function (e) {
|
2026-03-25 18:47:26 -04:00
|
|
|
if (e.target.classList.contains('lt-modal-overlay')) { closeModal(e.target); return; }
|
2026-03-14 21:08:57 -04:00
|
|
|
const closeBtn = e.target.closest('[data-modal-close]');
|
2026-03-25 18:47:26 -04:00
|
|
|
if (closeBtn) { const overlay = closeBtn.closest('.lt-modal-overlay'); if (overlay) closeModal(overlay); }
|
2026-03-14 21:08:57 -04:00
|
|
|
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
|
|
|
|
|
----------------------------------------------------------------
|
2026-03-25 18:47:26 -04:00
|
|
|
lt.tabs.init()
|
|
|
|
|
lt.tabs.switch('panel-id')
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
function switchTab(panelId) {
|
2026-03-26 18:22:53 -04:00
|
|
|
document.querySelectorAll('.lt-tab[data-tab]').forEach(t => {
|
|
|
|
|
t.classList.remove('active');
|
|
|
|
|
t.setAttribute('aria-selected', 'false');
|
|
|
|
|
});
|
2026-03-14 21:08:57 -04:00
|
|
|
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);
|
2026-03-26 18:22:53 -04:00
|
|
|
if (btn) { btn.classList.add('active'); btn.setAttribute('aria-selected', 'true'); }
|
2026-03-14 21:08:57 -04:00
|
|
|
if (panel) panel.classList.add('active');
|
|
|
|
|
try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 18:22:53 -04:00
|
|
|
let _tabsInitialized = false;
|
2026-03-14 21:08:57 -04:00
|
|
|
function initTabs() {
|
2026-03-26 18:22:53 -04:00
|
|
|
if (_tabsInitialized) return; _tabsInitialized = true;
|
2026-03-14 21:08:57 -04:00
|
|
|
try {
|
|
|
|
|
const saved = localStorage.getItem('lt_activeTab_' + location.pathname);
|
2026-03-25 18:47:26 -04:00
|
|
|
if (saved && document.getElementById(saved)) { switchTab(saved); }
|
2026-03-14 21:08:57 -04:00
|
|
|
} catch (_) {}
|
2026-03-26 19:51:21 -04:00
|
|
|
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); }
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-03-14 21:08:57 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tabs = { init: initTabs, switch: switchTab };
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
6. BOOT SEQUENCE ANIMATION
|
|
|
|
|
----------------------------------------------------------------
|
2026-03-25 18:47:26 -04:00
|
|
|
lt.boot.run('APP NAME')
|
|
|
|
|
lt.boot.run('APP NAME', true) // force replay
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
function runBoot(appName, force) {
|
|
|
|
|
const storageKey = 'lt_booted_' + (appName || 'app');
|
|
|
|
|
if (!force && sessionStorage.getItem(storageKey)) return;
|
2026-03-27 14:12:05 -04:00
|
|
|
sessionStorage.setItem(storageKey, '1'); // Claim the run immediately to block double-init
|
2026-03-14 21:08:57 -04:00
|
|
|
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';
|
|
|
|
|
|
2026-03-14 21:27:31 -04:00
|
|
|
const name = (appName || 'TERMINAL').toUpperCase();
|
2026-03-25 18:47:26 -04:00
|
|
|
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 ║',
|
|
|
|
|
'╚════════════════════════════════════════════════════╝',
|
2026-03-14 21:08:57 -04:00
|
|
|
'',
|
2026-03-25 18:47:26 -04:00
|
|
|
'[ 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',
|
2026-03-14 21:08:57 -04:00
|
|
|
'',
|
2026-03-25 18:47:26 -04:00
|
|
|
'> ALL SYSTEMS NOMINAL — ' + name,
|
2026-03-14 21:08:57 -04:00
|
|
|
'',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let i = 0;
|
|
|
|
|
pre.textContent = '';
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
if (i < messages.length) {
|
|
|
|
|
pre.textContent += messages[i] + '\n';
|
|
|
|
|
i++;
|
|
|
|
|
} else {
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
setTimeout(() => {
|
2026-03-25 18:47:26 -04:00
|
|
|
overlay.style.transition = 'opacity 0.5s ease';
|
|
|
|
|
overlay.style.opacity = '0';
|
|
|
|
|
setTimeout(() => { overlay.style.display = 'none'; overlay.style.opacity = ''; overlay.style.transition = ''; }, 520);
|
|
|
|
|
}, 500);
|
2026-03-14 21:08:57 -04:00
|
|
|
}
|
2026-03-25 18:47:26 -04:00
|
|
|
}, 65);
|
2026-03-14 21:08:57 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const boot = { run: runBoot };
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
7. KEYBOARD SHORTCUTS
|
|
|
|
|
----------------------------------------------------------------
|
2026-03-25 18:47:26 -04:00
|
|
|
lt.keys.on('ctrl+k', fn)
|
|
|
|
|
lt.keys.off('ctrl+k')
|
|
|
|
|
lt.keys.initDefaults()
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
const _keyHandlers = {};
|
|
|
|
|
|
|
|
|
|
function normalizeKey(combo) {
|
2026-03-25 18:47:26 -04:00
|
|
|
return combo.replace(/ctrl\+/i, 'ctrl+').replace(/cmd\+/i, 'ctrl+').replace(/meta\+/i, 'ctrl+').toLowerCase();
|
2026-03-14 21:08:57 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-25 18:47:26 -04:00
|
|
|
function registerKey(combo, handler) { _keyHandlers[normalizeKey(combo)] = handler; }
|
|
|
|
|
function unregisterKey(combo) { delete _keyHandlers[normalizeKey(combo)]; }
|
2026-03-14 21:08:57 -04:00
|
|
|
|
|
|
|
|
document.addEventListener('keydown', function (e) {
|
2026-03-25 18:47:26 -04:00
|
|
|
const inInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName) || e.target.isContentEditable;
|
2026-03-14 21:08:57 -04:00
|
|
|
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];
|
2026-03-25 18:47:26 -04:00
|
|
|
if (handler) { e.preventDefault(); handler(e); }
|
2026-03-14 21:08:57 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function initDefaultKeys() {
|
2026-03-25 18:47:26 -04:00
|
|
|
registerKey('escape', closeAllModals);
|
|
|
|
|
registerKey('?', () => { const h = document.getElementById('lt-keys-help'); if (h) openModal(h); });
|
2026-03-14 21:08:57 -04:00
|
|
|
registerKey('ctrl+k', () => {
|
2026-03-25 18:47:26 -04:00
|
|
|
const ov = document.getElementById('lt-cmd-overlay');
|
|
|
|
|
if (ov) { _cmdPaletteOpen(); return; }
|
|
|
|
|
const s = document.querySelector('.lt-search-input');
|
|
|
|
|
if (s) { s.focus(); s.select(); }
|
2026-03-14 21:08:57 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 18:47:26 -04:00
|
|
|
const keys = { on: registerKey, off: unregisterKey, initDefaults: initDefaultKeys };
|
2026-03-14 21:08:57 -04:00
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
8. SIDEBAR COLLAPSE
|
|
|
|
|
----------------------------------------------------------------
|
2026-03-25 18:47:26 -04:00
|
|
|
lt.sidebar.init()
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
2026-03-26 18:22:53 -04:00
|
|
|
let _sidebarInitialized = false;
|
2026-03-14 21:08:57 -04:00
|
|
|
function initSidebar() {
|
2026-03-26 18:22:53 -04:00
|
|
|
if (_sidebarInitialized) return; _sidebarInitialized = true;
|
2026-03-14 21:08:57 -04:00
|
|
|
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';
|
2026-03-25 18:47:26 -04:00
|
|
|
if (collapsed) { sidebar.classList.add('collapsed'); btn.textContent = '▶'; }
|
2026-03-14 21:08:57 -04:00
|
|
|
btn.addEventListener('click', () => {
|
|
|
|
|
sidebar.classList.toggle('collapsed');
|
2026-03-25 18:47:26 -04:00
|
|
|
const c = sidebar.classList.contains('collapsed');
|
|
|
|
|
btn.textContent = c ? '▶' : '◀';
|
|
|
|
|
try { sessionStorage.setItem('lt_sidebar_' + btn.dataset.sidebarToggle, c ? '1' : '0'); } catch (_) {}
|
2026-03-14 21:08:57 -04:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sidebar = { init: initSidebar };
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
9. CSRF TOKEN HELPERS
|
|
|
|
|
----------------------------------------------------------------
|
2026-03-25 18:47:26 -04:00
|
|
|
lt.csrf.headers()
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
function csrfHeaders() {
|
|
|
|
|
const token = global.CSRF_TOKEN || '';
|
|
|
|
|
return token ? { 'X-CSRF-Token': token } : {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const csrf = { headers: csrfHeaders };
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
10. FETCH HELPERS
|
|
|
|
|
----------------------------------------------------------------
|
2026-03-25 18:47:26 -04:00
|
|
|
lt.api.get(url)
|
|
|
|
|
lt.api.post(url, body)
|
|
|
|
|
lt.api.put / patch / delete
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
async function apiFetch(method, url, body) {
|
2026-03-26 18:22:53 -04:00
|
|
|
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);
|
2026-03-14 21:08:57 -04:00
|
|
|
let resp;
|
2026-03-25 18:47:26 -04:00
|
|
|
try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); }
|
2026-03-14 21:08:57 -04:00
|
|
|
let data;
|
2026-03-25 18:47:26 -04:00
|
|
|
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
|
|
|
|
|
if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status);
|
2026-03-14 21:08:57 -04:00
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const api = {
|
2026-03-25 18:47:26 -04:00
|
|
|
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),
|
2026-03-14 21:08:57 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
11. TIME FORMATTING
|
2026-03-25 18:47:26 -04:00
|
|
|
----------------------------------------------------------------
|
|
|
|
|
lt.time.ago(value)
|
|
|
|
|
lt.time.uptime(secs)
|
|
|
|
|
lt.time.format(value)
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
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);
|
2026-03-25 18:47:26 -04:00
|
|
|
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';
|
2026-03-14 21:08:57 -04:00
|
|
|
return Math.floor(diff / 86400) + 'd ago';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatUptime(secs) {
|
|
|
|
|
secs = Math.floor(secs);
|
2026-03-25 18:47:26 -04:00
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 21:08:57 -04:00
|
|
|
function formatDate(value) {
|
|
|
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
|
|
|
if (isNaN(date)) return '—';
|
|
|
|
|
try {
|
|
|
|
|
return date.toLocaleString(undefined, {
|
2026-03-25 18:47:26 -04:00
|
|
|
timeZone: global.APP_TIMEZONE || undefined,
|
|
|
|
|
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
2026-03-14 21:08:57 -04:00
|
|
|
});
|
2026-03-25 18:47:26 -04:00
|
|
|
} catch (_) { return date.toLocaleString(); }
|
2026-03-14 21:08:57 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const time = { ago: timeAgo, uptime: formatUptime, format: formatDate };
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
12. BYTES FORMATTING
|
2026-03-25 18:47:26 -04:00
|
|
|
----------------------------------------------------------------
|
|
|
|
|
lt.bytes.format(n)
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
function formatBytes(bytes) {
|
2026-03-25 18:47:26 -04:00
|
|
|
if (bytes == null) return '—';
|
|
|
|
|
const units = ['B','KB','MB','GB','TB']; let i = 0;
|
2026-03-14 21:08:57 -04:00
|
|
|
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
|
|
|
|
|
return bytes.toFixed(i === 0 ? 0 : 2) + ' ' + units[i];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
2026-03-25 18:47:26 -04:00
|
|
|
13. TABLE KEYBOARD NAVIGATION
|
2026-03-14 21:08:57 -04:00
|
|
|
----------------------------------------------------------------
|
2026-03-25 18:47:26 -04:00
|
|
|
lt.tableNav.init('table-id')
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
function initTableNav(tableId) {
|
|
|
|
|
const table = tableId ? document.getElementById(tableId) : document.querySelector('.lt-table');
|
|
|
|
|
if (!table) return;
|
2026-03-25 18:47:26 -04:00
|
|
|
function rows() { return Array.from(table.querySelectorAll('tbody tr')); }
|
2026-03-14 21:08:57 -04:00
|
|
|
function selected() { return table.querySelector('tbody tr.lt-row-selected'); }
|
|
|
|
|
function move(dir) {
|
2026-03-25 18:47:26 -04:00
|
|
|
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];
|
2026-03-14 21:08:57 -04:00
|
|
|
if (cur) cur.classList.remove('lt-row-selected');
|
|
|
|
|
next.classList.add('lt-row-selected');
|
|
|
|
|
next.scrollIntoView({ block: 'nearest' });
|
|
|
|
|
}
|
2026-03-25 18:47:26 -04:00
|
|
|
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; } });
|
2026-03-14 21:08:57 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tableNav = { init: initTableNav };
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
14. SORTABLE TABLE HEADERS
|
|
|
|
|
----------------------------------------------------------------
|
2026-03-25 18:47:26 -04:00
|
|
|
lt.sortTable.init('table-id')
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
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';
|
2026-03-26 18:22:53 -04:00
|
|
|
th.setAttribute('aria-sort', 'none');
|
2026-03-26 20:09:29 -04:00
|
|
|
th.setAttribute('tabindex', '0');
|
|
|
|
|
const _sort = () => {
|
2026-03-26 18:22:53 -04:00
|
|
|
ths.forEach(h => { h.removeAttribute('data-sort'); h.setAttribute('aria-sort', 'none'); });
|
2026-03-14 21:08:57 -04:00
|
|
|
th.setAttribute('data-sort', dir);
|
2026-03-26 18:22:53 -04:00
|
|
|
th.setAttribute('aria-sort', dir === 'asc' ? 'ascending' : 'descending');
|
2026-03-14 21:08:57 -04:00
|
|
|
const tbody = table.querySelector('tbody');
|
|
|
|
|
const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
|
|
|
rows.sort((a, b) => {
|
2026-03-25 18:47:26 -04:00
|
|
|
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);
|
2026-03-14 21:08:57 -04:00
|
|
|
return dir === 'asc' ? cmp : -cmp;
|
|
|
|
|
});
|
|
|
|
|
rows.forEach(r => tbody.appendChild(r));
|
|
|
|
|
dir = dir === 'asc' ? 'desc' : 'asc';
|
2026-03-26 20:09:29 -04:00
|
|
|
};
|
|
|
|
|
th.addEventListener('click', _sort);
|
|
|
|
|
th.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _sort(); } });
|
2026-03-14 21:08:57 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sortTable = { init: initSortTable };
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
15. STATS WIDGET FILTERING
|
|
|
|
|
----------------------------------------------------------------
|
2026-03-25 18:47:26 -04:00
|
|
|
lt.statsFilter.init()
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
function initStatsFilter() {
|
|
|
|
|
document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => {
|
2026-03-26 20:02:15 -04:00
|
|
|
const _activate = () => {
|
2026-03-25 18:47:26 -04:00
|
|
|
const key = card.dataset.filterKey, val = card.dataset.filterVal;
|
2026-03-14 21:08:57 -04:00
|
|
|
const wasActive = card.classList.contains('active');
|
|
|
|
|
document.querySelectorAll('.lt-stat-card').forEach(c => c.classList.remove('active'));
|
|
|
|
|
if (!wasActive) card.classList.add('active');
|
2026-03-25 18:47:26 -04:00
|
|
|
if (typeof global.lt_onStatFilter === 'function')
|
2026-03-14 21:08:57 -04:00
|
|
|
global.lt_onStatFilter(wasActive ? null : key, wasActive ? null : val);
|
2026-03-26 20:02:15 -04:00
|
|
|
};
|
|
|
|
|
card.addEventListener('click', _activate);
|
|
|
|
|
card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _activate(); } });
|
2026-03-14 21:08:57 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const statsFilter = { init: initStatsFilter };
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
16. AUTO-REFRESH MANAGER
|
|
|
|
|
----------------------------------------------------------------
|
2026-03-25 18:47:26 -04:00
|
|
|
lt.autoRefresh.start(fn, ms)
|
|
|
|
|
lt.autoRefresh.stop()
|
|
|
|
|
lt.autoRefresh.now()
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
2026-03-25 18:47:26 -04:00
|
|
|
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 };
|
2026-03-14 21:08:57 -04:00
|
|
|
|
2026-03-25 18:47:26 -04:00
|
|
|
/* ================================================================
|
|
|
|
|
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);
|
|
|
|
|
});
|
2026-03-14 21:08:57 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-25 18:47:26 -04:00
|
|
|
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');
|
|
|
|
|
});
|
2026-03-14 21:08:57 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-26 18:22:53 -04:00
|
|
|
let _accordionInitialized = false;
|
2026-03-25 18:47:26 -04:00
|
|
|
function initAccordion() {
|
2026-03-26 18:22:53 -04:00
|
|
|
if (_accordionInitialized) return; _accordionInitialized = true;
|
2026-03-25 18:47:26 -04:00
|
|
|
// 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;
|
2026-03-14 21:08:57 -04:00
|
|
|
}
|
2026-03-26 18:22:53 -04:00
|
|
|
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';
|
2026-03-25 18:47:26 -04:00
|
|
|
requestAnimationFrame(() => tip.classList.add('is-visible'));
|
2026-03-14 21:08:57 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-25 18:47:26 -04:00
|
|
|
function _tooltipHide() {
|
|
|
|
|
if (_tooltipEl) { _tooltipEl.remove(); _tooltipEl = null; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 18:22:53 -04:00
|
|
|
let _tooltipInitialized = false;
|
2026-03-25 18:47:26 -04:00
|
|
|
function initTooltips() {
|
2026-03-26 18:22:53 -04:00
|
|
|
if (_tooltipInitialized) return;
|
|
|
|
|
_tooltipInitialized = true;
|
2026-03-25 18:47:26 -04:00
|
|
|
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; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 18:22:53 -04:00
|
|
|
let _copyInitialized = false;
|
2026-03-25 18:47:26 -04:00
|
|
|
function initCopyButtons() {
|
2026-03-26 18:22:53 -04:00
|
|
|
if (_copyInitialized) return; _copyInitialized = true;
|
2026-03-25 18:47:26 -04:00
|
|
|
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');
|
2026-03-26 18:22:53 -04:00
|
|
|
setTimeout(() => { if (document.contains(btn)) { btn.textContent = orig; btn.disabled = false; } }, 1500);
|
2026-03-25 18:47:26 -04:00
|
|
|
} 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);
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 18:22:53 -04:00
|
|
|
let _alertsInitialized = false;
|
2026-03-25 18:47:26 -04:00
|
|
|
function initAlerts() {
|
2026-03-26 18:22:53 -04:00
|
|
|
if (_alertsInitialized) return; _alertsInitialized = true;
|
2026-03-25 18:47:26 -04:00
|
|
|
document.addEventListener('click', function (e) {
|
2026-03-26 18:22:53 -04:00
|
|
|
const btn = e.target.closest('.lt-alert-close, .lt-alert-dismiss'); if (!btn) return;
|
2026-03-25 18:47:26 -04:00
|
|
|
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 }
|
|
|
|
|
---------------------------------------------------------------- */
|
2026-03-26 18:22:53 -04:00
|
|
|
let _cpCommands = [], _cpSelected = 0, _cpTrigger = null;
|
2026-03-25 18:47:26 -04:00
|
|
|
const _cpRecentKey = 'lt_cmd_recent';
|
|
|
|
|
|
|
|
|
|
function _cmdPaletteOpen() {
|
|
|
|
|
const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return;
|
|
|
|
|
if (_mnOpen) _mnSetOpen(false);
|
2026-03-26 18:22:53 -04:00
|
|
|
_cpTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null;
|
2026-03-25 18:47:26 -04:00
|
|
|
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();
|
2026-03-26 20:02:15 -04:00
|
|
|
const inp = document.querySelector('#lt-cmd-palette .lt-cmd-input');
|
|
|
|
|
if (inp) inp.removeAttribute('aria-activedescendant');
|
2026-03-26 19:51:21 -04:00
|
|
|
if (_cpTrigger) { if (document.contains(_cpTrigger)) _cpTrigger.focus(); _cpTrigger = null; }
|
2026-03-25 18:47:26 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 “' + escHtml(query) + '”</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 => {
|
2026-03-26 20:02:15 -04:00
|
|
|
html += '<div class="lt-cmd-item' + (idx === 0 ? ' is-selected' : '') + '" id="lt-cmd-item-' + idx + '" data-cmd-id="' + escHtml(cmd.id) + '">' +
|
2026-03-25 18:47:26 -04:00
|
|
|
'<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;
|
2026-03-26 20:02:15 -04:00
|
|
|
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) => {
|
2026-03-25 18:47:26 -04:00
|
|
|
item.addEventListener('mouseenter', () => {
|
2026-03-26 20:02:15 -04:00
|
|
|
allItems.forEach(x => x.classList.remove('is-selected'));
|
2026-03-25 18:47:26 -04:00
|
|
|
item.classList.add('is-selected'); _cpSelected = i;
|
2026-03-26 20:02:15 -04:00
|
|
|
if (inp) inp.setAttribute('aria-activedescendant', item.id);
|
2026-03-25 18:47:26 -04:00
|
|
|
});
|
|
|
|
|
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' });
|
2026-03-26 20:02:15 -04:00
|
|
|
const inp = ov.querySelector('.lt-cmd-input');
|
|
|
|
|
if (inp && items[_cpSelected]) inp.setAttribute('aria-activedescendant', items[_cpSelected].id);
|
2026-03-25 18:47:26 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
2026-03-26 19:51:21 -04:00
|
|
|
if ((type === 'checkbox' || type === 'radio') && el.required && !el.checked) return { valid: false, message: 'This field is required' };
|
2026-03-26 20:09:29 -04:00
|
|
|
if (el.tagName === 'SELECT' && el.multiple && el.required && el.selectedOptions.length === 0) return { valid: false, message: 'Please select at least one option' };
|
2026-03-25 18:47:26 -04:00
|
|
|
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');
|
2026-03-26 18:22:53 -04:00
|
|
|
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);
|
|
|
|
|
}
|
2026-03-25 18:47:26 -04:00
|
|
|
err.textContent = msg;
|
2026-03-26 18:22:53 -04:00
|
|
|
err.setAttribute('role', 'alert');
|
|
|
|
|
el.setAttribute('aria-describedby', err.id);
|
|
|
|
|
el.setAttribute('aria-invalid', 'true');
|
2026-03-25 18:47:26 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _clearError(el) {
|
|
|
|
|
el.classList.remove('is-invalid'); el.classList.add('is-valid');
|
2026-03-26 18:22:53 -04:00
|
|
|
el.removeAttribute('aria-invalid');
|
|
|
|
|
el.removeAttribute('aria-describedby');
|
2026-03-25 18:47:26 -04:00
|
|
|
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);
|
2026-03-26 18:22:53 -04:00
|
|
|
else if (!r.valid && r.errors.length) r.errors[0].el.focus();
|
2026-03-25 18:47:26 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(); },
|
|
|
|
|
};
|
2026-03-14 21:08:57 -04:00
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
2026-03-25 18:47:26 -04:00
|
|
|
30. TABLE COLUMN VISIBILITY
|
2026-03-14 21:08:57 -04:00
|
|
|
----------------------------------------------------------------
|
2026-03-25 18:47:26 -04:00
|
|
|
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
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
function init() {
|
2026-03-25 18:47:26 -04:00
|
|
|
/* Core */
|
2026-03-14 21:08:57 -04:00
|
|
|
initTabs();
|
|
|
|
|
initSidebar();
|
|
|
|
|
initDefaultKeys();
|
|
|
|
|
initStatsFilter();
|
2026-03-25 18:47:26 -04:00
|
|
|
/* v1.2 */
|
|
|
|
|
initAccordion();
|
|
|
|
|
initTooltips();
|
|
|
|
|
initCopyButtons();
|
|
|
|
|
initAlerts();
|
|
|
|
|
_initAllDropzones();
|
|
|
|
|
observeLazy('[data-lazy]');
|
|
|
|
|
/* v1.3 */
|
|
|
|
|
initMobileNav();
|
2026-03-25 22:42:16 -04:00
|
|
|
initSidebarSubmenus();
|
2026-03-25 18:47:26 -04:00
|
|
|
/* Boot */
|
2026-03-14 21:08:57 -04:00
|
|
|
const bootEl = document.getElementById('lt-boot');
|
2026-03-25 18:47:26 -04:00
|
|
|
if (bootEl) runBoot(bootEl.dataset.appName || document.title);
|
2026-03-14 21:08:57 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (document.readyState === 'loading') {
|
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
|
|
|
} else {
|
|
|
|
|
init();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 18:47:26 -04:00
|
|
|
|
|
|
|
|
/* ================================================================
|
|
|
|
|
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.
|
|
|
|
|
================================================================ */
|
2026-03-26 20:02:15 -04:00
|
|
|
let _mnOpen = false, _mnTrigger = null;
|
2026-03-25 18:47:26 -04:00
|
|
|
|
|
|
|
|
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) {
|
2026-03-26 20:02:15 -04:00
|
|
|
_mnTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null;
|
2026-03-25 18:47:26 -04:00
|
|
|
drawer.classList.add('open');
|
2026-03-26 20:02:15 -04:00
|
|
|
drawer.removeAttribute('aria-hidden');
|
2026-03-25 18:47:26 -04:00
|
|
|
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
|
2026-03-26 20:02:15 -04:00
|
|
|
if (!drawer._mnTrapHandler) {
|
|
|
|
|
drawer._mnTrapHandler = e => { if (e.key === 'Tab') _trapFocus(drawer, e); };
|
|
|
|
|
drawer.addEventListener('keydown', drawer._mnTrapHandler);
|
|
|
|
|
}
|
2026-03-25 18:47:26 -04:00
|
|
|
const first = drawer.querySelector('button, a, [tabindex]');
|
2026-03-26 20:02:15 -04:00
|
|
|
if (first) setTimeout(() => { if (document.contains(first)) first.focus(); }, 50);
|
2026-03-25 18:47:26 -04:00
|
|
|
} 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 = '';
|
2026-03-26 20:02:15 -04:00
|
|
|
if (drawer._mnTrapHandler) { drawer.removeEventListener('keydown', drawer._mnTrapHandler); delete drawer._mnTrapHandler; }
|
|
|
|
|
if (_mnTrigger && document.contains(_mnTrigger)) { _mnTrigger.focus(); }
|
|
|
|
|
_mnTrigger = null;
|
2026-03-25 18:47:26 -04:00
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
2026-03-25 22:29:55 -04:00
|
|
|
/* ================================================================
|
|
|
|
|
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);
|
2026-03-26 00:12:04 -04:00
|
|
|
document.documentElement.style.colorScheme = t;
|
2026-03-25 22:29:55 -04:00
|
|
|
try { localStorage.setItem(_themeKey, t); } catch(_) {}
|
2026-03-26 00:12:04 -04:00
|
|
|
// Sync theme-color meta for browser chrome
|
|
|
|
|
const metaTheme = document.querySelector('meta[name="theme-color"]');
|
|
|
|
|
if (metaTheme) metaTheme.setAttribute('content', t === 'light' ? '#edf0f5' : '#030508');
|
2026-03-25 22:29:55 -04:00
|
|
|
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');
|
|
|
|
|
});
|
2026-03-26 00:12:04 -04:00
|
|
|
// Sync theme across tabs via storage event
|
|
|
|
|
window.addEventListener('storage', e => {
|
|
|
|
|
if (e.key === _themeKey && e.newValue) _applyTheme(e.newValue);
|
|
|
|
|
});
|
2026-03-25 22:29:55 -04:00
|
|
|
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');
|
2026-03-26 20:16:12 -04:00
|
|
|
drawer.removeAttribute('aria-hidden');
|
2026-03-25 22:29:55 -04:00
|
|
|
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);
|
2026-03-26 18:22:53 -04:00
|
|
|
// ESC to close + Tab trap
|
2026-03-25 22:29:55 -04:00
|
|
|
drawer._rdKeyHandler = e => { if (e.key === 'Escape') _rdClose(drawer); };
|
|
|
|
|
document.addEventListener('keydown', drawer._rdKeyHandler);
|
2026-03-26 18:22:53 -04:00
|
|
|
drawer._rdTrapHandler = e => { if (e.key === 'Tab') _trapFocus(drawer, e); };
|
|
|
|
|
drawer.addEventListener('keydown', drawer._rdTrapHandler);
|
2026-03-25 22:29:55 -04:00
|
|
|
// 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; } }
|
2026-03-26 18:22:53 -04:00
|
|
|
drawer.querySelectorAll('[data-drawer-close]').forEach(btn => {
|
|
|
|
|
if (btn._rdHandler) { btn.removeEventListener('click', btn._rdHandler); delete btn._rdHandler; }
|
|
|
|
|
});
|
2026-03-25 22:29:55 -04:00
|
|
|
_unlockScroll();
|
2026-03-26 18:22:53 -04:00
|
|
|
if (drawer._rdKeyHandler) { document.removeEventListener('keydown', drawer._rdKeyHandler); delete drawer._rdKeyHandler; }
|
|
|
|
|
if (drawer._rdTrapHandler) { drawer.removeEventListener('keydown', drawer._rdTrapHandler); delete drawer._rdTrapHandler; }
|
2026-03-25 22:29:55 -04:00
|
|
|
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 }]
|
|
|
|
|
================================================================ */
|
2026-03-26 20:46:31 -04:00
|
|
|
let _ctxMenu = null, _ctxTrigger = null;
|
2026-03-25 23:04:24 -04:00
|
|
|
const _ctxItems = {};
|
2026-03-26 20:46:31 -04:00
|
|
|
function _ctxShow(x, y, items, trigger) {
|
|
|
|
|
_ctxTrigger = trigger || (document.activeElement !== document.body ? document.activeElement : null);
|
2026-03-25 22:29:55 -04:00
|
|
|
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(); });
|
2026-03-27 14:18:17 -04:00
|
|
|
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(); }
|
|
|
|
|
});
|
2026-03-25 22:29:55 -04:00
|
|
|
_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;
|
2026-03-26 19:51:21 -04:00
|
|
|
_ctxMenu.style.left = Math.max(8, Math.min(x, vw - mw - 8)) + 'px';
|
2026-03-25 22:29:55 -04:00
|
|
|
_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');
|
2026-03-26 20:46:31 -04:00
|
|
|
if (_ctxTrigger && document.contains(_ctxTrigger)) { _ctxTrigger.focus(); }
|
|
|
|
|
_ctxTrigger = null;
|
2026-03-25 22:29:55 -04:00
|
|
|
}
|
|
|
|
|
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];
|
2026-03-26 20:46:31 -04:00
|
|
|
if (items) _ctxShow(e.clientX, e.clientY, items, target);
|
2026-03-25 22:29:55 -04:00
|
|
|
});
|
|
|
|
|
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];
|
|
|
|
|
|
2026-03-26 18:22:53 -04:00
|
|
|
// 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');
|
|
|
|
|
|
2026-03-25 22:29:55 -04:00
|
|
|
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 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');
|
2026-03-26 20:02:15 -04:00
|
|
|
el.id = dropId + '-opt-' + i;
|
2026-03-25 22:29:55 -04:00
|
|
|
el.className = 'lt-combobox-option' + (selected.includes(opt.value) ? ' is-selected' : '');
|
|
|
|
|
el.setAttribute('role', 'option');
|
|
|
|
|
el.setAttribute('data-value', opt.value);
|
2026-03-26 18:22:53 -04:00
|
|
|
const safeLabel = escHtml(opt.label);
|
|
|
|
|
const hl = q ? safeLabel.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>') : safeLabel;
|
2026-03-25 22:29:55 -04:00
|
|
|
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) {
|
2026-03-26 20:02:15 -04:00
|
|
|
const items = Array.from(dropdown.querySelectorAll('.lt-combobox-option'));
|
2026-03-25 22:29:55 -04:00
|
|
|
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' });
|
2026-03-26 20:02:15 -04:00
|
|
|
inputEl.setAttribute('aria-activedescendant', items[focusedIdx].id);
|
2026-03-25 22:29:55 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-26 18:22:53 -04:00
|
|
|
function _setOpen(open) {
|
|
|
|
|
dropdown.classList.toggle('is-open', open);
|
|
|
|
|
inputEl.setAttribute('aria-expanded', open ? 'true' : 'false');
|
2026-03-26 20:02:15 -04:00
|
|
|
if (!open) { inputEl.removeAttribute('aria-activedescendant'); focusedIdx = -1; }
|
2026-03-26 18:22:53 -04:00
|
|
|
}
|
|
|
|
|
inputEl.addEventListener('input', () => { _setOpen(true); _renderDropdown(inputEl.value); });
|
|
|
|
|
inputEl.addEventListener('focus', () => { _setOpen(true); _renderDropdown(inputEl.value); });
|
2026-03-25 22:29:55 -04:00
|
|
|
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); }
|
2026-03-26 18:22:53 -04:00
|
|
|
if (e.key === 'Escape') { _setOpen(false); }
|
2026-03-25 22:29:55 -04:00
|
|
|
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();
|
|
|
|
|
});
|
2026-03-26 18:22:53 -04:00
|
|
|
document.addEventListener('click', e => { if (!wrap.contains(e.target)) _setOpen(false); });
|
2026-03-25 22:29:55 -04:00
|
|
|
|
|
|
|
|
_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');
|
2026-03-26 20:02:15 -04:00
|
|
|
el.id = (inputEl.id || 'lt-ta') + '-item-' + i;
|
2026-03-25 22:29:55 -04:00
|
|
|
el.className = 'lt-typeahead-item';
|
|
|
|
|
el.setAttribute('role', 'option');
|
2026-03-26 18:22:53 -04:00
|
|
|
const safeItemLabel = escHtml(item.label);
|
|
|
|
|
const hl = safeItemLabel.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>');
|
2026-03-25 22:29:55 -04:00
|
|
|
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');
|
2026-03-26 20:02:15 -04:00
|
|
|
inputEl.setAttribute('aria-busy', 'true');
|
2026-03-25 22:29:55 -04:00
|
|
|
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>';
|
2026-03-26 20:02:15 -04:00
|
|
|
} finally {
|
|
|
|
|
inputEl.setAttribute('aria-busy', 'false');
|
2026-03-25 22:29:55 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _select(item) {
|
|
|
|
|
inputEl.value = item.label;
|
2026-03-26 20:02:15 -04:00
|
|
|
inputEl.removeAttribute('aria-activedescendant');
|
2026-03-25 22:29:55 -04:00
|
|
|
dropdown.classList.remove('is-open');
|
|
|
|
|
if (onSelect) onSelect(item);
|
|
|
|
|
bus.emit('typeahead:select', { item });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _moveFocus(dir) {
|
2026-03-26 20:02:15 -04:00
|
|
|
const els = Array.from(dropdown.querySelectorAll('.lt-typeahead-item'));
|
2026-03-25 22:29:55 -04:00
|
|
|
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' });
|
2026-03-26 20:02:15 -04:00
|
|
|
inputEl.setAttribute('aria-activedescendant', els[_focusedIdx].id);
|
2026-03-25 22:29:55 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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'); });
|
|
|
|
|
|
2026-03-26 20:09:29 -04:00
|
|
|
// 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); }
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-25 22:29:55 -04:00
|
|
|
_setRatio(initial);
|
|
|
|
|
return { setRatio: _setRatio };
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-25 22:42:16 -04:00
|
|
|
/* ================================================================
|
|
|
|
|
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;
|
2026-03-26 19:51:21 -04:00
|
|
|
label.setAttribute('tabindex', '0');
|
|
|
|
|
label.setAttribute('role', 'button');
|
2026-03-26 20:16:12 -04:00
|
|
|
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');
|
2026-03-26 19:51:21 -04:00
|
|
|
label.addEventListener('click', _toggle);
|
|
|
|
|
label.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _toggle(); } });
|
2026-03-25 22:42:16 -04:00
|
|
|
// 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');
|
2026-03-26 20:16:12 -04:00
|
|
|
label.setAttribute('aria-expanded', 'true');
|
2026-03-25 22:42:16 -04:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ================================================================
|
|
|
|
|
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();
|
|
|
|
|
}
|
2026-03-26 18:22:53 -04:00
|
|
|
const _onScrollThrottled = throttle(_onScroll, 150);
|
|
|
|
|
scrollRoot.addEventListener('scroll', _onScrollThrottled, { passive: true });
|
|
|
|
|
return { reset() { _done = false; _loading = false; }, stop() { scrollRoot.removeEventListener('scroll', _onScrollThrottled); } };
|
2026-03-25 22:42:16 -04:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ================================================================
|
|
|
|
|
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);
|
2026-03-26 19:51:21 -04:00
|
|
|
if (i !== idx) s.setAttribute('aria-hidden', 'true'); else s.removeAttribute('aria-hidden');
|
2026-03-25 22:42:16 -04:00
|
|
|
});
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 18:22:53 -04:00
|
|
|
let _wizBusy = false;
|
2026-03-25 22:42:16 -04:00
|
|
|
async function _next() {
|
2026-03-26 18:22:53 -04:00
|
|
|
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;
|
|
|
|
|
}
|
2026-03-25 22:42:16 -04:00
|
|
|
}
|
2026-03-26 18:22:53 -04:00
|
|
|
Object.assign(formData, _getStepData(current));
|
|
|
|
|
if (current < total - 1) { current++; _show(current); }
|
|
|
|
|
} finally {
|
|
|
|
|
_wizBusy = false;
|
2026-03-25 22:42:16 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 }
|
2026-03-25 23:19:49 -04:00
|
|
|
Supports cross-list dragging when group matches.
|
|
|
|
|
Returns { refresh(), getOrder() }; emits 'sortable:change' on bus
|
2026-03-25 22:42:16 -04:00
|
|
|
================================================================ */
|
2026-03-25 23:19:49 -04:00
|
|
|
// Shared cross-instance drag state (enables group/cross-column dragging)
|
|
|
|
|
let _srtDragging = null, _srtPlaceholder = null, _srtSrcList = null;
|
|
|
|
|
|
2026-03-25 22:42:16 -04:00
|
|
|
const sortable = {
|
|
|
|
|
init(list, opts = {}) {
|
2026-03-25 23:19:49 -04:00
|
|
|
const { handle = null, onSort = null, group = null } = opts;
|
2026-03-25 22:42:16 -04:00
|
|
|
list.setAttribute('data-sortable-group', group || '');
|
|
|
|
|
|
2026-03-25 23:19:49 -04:00
|
|
|
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]')); }
|
2026-03-25 22:42:16 -04:00
|
|
|
|
|
|
|
|
function _makePlaceholder(el) {
|
|
|
|
|
const ph = document.createElement(el.tagName);
|
|
|
|
|
ph.className = 'lt-sortable-placeholder';
|
|
|
|
|
ph.style.height = el.offsetHeight + 'px';
|
2026-03-25 23:19:49 -04:00
|
|
|
ph.style.width = '100%';
|
2026-03-25 22:42:16 -04:00
|
|
|
return ph;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 23:19:49 -04:00
|
|
|
function _sameGroup(otherList) {
|
|
|
|
|
if (!group) return false;
|
|
|
|
|
return otherList.getAttribute('data-sortable-group') === group;
|
|
|
|
|
}
|
2026-03-25 22:42:16 -04:00
|
|
|
|
2026-03-25 23:19:49 -04:00
|
|
|
// Mark all current children
|
|
|
|
|
Array.from(list.children).forEach(_mark);
|
2026-03-25 22:42:16 -04:00
|
|
|
|
|
|
|
|
list.addEventListener('dragstart', e => {
|
|
|
|
|
const item = e.target.closest('[data-sortable-item]');
|
|
|
|
|
if (!item || !list.contains(item)) return;
|
2026-03-25 23:19:49 -04:00
|
|
|
_srtDragging = item;
|
|
|
|
|
_srtSrcList = list;
|
|
|
|
|
_srtPlaceholder = _makePlaceholder(item);
|
2026-03-25 22:42:16 -04:00
|
|
|
requestAnimationFrame(() => { item.classList.add('is-dragging'); });
|
|
|
|
|
e.dataTransfer.effectAllowed = 'move';
|
2026-03-25 23:19:49 -04:00
|
|
|
e.dataTransfer.setData('text/plain', '');
|
2026-03-25 22:42:16 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
list.addEventListener('dragend', () => {
|
2026-03-25 23:19:49 -04:00
|
|
|
if (!_srtDragging) return;
|
|
|
|
|
_srtDragging.classList.remove('is-dragging');
|
|
|
|
|
if (_srtPlaceholder && _srtPlaceholder.parentNode) {
|
|
|
|
|
_srtPlaceholder.parentNode.insertBefore(_srtDragging, _srtPlaceholder);
|
|
|
|
|
_srtPlaceholder.remove();
|
2026-03-25 22:42:16 -04:00
|
|
|
}
|
2026-03-25 23:19:49 -04:00
|
|
|
if (onSort) onSort(_getItems(), _srtDragging);
|
|
|
|
|
bus.emit('sortable:change', { list, items: _getItems(), moved: _srtDragging });
|
|
|
|
|
_srtDragging = null; _srtPlaceholder = null; _srtSrcList = null;
|
2026-03-25 22:42:16 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
list.addEventListener('dragover', e => {
|
2026-03-25 23:19:49 -04:00
|
|
|
if (!_srtDragging) return;
|
|
|
|
|
// Allow drop only within same list or same group
|
|
|
|
|
if (list !== _srtSrcList && !_sameGroup(_srtSrcList)) return;
|
2026-03-25 22:42:16 -04:00
|
|
|
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
|
2026-03-25 23:19:49 -04:00
|
|
|
if (!_srtPlaceholder) _srtPlaceholder = _makePlaceholder(_srtDragging);
|
2026-03-25 22:42:16 -04:00
|
|
|
const over = e.target.closest('[data-sortable-item]');
|
2026-03-25 23:19:49 -04:00
|
|
|
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);
|
2026-03-25 22:42:16 -04:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
list.addEventListener('drop', e => { e.preventDefault(); });
|
|
|
|
|
|
|
|
|
|
return {
|
2026-03-25 23:19:49 -04:00
|
|
|
refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); },
|
2026-03-25 22:42:16 -04:00
|
|
|
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;
|
|
|
|
|
}
|
2026-03-25 23:04:24 -04:00
|
|
|
if (diff <= urgent) urgentClass.split(/\s+/).filter(Boolean).forEach(c => dom.classList.add(c));
|
2026-03-25 22:42:16 -04:00
|
|
|
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;
|
2026-03-26 20:09:29 -04:00
|
|
|
let _images = [], _current = 0, _overlay = null, _lbKeyBound = null, _lbTrigger = null;
|
2026-03-25 22:42:16 -04:00
|
|
|
|
|
|
|
|
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 class="lt-lightbox-close" aria-label="Close">×</button>
|
|
|
|
|
<button class="lt-lightbox-prev" aria-label="Previous">‹</button>
|
|
|
|
|
<button class="lt-lightbox-next" aria-label="Next">›</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(); });
|
2026-03-26 20:09:29 -04:00
|
|
|
_lbKeyBound = _lbKey.bind(null);
|
|
|
|
|
document.addEventListener('keydown', _lbKeyBound);
|
2026-03-25 22:42:16 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
2026-03-26 20:09:29 -04:00
|
|
|
if (!_overlay.classList.contains('is-open')) {
|
|
|
|
|
_lbTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null;
|
|
|
|
|
}
|
2026-03-25 22:42:16 -04:00
|
|
|
_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();
|
2026-03-26 20:02:15 -04:00
|
|
|
setTimeout(() => { if (document.contains(el)) el.focus?.(); }, 50);
|
2026-03-25 22:42:16 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
2026-03-26 20:16:12 -04:00
|
|
|
if (_lbKeyBound) { document.removeEventListener('keydown', _lbKeyBound); _lbKeyBound = null; }
|
2026-03-26 20:09:29 -04:00
|
|
|
if (_lbTrigger && document.contains(_lbTrigger)) { _lbTrigger.focus(); }
|
|
|
|
|
_lbTrigger = null;
|
2026-03-25 22:42:16 -04:00
|
|
|
},
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-25 23:04:24 -04:00
|
|
|
// 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();
|
2026-03-25 22:42:16 -04:00
|
|
|
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); }
|
2026-03-25 23:04:24 -04:00
|
|
|
// Auto-retry once on 401 after silent token refresh
|
2026-03-25 22:42:16 -04:00
|
|
|
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;
|
|
|
|
|
}
|
2026-03-25 23:04:24 -04:00
|
|
|
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);
|
2026-03-25 22:42:16 -04:00
|
|
|
|
|
|
|
|
/* ================================================================
|
|
|
|
|
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>')
|
2026-03-26 20:16:12 -04:00
|
|
|
// Links — block javascript: and data: URIs
|
|
|
|
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
|
|
|
|
|
const safeUrl = /^(https?:\/\/|\/|#|\.\.?\/)/i.test(url) ? url : '#';
|
2026-03-26 20:46:31 -04:00
|
|
|
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${escHtml(text)}</a>`;
|
2026-03-26 20:16:12 -04:00
|
|
|
})
|
|
|
|
|
// Images — block javascript: and data: URIs
|
|
|
|
|
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
|
|
|
|
|
const safeSrc = /^(https?:\/\/|\/|\.\.?\/)/i.test(src) ? src : '';
|
2026-03-26 20:46:31 -04:00
|
|
|
return `<img src="${safeSrc}" alt="${escHtml(alt)}" style="max-width:100%">`;
|
2026-03-26 20:16:12 -04:00
|
|
|
})
|
2026-03-25 22:42:16 -04:00
|
|
|
// Blockquote
|
|
|
|
|
.replace(/^>\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');
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-25 23:19:49 -04:00
|
|
|
/* ================================================================
|
|
|
|
|
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
|
2026-03-26 20:46:31 -04:00
|
|
|
html += `<button class="lt-page-btn" ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}" aria-label="Previous page">«</button>`;
|
2026-03-25 23:19:49 -04:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-03-26 20:46:31 -04:00
|
|
|
html += `<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 class="lt-page-btn" disabled aria-hidden="true">…</button>`;
|
2026-03-25 23:19:49 -04:00
|
|
|
for (let i = start; i <= end; i++) {
|
2026-03-26 20:46:31 -04:00
|
|
|
html += `<button class="lt-page-btn${page === i ? ' active' : ''}" data-page="${i}"${page === i ? ' aria-current="page"' : ''} aria-label="Page ${i}">${i}</button>`;
|
2026-03-25 23:19:49 -04:00
|
|
|
}
|
2026-03-26 20:46:31 -04:00
|
|
|
if (end < pages - 1) html += `<button class="lt-page-btn" disabled aria-hidden="true">…</button>`;
|
|
|
|
|
if (pages > 1) html += `<button class="lt-page-btn${page === pages ? ' active' : ''}" data-page="${pages}"${page === pages ? ' aria-current="page"' : ''} aria-label="Page ${pages}">${pages}</button>`;
|
2026-03-25 23:19:49 -04:00
|
|
|
// Next
|
2026-03-26 20:46:31 -04:00
|
|
|
html += `<button class="lt-page-btn" ${page >= pages ? 'disabled' : ''} data-page="${page + 1}" aria-label="Next page">»</button>`;
|
|
|
|
|
if (!nav.getAttribute('role')) nav.setAttribute('role', 'navigation');
|
|
|
|
|
if (!nav.getAttribute('aria-label')) nav.setAttribute('aria-label', 'Pagination');
|
2026-03-25 23:19:49 -04:00
|
|
|
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(); },
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-26 00:12:04 -04:00
|
|
|
/* ================================================================
|
|
|
|
|
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.
|
|
|
|
|
================================================================ */
|
2026-03-26 18:22:53 -04:00
|
|
|
let _ltInitialized = false;
|
2026-03-26 00:12:04 -04:00
|
|
|
function ltInit(opts) {
|
2026-03-26 18:22:53 -04:00
|
|
|
if (_ltInitialized) return; // Guard: safe to call multiple times
|
|
|
|
|
_ltInitialized = true;
|
2026-03-26 00:12:04 -04:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 22:29:55 -04:00
|
|
|
/* ================================================================
|
2026-03-25 18:47:26 -04:00
|
|
|
PUBLIC API
|
2026-03-14 21:08:57 -04:00
|
|
|
---------------------------------------------------------------- */
|
|
|
|
|
global.lt = {
|
2026-03-26 00:12:04 -04:00
|
|
|
/* Master initializer */
|
|
|
|
|
init: ltInit,
|
2026-03-25 18:47:26 -04:00
|
|
|
/* Core */
|
2026-03-14 21:08:57 -04:00
|
|
|
escHtml,
|
|
|
|
|
toast,
|
2026-03-25 18:47:26 -04:00
|
|
|
beep: _beep,
|
2026-03-14 21:08:57 -04:00
|
|
|
modal,
|
|
|
|
|
tabs,
|
|
|
|
|
boot,
|
|
|
|
|
keys,
|
|
|
|
|
sidebar,
|
|
|
|
|
csrf,
|
|
|
|
|
api,
|
|
|
|
|
time,
|
|
|
|
|
bytes: { format: formatBytes },
|
|
|
|
|
tableNav,
|
|
|
|
|
sortTable,
|
|
|
|
|
statsFilter,
|
|
|
|
|
autoRefresh,
|
2026-03-25 18:47:26 -04:00
|
|
|
/* 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,
|
2026-03-25 22:29:55 -04:00
|
|
|
/* v1.1 new features */
|
|
|
|
|
theme,
|
|
|
|
|
notif,
|
|
|
|
|
rightDrawer,
|
|
|
|
|
contextMenu,
|
|
|
|
|
offline,
|
|
|
|
|
ws,
|
|
|
|
|
combobox,
|
|
|
|
|
typeahead,
|
|
|
|
|
cookie,
|
|
|
|
|
splitPane,
|
2026-03-25 22:42:16 -04:00
|
|
|
infiniteScroll,
|
|
|
|
|
wizard,
|
|
|
|
|
sortable,
|
|
|
|
|
timer,
|
|
|
|
|
lightbox,
|
|
|
|
|
auth,
|
|
|
|
|
markdown,
|
2026-03-25 23:19:49 -04:00
|
|
|
pagination,
|
2026-03-25 22:42:16 -04:00
|
|
|
sidebarSubmenus: { init: initSidebarSubmenus },
|
2026-03-14 21:08:57 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
}(window));
|