2026-03-17 23:44:46 -04:00
/**
2026-03-27 19:05:42 -04:00
* LOTUSGUILD TERMINAL DESIGN SYSTEM v1.2 — base.js
2026-03-17 23:44:46 -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-27 19:05:42 -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-17 23:44:46 -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-27 19:05:42 -04:00
lt.toast.success(msg, duration?)
lt.toast.error(msg, duration?)
lt.toast.warning(msg, duration?)
lt.toast.info(msg, duration?)
2026-03-17 23:44:46 -04:00
---------------------------------------------------------------- */
const _toastQueue = [];
let _toastActive = false ;
function showToast ( message , type , duration ) {
type = type || 'info' ;
duration = duration || 3500 ;
2026-03-27 19:05:42 -04:00
if ( _toastActive ) { if ( _toastQueue . length < 12 ) _toastQueue . push ({ message , type , duration }); return ; }
2026-03-17 23:44:46 -04:00
_displayToast ( message , type , duration );
}
function _displayToast ( message , type , duration ) {
_toastActive = true ;
2026-03-27 19:05:42 -04:00
let container = document . getElementById ( 'lt-toast-container' );
2026-03-17 23:44:46 -04:00
if ( ! container ) {
container = document . createElement ( 'div' );
2026-03-27 19:05:42 -04:00
container . id = 'lt-toast-container' ;
2026-03-17 23:44:46 -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-27 19:05:42 -04:00
const iconEl = document . createElement ( 'span' );
2026-03-17 23:44:46 -04:00
iconEl . className = 'lt-toast-icon' ;
iconEl . textContent = '[' + ( icons [ type ] || 'i' ) + ']' ;
2026-03-27 19:05:42 -04:00
const msgEl = document . createElement ( 'span' );
2026-03-17 23:44:46 -04:00
msgEl . className = 'lt-toast-msg' ;
msgEl . textContent = message ;
const closeEl = document . createElement ( 'button' );
2026-03-27 19:05:42 -04:00
closeEl . type = 'button' ;
2026-03-17 23:44:46 -04:00
closeEl . className = 'lt-toast-close' ;
closeEl . textContent = '✕' ;
closeEl . setAttribute ( 'aria-label' , 'Dismiss' );
closeEl . addEventListener ( 'click' , () => _dismissToast ( toast ));
2026-03-27 19:05:42 -04:00
const progressEl = document . createElement ( 'div' );
progressEl . className = 'lt-toast-progress' ;
progressEl . style . animationDuration = duration + 'ms' ;
2026-03-17 23:44:46 -04:00
toast . appendChild ( iconEl );
toast . appendChild ( msgEl );
toast . appendChild ( closeEl );
2026-03-27 19:05:42 -04:00
toast . appendChild ( progressEl );
2026-03-17 23:44:46 -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 );
2026-03-27 19:05:42 -04:00
toast . style . opacity = '0' ;
toast . style . transition = 'opacity 0.3s ease' ;
2026-03-17 23:44:46 -04:00
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-27 19:05:42 -04:00
osc . frequency . value = type === 'success' ? 880 : type === 'error' ? 220 : 440 ;
2026-03-17 23:44:46 -04:00
osc . type = 'sine' ;
2026-03-27 19:05:42 -04:00
gain . gain . setValueAtTime ( 0.06 , ctx . currentTime );
gain . gain . exponentialRampToValueAtTime ( 0.001 , ctx . currentTime + 0.1 );
2026-03-17 23:44:46 -04:00
osc . start ( ctx . currentTime );
2026-03-27 19:05:42 -04:00
osc . stop ( ctx . currentTime + 0.1 );
} catch ( _ ) {}
2026-03-17 23:44:46 -04:00
}
/* ----------------------------------------------------------------
4. MODAL MANAGEMENT
----------------------------------------------------------------
2026-03-27 19:05:42 -04:00
lt.modal.open('modal-id')
lt.modal.close('modal-id')
lt.modal.closeAll()
2026-03-17 23:44:46 -04:00
---------------------------------------------------------------- */
2026-03-27 19:05:42 -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-17 23:44:46 -04:00
function openModal ( id ) {
const el = typeof id === 'string' ? document . getElementById ( id ) : id ;
if ( ! el ) return ;
2026-03-27 19:05:42 -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' );
el . removeAttribute ( 'aria-hidden' ); /* removing is correct; setting 'false' is an anti-pattern */
_lockScroll ();
// Focus first focusable element
const first = el . querySelector ( _FOCUSABLE );
2026-03-17 23:44:46 -04:00
if ( first ) setTimeout (() => first . focus (), 50 );
2026-03-27 19:05:42 -04:00
// Tab focus trap
el . _ltTrapHandler = e => { if ( e . key === 'Tab' ) _trapFocus ( el , e ); };
el . addEventListener ( 'keydown' , el . _ltTrapHandler );
2026-03-17 23:44:46 -04:00
}
function closeModal ( id ) {
const el = typeof id === 'string' ? document . getElementById ( id ) : id ;
2026-03-27 19:05:42 -04:00
if ( ! el || ! el . classList . contains ( 'is-open' )) return ;
el . classList . remove ( 'is-open' );
2026-03-17 23:44:46 -04:00
el . setAttribute ( 'aria-hidden' , 'true' );
2026-03-27 19:05:42 -04:00
_unlockScroll ();
// Remove trap handler
if ( el . _ltTrapHandler ) { el . removeEventListener ( 'keydown' , el . _ltTrapHandler ); delete el . _ltTrapHandler ; }
// Return focus to trigger (only if no other modal remains open)
const trigger = _modalTriggers . get ( el );
if ( trigger ) {
_modalTriggers . delete ( el );
if ( ! document . querySelector ( '.lt-modal-overlay.is-open' ) && document . contains ( trigger )) {
trigger . focus ();
}
}
2026-08-07 23:07:06 -04:00
// Announce the close so whoever opened the modal can undo optimistic UI or
// clean up a dynamically-inserted overlay. A modal can be dismissed four
// ways — the ✕ button, a Cancel button, a backdrop click, and Escape — and
// the last two are handled globally here, so button-only listeners miss them.
el . dispatchEvent ( new CustomEvent ( 'lt:modalclose' , { bubbles : true }));
2026-03-17 23:44:46 -04:00
}
function closeAllModals () {
2026-03-27 19:05:42 -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-17 23:44:46 -04:00
}
document . addEventListener ( 'click' , function ( e ) {
2026-03-27 19:05:42 -04:00
if ( e . target . classList . contains ( 'lt-modal-overlay' )) { closeModal ( e . target ); return ; }
2026-03-17 23:44:46 -04:00
const closeBtn = e . target . closest ( '[data-modal-close]' );
2026-03-27 19:05:42 -04:00
if ( closeBtn ) { const overlay = closeBtn . closest ( '.lt-modal-overlay' ); if ( overlay ) closeModal ( overlay ); }
2026-03-17 23:44:46 -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-27 19:05:42 -04:00
lt.tabs.init()
lt.tabs.switch('panel-id')
2026-03-17 23:44:46 -04:00
---------------------------------------------------------------- */
function switchTab ( panelId ) {
2026-03-27 19:05:42 -04:00
document . querySelectorAll ( '.lt-tab[data-tab]' ). forEach ( t => {
t . classList . remove ( 'active' );
t . setAttribute ( 'aria-selected' , 'false' );
});
2026-03-17 23:44:46 -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-27 19:05:42 -04:00
if ( btn ) { btn . classList . add ( 'active' ); btn . setAttribute ( 'aria-selected' , 'true' ); }
2026-03-17 23:44:46 -04:00
if ( panel ) panel . classList . add ( 'active' );
try { localStorage . setItem ( 'lt_activeTab_' + location . pathname , panelId ); } catch ( _ ) {}
}
2026-03-27 19:05:42 -04:00
let _tabsInitialized = false ;
2026-03-17 23:44:46 -04:00
function initTabs () {
2026-03-27 19:05:42 -04:00
if ( _tabsInitialized ) return ; _tabsInitialized = true ;
2026-03-17 23:44:46 -04:00
try {
const saved = localStorage . getItem ( 'lt_activeTab_' + location . pathname );
2026-03-27 19:05:42 -04:00
if ( saved && document . getElementById ( saved )) { switchTab ( saved ); }
2026-03-17 23:44:46 -04:00
} catch ( _ ) {}
2026-03-27 19:05:42 -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-17 23:44:46 -04:00
});
}
const tabs = { init : initTabs , switch : switchTab };
/* ----------------------------------------------------------------
6. BOOT SEQUENCE ANIMATION
----------------------------------------------------------------
2026-03-27 19:05:42 -04:00
lt.boot.run('APP NAME')
lt.boot.run('APP NAME', true) // force replay
2026-03-17 23:44:46 -04:00
---------------------------------------------------------------- */
2026-03-27 19:05:42 -04:00
let _bootFired = false ; // in-memory guard: survives within a JS context, resets on true page reload
2026-03-17 23:44:46 -04:00
function runBoot ( appName , force ) {
2026-03-27 19:05:42 -04:00
if ( ! force && _bootFired ) return ; // Fastest guard — blocks any same-page double-call
2026-03-17 23:44:46 -04:00
const storageKey = 'lt_booted_' + ( appName || 'app' );
if ( ! force && sessionStorage . getItem ( storageKey )) return ;
2026-03-27 19:05:42 -04:00
_bootFired = true ;
sessionStorage . setItem ( storageKey , '1' );
2026-03-17 23:44:46 -04:00
const overlay = document . getElementById ( 'lt-boot' );
const pre = document . getElementById ( 'lt-boot-text' );
if ( ! overlay || ! pre ) return ;
overlay . style . display = 'flex' ;
2026-03-27 19:05:42 -04:00
overlay . style . opacity = '1' ;
2026-03-17 23:44:46 -04:00
const name = ( appName || 'TERMINAL' ). toUpperCase ();
2026-03-27 19:05:42 -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-17 23:44:46 -04:00
'' ,
2026-03-27 19:05:42 -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-17 23:44:46 -04:00
'' ,
2026-03-27 19:05:42 -04:00
'> ALL SYSTEMS NOMINAL — ' + name ,
2026-03-17 23:44:46 -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-27 19:05:42 -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-17 23:44:46 -04:00
}
2026-03-27 19:05:42 -04:00
}, 65 );
2026-03-17 23:44:46 -04:00
}
const boot = { run : runBoot };
/* ----------------------------------------------------------------
7. KEYBOARD SHORTCUTS
----------------------------------------------------------------
2026-03-27 19:05:42 -04:00
lt.keys.on('ctrl+k', fn)
lt.keys.off('ctrl+k')
lt.keys.initDefaults()
2026-03-17 23:44:46 -04:00
---------------------------------------------------------------- */
const _keyHandlers = {};
function normalizeKey ( combo ) {
2026-03-27 19:05:42 -04:00
return combo . replace ( /ctrl\+/i , 'ctrl+' ). replace ( /cmd\+/i , 'ctrl+' ). replace ( /meta\+/i , 'ctrl+' ). toLowerCase ();
2026-03-17 23:44:46 -04:00
}
2026-03-27 19:05:42 -04:00
function registerKey ( combo , handler ) { _keyHandlers [ normalizeKey ( combo )] = handler ; }
function unregisterKey ( combo ) { delete _keyHandlers [ normalizeKey ( combo )]; }
2026-03-17 23:44:46 -04:00
document . addEventListener ( 'keydown' , function ( e ) {
2026-03-27 19:05:42 -04:00
const inInput = [ 'INPUT' , 'TEXTAREA' , 'SELECT' ]. includes ( e . target . tagName ) || e . target . isContentEditable ;
2026-03-17 23:44:46 -04:00
let combo = '' ;
if ( e . ctrlKey || e . metaKey ) combo += 'ctrl+' ;
if ( e . altKey ) combo += 'alt+' ;
2026-04-06 23:52:06 -04:00
// Only add shift+ for letter keys — for symbol keys (?, !, @, etc.) the shift state
// is already encoded in e.key itself, so adding shift+ would break registrations like '?'.
const isLetter = e . key . length === 1 && /[a-zA-Z]/ . test ( e . key );
if ( e . shiftKey && isLetter ) combo += 'shift+' ;
2026-03-17 23:44:46 -04:00
combo += e . key . toLowerCase ();
const alwaysFire = e . key === 'Escape' || e . ctrlKey || e . metaKey ;
if ( inInput && ! alwaysFire ) return ;
const handler = _keyHandlers [ combo ];
2026-03-27 19:05:42 -04:00
if ( handler ) { e . preventDefault (); handler ( e ); }
2026-03-17 23:44:46 -04:00
});
function initDefaultKeys () {
2026-03-27 19:05:42 -04:00
registerKey ( 'escape' , closeAllModals );
registerKey ( '?' , () => { const h = document . getElementById ( 'lt-keys-help' ); if ( h ) openModal ( h ); });
2026-03-17 23:44:46 -04:00
registerKey ( 'ctrl+k' , () => {
2026-03-27 19:05:42 -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-17 23:44:46 -04:00
});
}
2026-03-27 19:05:42 -04:00
const keys = { on : registerKey , off : unregisterKey , initDefaults : initDefaultKeys };
2026-03-17 23:44:46 -04:00
/* ----------------------------------------------------------------
8. SIDEBAR COLLAPSE
----------------------------------------------------------------
2026-03-27 19:05:42 -04:00
lt.sidebar.init()
2026-03-17 23:44:46 -04:00
---------------------------------------------------------------- */
2026-03-27 19:05:42 -04:00
let _sidebarInitialized = false ;
2026-03-17 23:44:46 -04:00
function initSidebar () {
2026-03-27 19:05:42 -04:00
if ( _sidebarInitialized ) return ; _sidebarInitialized = true ;
2026-03-17 23:44:46 -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-27 19:05:42 -04:00
if ( collapsed ) { sidebar . classList . add ( 'collapsed' ); btn . textContent = '▶' ; }
2026-03-17 23:44:46 -04:00
btn . addEventListener ( 'click' , () => {
sidebar . classList . toggle ( 'collapsed' );
2026-03-27 19:05:42 -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-17 23:44:46 -04:00
});
});
}
const sidebar = { init : initSidebar };
/* ----------------------------------------------------------------
9. CSRF TOKEN HELPERS
----------------------------------------------------------------
2026-03-27 19:05:42 -04:00
lt.csrf.headers()
2026-03-17 23:44:46 -04:00
---------------------------------------------------------------- */
function csrfHeaders () {
const token = global . CSRF_TOKEN || '' ;
return token ? { 'X-CSRF-Token' : token } : {};
}
const csrf = { headers : csrfHeaders };
/* ----------------------------------------------------------------
10. FETCH HELPERS
----------------------------------------------------------------
2026-03-27 19:05:42 -04:00
lt.api.get(url)
lt.api.post(url, body)
lt.api.put / patch / delete
2026-03-17 23:44:46 -04:00
---------------------------------------------------------------- */
async function apiFetch ( method , url , body ) {
2026-03-27 19:05:42 -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-17 23:44:46 -04:00
let resp ;
2026-03-27 19:05:42 -04:00
try { resp = await fetch ( url , opts ); } catch ( err ) { throw new Error ( 'Network error: ' + err . message ); }
2026-03-17 23:44:46 -04:00
let data ;
2026-03-27 19:05:42 -04:00
try { data = await resp . json (); } catch ( _ ) { data = { success : resp . ok }; }
2026-07-10 13:50:27 -04:00
// Resync CSRF token from any response body that carries a fresh one
// (bootstrap rotates on success and returns the current token on rejection).
if ( data && data . csrf_token ) global . CSRF_TOKEN = data . csrf_token ;
if ( ! resp . ok ) {
const err = new Error ( data . error || data . message || 'HTTP ' + resp . status );
err . data = data ;
err . status = resp . status ;
throw err ;
}
2026-03-17 23:44:46 -04:00
return data ;
}
const api = {
2026-03-27 19:05:42 -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-17 23:44:46 -04:00
};
/* ----------------------------------------------------------------
11. TIME FORMATTING
2026-03-27 19:05:42 -04:00
----------------------------------------------------------------
lt.time.ago(value)
lt.time.uptime(secs)
lt.time.format(value)
2026-03-17 23:44:46 -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-27 19:05:42 -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-17 23:44:46 -04:00
return Math . floor ( diff / 86400 ) + 'd ago' ;
}
function formatUptime ( secs ) {
secs = Math . floor ( secs );
2026-03-27 19:05:42 -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-17 23:44:46 -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-27 19:05:42 -04:00
timeZone : global . APP_TIMEZONE || undefined ,
year : 'numeric' , month : '2-digit' , day : '2-digit' , hour : '2-digit' , minute : '2-digit' ,
2026-03-17 23:44:46 -04:00
});
2026-03-27 19:05:42 -04:00
} catch ( _ ) { return date . toLocaleString (); }
2026-03-17 23:44:46 -04:00
}
const time = { ago : timeAgo , uptime : formatUptime , format : formatDate };
/* ----------------------------------------------------------------
12. BYTES FORMATTING
2026-03-27 19:05:42 -04:00
----------------------------------------------------------------
lt.bytes.format(n)
2026-03-17 23:44:46 -04:00
---------------------------------------------------------------- */
function formatBytes ( bytes ) {
2026-03-27 19:05:42 -04:00
if ( bytes == null ) return '—' ;
const units = [ 'B' , 'KB' , 'MB' , 'GB' , 'TB' ]; let i = 0 ;
2026-03-17 23:44:46 -04:00
while ( bytes >= 1024 && i < units . length - 1 ) { bytes /= 1024 ; i ++ ; }
return bytes . toFixed ( i === 0 ? 0 : 2 ) + ' ' + units [ i ];
}
/* ----------------------------------------------------------------
2026-03-27 19:05:42 -04:00
13. TABLE KEYBOARD NAVIGATION
2026-03-17 23:44:46 -04:00
----------------------------------------------------------------
2026-03-27 19:05:42 -04:00
lt.tableNav.init('table-id')
2026-03-17 23:44:46 -04:00
---------------------------------------------------------------- */
function initTableNav ( tableId ) {
const table = tableId ? document . getElementById ( tableId ) : document . querySelector ( '.lt-table' );
if ( ! table ) return ;
2026-03-27 19:05:42 -04:00
function rows () { return Array . from ( table . querySelectorAll ( 'tbody tr' )); }
2026-03-17 23:44:46 -04:00
function selected () { return table . querySelector ( 'tbody tr.lt-row-selected' ); }
function move ( dir ) {
2026-03-27 19:05:42 -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-17 23:44:46 -04:00
if ( cur ) cur . classList . remove ( 'lt-row-selected' );
next . classList . add ( 'lt-row-selected' );
next . scrollIntoView ({ block : 'nearest' });
}
2026-03-27 19:05:42 -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-17 23:44:46 -04:00
}
const tableNav = { init : initTableNav };
/* ----------------------------------------------------------------
14. SORTABLE TABLE HEADERS
----------------------------------------------------------------
2026-03-27 19:05:42 -04:00
lt.sortTable.init('table-id')
2026-03-17 23:44:46 -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-27 19:05:42 -04:00
th . setAttribute ( 'aria-sort' , 'none' );
th . setAttribute ( 'tabindex' , '0' );
const _sort = () => {
ths . forEach ( h => { h . removeAttribute ( 'data-sort' ); h . setAttribute ( 'aria-sort' , 'none' ); });
2026-03-17 23:44:46 -04:00
th . setAttribute ( 'data-sort' , dir );
2026-03-27 19:05:42 -04:00
th . setAttribute ( 'aria-sort' , dir === 'asc' ? 'ascending' : 'descending' );
2026-03-17 23:44:46 -04:00
const tbody = table . querySelector ( 'tbody' );
const rows = Array . from ( tbody . querySelectorAll ( 'tr' ));
rows . sort (( a , b ) => {
2026-03-27 19:05:42 -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-17 23:44:46 -04:00
return dir === 'asc' ? cmp : - cmp ;
});
rows . forEach ( r => tbody . appendChild ( r ));
dir = dir === 'asc' ? 'desc' : 'asc' ;
2026-03-27 19:05:42 -04:00
};
th . addEventListener ( 'click' , _sort );
th . addEventListener ( 'keydown' , e => { if ( e . key === 'Enter' || e . key === ' ' ) { e . preventDefault (); _sort (); } });
2026-03-17 23:44:46 -04:00
});
}
const sortTable = { init : initSortTable };
/* ----------------------------------------------------------------
15. STATS WIDGET FILTERING
----------------------------------------------------------------
2026-03-27 19:05:42 -04:00
lt.statsFilter.init()
2026-03-17 23:44:46 -04:00
---------------------------------------------------------------- */
function initStatsFilter () {
document . querySelectorAll ( '.lt-stat-card[data-filter-key]' ). forEach ( card => {
2026-03-27 19:05:42 -04:00
const _activate = () => {
const key = card . dataset . filterKey , val = card . dataset . filterVal ;
2026-03-17 23:44:46 -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-27 19:05:42 -04:00
if ( typeof global . lt_onStatFilter === 'function' )
2026-03-17 23:44:46 -04:00
global . lt_onStatFilter ( wasActive ? null : key , wasActive ? null : val );
2026-03-27 19:05:42 -04:00
};
card . addEventListener ( 'click' , _activate );
card . addEventListener ( 'keydown' , e => { if ( e . key === 'Enter' || e . key === ' ' ) { e . preventDefault (); _activate (); } });
2026-03-17 23:44:46 -04:00
});
}
const statsFilter = { init : initStatsFilter };
/* ----------------------------------------------------------------
16. AUTO-REFRESH MANAGER
----------------------------------------------------------------
2026-03-27 19:05:42 -04:00
lt.autoRefresh.start(fn, ms)
lt.autoRefresh.stop()
lt.autoRefresh.now()
2026-03-17 23:44:46 -04:00
---------------------------------------------------------------- */
2026-03-27 19:05:42 -04:00
let _arTimer = null , _arFn = null , _arInterval = 30000 ;
2026-03-17 23:44:46 -04:00
2026-03-27 19:05:42 -04:00
function arStart ( fn , ms ) { arStop (); _arFn = fn ; _arInterval = ms || 30000 ; _arTimer = setInterval ( _arFn , _arInterval ); }
function arStop () { if ( _arTimer ) { clearInterval ( _arTimer ); _arTimer = null ; } }
function arNow () { arStop (); if ( _arFn ) { _arFn (); _arTimer = setInterval ( _arFn , _arInterval ); } }
const autoRefresh = { start : arStart , stop : arStop , now : arNow };
/* ================================================================
v1.2 NEW MODULES
================================================================ */
/* ----------------------------------------------------------------
17. ACCORDION
----------------------------------------------------------------
lt.accordion.init()
lt.accordion.open(triggerEl)
lt.accordion.close(triggerEl)
---------------------------------------------------------------- */
function _accordionOpen ( trigger ) {
const body = trigger . nextElementSibling ;
if ( ! body || ! body . classList . contains ( 'lt-accordion-body' )) return ;
trigger . setAttribute ( 'aria-expanded' , 'true' );
body . style . height = body . scrollHeight + 'px' ;
body . classList . add ( 'is-open' );
body . addEventListener ( 'transitionend' , function once () {
if ( body . classList . contains ( 'is-open' )) body . style . height = 'auto' ;
body . removeEventListener ( 'transitionend' , once );
});
}
function _accordionClose ( trigger ) {
const body = trigger . nextElementSibling ;
if ( ! body || ! body . classList . contains ( 'lt-accordion-body' )) return ;
body . style . height = body . scrollHeight + 'px' ;
requestAnimationFrame (() => {
body . style . height = '0' ;
body . classList . remove ( 'is-open' );
trigger . setAttribute ( 'aria-expanded' , 'false' );
});
2026-03-17 23:44:46 -04:00
}
2026-03-27 19:05:42 -04:00
let _accordionInitialized = false ;
function initAccordion () {
if ( _accordionInitialized ) return ; _accordionInitialized = true ;
// Support both data-accordion attribute (HTML) and .lt-accordion-trigger class
document . querySelectorAll ( '[data-accordion], .lt-accordion-trigger' ). forEach ( trigger => {
if ( trigger . getAttribute ( 'aria-expanded' ) === 'true' ) {
const body = trigger . nextElementSibling ;
if ( body ) { body . style . height = 'auto' ; body . classList . add ( 'is-open' ); }
}
trigger . addEventListener ( 'click' , () => {
const isOpen = trigger . getAttribute ( 'aria-expanded' ) === 'true' ;
const acc = trigger . closest ( '[data-accordion-single]' );
if ( acc ) {
acc . querySelectorAll ( '[data-accordion], .lt-accordion-trigger' ). forEach ( t => {
if ( t !== trigger && t . getAttribute ( 'aria-expanded' ) === 'true' ) _accordionClose ( t );
});
}
isOpen ? _accordionClose ( trigger ) : _accordionOpen ( trigger );
});
});
2026-03-17 23:44:46 -04:00
}
2026-03-27 19:05:42 -04:00
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-17 23:44:46 -04:00
}
2026-03-27 19:05:42 -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' ;
requestAnimationFrame (() => tip . classList . add ( 'is-visible' ));
2026-03-17 23:44:46 -04:00
}
2026-03-27 19:05:42 -04:00
function _tooltipHide () {
if ( _tooltipEl ) { _tooltipEl . remove (); _tooltipEl = null ; }
}
let _tooltipInitialized = false ;
function initTooltips () {
if ( _tooltipInitialized ) return ;
_tooltipInitialized = true ;
document . addEventListener ( 'mouseover' , e => { const a = e . target . closest ( '[data-tooltip]' ); if ( a ) _tooltipShow ( a ); });
document . addEventListener ( 'mouseout' , e => { if ( ! e . target . closest ( '[data-tooltip]' )) return ; if ( ! e . relatedTarget || ! e . relatedTarget . closest ( '[data-tooltip]' )) _tooltipHide (); });
document . addEventListener ( 'focusin' , e => { const a = e . target . closest ( '[data-tooltip]' ); if ( a ) _tooltipShow ( a ); });
document . addEventListener ( 'focusout' , _tooltipHide );
document . addEventListener ( 'scroll' , _tooltipHide , { passive : true });
global . addEventListener ( 'resize' , _tooltipHide , { passive : true });
}
const tooltip = { init : initTooltips , show : _tooltipShow , hide : _tooltipHide };
/* ----------------------------------------------------------------
19. CLIPBOARD COPY
----------------------------------------------------------------
lt.clipboard.copy(text) → Promise<boolean>
lt.clipboard.initCopyButtons()
HTML: <button data-copy="text" [data-copy-toast]>COPY</button>
---------------------------------------------------------------- */
async function clipboardCopy ( text ) {
try {
if ( navigator . clipboard && navigator . clipboard . writeText ) { await navigator . clipboard . writeText ( text ); return true ; }
const ta = document . createElement ( 'textarea' );
ta . value = text ; ta . style . cssText = 'position:fixed;top:-9999px;left:-9999px' ;
document . body . appendChild ( ta ); ta . focus (); ta . select ();
const ok = document . execCommand ( 'copy' );
document . body . removeChild ( ta ); return ok ;
} catch ( _ ) { return false ; }
}
let _copyInitialized = false ;
function initCopyButtons () {
if ( _copyInitialized ) return ; _copyInitialized = true ;
document . addEventListener ( 'click' , async function ( e ) {
const btn = e . target . closest ( '[data-copy]' ); if ( ! btn ) return ;
const orig = btn . textContent ;
const ok = await clipboardCopy ( btn . dataset . copy );
if ( ok ) {
btn . textContent = 'COPIED ✓' ; btn . disabled = true ;
if ( btn . hasAttribute ( 'data-copy-toast' )) toast . success ( 'Copied to clipboard' );
setTimeout (() => { if ( document . contains ( btn )) { btn . textContent = orig ; btn . disabled = false ; } }, 1500 );
} else { toast . error ( 'Copy failed' ); }
});
}
const clipboard = { copy : clipboardCopy , initCopyButtons };
/* ----------------------------------------------------------------
20. ALERT / BANNER DISMISS
----------------------------------------------------------------
lt.alerts.init()
lt.alerts.dismiss(el)
---------------------------------------------------------------- */
function dismissAlert ( el ) {
el . style . transition = 'opacity 0.3s ease, max-height 0.35s ease, padding 0.35s ease, margin 0.35s ease' ;
el . style . overflow = 'hidden' ;
el . style . opacity = '0' ;
el . style . maxHeight = el . offsetHeight + 'px' ;
requestAnimationFrame (() => requestAnimationFrame (() => {
el . style . maxHeight = '0' ; el . style . margin = '0' ; el . style . padding = '0' ;
setTimeout (() => el . remove (), 360 );
}));
}
let _alertsInitialized = false ;
function initAlerts () {
if ( _alertsInitialized ) return ; _alertsInitialized = true ;
document . addEventListener ( 'click' , function ( e ) {
const btn = e . target . closest ( '.lt-alert-close, .lt-alert-dismiss' ); if ( ! btn ) return ;
const al = btn . closest ( '.lt-alert' ); if ( al ) dismissAlert ( al );
});
document . querySelectorAll ( '.lt-alert[data-alert-auto-dismiss]' ). forEach ( el => {
const ms = parseInt ( el . dataset . alertAutoDismiss , 10 );
if ( ms > 0 ) setTimeout (() => dismissAlert ( el ), ms );
});
}
const alerts = { init : initAlerts , dismiss : dismissAlert };
/* ----------------------------------------------------------------
21. PROGRESS BAR
----------------------------------------------------------------
lt.progress.set(el, value, max?)
lt.progress.animate(el, from, to, durationMs?)
---------------------------------------------------------------- */
function _pEl ( el ) { return typeof el === 'string' ? document . querySelector ( el ) : el ; }
function progressSet ( el , value , max ) {
el = _pEl ( el ); if ( ! el ) return ;
const pct = Math . min ( 100 , Math . max ( 0 , ( value / ( max || 100 )) * 100 ));
const bar = el . querySelector ( '.lt-progress-bar' );
const valEl = el . parentElement && el . parentElement . querySelector ( '.lt-progress-value' );
if ( bar ) bar . style . width = pct + '%' ;
if ( valEl ) valEl . textContent = Math . round ( pct ) + '%' ;
el . setAttribute ( 'aria-valuenow' , Math . round ( pct ));
}
function progressAnimate ( el , from , to , dur ) {
el = _pEl ( el ); if ( ! el ) return ;
dur = dur || 600 ;
const start = performance . now ();
function step ( now ) {
const t = Math . min ( 1 , ( now - start ) / dur );
progressSet ( el , from + ( to - from ) * ( 1 - Math . pow ( 1 - t , 3 )));
if ( t < 1 ) requestAnimationFrame ( step );
}
requestAnimationFrame ( step );
}
const progress = { set : progressSet , animate : progressAnimate };
/* ----------------------------------------------------------------
22. COMMAND PALETTE
----------------------------------------------------------------
lt.cmdPalette.init(commands)
lt.cmdPalette.open()
lt.cmdPalette.close()
lt.cmdPalette.register(cmd)
Command: { id, label, icon?, description?, kbd?, group?, tags?, action }
---------------------------------------------------------------- */
let _cpCommands = [], _cpSelected = 0 , _cpTrigger = null ;
const _cpRecentKey = 'lt_cmd_recent' ;
function _cmdPaletteOpen () {
const ov = document . getElementById ( 'lt-cmd-overlay' ); if ( ! ov ) return ;
if ( _mnOpen ) _mnSetOpen ( false );
_cpTrigger = ( document . activeElement && document . activeElement !== document . body ) ? document . activeElement : null ;
ov . classList . add ( 'is-open' );
_lockScroll ();
const palette = document . getElementById ( 'lt-cmd-palette' );
const inp = palette && palette . querySelector ( '.lt-cmd-input' );
if ( inp ) { inp . value = '' ; inp . focus (); inp . select (); }
_cpRender ( '' );
}
function _cmdPaletteClose () {
const ov = document . getElementById ( 'lt-cmd-overlay' ); if ( ! ov ) return ;
ov . classList . remove ( 'is-open' );
_unlockScroll ();
const inp = document . querySelector ( '#lt-cmd-palette .lt-cmd-input' );
if ( inp ) inp . removeAttribute ( 'aria-activedescendant' );
if ( _cpTrigger ) { if ( document . contains ( _cpTrigger )) _cpTrigger . focus (); _cpTrigger = null ; }
}
function _cpHighlight ( text , q ) {
if ( ! q ) return escHtml ( text );
const i = text . toLowerCase (). indexOf ( q . toLowerCase ());
if ( i < 0 ) return escHtml ( text );
return escHtml ( text . slice ( 0 , i )) +
'<mark style="background:rgba(255,107,0,0.35);color:inherit">' + escHtml ( text . slice ( i , i + q . length )) + '</mark>' +
escHtml ( text . slice ( i + q . length ));
}
function _cpRender ( query ) {
const ov = document . getElementById ( 'lt-cmd-palette' ); if ( ! ov ) return ;
const results = ov . querySelector ( '.lt-cmd-results' ); if ( ! results ) return ;
query = query . trim (). toLowerCase ();
let recents = [];
try { recents = JSON . parse ( localStorage . getItem ( _cpRecentKey ) || '[]' ); } catch ( _ ) {}
const filtered = _cpCommands . filter ( cmd =>
! query || [ cmd . label , cmd . description || '' , ...( cmd . tags || [])]. join ( ' ' ). toLowerCase (). includes ( query )
);
_cpSelected = 0 ;
if ( ! filtered . length ) { results . innerHTML = '<div class="lt-cmd-empty">No results for “' + 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 => {
html += '<div class="lt-cmd-item' + ( idx === 0 ? ' is-selected' : '' ) + '" id="lt-cmd-item-' + idx + '" data-cmd-id="' + escHtml ( cmd . id ) + '">' +
'<span class="lt-cmd-item-icon">' + escHtml ( cmd . icon || '◦' ) + '</span>' +
'<span class="lt-cmd-item-label">' + _cpHighlight ( cmd . label , query ) + '</span>' +
( cmd . kbd ? '<span class="lt-cmd-item-kbd">' + escHtml ( cmd . kbd ) + '</span>' : '' ) +
'</div>' ;
idx ++ ;
});
});
results . innerHTML = html ;
const pal = document . getElementById ( 'lt-cmd-palette' );
const inp = pal && pal . querySelector ( '.lt-cmd-input' );
const allItems = Array . from ( results . querySelectorAll ( '.lt-cmd-item' ));
if ( inp && allItems [ 0 ]) inp . setAttribute ( 'aria-activedescendant' , allItems [ 0 ]. id );
allItems . forEach (( item , i ) => {
item . addEventListener ( 'mouseenter' , () => {
allItems . forEach ( x => x . classList . remove ( 'is-selected' ));
item . classList . add ( 'is-selected' ); _cpSelected = i ;
if ( inp ) inp . setAttribute ( 'aria-activedescendant' , item . id );
});
item . addEventListener ( 'click' , () => _cpExec ( item . dataset . cmdId ));
});
}
function _cpExec ( id ) {
const cmd = _cpCommands . find ( c => c . id === id );
if ( ! cmd || typeof cmd . action !== 'function' ) return ;
_cmdPaletteClose ();
try {
let r = JSON . parse ( localStorage . getItem ( _cpRecentKey ) || '[]' );
r = [ id , ... r . filter ( x => x !== id )]. slice ( 0 , 5 );
localStorage . setItem ( _cpRecentKey , JSON . stringify ( r ));
} catch ( _ ) {}
cmd . action ();
}
function _cpMove ( dir ) {
const ov = document . getElementById ( 'lt-cmd-palette' ); if ( ! ov ) return ;
const items = Array . from ( ov . querySelectorAll ( '.lt-cmd-item' )); if ( ! items . length ) return ;
items [ _cpSelected ] && items [ _cpSelected ]. classList . remove ( 'is-selected' );
_cpSelected = ( _cpSelected + dir + items . length ) % items . length ;
items [ _cpSelected ] && items [ _cpSelected ]. classList . add ( 'is-selected' );
items [ _cpSelected ] && items [ _cpSelected ]. scrollIntoView ({ block : 'nearest' });
const inp = ov . querySelector ( '.lt-cmd-input' );
if ( inp && items [ _cpSelected ]) inp . setAttribute ( 'aria-activedescendant' , items [ _cpSelected ]. id );
}
function initCmdPalette ( commands ) {
_cpCommands = commands || [];
const ov = document . getElementById ( 'lt-cmd-palette' ); if ( ! ov ) return ;
const inp = ov . querySelector ( '.lt-cmd-input' );
if ( inp ) {
inp . addEventListener ( 'input' , () => _cpRender ( inp . value ));
inp . addEventListener ( 'keydown' , e => {
if ( e . key === 'ArrowDown' ) { e . preventDefault (); _cpMove ( 1 ); }
if ( e . key === 'ArrowUp' ) { e . preventDefault (); _cpMove ( - 1 ); }
if ( e . key === 'Enter' ) { e . preventDefault (); const s = ov . querySelector ( '.lt-cmd-item.is-selected' ); if ( s ) _cpExec ( s . dataset . cmdId ); }
if ( e . key === 'Escape' ) { e . preventDefault (); _cmdPaletteClose (); }
});
}
const overlay = document . getElementById ( 'lt-cmd-overlay' );
if ( overlay ) overlay . addEventListener ( 'click' , e => { if ( e . target === overlay ) _cmdPaletteClose (); });
}
const cmdPalette = {
init : initCmdPalette ,
open : _cmdPaletteOpen ,
close : _cmdPaletteClose ,
register : cmd => { const i = _cpCommands . findIndex ( c => c . id === cmd . id ); if ( i >= 0 ) _cpCommands [ i ] = cmd ; else _cpCommands . push ( cmd ); },
};
2026-03-17 23:44:46 -04:00
/* ----------------------------------------------------------------
2026-03-27 19:05:42 -04:00
23. FORM VALIDATION
2026-03-17 23:44:46 -04:00
----------------------------------------------------------------
2026-03-27 19:05:42 -04:00
lt.validate.field(el) → { valid, message }
lt.validate.form(formEl) → { valid, errors }
lt.validate.showError(el, msg)
lt.validate.clearError(el)
lt.validate.init(formEl, onSubmit)
lt.validate.custom = {}
---------------------------------------------------------------- */
const _validateCustom = {};
function _validateField ( el ) {
const val = el . value || '' , type = ( el . type || '' ). toLowerCase ();
if (( type === 'checkbox' || type === 'radio' ) && el . required && ! el . checked ) return { valid : false , message : 'This field is required' };
if ( el . tagName === 'SELECT' && el . multiple && el . required && el . selectedOptions . length === 0 ) return { valid : false , message : 'Please select at least one option' };
if ( el . required && ! val . trim ()) return { valid : false , message : 'This field is required' };
if ( el . minLength > 0 && val . length < el . minLength ) return { valid : false , message : 'Minimum ' + el . minLength + ' characters' };
if ( el . maxLength > 0 && val . length > el . maxLength ) return { valid : false , message : 'Maximum ' + el . maxLength + ' characters' };
if ( val && type === 'email' && ! /^[^\s@]+@[^\s@]+\.[^\s@]+$/ . test ( val )) return { valid : false , message : 'Enter a valid email address' };
if ( val && type === 'url' ) { try { new URL ( val ); } catch ( _ ) { return { valid : false , message : 'Enter a valid URL' }; } }
if ( val && type === 'number' && isNaN ( Number ( val ))) return { valid : false , message : 'Enter a valid number' };
if ( val && el . pattern && ! new RegExp ( '^(?:' + el . pattern + ')$' ). test ( val )) return { valid : false , message : el . dataset . validateMsg || 'Invalid format' };
if ( el . dataset . validate ) {
const fn = _validateCustom [ el . dataset . validate ];
if ( typeof fn === 'function' ) { const r = fn ( val , el ); if ( r !== true ) return { valid : false , message : r || 'Invalid value' }; }
}
return { valid : true , message : '' };
}
function _showError ( el , msg ) {
el . classList . add ( 'is-invalid' ); el . classList . remove ( 'is-valid' );
let err = el . parentElement && el . parentElement . querySelector ( '.lt-field-error' );
if ( ! err ) {
err = document . createElement ( 'span' );
err . className = 'lt-field-error' ;
err . id = ( el . id || ( 'lt-field-' + Math . random (). toString ( 36 ). slice ( 2 ))) + '-err' ;
if ( el . parentElement ) el . parentElement . appendChild ( err );
}
err . textContent = msg ;
err . setAttribute ( 'role' , 'alert' );
el . setAttribute ( 'aria-describedby' , err . id );
el . setAttribute ( 'aria-invalid' , 'true' );
}
function _clearError ( el ) {
el . classList . remove ( 'is-invalid' ); el . classList . add ( 'is-valid' );
el . removeAttribute ( 'aria-invalid' );
el . removeAttribute ( 'aria-describedby' );
const err = el . parentElement && el . parentElement . querySelector ( '.lt-field-error' );
if ( err ) err . remove ();
}
function _validateForm ( formEl ) {
const errors = []; let valid = true ;
Array . from ( formEl . querySelectorAll ( 'input, select, textarea' )). forEach ( f => {
if ( f . disabled || f . readOnly ) return ;
const r = _validateField ( f );
if ( ! r . valid ) { valid = false ; _showError ( f , r . message ); errors . push ({ el : f , message : r . message }); }
else _clearError ( f );
});
return { valid , errors };
}
function initFormValidation ( formEl , onSubmit ) {
if ( ! formEl ) return ;
formEl . querySelectorAll ( 'input, select, textarea' ). forEach ( f => {
f . addEventListener ( 'blur' , () => { const r = _validateField ( f ); r . valid ? _clearError ( f ) : _showError ( f , r . message ); });
});
formEl . addEventListener ( 'submit' , e => {
e . preventDefault ();
const r = _validateForm ( formEl );
if ( r . valid && typeof onSubmit === 'function' ) onSubmit ( formEl , e );
else if ( ! r . valid && r . errors . length ) r . errors [ 0 ]. el . focus ();
});
}
const validate = { field : _validateField , form : _validateForm , showError : _showError , clearError : _clearError , init : initFormValidation , custom : _validateCustom };
/* ----------------------------------------------------------------
24. DEBOUNCE & THROTTLE
----------------------------------------------------------------
lt.debounce(fn, wait) → fn with .cancel()
lt.throttle(fn, wait) → throttled fn
---------------------------------------------------------------- */
function debounce ( fn , wait ) {
let t ;
function d () { const a = arguments , c = this ; clearTimeout ( t ); t = setTimeout (() => fn . apply ( c , a ), wait ); }
d . cancel = () => clearTimeout ( t );
return d ;
}
function throttle ( fn , wait ) {
let last = 0 ;
return function () { const now = Date . now (); if ( now - last >= wait ) { last = now ; fn . apply ( this , arguments ); } };
}
/* ----------------------------------------------------------------
25. EVENT BUS
----------------------------------------------------------------
lt.bus.on / off / emit / once
---------------------------------------------------------------- */
const _busH = new Map ();
function busOn ( e , h ) { if ( ! _busH . has ( e )) _busH . set ( e , []); _busH . get ( e ). push ( h ); }
function busOff ( e , h ) { const hs = _busH . get ( e ); if ( hs ) _busH . set ( e , hs . filter ( x => x !== h )); }
function busEmit ( e , d ) { ( _busH . get ( e ) || []). slice (). forEach ( h => { try { h ( d ); } catch ( err ) { console . error ( 'lt.bus:' , err ); } }); }
function busOnce ( e , h ) { function w ( d ) { h ( d ); busOff ( e , w ); } busOn ( e , w ); }
const bus = { on : busOn , off : busOff , emit : busEmit , once : busOnce };
/* ----------------------------------------------------------------
26. STORAGE HELPERS
----------------------------------------------------------------
lt.store.set / get / remove / clear
lt.store.session.*
---------------------------------------------------------------- */
function _mkStore ( s ) {
return {
set ( k , v ) { try { s . setItem ( 'lt_' + k , JSON . stringify ( v )); } catch ( _ ) {} },
get ( k , fb ) { try { const r = s . getItem ( 'lt_' + k ); return r !== null ? JSON . parse ( r ) : ( fb !== undefined ? fb : null ); } catch ( _ ) { return fb !== undefined ? fb : null ; } },
remove ( k ) { try { s . removeItem ( 'lt_' + k ); } catch ( _ ) {} },
clear () { try { Object . keys ( s ). filter ( k => k . startsWith ( 'lt_' )). forEach ( k => s . removeItem ( k )); } catch ( _ ) {} },
};
}
const store = Object . assign ( _mkStore ( localStorage ), { session : _mkStore ( sessionStorage ) });
/* ----------------------------------------------------------------
27. URL HELPERS
----------------------------------------------------------------
lt.url.params / get / set / remove / setMultiple
---------------------------------------------------------------- */
const url = {
params () { return new URLSearchParams ( global . location . search ); },
get ( k ) { return this . params (). get ( k ); },
set ( k , v ) { const p = this . params (); p . set ( k , v ); global . history . pushState ({}, '' , global . location . pathname + '?' + p ); },
remove ( k ) { const p = this . params (); p . delete ( k ); const q = p . toString (); global . history . pushState ({}, '' , global . location . pathname + ( q ? '?' + q : '' )); },
setMultiple ( obj ) { const p = this . params (); Object . entries ( obj ). forEach (([ k , v ]) => v == null ? p . delete ( k ) : p . set ( k , v )); global . history . pushState ({}, '' , global . location . pathname + '?' + p ); },
};
/* ----------------------------------------------------------------
28. NUMBER FORMATTER
----------------------------------------------------------------
lt.num.format / compact / percent / pad / clamp / lerp
---------------------------------------------------------------- */
const num = {
format ( n , d ) { if ( n == null || isNaN ( n )) return '—' ; return Number ( n ). toLocaleString ( undefined , { minimumFractionDigits : d || 0 , maximumFractionDigits : d != null ? d : 2 }); },
compact ( n ) { if ( Math . abs ( n ) >= 1e9 ) return ( n / 1e9 ). toFixed ( 1 ) + 'B' ; if ( Math . abs ( n ) >= 1e6 ) return ( n / 1e6 ). toFixed ( 1 ) + 'M' ; if ( Math . abs ( n ) >= 1e3 ) return ( n / 1e3 ). toFixed ( 1 ) + 'K' ; return String ( n ); },
percent ( n , total ) { return total ? (( n / total ) * 100 ). toFixed ( 1 ) + '%' : '—' ; },
pad ( n , w ) { return String ( Math . floor ( n )). padStart ( w , '0' ); },
clamp ( n , a , b ) { return Math . min ( b , Math . max ( a , n )); },
lerp ( a , b , t ) { return a + ( b - a ) * t ; },
};
/* ----------------------------------------------------------------
29. DOM HELPERS
----------------------------------------------------------------
lt.dom.el / qs / qsa / on / off / show / hide / toggle /
empty / append / closest / ready
---------------------------------------------------------------- */
const dom = {
el ( tag , attrs ) {
const el = document . createElement ( tag );
if ( attrs ) Object . entries ( attrs ). forEach (([ k , v ]) => {
if ( k === 'class' ) el . className = v ;
else if ( k === 'text' ) el . textContent = v ;
else if ( k === 'html' ) el . innerHTML = v ;
else el . setAttribute ( k , v );
});
for ( let i = 2 ; i < arguments . length ; i ++ ) {
const c = arguments [ i ];
el . appendChild ( typeof c === 'string' ? document . createTextNode ( c ) : c );
}
return el ;
},
qs ( sel , ctx ) { return ( ctx || document ). querySelector ( sel ); },
qsa ( sel , ctx ) { return Array . from (( ctx || document ). querySelectorAll ( sel )); },
on ( el , ev , fn , o ) { el . addEventListener ( ev , fn , o ); return () => el . removeEventListener ( ev , fn , o ); },
off ( el , ev , fn ) { el . removeEventListener ( ev , fn ); },
show ( el ) { if ( el ) el . classList . remove ( 'lt-hidden' ); },
hide ( el ) { if ( el ) el . classList . add ( 'lt-hidden' ); },
toggle ( el , force ) { if ( el ) el . classList . toggle ( 'lt-hidden' , force === undefined ? undefined : ! force ); },
empty ( el ) { while ( el && el . firstChild ) el . removeChild ( el . firstChild ); },
append ( p ) { for ( let i = 1 ; i < arguments . length ; i ++ ) if ( arguments [ i ]) p . appendChild ( arguments [ i ]); },
closest ( el , sel ) { return el ? el . closest ( sel ) : null ; },
ready ( fn ) { document . readyState === 'loading' ? document . addEventListener ( 'DOMContentLoaded' , fn ) : fn (); },
};
/* ----------------------------------------------------------------
30. TABLE COLUMN VISIBILITY
----------------------------------------------------------------
lt.tableColumns.init / show / hide / toggle
---------------------------------------------------------------- */
function _cells ( t , i ) { const c = []; t . querySelectorAll ( 'tr' ). forEach ( r => { if ( r . cells [ i ]) c . push ( r . cells [ i ]); }); return c ; }
function _saveCols ( id , i , v ) { try { const k = 'lt_cols_' + id , s = JSON . parse ( localStorage . getItem ( k ) || '{}' ); s [ i ] = v ; localStorage . setItem ( k , JSON . stringify ( s )); } catch ( _ ) {} }
function _colShow ( id , i ) { const t = document . getElementById ( id ); if ( ! t ) return ; _cells ( t , i ). forEach ( c => c . style . display = '' ); _saveCols ( id , i , true ); }
function _colHide ( id , i ) { const t = document . getElementById ( id ); if ( ! t ) return ; _cells ( t , i ). forEach ( c => c . style . display = 'none' ); _saveCols ( id , i , false ); }
function _colToggle ( id , i ) { const t = document . getElementById ( id ); if ( ! t ) return ; const c0 = _cells ( t , i )[ 0 ]; ( c0 && c0 . style . display === 'none' ) ? _colShow ( id , i ) : _colHide ( id , i ); }
function initTableColumns ( tableId ) {
try { const s = JSON . parse ( localStorage . getItem ( 'lt_cols_' + tableId ) || '{}' ); Object . entries ( s ). forEach (([ i , v ]) => { if ( ! v ) _colHide ( tableId , + i ); }); } catch ( _ ) {}
document . querySelectorAll ( '[data-toggle-col]' ). forEach ( btn => {
btn . addEventListener ( 'click' , () => _colToggle ( btn . dataset . tableId || tableId , + btn . dataset . toggleCol ));
});
}
const tableColumns = { init : initTableColumns , show : _colShow , hide : _colHide , toggle : _colToggle };
/* ----------------------------------------------------------------
31. POLLING & ASYNC RETRY
----------------------------------------------------------------
lt.poll(fn, ms, opts?) → { stop }
lt.retry(fn, opts?) → Promise
---------------------------------------------------------------- */
function poll ( fn , ms , opts ) {
opts = opts || {}; let stopped = false , timer = null ;
async function tick () {
if ( stopped ) return ;
try { await fn (); } catch ( e ) { if ( typeof opts . onError === 'function' ) opts . onError ( e ); }
if ( ! stopped ) timer = setTimeout ( tick , ms );
}
opts . immediate ? tick () : ( timer = setTimeout ( tick , ms ));
return { stop () { stopped = true ; clearTimeout ( timer ); } };
}
async function retry ( fn , opts ) {
opts = opts || {};
const retries = opts . retries || 3 , delay = opts . delay || 1000 , backoff = opts . backoff || 1 ;
let lastErr ;
for ( let i = 0 ; i <= retries ; i ++ ) {
try { return await fn ( i ); } catch ( e ) {
lastErr = e ;
if ( i < retries ) {
if ( typeof opts . onRetry === 'function' ) opts . onRetry ( e , i + 1 );
await new Promise ( r => setTimeout ( r , delay * Math . pow ( backoff , i )));
}
}
}
throw lastErr ;
}
/* ----------------------------------------------------------------
32. DRAG & DROP UPLOAD
----------------------------------------------------------------
lt.dropzone.init(el, opts)
opts: { onFiles, accept, maxSize, multiple }
---------------------------------------------------------------- */
function initDropzone ( el , opts ) {
if ( typeof el === 'string' ) el = document . querySelector ( el );
if ( ! el ) return ;
opts = opts || {};
const maxSize = opts . maxSize || 10 * 1024 * 1024 ;
function validate ( files ) {
const valid = [], invalid = [];
Array . from ( files ). forEach ( f => {
if ( f . size > maxSize ) { invalid . push ( f . name + ' (too large)' ); return ; }
if ( opts . accept && opts . accept . length ) {
const ok = opts . accept . some ( a => a . startsWith ( '.' ) ? f . name . toLowerCase (). endsWith ( a ) : f . type . match ( new RegExp ( a . replace ( '*' , '.*' ))));
if ( ! ok ) { invalid . push ( f . name + ' (not allowed)' ); return ; }
}
valid . push ( f );
});
if ( invalid . length ) toast . error ( 'Rejected: ' + invalid . join ( ', ' ));
return valid ;
}
function deliver ( files ) {
const v = validate ( files );
if ( v . length ) { el . classList . add ( 'has-file' ); if ( typeof opts . onFiles === 'function' ) opts . onFiles ( opts . multiple !== false ? v : [ v [ 0 ]]); }
}
el . addEventListener ( 'dragover' , e => { e . preventDefault (); el . classList . add ( 'is-dragging' ); });
el . addEventListener ( 'dragleave' , e => { if ( ! el . contains ( e . relatedTarget )) el . classList . remove ( 'is-dragging' ); });
el . addEventListener ( 'drop' , e => { e . preventDefault (); el . classList . remove ( 'is-dragging' ); deliver ( e . dataTransfer . files ); });
el . addEventListener ( 'click' , () => {
const inp = document . createElement ( 'input' );
inp . type = 'file' ; inp . multiple = opts . multiple !== false ;
if ( opts . accept ) inp . accept = opts . accept . join ( ',' );
inp . addEventListener ( 'change' , () => deliver ( inp . files ));
inp . click ();
});
}
function _initAllDropzones () {
document . querySelectorAll ( '[data-dropzone]' ). forEach ( el => {
initDropzone ( el , { onFiles : files => toast . info ( Array . from ( files ). length + ' file(s) selected: ' + Array . from ( files ). map ( f => f . name ). join ( ', ' )) });
});
}
const dropzone = { init : initDropzone };
/* ----------------------------------------------------------------
33. INTERSECTION OBSERVER
----------------------------------------------------------------
lt.observe.lazy(selector, opts?) → adds .is-visible on enter
lt.observe.sentinel(el, callback) → fires when el enters viewport
---------------------------------------------------------------- */
function observeLazy ( selector , opts ) {
if ( ! global . IntersectionObserver ) {
document . querySelectorAll ( selector ). forEach ( el => el . classList . add ( 'is-visible' ));
return ;
}
opts = opts || {};
const obs = new IntersectionObserver (( entries , o ) => {
entries . forEach ( entry => {
if ( entry . isIntersecting ) {
entry . target . classList . add ( 'is-visible' );
if ( opts . once !== false ) o . unobserve ( entry . target );
}
});
}, { threshold : opts . threshold || 0.1 , rootMargin : opts . rootMargin || '0px' });
document . querySelectorAll ( selector ). forEach ( el => obs . observe ( el ));
return obs ;
}
function observeSentinel ( el , cb ) {
if ( ! el || ! global . IntersectionObserver ) { if ( el ) cb (); return ; }
const obs = new IntersectionObserver ( entries => { if ( entries [ 0 ]. isIntersecting ) cb (); }, { threshold : 0.1 });
obs . observe ( el );
return { stop : () => obs . disconnect () };
}
const observe = { lazy : observeLazy , sentinel : observeSentinel };
/* ----------------------------------------------------------------
34. FULL INITIALISATION
2026-03-17 23:44:46 -04:00
---------------------------------------------------------------- */
function init () {
2026-03-27 19:05:42 -04:00
/* Core */
2026-03-17 23:44:46 -04:00
initTabs ();
initSidebar ();
initDefaultKeys ();
initStatsFilter ();
2026-03-27 19:05:42 -04:00
/* v1.2 */
initAccordion ();
initTooltips ();
initCopyButtons ();
initAlerts ();
_initAllDropzones ();
observeLazy ( '[data-lazy]' );
/* v1.3 */
initMobileNav ();
initSidebarSubmenus ();
/* Boot */
2026-03-17 23:44:46 -04:00
const bootEl = document . getElementById ( 'lt-boot' );
2026-03-27 19:05:42 -04:00
if ( bootEl ) runBoot ( bootEl . dataset . appName || document . title );
2026-03-17 23:44:46 -04:00
}
if ( document . readyState === 'loading' ) {
document . addEventListener ( 'DOMContentLoaded' , init );
} else {
init ();
}
2026-03-27 19:05:42 -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.
================================================================ */
let _mnOpen = false , _mnTrigger = null ;
function _mnSetOpen ( open ) {
_mnOpen = open ;
const btn = document . getElementById ( 'lt-menu-btn' );
const drawer = document . getElementById ( 'lt-nav-drawer' );
const overlay = document . getElementById ( 'lt-nav-overlay' );
if ( ! drawer ) return ;
if ( open ) {
_mnTrigger = ( document . activeElement && document . activeElement !== document . body ) ? document . activeElement : null ;
drawer . classList . add ( 'open' );
drawer . removeAttribute ( 'aria-hidden' );
if ( overlay ) overlay . classList . add ( 'open' );
if ( btn ) { btn . classList . add ( 'open' ); btn . setAttribute ( 'aria-expanded' , 'true' ); }
document . body . style . overflow = 'hidden' ;
// Trap focus inside drawer
if ( ! drawer . _mnTrapHandler ) {
drawer . _mnTrapHandler = e => { if ( e . key === 'Tab' ) _trapFocus ( drawer , e ); };
drawer . addEventListener ( 'keydown' , drawer . _mnTrapHandler );
}
const first = drawer . querySelector ( 'button, a, [tabindex]' );
if ( first ) setTimeout (() => { if ( document . contains ( first )) first . focus (); }, 50 );
} else {
drawer . classList . remove ( 'open' );
drawer . setAttribute ( 'aria-hidden' , 'true' );
if ( overlay ) overlay . classList . remove ( 'open' );
if ( btn ) { btn . classList . remove ( 'open' ); btn . setAttribute ( 'aria-expanded' , 'false' ); }
document . body . style . overflow = '' ;
if ( drawer . _mnTrapHandler ) { drawer . removeEventListener ( 'keydown' , drawer . _mnTrapHandler ); delete drawer . _mnTrapHandler ; }
if ( _mnTrigger && document . contains ( _mnTrigger )) { _mnTrigger . focus (); }
_mnTrigger = null ;
}
bus . emit ( 'mobileNav:' + ( open ? 'open' : 'close' ));
}
let _mnInitialized = false ;
function initMobileNav () {
if ( _mnInitialized ) return ; // prevent duplicate init / memory leaks
_mnInitialized = true ;
const btn = document . getElementById ( 'lt-menu-btn' );
const overlay = document . getElementById ( 'lt-nav-overlay' );
const closeBtn = document . getElementById ( 'lt-nav-drawer-close' );
if ( btn ) btn . addEventListener ( 'click' , () => _mnSetOpen ( ! _mnOpen ));
if ( overlay ) overlay . addEventListener ( 'click' , () => _mnSetOpen ( false ));
if ( closeBtn ) closeBtn . addEventListener ( 'click' , () => _mnSetOpen ( false ));
// Close on Escape
document . addEventListener ( 'keydown' , e => {
if ( e . key === 'Escape' && _mnOpen ) _mnSetOpen ( false );
});
// Close when nav link is clicked (navigates away)
const drawer = document . getElementById ( 'lt-nav-drawer' );
if ( drawer ) {
drawer . querySelectorAll ( 'a' ). forEach ( a => {
a . addEventListener ( 'click' , () => _mnSetOpen ( false ));
});
}
// Swipe gesture — open from left edge, close by swiping left
let _touchStartX = 0 ;
let _touchStartY = 0 ;
let _isSwiping = false ;
document . addEventListener ( 'touchstart' , e => {
_touchStartX = e . touches [ 0 ]. clientX ;
_touchStartY = e . touches [ 0 ]. clientY ;
// Only initiate open-swipe from left 20px edge
_isSwiping = ! _mnOpen && _touchStartX <= 20 ;
}, { passive : true });
document . addEventListener ( 'touchmove' , e => {
if ( ! _isSwiping && ! _mnOpen ) return ;
const dx = e . touches [ 0 ]. clientX - _touchStartX ;
const dy = e . touches [ 0 ]. clientY - _touchStartY ;
// Ignore mostly-vertical scrolls
if ( Math . abs ( dy ) > Math . abs ( dx ) * 1.5 ) { _isSwiping = false ; return ; }
// Live drag feedback on the drawer
const drawerEl = document . getElementById ( 'lt-nav-drawer' );
if ( ! drawerEl ) return ;
if ( ! _mnOpen && _isSwiping && dx > 0 ) {
const pct = Math . min ( dx / 280 , 1 );
drawerEl . style . transform = `translateX( ${ - 100 + pct * 100 } %)` ;
drawerEl . style . transition = 'none' ;
} else if ( _mnOpen && dx < 0 ) {
const pct = Math . max ( 0 , 1 + dx / 280 );
drawerEl . style . transform = `translateX( ${ - ( 1 - pct ) * 100 } %)` ;
drawerEl . style . transition = 'none' ;
}
}, { passive : true });
document . addEventListener ( 'touchend' , e => {
const drawerEl = document . getElementById ( 'lt-nav-drawer' );
if ( ! drawerEl ) return ;
drawerEl . style . transform = '' ;
drawerEl . style . transition = '' ;
const dx = e . changedTouches [ 0 ]. clientX - _touchStartX ;
if ( ! _mnOpen && _isSwiping && dx > 60 ) _mnSetOpen ( true );
if ( _mnOpen && dx < - 60 ) _mnSetOpen ( false );
_isSwiping = false ;
}, { passive : true });
// Auto-close when viewport becomes desktop width
viewport . on (({ bp }) => {
if ([ 'lg' , 'xl' , '2xl' , '3xl' , '4k' ]. includes ( bp ) && _mnOpen ) _mnSetOpen ( false );
});
}
const mobileNav = {
open : () => _mnSetOpen ( true ),
close : () => _mnSetOpen ( false ),
toggle : () => _mnSetOpen ( ! _mnOpen ),
isOpen : () => _mnOpen ,
};
/* ================================================================
MODULE 37 — THEME TOGGLE
lt.theme.toggle()
lt.theme.set('light'|'dark')
lt.theme.get()
================================================================ */
const _themeKey = 'lt_theme' ;
function _applyTheme ( t ) {
document . documentElement . setAttribute ( 'data-theme' , t );
document . documentElement . style . colorScheme = t ;
try { localStorage . setItem ( _themeKey , t ); } catch ( _ ) {}
// Sync theme-color meta for browser chrome
const metaTheme = document . querySelector ( 'meta[name="theme-color"]' );
if ( metaTheme ) metaTheme . setAttribute ( 'content' , t === 'light' ? '#edf0f5' : '#030508' );
document . querySelectorAll ( '.lt-theme-btn' ). forEach ( btn => {
btn . textContent = t === 'light' ? '◐' : '☀' ;
btn . setAttribute ( 'aria-label' , t === 'light' ? 'Switch to dark mode' : 'Switch to light mode' );
btn . setAttribute ( 'title' , t === 'light' ? 'Switch to dark mode' : 'Switch to light mode' );
});
bus . emit ( 'theme:change' , { theme : t });
}
const _initTheme = ( function () {
let saved ;
try { saved = localStorage . getItem ( _themeKey ); } catch ( _ ) {}
return saved || ( window . matchMedia ( '(prefers-color-scheme: light)' ). matches ? 'light' : 'dark' );
})();
_applyTheme ( _initTheme );
window . matchMedia ( '(prefers-color-scheme: light)' ). addEventListener ( 'change' , e => {
let saved ; try { saved = localStorage . getItem ( _themeKey ); } catch ( _ ) {}
if ( ! saved ) _applyTheme ( e . matches ? 'light' : 'dark' );
});
// Sync theme across tabs via storage event
window . addEventListener ( 'storage' , e => {
if ( e . key === _themeKey && e . newValue ) _applyTheme ( e . newValue );
});
const theme = {
toggle : () => _applyTheme ( document . documentElement . getAttribute ( 'data-theme' ) === 'light' ? 'dark' : 'light' ),
set : t => _applyTheme ( t ),
get : () => document . documentElement . getAttribute ( 'data-theme' ) || 'dark' ,
};
/* ================================================================
MODULE 38 — NOTIFICATION BADGE
lt.notif.set(el, count)
lt.notif.inc(el)
lt.notif.clear(el)
el = CSS selector string or DOM element
================================================================ */
function _notifEl ( el ) { return typeof el === 'string' ? document . querySelector ( el ) : el ; }
function _notifBadge ( el ) {
const wrap = _notifEl ( el ); if ( ! wrap ) return null ;
let b = wrap . querySelector ( ':scope > .lt-notif-badge' );
if ( ! b ) {
b = document . createElement ( 'span' );
b . className = 'lt-notif-badge' ;
b . setAttribute ( 'aria-live' , 'polite' );
b . setAttribute ( 'role' , 'status' );
wrap . classList . add ( 'lt-notif-wrap' );
wrap . appendChild ( b );
}
return b ;
}
const notif = {
set ( el , n ) {
const b = _notifBadge ( el ); if ( ! b ) return ;
const label = n > 99 ? '99+' : n > 0 ? String ( n ) : '' ;
b . textContent = label ;
b . setAttribute ( 'data-count' , n );
b . setAttribute ( 'aria-label' , n > 0 ? ` ${ n } notification ${ n !== 1 ? 's' : '' } ` : '' );
},
inc ( el ) {
const b = _notifBadge ( el ); if ( ! b ) return ;
const cur = parseInt ( b . getAttribute ( 'data-count' ) || '0' , 10 );
notif . set ( el , cur + 1 );
},
clear ( el ) { notif . set ( el , 0 ); },
};
/* ================================================================
MODULE 39 — RIGHT-SIDE DRAWER
lt.rightDrawer.open(id)
lt.rightDrawer.close(id)
lt.rightDrawer.toggle(id)
================================================================ */
function _rdOpen ( id , triggerEl ) {
const drawer = typeof id === 'string' ? document . getElementById ( id ) : id ;
if ( ! drawer ) return ;
const ovId = drawer . dataset . overlay || id + '-overlay' ;
const ov = document . getElementById ( ovId );
if ( _mnOpen ) _mnSetOpen ( false );
drawer . classList . add ( 'is-open' );
drawer . removeAttribute ( 'aria-hidden' );
if ( ov ) ov . classList . add ( 'is-open' );
_lockScroll ();
if ( triggerEl ) _modalTriggers . set ( drawer , triggerEl );
const first = drawer . querySelector ( _FOCUSABLE );
if ( first ) setTimeout (() => first . focus (), 50 );
// ESC to close + Tab trap
drawer . _rdKeyHandler = e => { if ( e . key === 'Escape' ) _rdClose ( drawer ); };
document . addEventListener ( 'keydown' , drawer . _rdKeyHandler );
drawer . _rdTrapHandler = e => { if ( e . key === 'Tab' ) _trapFocus ( drawer , e ); };
drawer . addEventListener ( 'keydown' , drawer . _rdTrapHandler );
// Overlay click
if ( ov ) ov . _rdClick = () => _rdClose ( drawer );
if ( ov ) ov . addEventListener ( 'click' , ov . _rdClick );
// Close button
drawer . querySelectorAll ( '[data-drawer-close]' ). forEach ( btn => {
btn . _rdHandler = () => _rdClose ( drawer );
btn . addEventListener ( 'click' , btn . _rdHandler );
});
}
function _rdClose ( id ) {
const drawer = typeof id === 'string' ? document . getElementById ( id ) : id ;
if ( ! drawer || ! drawer . classList . contains ( 'is-open' )) return ;
const ovId = drawer . dataset . overlay || ( drawer . id ? drawer . id + '-overlay' : null );
const ov = ovId ? document . getElementById ( ovId ) : null ;
drawer . classList . remove ( 'is-open' );
drawer . setAttribute ( 'aria-hidden' , 'true' );
if ( ov ) { ov . classList . remove ( 'is-open' ); if ( ov . _rdClick ) { ov . removeEventListener ( 'click' , ov . _rdClick ); delete ov . _rdClick ; } }
drawer . querySelectorAll ( '[data-drawer-close]' ). forEach ( btn => {
if ( btn . _rdHandler ) { btn . removeEventListener ( 'click' , btn . _rdHandler ); delete btn . _rdHandler ; }
});
_unlockScroll ();
if ( drawer . _rdKeyHandler ) { document . removeEventListener ( 'keydown' , drawer . _rdKeyHandler ); delete drawer . _rdKeyHandler ; }
if ( drawer . _rdTrapHandler ) { drawer . removeEventListener ( 'keydown' , drawer . _rdTrapHandler ); delete drawer . _rdTrapHandler ; }
const trigger = _modalTriggers . get ( drawer );
if ( trigger ) { trigger . focus (); _modalTriggers . delete ( drawer ); }
}
const rightDrawer = {
open : ( id ) => _rdOpen ( id , document . activeElement !== document . body ? document . activeElement : null ),
close : ( id ) => _rdClose ( id ),
toggle : ( id ) => {
const el = typeof id === 'string' ? document . getElementById ( id ) : id ;
if ( el && el . classList . contains ( 'is-open' )) _rdClose ( el ); else _rdOpen ( id );
},
};
// data-drawer-open="drawer-id" trigger wiring
document . addEventListener ( 'click' , e => {
const btn = e . target . closest ( '[data-drawer-open]' );
if ( btn ) { e . preventDefault (); _rdOpen ( btn . dataset . drawerOpen , btn ); }
});
/* ================================================================
MODULE 40 — CONTEXT MENU
lt.contextMenu.register(selector, items)
items = [{ label, icon, kbd, danger, divider, action }]
================================================================ */
let _ctxMenu = null , _ctxTrigger = null ;
const _ctxItems = {};
function _ctxShow ( x , y , items , trigger ) {
_ctxTrigger = trigger || ( document . activeElement !== document . body ? document . activeElement : null );
if ( ! _ctxMenu ) {
_ctxMenu = document . createElement ( 'div' );
_ctxMenu . className = 'lt-context-menu' ;
_ctxMenu . setAttribute ( 'role' , 'menu' );
document . body . appendChild ( _ctxMenu );
}
_ctxMenu . innerHTML = '' ;
items . forEach ( item => {
if ( item . divider ) { const d = document . createElement ( 'div' ); d . className = 'lt-context-menu-divider' ; _ctxMenu . appendChild ( d ); return ; }
if ( item . label && ! item . action ) { const l = document . createElement ( 'div' ); l . className = 'lt-context-menu-label' ; l . textContent = item . label ; _ctxMenu . appendChild ( l ); return ; }
const el = document . createElement ( 'div' );
el . className = 'lt-context-menu-item' + ( item . danger ? ' is-danger' : '' );
el . setAttribute ( 'role' , 'menuitem' );
el . setAttribute ( 'tabindex' , '0' );
el . innerHTML = ` ${ item . icon ? `<span class="icon"> ${ escHtml ( item . icon ) } </span>` : '<span class="icon"></span>' } <span> ${ escHtml ( item . label || '' ) } </span> ${ item . kbd ? `<kbd> ${ escHtml ( item . kbd ) } </kbd>` : '' } ` ;
el . addEventListener ( 'click' , () => { _ctxHide (); if ( item . action ) item . action (); });
el . addEventListener ( 'keydown' , e => {
const items = Array . from ( _ctxMenu . querySelectorAll ( '[role="menuitem"]' ));
const idx = items . indexOf ( e . currentTarget );
if ( e . key === 'Enter' || e . key === ' ' ) { e . preventDefault (); el . click (); }
else if ( e . key === 'ArrowDown' ) { e . preventDefault (); ( items [ idx + 1 ] || items [ 0 ]). focus (); }
else if ( e . key === 'ArrowUp' ) { e . preventDefault (); ( items [ idx - 1 ] || items [ items . length - 1 ]). focus (); }
else if ( e . key === 'Home' ) { e . preventDefault (); items [ 0 ]. focus (); }
else if ( e . key === 'End' ) { e . preventDefault (); items [ items . length - 1 ]. focus (); }
});
_ctxMenu . appendChild ( el );
});
_ctxMenu . classList . add ( 'is-open' );
// Position — keep on screen
const vw = window . innerWidth , vh = window . innerHeight ;
const mw = _ctxMenu . offsetWidth || 180 , mh = _ctxMenu . offsetHeight || 200 ;
_ctxMenu . style . left = Math . max ( 8 , Math . min ( x , vw - mw - 8 )) + 'px' ;
_ctxMenu . style . top = Math . min ( y , vh - mh - 8 ) + 'px' ;
// Focus first item
const first = _ctxMenu . querySelector ( '[role="menuitem"]' );
if ( first ) setTimeout (() => first . focus (), 20 );
}
function _ctxHide () {
if ( _ctxMenu ) _ctxMenu . classList . remove ( 'is-open' );
if ( _ctxTrigger && document . contains ( _ctxTrigger )) { _ctxTrigger . focus (); }
_ctxTrigger = null ;
}
document . addEventListener ( 'click' , () => _ctxHide ());
document . addEventListener ( 'contextmenu' , e => {
const target = e . target . closest ( '[data-context-menu]' );
if ( ! target ) { _ctxHide (); return ; }
e . preventDefault ();
const menuId = target . dataset . contextMenu ;
const items = _ctxItems [ menuId ];
if ( items ) _ctxShow ( e . clientX , e . clientY , items , target );
});
document . addEventListener ( 'keydown' , e => { if ( e . key === 'Escape' ) _ctxHide (); });
const contextMenu = {
register ( id , items ) { _ctxItems [ id ] = items ; },
show : ( x , y , items ) => _ctxShow ( x , y , items ),
hide : () => _ctxHide (),
};
/* ================================================================
MODULE 41 — OFFLINE DETECTION
lt.offline.onOnline(fn)
lt.offline.onOffline(fn)
lt.offline.isOnline()
================================================================ */
let _offlineBanner = null ;
function _offlineGetBanner () {
if ( ! _offlineBanner ) {
_offlineBanner = document . getElementById ( 'lt-offline-banner' );
if ( ! _offlineBanner ) {
_offlineBanner = document . createElement ( 'div' );
_offlineBanner . id = 'lt-offline-banner' ;
_offlineBanner . className = 'lt-offline-banner' ;
_offlineBanner . setAttribute ( 'role' , 'alert' );
_offlineBanner . setAttribute ( 'aria-live' , 'assertive' );
_offlineBanner . innerHTML = '<span class="lt-dot lt-dot--red"></span> NO NETWORK CONNECTION — RECONNECTING...' ;
document . body . appendChild ( _offlineBanner );
}
}
return _offlineBanner ;
}
const _offlineHandlers = [];
const _onlineHandlers = [];
window . addEventListener ( 'offline' , () => {
document . body . classList . add ( 'lt-is-offline' );
_offlineGetBanner (). classList . add ( 'is-visible' );
toast . warning ( 'Connection lost — working offline' );
_offlineHandlers . forEach ( fn => fn ());
});
window . addEventListener ( 'online' , () => {
document . body . classList . remove ( 'lt-is-offline' );
_offlineGetBanner (). classList . remove ( 'is-visible' );
toast . success ( 'Connection restored' );
_onlineHandlers . forEach ( fn => fn ());
});
const offline = {
isOnline : () => navigator . onLine ,
onOffline : fn => _offlineHandlers . push ( fn ),
onOnline : fn => _onlineHandlers . push ( fn ),
};
/* ================================================================
MODULE 42 — WEBSOCKET MANAGER
lt.ws.connect(url, opts)
opts: { protocols, onOpen, onMessage, onClose, onError,
reconnect: true, reconnectDelay: 2000, maxRetries: 10 }
Returns a handle: { send, close, status, on, off }
================================================================ */
const ws = {
connect ( url , opts = {}) {
const {
protocols = [],
onOpen = null ,
onMessage = null ,
onClose = null ,
onError = null ,
reconnect = true ,
reconnectDelay = 2000 ,
maxRetries = 10 ,
} = opts ;
let _sock = null ;
let _retries = 0 ;
let _closed = false ;
let _statusEl = opts . statusEl ? ( typeof opts . statusEl === 'string' ? document . querySelector ( opts . statusEl ) : opts . statusEl ) : null ;
const _handlers = {};
function _setStatus ( state ) {
if ( _statusEl ) {
_statusEl . setAttribute ( 'data-state' , state );
_statusEl . querySelector ( '.lt-dot' ); // force repaint
const labels = { connected : 'Connected' , connecting : 'Connecting…' , disconnected : 'Disconnected' };
const dot = _statusEl . querySelector ( '.lt-dot' );
const span = _statusEl . querySelector ( 'span:last-child' );
if ( span ) span . textContent = labels [ state ] || state ;
}
bus . emit ( 'ws:status' , { url , state });
}
function _connect () {
_setStatus ( 'connecting' );
try { _sock = protocols . length ? new WebSocket ( url , protocols ) : new WebSocket ( url ); }
catch ( e ) { console . error ( '[lt.ws] Failed to create WebSocket:' , e ); return ; }
_sock . addEventListener ( 'open' , e => {
_retries = 0 ;
_setStatus ( 'connected' );
if ( onOpen ) onOpen ( e );
( _handlers [ 'open' ] || []). forEach ( fn => fn ( e ));
});
_sock . addEventListener ( 'message' , e => {
let data = e . data ;
try { data = JSON . parse ( e . data ); } catch ( _ ) {}
if ( onMessage ) onMessage ( data , e );
( _handlers [ 'message' ] || []). forEach ( fn => fn ( data , e ));
});
_sock . addEventListener ( 'close' , e => {
_setStatus ( 'disconnected' );
if ( onClose ) onClose ( e );
( _handlers [ 'close' ] || []). forEach ( fn => fn ( e ));
if ( reconnect && ! _closed && _retries < maxRetries ) {
_retries ++ ;
const delay = Math . min ( reconnectDelay * Math . pow ( 1.5 , _retries - 1 ), 30000 );
setTimeout ( _connect , delay );
}
});
_sock . addEventListener ( 'error' , e => {
if ( onError ) onError ( e );
( _handlers [ 'error' ] || []). forEach ( fn => fn ( e ));
});
}
_connect ();
return {
send ( data ) {
if ( ! _sock || _sock . readyState !== WebSocket . OPEN ) { console . warn ( '[lt.ws] Not connected' ); return false ; }
_sock . send ( typeof data === 'string' ? data : JSON . stringify ( data ));
return true ;
},
close () { _closed = true ; if ( _sock ) _sock . close (); _setStatus ( 'disconnected' ); },
get status () { return _sock ? [ 'connecting' , 'open' , 'closing' , 'closed' ][ _sock . readyState ] : 'disconnected' ; },
on ( event , fn ) { if ( ! _handlers [ event ]) _handlers [ event ] = []; _handlers [ event ]. push ( fn ); return this ; },
off ( event , fn ) { if ( _handlers [ event ]) _handlers [ event ] = _handlers [ event ]. filter ( f => f !== fn ); return this ; },
};
},
};
/* ================================================================
MODULE 43 — MULTI-SELECT COMBOBOX
lt.combobox.init(inputEl, options, opts)
options = [{ value, label, icon? }]
opts: { max, placeholder, onChange }
================================================================ */
const combobox = {
init ( inputEl , options = [], opts = {}) {
const wrap = inputEl . closest ( '.lt-combobox' ) || inputEl . parentElement ;
const inputWrap = wrap . querySelector ( '.lt-combobox-input-wrap' ) || wrap ;
const dropdown = wrap . querySelector ( '.lt-combobox-dropdown' );
if ( ! dropdown ) return ;
const { max = Infinity , placeholder = 'Search…' , onChange = null } = opts ;
let selected = [];
let focusedIdx = - 1 ;
let filtered = [... options ];
// ARIA combobox wiring
const dropId = dropdown . id || ( 'lt-cb-drop-' + Math . random (). toString ( 36 ). slice ( 2 ));
dropdown . id = dropId ;
dropdown . setAttribute ( 'role' , 'listbox' );
inputEl . setAttribute ( 'role' , 'combobox' );
inputEl . setAttribute ( 'aria-expanded' , 'false' );
inputEl . setAttribute ( 'aria-controls' , dropId );
inputEl . setAttribute ( 'aria-autocomplete' , 'list' );
function _renderTags () {
wrap . querySelectorAll ( '.lt-combobox-tag' ). forEach ( t => t . remove ());
selected . forEach ( v => {
const opt = options . find ( o => o . value === v );
if ( ! opt ) return ;
const tag = document . createElement ( 'span' );
tag . className = 'lt-combobox-tag' ;
tag . innerHTML = ` ${ escHtml ( opt . label ) } <button type="button" class="lt-combobox-tag-remove" data-value=" ${ escHtml ( v ) } " aria-label="Remove ${ escHtml ( opt . label ) } ">✕</button>` ;
inputWrap . insertBefore ( tag , inputEl );
});
}
function _renderDropdown ( query ) {
const q = query . toLowerCase (). trim ();
filtered = options . filter ( o => ! selected . includes ( o . value ) && ( ! q || o . label . toLowerCase (). includes ( q )));
dropdown . innerHTML = '' ;
if ( ! filtered . length ) {
dropdown . innerHTML = `<div class="lt-combobox-empty"> ${ q ? 'No matches' : 'All selected' } </div>` ;
return ;
}
filtered . forEach (( opt , i ) => {
const el = document . createElement ( 'div' );
el . id = dropId + '-opt-' + i ;
el . className = 'lt-combobox-option' + ( selected . includes ( opt . value ) ? ' is-selected' : '' );
el . setAttribute ( 'role' , 'option' );
el . setAttribute ( 'data-value' , opt . value );
const safeLabel = escHtml ( opt . label );
const hl = q ? safeLabel . replace ( new RegExp ( `( ${ q . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' ) } )` , 'gi' ), '<mark>$1</mark>' ) : safeLabel ;
el . innerHTML = ` ${ opt . icon ? `<span class="icon"> ${ escHtml ( opt . icon ) } </span>` : '' } <span> ${ hl } </span>` ;
el . addEventListener ( 'mousedown' , e => { e . preventDefault (); _toggle ( opt . value ); });
dropdown . appendChild ( el );
});
focusedIdx = - 1 ;
}
function _toggle ( value ) {
const idx = selected . indexOf ( value );
if ( idx >= 0 ) { selected . splice ( idx , 1 ); }
else if ( selected . length < max ) { selected . push ( value ); }
_renderTags ();
_renderDropdown ( inputEl . value );
inputEl . value = '' ;
inputEl . focus ();
if ( onChange ) onChange ([... selected ]);
}
function _moveFocus ( dir ) {
const items = Array . from ( dropdown . querySelectorAll ( '.lt-combobox-option' ));
if ( ! items . length ) return ;
focusedIdx = Math . max ( 0 , Math . min ( focusedIdx + dir , items . length - 1 ));
items . forEach (( el , i ) => el . classList . toggle ( 'is-focused' , i === focusedIdx ));
items [ focusedIdx ]. scrollIntoView ({ block : 'nearest' });
inputEl . setAttribute ( 'aria-activedescendant' , items [ focusedIdx ]. id );
}
function _setOpen ( open ) {
dropdown . classList . toggle ( 'is-open' , open );
inputEl . setAttribute ( 'aria-expanded' , open ? 'true' : 'false' );
if ( ! open ) { inputEl . removeAttribute ( 'aria-activedescendant' ); focusedIdx = - 1 ; }
}
inputEl . addEventListener ( 'input' , () => { _setOpen ( true ); _renderDropdown ( inputEl . value ); });
inputEl . addEventListener ( 'focus' , () => { _setOpen ( true ); _renderDropdown ( inputEl . value ); });
inputEl . addEventListener ( 'keydown' , e => {
if ( e . key === 'ArrowDown' ) { e . preventDefault (); _moveFocus ( 1 ); }
if ( e . key === 'ArrowUp' ) { e . preventDefault (); _moveFocus ( - 1 ); }
2026-04-05 12:31:30 -04:00
if ( e . key === 'Enter' ) { e . preventDefault (); const idx = focusedIdx >= 0 ? focusedIdx : 0 ; if ( filtered [ idx ]) _toggle ( filtered [ idx ]. value ); }
2026-03-27 19:05:42 -04:00
if ( e . key === 'Escape' ) { _setOpen ( false ); }
if ( e . key === 'Backspace' && ! inputEl . value && selected . length ) { _toggle ( selected [ selected . length - 1 ]); }
});
inputWrap . addEventListener ( 'mousedown' , e => {
const rmBtn = e . target . closest ( '.lt-combobox-tag-remove' );
if ( rmBtn ) { e . preventDefault (); _toggle ( rmBtn . dataset . value ); return ; }
inputEl . focus ();
});
document . addEventListener ( 'click' , e => { if ( ! wrap . contains ( e . target )) _setOpen ( false ); });
_renderTags ();
_renderDropdown ( '' );
return {
getValue : () => [... selected ],
setValue : vals => { selected = [... vals ]; _renderTags (); _renderDropdown ( '' ); },
clear : () => { selected = []; _renderTags (); _renderDropdown ( '' ); },
};
},
};
/* ================================================================
MODULE 44 — AUTOCOMPLETE / TYPEAHEAD
lt.typeahead.init(inputEl, source, opts)
source: array or async fn(query) => [{value, label, icon?, meta?}]
opts: { minChars, debounceMs, onSelect, maxResults }
================================================================ */
const typeahead = {
init ( inputEl , source , opts = {}) {
const wrap = inputEl . closest ( '.lt-typeahead' ) || inputEl . parentElement ;
const dropdown = wrap . querySelector ( '.lt-typeahead-dropdown' );
if ( ! dropdown ) return ;
const { minChars = 1 , debounceMs = 150 , onSelect = null , maxResults = 10 } = opts ;
let _focusedIdx = - 1 ;
let _items = [];
let _debTimer = null ;
2026-07-10 13:50:27 -04:00
let _searchSeq = 0 ;
2026-03-27 19:05:42 -04:00
function _render ( items , query ) {
_items = items . slice ( 0 , maxResults );
dropdown . innerHTML = '' ;
if ( ! _items . length ) {
dropdown . innerHTML = `<div class="lt-typeahead-empty">[ NO RESULTS ]</div>` ;
return ;
}
const q = query . toLowerCase ();
_items . forEach (( item , i ) => {
const el = document . createElement ( 'div' );
el . id = ( inputEl . id || 'lt-ta' ) + '-item-' + i ;
el . className = 'lt-typeahead-item' ;
el . setAttribute ( 'role' , 'option' );
const safeItemLabel = escHtml ( item . label );
const hl = safeItemLabel . replace ( new RegExp ( `( ${ q . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' ) } )` , 'gi' ), '<mark>$1</mark>' );
el . innerHTML = ` ${ item . icon ? `<span class="icon"> ${ escHtml ( item . icon ) } </span>` : '' } <span> ${ hl } </span> ${ item . meta ? `<span style="margin-left:auto;color:var(--text-muted);font-size:0.68rem"> ${ escHtml ( item . meta ) } </span>` : '' } ` ;
el . addEventListener ( 'mousedown' , e => { e . preventDefault (); _select ( item ); });
dropdown . appendChild ( el );
});
_focusedIdx = - 1 ;
}
async function _search ( query ) {
2026-07-10 13:50:27 -04:00
// Sequence guard: only the latest query is allowed to render, so a slow
// earlier async source() cannot overwrite a newer query's results.
const seq = ++ _searchSeq ;
2026-03-27 19:05:42 -04:00
dropdown . innerHTML = '<div class="lt-typeahead-loading">Searching…</div>' ;
dropdown . classList . add ( 'is-open' );
inputEl . setAttribute ( 'aria-busy' , 'true' );
try {
const results = typeof source === 'function' ? await source ( query ) : source . filter ( i => i . label . toLowerCase (). includes ( query . toLowerCase ()));
2026-07-10 13:50:27 -04:00
if ( seq !== _searchSeq ) return ;
2026-03-27 19:05:42 -04:00
_render ( results , query );
} catch ( e ) {
2026-07-10 13:50:27 -04:00
if ( seq !== _searchSeq ) return ;
2026-03-27 19:05:42 -04:00
dropdown . innerHTML = '<div class="lt-typeahead-empty">Error loading results</div>' ;
} finally {
2026-07-10 13:50:27 -04:00
if ( seq === _searchSeq ) inputEl . setAttribute ( 'aria-busy' , 'false' );
2026-03-27 19:05:42 -04:00
}
}
function _select ( item ) {
inputEl . value = item . label ;
inputEl . removeAttribute ( 'aria-activedescendant' );
dropdown . classList . remove ( 'is-open' );
if ( onSelect ) onSelect ( item );
bus . emit ( 'typeahead:select' , { item });
}
function _moveFocus ( dir ) {
const els = Array . from ( dropdown . querySelectorAll ( '.lt-typeahead-item' ));
if ( ! els . length ) return ;
_focusedIdx = Math . max ( 0 , Math . min ( _focusedIdx + dir , els . length - 1 ));
els . forEach (( el , i ) => el . classList . toggle ( 'is-focused' , i === _focusedIdx ));
els [ _focusedIdx ]. scrollIntoView ({ block : 'nearest' });
inputEl . setAttribute ( 'aria-activedescendant' , els [ _focusedIdx ]. id );
}
inputEl . addEventListener ( 'input' , () => {
clearTimeout ( _debTimer );
const q = inputEl . value . trim ();
if ( q . length < minChars ) { dropdown . classList . remove ( 'is-open' ); return ; }
_debTimer = setTimeout (() => _search ( q ), debounceMs );
});
inputEl . addEventListener ( 'keydown' , e => {
if ( ! dropdown . classList . contains ( 'is-open' )) return ;
if ( e . key === 'ArrowDown' ) { e . preventDefault (); _moveFocus ( 1 ); }
if ( e . key === 'ArrowUp' ) { e . preventDefault (); _moveFocus ( - 1 ); }
2026-04-05 12:35:32 -04:00
if ( e . key === 'Enter' ) { e . preventDefault (); const idx = _focusedIdx >= 0 ? _focusedIdx : 0 ; if ( _items [ idx ]) _select ( _items [ idx ]); }
2026-03-27 19:05:42 -04:00
if ( e . key === 'Escape' ) { dropdown . classList . remove ( 'is-open' ); }
if ( e . key === 'Tab' ) { dropdown . classList . remove ( 'is-open' ); }
});
inputEl . addEventListener ( 'blur' , () => setTimeout (() => dropdown . classList . remove ( 'is-open' ), 150 ));
document . addEventListener ( 'click' , e => { if ( ! wrap . contains ( e . target )) dropdown . classList . remove ( 'is-open' ); });
return { search : q => _search ( q ) };
},
};
/* ================================================================
MODULE 45 — COOKIE UTILITY
lt.cookie.set(name, value, days?, opts?)
lt.cookie.get(name)
lt.cookie.del(name)
================================================================ */
const cookie = {
set ( name , value , days = 0 , opts = {}) {
let str = ` ${ encodeURIComponent ( name ) } = ${ encodeURIComponent ( value ) } ` ;
if ( days ) { const d = new Date (); d . setDate ( d . getDate () + days ); str += `; expires= ${ d . toUTCString () } ` ; }
str += `; path= ${ opts . path || '/' } ` ;
if ( opts . sameSite ) str += `; SameSite= ${ opts . sameSite } ` ;
if ( opts . secure || location . protocol === 'https:' ) str += '; Secure' ;
document . cookie = str ;
},
get ( name ) {
const key = encodeURIComponent ( name ) + '=' ;
const found = document . cookie . split ( ';' ). map ( c => c . trim ()). find ( c => c . startsWith ( key ));
return found ? decodeURIComponent ( found . slice ( key . length )) : null ;
},
del ( name , opts = {}) { cookie . set ( name , '' , - 1 , opts ); },
getAll () {
const out = {};
document . cookie . split ( ';' ). forEach ( c => {
const [ k , ... v ] = c . trim (). split ( '=' );
if ( k ) out [ decodeURIComponent ( k )] = decodeURIComponent ( v . join ( '=' ));
});
return out ;
},
};
/* ================================================================
MODULE 46 — SPLIT PANE
lt.splitPane.init(containerEl, opts)
opts: { minA, minB, initial (0-1), vertical, onResize }
================================================================ */
const splitPane = {
init ( container , opts = {}) {
const { minA = 80 , minB = 80 , initial = 0.5 , vertical = false , onResize = null } = opts ;
const panes = container . querySelectorAll ( '.lt-split-pane' );
const divider = container . querySelector ( '.lt-split-divider' );
if ( ! panes [ 0 ] || ! panes [ 1 ] || ! divider ) return ;
const dim = vertical ? 'height' : 'width' ;
const client = vertical ? 'clientY' : 'clientX' ;
let dragging = false ;
let startPos , startSizeA ;
function _setRatio ( ratio ) {
const total = vertical ? container . clientHeight : container . clientWidth ;
const divSize = vertical ? divider . offsetHeight : divider . offsetWidth ;
const available = total - divSize ;
const sizeA = Math . max ( minA , Math . min ( available - minB , ratio * available ));
panes [ 0 ]. style [ dim ] = sizeA + 'px' ;
panes [ 0 ]. style . flex = 'none' ;
panes [ 1 ]. style . flex = '1' ;
if ( onResize ) onResize ( sizeA , available - sizeA );
}
// Pointer events (handles both mouse and touch)
divider . addEventListener ( 'pointerdown' , e => {
e . preventDefault ();
dragging = true ;
divider . setPointerCapture ( e . pointerId );
divider . classList . add ( 'is-dragging' );
startPos = e [ client ];
startSizeA = vertical ? panes [ 0 ]. offsetHeight : panes [ 0 ]. offsetWidth ;
});
divider . addEventListener ( 'pointermove' , e => {
if ( ! dragging ) return ;
const delta = e [ client ] - startPos ;
const total = vertical ? container . clientHeight : container . clientWidth ;
const divSize = vertical ? divider . offsetHeight : divider . offsetWidth ;
const newSize = Math . max ( minA , Math . min ( total - divSize - minB , startSizeA + delta ));
panes [ 0 ]. style [ dim ] = newSize + 'px' ;
panes [ 0 ]. style . flex = 'none' ;
panes [ 1 ]. style . flex = '1' ;
if ( onResize ) onResize ( newSize , total - divSize - newSize );
});
divider . addEventListener ( 'pointerup' , () => { dragging = false ; divider . classList . remove ( 'is-dragging' ); });
// Keyboard resize support
divider . setAttribute ( 'tabindex' , '0' );
divider . setAttribute ( 'role' , 'separator' );
divider . setAttribute ( 'aria-label' , 'Resize panes' );
divider . addEventListener ( 'keydown' , e => {
const step = 0.05 ;
const total = vertical ? container . clientHeight : container . clientWidth ;
const divSize = vertical ? divider . offsetHeight : divider . offsetWidth ;
const available = total - divSize ;
const currentSize = vertical ? panes [ 0 ]. offsetHeight : panes [ 0 ]. offsetWidth ;
const currentRatio = currentSize / available ;
if (( e . key === 'ArrowRight' && ! vertical ) || ( e . key === 'ArrowDown' && vertical )) {
e . preventDefault (); _setRatio ( Math . min ( 1 , currentRatio + step ));
} else if (( e . key === 'ArrowLeft' && ! vertical ) || ( e . key === 'ArrowUp' && vertical )) {
e . preventDefault (); _setRatio ( Math . max ( 0 , currentRatio - step ));
} else if ( e . key === 'Home' ) { e . preventDefault (); _setRatio ( 0 ); }
else if ( e . key === 'End' ) { e . preventDefault (); _setRatio ( 1 ); }
});
_setRatio ( initial );
return { setRatio : _setRatio };
},
};
/* ================================================================
MODULE 48 — SIDEBAR SUBMENUS
Auto-inits .lt-sidebar-group elements.
Click label → toggle .is-open + animate submenu.
================================================================ */
function initSidebarSubmenus ( root ) {
const container = root || document ;
container . querySelectorAll ( '.lt-sidebar-group' ). forEach ( group => {
if ( group . _sbInit ) return ;
group . _sbInit = true ;
const label = group . querySelector ( '.lt-sidebar-group-label' );
if ( ! label ) return ;
label . setAttribute ( 'tabindex' , '0' );
label . setAttribute ( 'role' , 'button' );
const chevron = label . querySelector ( '.chevron, .lt-sidebar-chevron' );
if ( chevron ) chevron . setAttribute ( 'aria-hidden' , 'true' );
const _toggle = () => {
group . classList . toggle ( 'is-open' );
label . setAttribute ( 'aria-expanded' , group . classList . contains ( 'is-open' ) ? 'true' : 'false' );
};
label . setAttribute ( 'aria-expanded' , group . classList . contains ( 'is-open' ) ? 'true' : 'false' );
label . addEventListener ( 'click' , _toggle );
label . addEventListener ( 'keydown' , e => { if ( e . key === 'Enter' || e . key === ' ' ) { e . preventDefault (); _toggle (); } });
// Open group if it contains the active link
if ( group . querySelector ( '.lt-sidebar-sub-link.active, .lt-sidebar-sub-link[aria-current="page"]' )) {
group . classList . add ( 'is-open' );
label . setAttribute ( 'aria-expanded' , 'true' );
}
});
}
/* ================================================================
MODULE 49 — INFINITE SCROLL
lt.infiniteScroll.init(containerEl, loadFn, opts)
loadFn: async fn() → { items: [], done: bool }
opts: { threshold (px from bottom), loadingClass, sentinelClass }
================================================================ */
const infiniteScroll = {
init ( container , loadFn , opts = {}) {
const { threshold = 200 , onEmpty = null } = opts ;
let _loading = false ;
let _done = false ;
// Sentinel element at the bottom of the container
const sentinel = document . createElement ( 'div' );
sentinel . className = 'lt-infinite-sentinel' ;
sentinel . setAttribute ( 'aria-hidden' , 'true' );
container . appendChild ( sentinel );
// Loading indicator
const loadingEl = document . createElement ( 'div' );
loadingEl . className = 'lt-infinite-loading lt-loading lt-hidden' ;
loadingEl . setAttribute ( 'aria-live' , 'polite' );
loadingEl . setAttribute ( 'aria-label' , 'Loading more items' );
container . appendChild ( loadingEl );
async function _load () {
if ( _loading || _done ) return ;
_loading = true ;
loadingEl . classList . remove ( 'lt-hidden' );
try {
const result = await loadFn ();
if ( result && result . done ) {
_done = true ;
sentinel . remove ();
loadingEl . remove ();
if ( onEmpty ) onEmpty ();
}
} catch ( e ) {
console . error ( '[lt.infiniteScroll]' , e );
} finally {
_loading = false ;
loadingEl . classList . add ( 'lt-hidden' );
}
}
// Use IntersectionObserver if available, else scroll listener
if ( global . IntersectionObserver ) {
const io = new IntersectionObserver ( entries => {
if ( entries [ 0 ]. isIntersecting ) _load ();
}, { rootMargin : `0px 0px ${ threshold } px 0px` });
io . observe ( sentinel );
return { reset () { _done = false ; _loading = false ; }, stop () { io . disconnect (); } };
} else {
const scrollRoot = container === document . body ? window : container ;
function _onScroll () {
const el = container === document . body ? document . documentElement : container ;
const dist = el . scrollHeight - el . scrollTop - el . clientHeight ;
if ( dist < threshold ) _load ();
}
const _onScrollThrottled = throttle ( _onScroll , 150 );
scrollRoot . addEventListener ( 'scroll' , _onScrollThrottled , { passive : true });
return { reset () { _done = false ; _loading = false ; }, stop () { scrollRoot . removeEventListener ( 'scroll' , _onScrollThrottled ); } };
}
},
};
/* ================================================================
MODULE 49 — WIZARD / MULTI-STEP FORM
lt.wizard.init(containerEl, opts)
opts: { onStep(n, total), onComplete(data), validate(n) }
HTML: [data-wizard-step="1"] ... [data-wizard-nav]
================================================================ */
const wizard = {
init ( container , opts = {}) {
const { onStep = null , onComplete = null , validate = null } = opts ;
const steps = Array . from ( container . querySelectorAll ( '[data-wizard-step]' ));
const total = steps . length ;
let current = 0 ;
const formData = {};
function _getStepData ( idx ) {
const step = steps [ idx ];
const data = {};
step . querySelectorAll ( 'input, select, textarea' ). forEach ( el => {
if ( el . name ) data [ el . name ] = el . type === 'checkbox' ? el . checked : el . value ;
});
return data ;
}
function _show ( idx ) {
steps . forEach (( s , i ) => {
s . classList . toggle ( 'is-active' , i === idx );
if ( i !== idx ) s . setAttribute ( 'aria-hidden' , 'true' ); else s . removeAttribute ( 'aria-hidden' );
});
// Update step indicators
container . querySelectorAll ( '[data-wizard-indicator]' ). forEach (( ind , i ) => {
ind . classList . toggle ( 'is-active' , i === idx );
ind . classList . toggle ( 'is-complete' , i < idx );
ind . classList . remove ( 'is-error' );
});
// Update nav buttons
const prevBtn = container . querySelector ( '[data-wizard-prev]' );
const nextBtn = container . querySelector ( '[data-wizard-next]' );
const doneBtn = container . querySelector ( '[data-wizard-done]' );
if ( prevBtn ) prevBtn . disabled = idx === 0 ;
if ( nextBtn ) nextBtn . style . display = idx < total - 1 ? '' : 'none' ;
if ( doneBtn ) doneBtn . style . display = idx === total - 1 ? '' : 'none' ;
// Update step counter
container . querySelectorAll ( '[data-wizard-current]' ). forEach ( el => el . textContent = idx + 1 );
container . querySelectorAll ( '[data-wizard-total]' ). forEach ( el => el . textContent = total );
if ( onStep ) onStep ( idx + 1 , total , formData );
// Focus first input in step
const first = steps [ idx ]. querySelector ( 'input, select, textarea, button' );
if ( first ) setTimeout (() => first . focus (), 60 );
}
let _wizBusy = false ;
async function _next () {
if ( _wizBusy ) return ;
_wizBusy = true ;
try {
if ( validate ) {
const ok = await validate ( current + 1 , _getStepData ( current ));
if ( ! ok ) {
container . querySelectorAll ( '[data-wizard-indicator]' )[ current ] ? . classList . add ( 'is-error' );
return ;
}
}
Object . assign ( formData , _getStepData ( current ));
if ( current < total - 1 ) { current ++ ; _show ( current ); }
} finally {
_wizBusy = false ;
}
}
function _prev () {
if ( current > 0 ) { current -- ; _show ( current ); }
}
function _done () {
Object . assign ( formData , _getStepData ( current ));
if ( onComplete ) onComplete ({ ... formData });
}
function _goTo ( n ) { // 1-based
const idx = Math . max ( 0 , Math . min ( n - 1 , total - 1 ));
current = idx ; _show ( current );
}
// Wire up nav buttons
container . querySelector ( '[data-wizard-next]' ) ? . addEventListener ( 'click' , _next );
container . querySelector ( '[data-wizard-prev]' ) ? . addEventListener ( 'click' , _prev );
container . querySelector ( '[data-wizard-done]' ) ? . addEventListener ( 'click' , _done );
_show ( 0 );
return { next : _next , prev : _prev , goTo : _goTo , getData : () => ({ ... formData }), total };
},
};
/* ================================================================
MODULE 50 — SORTABLE (drag-to-reorder lists/kanban)
lt.sortable.init(listEl, opts)
opts: { handle (selector), onSort(newOrder, movedEl), group }
Supports cross-list dragging when group matches.
Returns { refresh(), getOrder() }; emits 'sortable:change' on bus
================================================================ */
// Shared cross-instance drag state (enables group/cross-column dragging)
let _srtDragging = null , _srtPlaceholder = null , _srtSrcList = null ;
const sortable = {
init ( list , opts = {}) {
const { handle = null , onSort = null , group = null } = opts ;
list . setAttribute ( 'data-sortable-group' , group || '' );
function _mark ( child ) {
child . setAttribute ( 'data-sortable-item' , '' );
child . setAttribute ( 'draggable' , handle ? 'false' : 'true' );
if ( handle ) {
const h = child . querySelector ( handle );
if ( h ) { h . setAttribute ( 'draggable' , 'true' ); h . style . cursor = 'grab' ; }
} else { child . style . cursor = 'grab' ; }
}
function _getItems () { return Array . from ( list . querySelectorAll ( ':scope > [data-sortable-item]' )); }
function _makePlaceholder ( el ) {
const ph = document . createElement ( el . tagName );
ph . className = 'lt-sortable-placeholder' ;
ph . style . height = el . offsetHeight + 'px' ;
ph . style . width = '100%' ;
return ph ;
}
function _sameGroup ( otherList ) {
if ( ! group ) return false ;
return otherList . getAttribute ( 'data-sortable-group' ) === group ;
}
// Mark all current children
Array . from ( list . children ). forEach ( _mark );
list . addEventListener ( 'dragstart' , e => {
const item = e . target . closest ( '[data-sortable-item]' );
if ( ! item || ! list . contains ( item )) return ;
_srtDragging = item ;
_srtSrcList = list ;
_srtPlaceholder = _makePlaceholder ( item );
requestAnimationFrame (() => { item . classList . add ( 'is-dragging' ); });
e . dataTransfer . effectAllowed = 'move' ;
e . dataTransfer . setData ( 'text/plain' , '' );
});
list . addEventListener ( 'dragend' , () => {
if ( ! _srtDragging ) return ;
_srtDragging . classList . remove ( 'is-dragging' );
if ( _srtPlaceholder && _srtPlaceholder . parentNode ) {
_srtPlaceholder . parentNode . insertBefore ( _srtDragging , _srtPlaceholder );
_srtPlaceholder . remove ();
}
if ( onSort ) onSort ( _getItems (), _srtDragging );
bus . emit ( 'sortable:change' , { list , items : _getItems (), moved : _srtDragging });
_srtDragging = null ; _srtPlaceholder = null ; _srtSrcList = null ;
});
list . addEventListener ( 'dragover' , e => {
if ( ! _srtDragging ) return ;
// Allow drop only within same list or same group
if ( list !== _srtSrcList && ! _sameGroup ( _srtSrcList )) return ;
e . preventDefault (); e . dataTransfer . dropEffect = 'move' ;
if ( ! _srtPlaceholder ) _srtPlaceholder = _makePlaceholder ( _srtDragging );
const over = e . target . closest ( '[data-sortable-item]' );
if ( over && over !== _srtDragging && list . contains ( over )) {
const rect = over . getBoundingClientRect ();
list . insertBefore ( _srtPlaceholder , e . clientY < rect . top + rect . height / 2 ? over : over . nextSibling );
} else if ( ! list . contains ( _srtPlaceholder )) {
list . appendChild ( _srtPlaceholder );
}
});
list . addEventListener ( 'drop' , e => { e . preventDefault (); });
return {
refresh () { Array . from ( list . children ). forEach ( child => { if ( ! child . hasAttribute ( 'data-sortable-item' )) _mark ( child ); }); },
getOrder : () => _getItems (). map ( el => el . dataset . id || el . textContent . trim ()),
};
},
};
/* ================================================================
MODULE 51 — COUNTDOWN / TIMER
lt.timer.countdown(el, targetDate, opts)
lt.timer.stopwatch(el, opts)
el = DOM element or selector; updates .textContent
opts: { onExpire, format, urgent (seconds), urgentClass }
================================================================ */
const timer = {
countdown ( el , target , opts = {}) {
const dom = typeof el === 'string' ? document . querySelector ( el ) : el ;
if ( ! dom ) return ;
const { onExpire = null , urgent = 300 , urgentClass = 'lt-text-red' } = opts ;
const end = target instanceof Date ? target : new Date ( target );
function _tick () {
const diff = Math . floor (( end - Date . now ()) / 1000 );
if ( diff <= 0 ) {
dom . textContent = '00:00:00' ;
dom . classList . add ( urgentClass );
if ( onExpire ) onExpire ();
clearInterval ( handle );
return ;
}
if ( diff <= urgent ) urgentClass . split ( /\s+/ ). filter ( Boolean ). forEach ( c => dom . classList . add ( c ));
const h = Math . floor ( diff / 3600 ), m = Math . floor (( diff % 3600 ) / 60 ), s = diff % 60 ;
dom . textContent = [ h , m , s ]. map ( n => String ( n ). padStart ( 2 , '0' )). join ( ':' );
}
_tick ();
const handle = setInterval ( _tick , 1000 );
return { stop : () => clearInterval ( handle ) };
},
stopwatch ( el , opts = {}) {
const dom = typeof el === 'string' ? document . querySelector ( el ) : el ;
if ( ! dom ) return ;
const { onTick = null } = opts ;
let start = Date . now (), paused = false , offset = 0 ;
function _tick () {
if ( paused ) return ;
const elapsed = Math . floor (( Date . now () - start + offset ) / 1000 );
const h = Math . floor ( elapsed / 3600 ), m = Math . floor (( elapsed % 3600 ) / 60 ), s = elapsed % 60 ;
dom . textContent = [ h , m , s ]. map ( n => String ( n ). padStart ( 2 , '0' )). join ( ':' );
if ( onTick ) onTick ( elapsed );
}
const handle = setInterval ( _tick , 1000 );
_tick ();
return {
pause () { paused = true ; offset += Date . now () - start ; },
resume () { paused = false ; start = Date . now (); },
reset () { offset = 0 ; start = Date . now (); _tick (); },
stop () { clearInterval ( handle ); },
elapsed : () => Math . floor (( Date . now () - start + offset ) / 1000 ),
};
},
};
/* ================================================================
MODULE 52 — IMAGE LIGHTBOX
lt.lightbox.init(selector, opts)
Clicking any matched image opens a full-screen overlay with
prev/next, keyboard nav, zoom.
opts: { caption (fn|'alt'|'title'), loop }
================================================================ */
const lightbox = {
init ( selector , opts = {}) {
const { caption = 'alt' , loop = true } = opts ;
let _images = [], _current = 0 , _overlay = null , _lbKeyBound = null , _lbTrigger = null ;
function _getCaption ( img ) {
if ( typeof caption === 'function' ) return caption ( img );
return img . getAttribute ( caption ) || '' ;
}
function _buildOverlay () {
if ( _overlay ) return ;
_overlay = document . createElement ( 'div' );
_overlay . className = 'lt-lightbox-overlay' ;
_overlay . setAttribute ( 'role' , 'dialog' );
_overlay . setAttribute ( 'aria-modal' , 'true' );
_overlay . setAttribute ( 'aria-label' , 'Image viewer' );
_overlay . innerHTML = `
<button type="button" class="lt-lightbox-close" aria-label="Close">×</button>
<button type="button" class="lt-lightbox-prev" aria-label="Previous">‹</button>
<button type="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 (); });
_lbKeyBound = _lbKey . bind ( null );
document . addEventListener ( 'keydown' , _lbKeyBound );
}
function _lbKey ( e ) {
if ( ! _overlay || ! _overlay . classList . contains ( 'is-open' )) return ;
if ( e . key === 'Escape' ) lightbox . close ();
if ( e . key === 'ArrowLeft' ) lightbox . prev ();
if ( e . key === 'ArrowRight' ) lightbox . next ();
}
function _show ( idx ) {
if ( ! _overlay ) _buildOverlay ();
if ( ! _overlay . classList . contains ( 'is-open' )) {
_lbTrigger = ( document . activeElement && document . activeElement !== document . body ) ? document . activeElement : null ;
}
_current = idx ;
const img = _images [ idx ];
const el = _overlay . querySelector ( '.lt-lightbox-img' );
el . src = img . src ; el . alt = img . alt || '' ;
_overlay . querySelector ( '.lt-lightbox-caption' ). textContent = _getCaption ( img );
_overlay . querySelector ( '.lt-lightbox-counter' ). textContent = ` ${ idx + 1 } / ${ _images . length } ` ;
// Hide prev/next when single image or at edges
_overlay . querySelector ( '.lt-lightbox-prev' ). style . display = ( loop || idx > 0 ) && _images . length > 1 ? '' : 'none' ;
_overlay . querySelector ( '.lt-lightbox-next' ). style . display = ( loop || idx < _images . length - 1 ) && _images . length > 1 ? '' : 'none' ;
_overlay . classList . add ( 'is-open' );
_lockScroll ();
setTimeout (() => { if ( document . contains ( el )) el . focus ? .(); }, 50 );
}
function _collect () {
_images = Array . from ( document . querySelectorAll ( selector ));
_images . forEach (( img , i ) => {
img . style . cursor = 'zoom-in' ;
img . setAttribute ( 'tabindex' , '0' );
img . removeEventListener ( 'click' , img . _lbHandler );
img . removeEventListener ( 'keydown' , img . _lbKeyHandler );
img . _lbHandler = () => _show ( i );
img . _lbKeyHandler = e => { if ( e . key === 'Enter' || e . key === ' ' ) _show ( i ); };
img . addEventListener ( 'click' , img . _lbHandler );
img . addEventListener ( 'keydown' , img . _lbKeyHandler );
});
}
_collect ();
return Object . assign ( lightbox , {
open : idx => _show ( idx ),
close () {
if ( ! _overlay ) return ;
_overlay . classList . remove ( 'is-open' );
_unlockScroll ();
if ( _lbKeyBound ) { document . removeEventListener ( 'keydown' , _lbKeyBound ); _lbKeyBound = null ; }
if ( _lbTrigger && document . contains ( _lbTrigger )) { _lbTrigger . focus (); }
_lbTrigger = null ;
},
prev () { _show ( loop ? ( _current - 1 + _images . length ) % _images . length : Math . max ( 0 , _current - 1 )); },
next () { _show ( loop ? ( _current + 1 ) % _images . length : Math . min ( _images . length - 1 , _current + 1 )); },
refresh : _collect ,
});
},
};
/* ================================================================
MODULE 53 — AUTH / JWT HELPERS
Extends lt.api with token refresh support.
lt.auth.setToken(accessToken, refreshToken, expiresIn)
lt.auth.getToken()
lt.auth.refresh() — explicit refresh
lt.auth.onExpire(fn) — callback when token expires & refresh fails
lt.auth.clear()
Auto-intercepts lt.api calls to inject Bearer header
and silently refreshes when token is within 60s of expiry.
================================================================ */
let _authAccess = null ;
let _authRefresh = null ;
let _authExpiry = 0 ; // epoch ms
let _authRefreshUrl = null ;
let _authRefreshing = null ; // in-flight promise
const _authExpireHandlers = [];
const auth = {
setToken ( access , refresh , expiresIn , refreshUrl ) {
_authAccess = access ;
_authRefresh = refresh ;
_authExpiry = expiresIn ? Date . now () + expiresIn * 1000 : 0 ;
if ( refreshUrl ) _authRefreshUrl = refreshUrl ;
try { sessionStorage . setItem ( 'lt_auth_access' , access ); } catch ( _ ) {}
},
getToken : () => _authAccess ,
clear () {
_authAccess = _authRefresh = null ; _authExpiry = 0 ;
try { sessionStorage . removeItem ( 'lt_auth_access' ); } catch ( _ ) {}
bus . emit ( 'auth:logout' );
},
onExpire : fn => _authExpireHandlers . push ( fn ),
async refresh () {
if ( ! _authRefreshUrl || ! _authRefresh ) return false ;
if ( _authRefreshing ) return _authRefreshing ;
_authRefreshing = fetch ( _authRefreshUrl , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ refresh_token : _authRefresh }),
})
. then ( r => r . json ())
. then ( data => {
if ( data . access_token ) {
_authAccess = data . access_token ;
_authExpiry = data . expires_in ? Date . now () + data . expires_in * 1000 : 0 ;
bus . emit ( 'auth:refreshed' );
return true ;
}
throw new Error ( 'Refresh failed' );
})
. catch ( e => {
console . error ( '[lt.auth]' , e );
_authExpireHandlers . forEach ( fn => fn ());
bus . emit ( 'auth:expired' );
return false ;
})
. finally (() => { _authRefreshing = null ; });
return _authRefreshing ;
},
isExpired : () => _authExpiry > 0 && Date . now () >= _authExpiry ,
isExpiringSoon : ( secs = 60 ) => _authExpiry > 0 && Date . now () >= _authExpiry - secs * 1000 ,
};
// Patch lt.api — auth-aware wrapper (renamed to avoid strict-mode duplicate declaration)
async function _apiFetchAuth ( method , url , body ) {
if ( _authAccess && auth . isExpiringSoon ()) await auth . refresh ();
const opts = { method , headers : Object . assign ({ 'Content-Type' : 'application/json' }, csrfHeaders ()) };
if ( _authAccess ) opts . headers [ 'Authorization' ] = 'Bearer ' + _authAccess ;
if ( body !== undefined ) opts . body = JSON . stringify ( body );
let resp ;
try { resp = await fetch ( url , opts ); } catch ( err ) { throw new Error ( 'Network error: ' + err . message ); }
// Auto-retry once on 401 after silent token refresh
if ( resp . status === 401 && _authRefresh ) {
const ok = await auth . refresh ();
if ( ok ) {
opts . headers [ 'Authorization' ] = 'Bearer ' + _authAccess ;
try { resp = await fetch ( url , opts ); } catch ( err ) { throw new Error ( 'Network error: ' + err . message ); }
}
}
let data ;
try { data = await resp . json (); } catch ( _ ) { data = { success : resp . ok }; }
2026-07-10 13:50:27 -04:00
// Resync CSRF token from any response body that carries a fresh one
// (bootstrap rotates on success and returns the current token on rejection).
if ( data && data . csrf_token ) global . CSRF_TOKEN = data . csrf_token ;
if ( ! resp . ok ) {
const err = new Error ( data . error || data . message || 'HTTP ' + resp . status );
err . data = data ;
err . status = resp . status ;
throw err ;
}
2026-03-27 19:05:42 -04:00
return data ;
}
api . get = url => _apiFetchAuth ( 'GET' , url );
api . post = ( u , b ) => _apiFetchAuth ( 'POST' , u , b );
api . put = ( u , b ) => _apiFetchAuth ( 'PUT' , u , b );
api . patch = ( u , b ) => _apiFetchAuth ( 'PATCH' , u , b );
api . delete = ( u , b ) => _apiFetchAuth ( 'DELETE' , u , b );
2026-07-10 13:50:27 -04:00
/* ================================================================
TICKET STATUS CHANGE (comment-aware)
lt.ticketStatus.submit(ticketId, newStatus, { comment? }) → Promise<data>
Posts /api/update_ticket.php. If the server rejects with
requires_comment, opens a comment modal, persists the comment via
/api/add_comment.php, then retries the update once WITH the comment.
Rejects with err.cancelled === true if the user cancels the modal.
================================================================ */
function _statusCommentModal ( newStatus ) {
return new Promise ( resolve => {
const modalId = 'ltStatusCommentModal' + Date . now ();
const safeStatus = escHtml ( newStatus );
document . body . insertAdjacentHTML ( 'beforeend' ,
'<div class="lt-modal-overlay" id="' + modalId + '" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="' + modalId + '_title">' +
'<div class="lt-modal lt-modal-sm">' +
'<div class="lt-modal-header" style="color:var(--terminal-amber)">' +
'<span class="lt-modal-title" id="' + modalId + '_title">[ ! ] Change Status to ' + safeStatus + '</span>' +
'<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>' +
'</div>' +
'<div class="lt-modal-body">' +
'<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.6rem">A comment is required when changing status to <strong>' + safeStatus + '</strong>. Enter your reason below.</p>' +
'<textarea id="' + modalId + '_comment" class="lt-input lt-w-full" rows="3" placeholder="Reason for status change…" style="resize:vertical;font-family:inherit;font-size:0.8rem" aria-label="Required comment for status change"></textarea>' +
'</div>' +
'<div class="lt-modal-footer">' +
'<button class="lt-btn lt-btn-primary" id="' + modalId + '_confirm">CONFIRM CHANGE</button>' +
'<button class="lt-btn lt-btn-ghost" id="' + modalId + '_cancel">CANCEL</button>' +
'</div>' +
'</div>' +
'</div>' );
const modalEl = document . getElementById ( modalId );
openModal ( modalId );
let done = false ;
const finish = ( value ) => {
if ( done ) return ;
done = true ;
closeModal ( modalId );
setTimeout (() => { if ( modalEl && modalEl . parentNode ) modalEl . remove (); }, 300 );
resolve ( value );
};
2026-08-07 23:07:06 -04:00
// Any dismissal counts as "no comment given", including a backdrop click or
// Escape, which close the overlay through the global handlers above.
modalEl . addEventListener ( 'lt:modalclose' , () => finish ( null ));
2026-07-10 13:50:27 -04:00
modalEl . querySelector ( '[data-modal-close]' ). addEventListener ( 'click' , () => finish ( null ));
document . getElementById ( modalId + '_cancel' ). addEventListener ( 'click' , () => finish ( null ));
document . getElementById ( modalId + '_confirm' ). addEventListener ( 'click' , () => {
const ta = document . getElementById ( modalId + '_comment' );
const comment = ta ? ta . value . trim () : '' ;
if ( ! comment ) { if ( ta ) ta . focus (); toast . warning ( 'Please enter a reason for this status change.' ); return ; }
finish ( comment );
});
setTimeout (() => { const ta = document . getElementById ( modalId + '_comment' ); if ( ta ) ta . focus (); }, 100 );
});
}
const ticketStatus = {
submit ( ticketId , newStatus , opts ) {
opts = opts || {};
const id = String ( ticketId );
const payload = { ticket_id : id , status : newStatus };
if ( opts . comment ) payload . comment = opts . comment ;
return api . post ( '/api/update_ticket.php' , payload ). catch ( err => {
if ( ! ( err && err . data && err . data . requires_comment )) throw err ;
return _statusCommentModal ( newStatus ). then ( comment => {
if ( ! comment ) {
const cancelErr = new Error ( 'Status change cancelled' );
cancelErr . cancelled = true ;
throw cancelErr ;
}
// Persist the comment, then retry the status change with it included.
return api . post ( '/api/add_comment.php' , { ticket_id : id , comment_text : comment })
. then (() => api . post ( '/api/update_ticket.php' , { ticket_id : id , status : newStatus , comment : comment }));
});
});
},
};
2026-03-27 19:05:42 -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 ) {
2026-07-10 13:50:27 -04:00
// Always use the built-in XSS-safe micro-renderer. Do NOT delegate to
// window.marked / window.markdownit: their raw HTML output is not sanitized
// here, so delegating would enable stored XSS if such a lib were ever loaded.
2026-03-27 19:05:42 -04:00
// Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr
let html = escHtml ( md )
// Fenced code blocks
. replace ( /```(\w*)\n([\s\S]*?)```/g , ( _ , lang , code ) => `<pre class="lt-code-block"><code class="lt-tok tok- ${ lang || 'plain' } "> ${ code . trim () } </code></pre>` )
// Inline code
. replace ( /`([^`]+)`/g , '<code>$1</code>' )
// Headings
. replace ( /^######\s(.+)$/gm , '<h6>$1</h6>' )
. replace ( /^#####\s(.+)$/gm , '<h5>$1</h5>' )
. replace ( /^####\s(.+)$/gm , '<h4>$1</h4>' )
. replace ( /^###\s(.+)$/gm , '<h3>$1</h3>' )
. replace ( /^##\s(.+)$/gm , '<h2>$1</h2>' )
. replace ( /^#\s(.+)$/gm , '<h1>$1</h1>' )
// Bold / italic
. replace ( /\*\*\*(.+?)\*\*\*/g , '<strong><em>$1</em></strong>' )
. replace ( /\*\*(.+?)\*\*/g , '<strong>$1</strong>' )
. replace ( /\*(.+?)\*/g , '<em>$1</em>' )
. replace ( /__(.+?)__/g , '<strong>$1</strong>' )
. replace ( /_(.+?)_/g , '<em>$1</em>' )
// Links — block javascript: and data: URIs
. replace ( /\[([^\]]+)\]\(([^)]+)\)/g , ( _ , text , url ) => {
const safeUrl = /^(https?:\/\/|\/|#|\.\.?\/)/i . test ( url ) ? url : '#' ;
return `<a href=" ${ safeUrl } " target="_blank" rel="noopener noreferrer"> ${ escHtml ( text ) } </a>` ;
})
// Images — block javascript: and data: URIs
. replace ( /!\[([^\]]*)\]\(([^)]+)\)/g , ( _ , alt , src ) => {
const safeSrc = /^(https?:\/\/|\/|\.\.?\/)/i . test ( src ) ? src : '' ;
return `<img src=" ${ safeSrc } " alt=" ${ escHtml ( alt ) } " style="max-width:100%">` ;
})
// Blockquote
. replace ( /^>\s(.+)$/gm , '<blockquote>$1</blockquote>' )
// Horizontal rule
. replace ( /^(-{3,}|\*{3,}|_{3,})$/gm , '<hr>' )
// Unordered list items
. replace ( /^[-*+]\s(.+)$/gm , '<li>$1</li>' )
. replace ( /(<li>[\s\S]+?<\/li>\n?)+/g , m => `<ul> ${ m } </ul>` )
// Ordered list items
. replace ( /^\d+\.\s(.+)$/gm , '<li>$1</li>' )
// Paragraphs (double newline)
. replace ( /\n{2,}/g , '</p><p>' )
. replace ( /\n/g , '<br>' );
return `<p> ${ html } </p>`
. replace ( /<p>(<(?:pre|ul|ol|h[1-6]|blockquote|hr)[^>]*>)/g , '$1' )
. replace ( /(<\/(?:pre|ul|ol|h[1-6]|blockquote|hr)>)<\/p>/g , '$1' );
},
init ( selector ) {
document . querySelectorAll ( selector ). forEach ( el => {
const raw = el . getAttribute ( 'data-markdown' ) || el . textContent ;
el . innerHTML = markdown . render ( raw );
el . classList . add ( 'lt-markdown' );
});
},
};
/* ================================================================
MODULE 55 — PAGINATION
lt.pagination.init(navEl, opts)
opts: { total, perPage, page, onChange(page) }
Renders page buttons inside navEl; re-renders on page change.
================================================================ */
const pagination = {
init ( nav , opts = {}) {
if ( typeof nav === 'string' ) nav = document . querySelector ( nav );
if ( ! nav ) return null ;
let { total = 0 , perPage = 10 , page = 1 , onChange = null , maxBtns = 7 } = opts ;
function _pages () { return Math . max ( 1 , Math . ceil ( total / perPage )); }
function render () {
const pages = _pages ();
let html = '' ;
// Prev
html += `<button type="button" class="lt-page-btn" ${ page <= 1 ? 'disabled' : '' } data-page=" ${ page - 1 } " aria-label="Previous page">«</button>` ;
// Page buttons with ellipsis
const half = Math . floor (( maxBtns - 2 ) / 2 );
let start = Math . max ( 2 , page - half );
let end = Math . min ( pages - 1 , page + half );
if ( end - start < maxBtns - 3 ) {
if ( start === 2 ) end = Math . min ( pages - 1 , start + maxBtns - 3 );
else start = Math . max ( 2 , end - maxBtns + 3 );
}
html += `<button type="button" class="lt-page-btn ${ page === 1 ? ' active' : '' } " data-page="1" ${ page === 1 ? ' aria-current="page"' : '' } aria-label="Page 1">1</button>` ;
if ( start > 2 ) html += `<button type="button" class="lt-page-btn" disabled aria-hidden="true">…</button>` ;
for ( let i = start ; i <= end ; i ++ ) {
html += `<button type="button" class="lt-page-btn ${ page === i ? ' active' : '' } " data-page=" ${ i } " ${ page === i ? ' aria-current="page"' : '' } aria-label="Page ${ i } "> ${ i } </button>` ;
}
if ( end < pages - 1 ) html += `<button type="button" class="lt-page-btn" disabled aria-hidden="true">…</button>` ;
if ( pages > 1 ) html += `<button type="button" class="lt-page-btn ${ page === pages ? ' active' : '' } " data-page=" ${ pages } " ${ page === pages ? ' aria-current="page"' : '' } aria-label="Page ${ pages } "> ${ pages } </button>` ;
// Next
html += `<button type="button" class="lt-page-btn" ${ page >= pages ? 'disabled' : '' } data-page=" ${ page + 1 } " aria-label="Next page">»</button>` ;
if ( ! nav . getAttribute ( 'role' )) nav . setAttribute ( 'role' , 'navigation' );
if ( ! nav . getAttribute ( 'aria-label' )) nav . setAttribute ( 'aria-label' , 'Pagination' );
nav . innerHTML = html ;
}
nav . addEventListener ( 'click' , e => {
const btn = e . target . closest ( '.lt-page-btn' );
if ( ! btn || btn . disabled || btn . classList . contains ( 'active' )) return ;
const p = parseInt ( btn . dataset . page , 10 );
if ( ! p || p < 1 || p > _pages ()) return ;
page = p ;
render ();
if ( onChange ) onChange ( page );
});
render ();
return {
setTotal ( n ) { total = n ; page = 1 ; render (); },
setPage ( p ) { page = Math . max ( 1 , Math . min ( _pages (), p )); render (); },
getPage () { return page ; },
getPages () { return _pages (); },
};
},
};
/* ================================================================
MASTER INIT
lt.init(opts?)
Call once after DOM ready. Runs all standard auto-init modules.
Opts: { boot: bool, bootName: str, tooltip: bool, accordion: bool,
alerts: bool, clipboard: bool, sidebar: bool, submenus: bool }
Individual modules can still be called manually.
================================================================ */
let _ltInitialized = false ;
function ltInit ( opts ) {
if ( _ltInitialized ) return ; // Guard: safe to call multiple times
_ltInitialized = true ;
const o = Object . assign ({
boot : true ,
bootName : null ,
tooltip : true ,
accordion : true ,
alerts : true ,
clipboard : true ,
sidebar : true ,
submenus : true ,
}, opts || {});
if ( o . accordion ) accordion . init ();
if ( o . tooltip ) tooltip . init ();
if ( o . alerts ) alerts . init ();
if ( o . clipboard ) initCopyButtons ();
if ( o . sidebar ) initSidebar ();
if ( o . submenus ) initSidebarSubmenus ();
if ( o . boot ) {
const bootEl = document . getElementById ( 'lt-boot' );
if ( bootEl ) {
const name = o . bootName || bootEl . dataset . appName || document . title . split ( '—' )[ 0 ]. trim ();
boot . run ( name );
}
}
}
/* ================================================================
PUBLIC API
2026-03-17 23:44:46 -04:00
---------------------------------------------------------------- */
global . lt = {
2026-03-27 19:05:42 -04:00
/* Master initializer */
init : ltInit ,
/* Core */
2026-03-17 23:44:46 -04:00
escHtml ,
toast ,
2026-03-27 19:05:42 -04:00
beep : _beep ,
2026-03-17 23:44:46 -04:00
modal ,
tabs ,
boot ,
keys ,
sidebar ,
csrf ,
api ,
time ,
bytes : { format : formatBytes },
tableNav ,
sortTable ,
statsFilter ,
autoRefresh ,
2026-03-27 19:05:42 -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 ,
/* v1.1 new features */
theme ,
notif ,
rightDrawer ,
contextMenu ,
offline ,
ws ,
combobox ,
typeahead ,
cookie ,
splitPane ,
infiniteScroll ,
wizard ,
sortable ,
timer ,
lightbox ,
auth ,
markdown ,
2026-07-10 13:50:27 -04:00
ticketStatus ,
2026-03-27 19:05:42 -04:00
pagination ,
sidebarSubmenus : { init : initSidebarSubmenus },
2026-03-17 23:44:46 -04:00
};
}( window ));