v1.1: Add 10 new feature modules + 18 CSS component sections

JS modules added:
- lt.theme    — dark/light toggle, OS preference sync, localStorage persist
- lt.notif    — notification badge (set/inc/clear) on any element
- lt.rightDrawer — right-side detail panel with focus trap + return focus
- lt.contextMenu — right-click custom menu, keyboard nav, danger variant
- lt.offline  — navigator.onLine banner + event hooks
- lt.ws       — WebSocket manager with exponential backoff reconnect
- lt.combobox — multi-select with search, tag chips, keyboard nav
- lt.typeahead — async/sync autocomplete with match highlighting
- lt.cookie   — get/set/del with SameSite/Secure helpers
- lt.splitPane — pointer-events resizable split pane (horizontal/vertical)
- Toast queue: max-stack, progress bar drain animation, auto-drain

CSS sections added (51–68):
- Light theme (html[data-theme="light"]) with full variable overrides
- Theme toggle button (.lt-theme-btn)
- Skeleton loader variants (card, row, text, title, avatar, btn, badge)
- Empty state component (.lt-empty-state, --sm variant)
- Nav notification badge (.lt-notif-wrap / .lt-notif-badge)
- Right-side drawer (.lt-drawer-right + overlay)
- Sticky table header (.lt-table-sticky-wrap)
- Multi-select combobox (.lt-combobox, tags, dropdown)
- Context menu (.lt-context-menu, divider, label, danger)
- Offline banner (.lt-offline-banner)
- Timeline / activity feed (.lt-timeline, color variants)
- Avatar + avatar group + status ring (.lt-avatar)
- Split pane (.lt-split, .lt-split-divider with pointer drag)
- Chart container (.lt-chart-wrap, legend, axis, loading state)
- Toast queue stack + progress drain bar
- Autocomplete / typeahead (.lt-typeahead-dropdown, match highlight)
- WebSocket status indicator (.lt-ws-status, data-state variants)
- Print enhancements (extended @media print rules)

HTML demo sections for all new components added to base.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 22:29:55 -04:00
parent db67f0c92b
commit 0eb91f1937
3 changed files with 1941 additions and 1 deletions
+635 -1
View File
@@ -1447,7 +1447,630 @@
};
/* ----------------------------------------------------------------
/* ================================================================
MODULE 37 — THEME TOGGLE
lt.theme.toggle()
lt.theme.set('light'|'dark')
lt.theme.get()
================================================================ */
const _themeKey = 'lt_theme';
function _applyTheme(t) {
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem(_themeKey, t); } catch(_) {}
document.querySelectorAll('.lt-theme-btn').forEach(btn => {
btn.textContent = t === 'light' ? '◐' : '☀';
btn.setAttribute('aria-label', t === 'light' ? 'Switch to dark mode' : 'Switch to light mode');
btn.setAttribute('title', t === 'light' ? 'Switch to dark mode' : 'Switch to light mode');
});
bus.emit('theme:change', { theme: t });
}
const _initTheme = (function() {
let saved;
try { saved = localStorage.getItem(_themeKey); } catch(_) {}
return saved || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
})();
_applyTheme(_initTheme);
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', e => {
let saved; try { saved = localStorage.getItem(_themeKey); } catch(_) {}
if (!saved) _applyTheme(e.matches ? 'light' : 'dark');
});
const theme = {
toggle: () => _applyTheme(document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light'),
set: t => _applyTheme(t),
get: () => document.documentElement.getAttribute('data-theme') || 'dark',
};
/* ================================================================
MODULE 38 — NOTIFICATION BADGE
lt.notif.set(el, count)
lt.notif.inc(el)
lt.notif.clear(el)
el = CSS selector string or DOM element
================================================================ */
function _notifEl(el) { return typeof el === 'string' ? document.querySelector(el) : el; }
function _notifBadge(el) {
const wrap = _notifEl(el); if (!wrap) return null;
let b = wrap.querySelector(':scope > .lt-notif-badge');
if (!b) {
b = document.createElement('span');
b.className = 'lt-notif-badge';
b.setAttribute('aria-live', 'polite');
b.setAttribute('role', 'status');
wrap.classList.add('lt-notif-wrap');
wrap.appendChild(b);
}
return b;
}
const notif = {
set(el, n) {
const b = _notifBadge(el); if (!b) return;
const label = n > 99 ? '99+' : n > 0 ? String(n) : '';
b.textContent = label;
b.setAttribute('data-count', n);
b.setAttribute('aria-label', n > 0 ? `${n} notification${n !== 1 ? 's' : ''}` : '');
},
inc(el) {
const b = _notifBadge(el); if (!b) return;
const cur = parseInt(b.getAttribute('data-count') || '0', 10);
notif.set(el, cur + 1);
},
clear(el) { notif.set(el, 0); },
};
/* ================================================================
MODULE 39 — RIGHT-SIDE DRAWER
lt.rightDrawer.open(id)
lt.rightDrawer.close(id)
lt.rightDrawer.toggle(id)
================================================================ */
function _rdOpen(id, triggerEl) {
const drawer = typeof id === 'string' ? document.getElementById(id) : id;
if (!drawer) return;
const ovId = drawer.dataset.overlay || id + '-overlay';
const ov = document.getElementById(ovId);
if (_mnOpen) _mnSetOpen(false);
drawer.classList.add('is-open');
drawer.setAttribute('aria-hidden', 'false');
if (ov) ov.classList.add('is-open');
_lockScroll();
if (triggerEl) _modalTriggers.set(drawer, triggerEl);
const first = drawer.querySelector(_FOCUSABLE);
if (first) setTimeout(() => first.focus(), 50);
// ESC to close
drawer._rdKeyHandler = e => { if (e.key === 'Escape') _rdClose(drawer); };
document.addEventListener('keydown', drawer._rdKeyHandler);
// Overlay click
if (ov) ov._rdClick = () => _rdClose(drawer);
if (ov) ov.addEventListener('click', ov._rdClick);
// Close button
drawer.querySelectorAll('[data-drawer-close]').forEach(btn => {
btn._rdHandler = () => _rdClose(drawer);
btn.addEventListener('click', btn._rdHandler);
});
}
function _rdClose(id) {
const drawer = typeof id === 'string' ? document.getElementById(id) : id;
if (!drawer || !drawer.classList.contains('is-open')) return;
const ovId = drawer.dataset.overlay || (drawer.id ? drawer.id + '-overlay' : null);
const ov = ovId ? document.getElementById(ovId) : null;
drawer.classList.remove('is-open');
drawer.setAttribute('aria-hidden', 'true');
if (ov) { ov.classList.remove('is-open'); if (ov._rdClick) { ov.removeEventListener('click', ov._rdClick); delete ov._rdClick; } }
_unlockScroll();
if (drawer._rdKeyHandler) { document.removeEventListener('keydown', drawer._rdKeyHandler); delete drawer._rdKeyHandler; }
const trigger = _modalTriggers.get(drawer);
if (trigger) { trigger.focus(); _modalTriggers.delete(drawer); }
}
const rightDrawer = {
open: (id) => _rdOpen(id, document.activeElement !== document.body ? document.activeElement : null),
close: (id) => _rdClose(id),
toggle: (id) => {
const el = typeof id === 'string' ? document.getElementById(id) : id;
if (el && el.classList.contains('is-open')) _rdClose(el); else _rdOpen(id);
},
};
// data-drawer-open="drawer-id" trigger wiring
document.addEventListener('click', e => {
const btn = e.target.closest('[data-drawer-open]');
if (btn) { e.preventDefault(); _rdOpen(btn.dataset.drawerOpen, btn); }
});
/* ================================================================
MODULE 40 — CONTEXT MENU
lt.contextMenu.register(selector, items)
items = [{ label, icon, kbd, danger, divider, action }]
================================================================ */
let _ctxMenu = null;
let _ctxItems = [];
function _ctxShow(x, y, items) {
if (!_ctxMenu) {
_ctxMenu = document.createElement('div');
_ctxMenu.className = 'lt-context-menu';
_ctxMenu.setAttribute('role', 'menu');
document.body.appendChild(_ctxMenu);
}
_ctxMenu.innerHTML = '';
items.forEach(item => {
if (item.divider) { const d = document.createElement('div'); d.className = 'lt-context-menu-divider'; _ctxMenu.appendChild(d); return; }
if (item.label && !item.action) { const l = document.createElement('div'); l.className = 'lt-context-menu-label'; l.textContent = item.label; _ctxMenu.appendChild(l); return; }
const el = document.createElement('div');
el.className = 'lt-context-menu-item' + (item.danger ? ' is-danger' : '');
el.setAttribute('role', 'menuitem');
el.setAttribute('tabindex', '0');
el.innerHTML = `${item.icon ? `<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 => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); } });
_ctxMenu.appendChild(el);
});
_ctxMenu.classList.add('is-open');
// Position — keep on screen
const vw = window.innerWidth, vh = window.innerHeight;
const mw = _ctxMenu.offsetWidth || 180, mh = _ctxMenu.offsetHeight || 200;
_ctxMenu.style.left = Math.min(x, vw - mw - 8) + 'px';
_ctxMenu.style.top = Math.min(y, vh - mh - 8) + 'px';
// Focus first item
const first = _ctxMenu.querySelector('[role="menuitem"]');
if (first) setTimeout(() => first.focus(), 20);
}
function _ctxHide() {
if (_ctxMenu) _ctxMenu.classList.remove('is-open');
}
document.addEventListener('click', () => _ctxHide());
document.addEventListener('contextmenu', e => {
const target = e.target.closest('[data-context-menu]');
if (!target) { _ctxHide(); return; }
e.preventDefault();
const menuId = target.dataset.contextMenu;
const items = _ctxItems[menuId];
if (items) _ctxShow(e.clientX, e.clientY, items);
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') _ctxHide(); });
const contextMenu = {
register(id, items) { _ctxItems[id] = items; },
show: (x, y, items) => _ctxShow(x, y, items),
hide: () => _ctxHide(),
};
/* ================================================================
MODULE 41 — OFFLINE DETECTION
lt.offline.onOnline(fn)
lt.offline.onOffline(fn)
lt.offline.isOnline()
================================================================ */
let _offlineBanner = null;
function _offlineGetBanner() {
if (!_offlineBanner) {
_offlineBanner = document.getElementById('lt-offline-banner');
if (!_offlineBanner) {
_offlineBanner = document.createElement('div');
_offlineBanner.id = 'lt-offline-banner';
_offlineBanner.className = 'lt-offline-banner';
_offlineBanner.setAttribute('role', 'alert');
_offlineBanner.setAttribute('aria-live', 'assertive');
_offlineBanner.innerHTML = '<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];
function _renderTags() {
wrap.querySelectorAll('.lt-combobox-tag').forEach(t => t.remove());
selected.forEach(v => {
const opt = options.find(o => o.value === v);
if (!opt) return;
const tag = document.createElement('span');
tag.className = 'lt-combobox-tag';
tag.innerHTML = `${escHtml(opt.label)}<button class="lt-combobox-tag-remove" data-value="${escHtml(v)}" aria-label="Remove ${escHtml(opt.label)}">✕</button>`;
inputWrap.insertBefore(tag, inputEl);
});
}
function _renderDropdown(query) {
const q = query.toLowerCase().trim();
filtered = options.filter(o => !selected.includes(o.value) && (!q || o.label.toLowerCase().includes(q)));
dropdown.innerHTML = '';
if (!filtered.length) {
dropdown.innerHTML = `<div class="lt-combobox-empty">${q ? 'No matches' : 'All selected'}</div>`;
return;
}
filtered.forEach((opt, i) => {
const el = document.createElement('div');
el.className = 'lt-combobox-option' + (selected.includes(opt.value) ? ' is-selected' : '');
el.setAttribute('role', 'option');
el.setAttribute('data-value', opt.value);
const hl = q ? opt.label.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>') : escHtml(opt.label);
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 = dropdown.querySelectorAll('.lt-combobox-option');
if (!items.length) return;
focusedIdx = Math.max(0, Math.min(focusedIdx + dir, items.length - 1));
items.forEach((el, i) => el.classList.toggle('is-focused', i === focusedIdx));
items[focusedIdx].scrollIntoView({ block: 'nearest' });
}
inputEl.addEventListener('input', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); });
inputEl.addEventListener('focus', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); });
inputEl.addEventListener('keydown', e => {
if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); }
if (e.key === 'Enter') { e.preventDefault(); if (focusedIdx >= 0 && filtered[focusedIdx]) _toggle(filtered[focusedIdx].value); }
if (e.key === 'Escape') { dropdown.classList.remove('is-open'); }
if (e.key === 'Backspace' && !inputEl.value && selected.length) { _toggle(selected[selected.length - 1]); }
});
inputWrap.addEventListener('mousedown', e => {
const rmBtn = e.target.closest('.lt-combobox-tag-remove');
if (rmBtn) { e.preventDefault(); _toggle(rmBtn.dataset.value); return; }
inputEl.focus();
});
document.addEventListener('click', e => { if (!wrap.contains(e.target)) dropdown.classList.remove('is-open'); });
_renderTags();
_renderDropdown('');
return {
getValue: () => [...selected],
setValue: vals => { selected = [...vals]; _renderTags(); _renderDropdown(''); },
clear: () => { selected = []; _renderTags(); _renderDropdown(''); },
};
},
};
/* ================================================================
MODULE 44 — AUTOCOMPLETE / TYPEAHEAD
lt.typeahead.init(inputEl, source, opts)
source: array or async fn(query) => [{value, label, icon?, meta?}]
opts: { minChars, debounceMs, onSelect, maxResults }
================================================================ */
const typeahead = {
init(inputEl, source, opts = {}) {
const wrap = inputEl.closest('.lt-typeahead') || inputEl.parentElement;
const dropdown = wrap.querySelector('.lt-typeahead-dropdown');
if (!dropdown) return;
const { minChars = 1, debounceMs = 150, onSelect = null, maxResults = 10 } = opts;
let _focusedIdx = -1;
let _items = [];
let _debTimer = null;
function _render(items, query) {
_items = items.slice(0, maxResults);
dropdown.innerHTML = '';
if (!_items.length) {
dropdown.innerHTML = `<div class="lt-typeahead-empty">[ NO RESULTS ]</div>`;
return;
}
const q = query.toLowerCase();
_items.forEach((item, i) => {
const el = document.createElement('div');
el.className = 'lt-typeahead-item';
el.setAttribute('role', 'option');
const hl = item.label.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>');
el.innerHTML = `${item.icon ? `<span class="icon">${escHtml(item.icon)}</span>` : ''}<span>${hl}</span>${item.meta ? `<span style="margin-left:auto;color:var(--text-muted);font-size:0.68rem">${escHtml(item.meta)}</span>` : ''}`;
el.addEventListener('mousedown', e => { e.preventDefault(); _select(item); });
dropdown.appendChild(el);
});
_focusedIdx = -1;
}
async function _search(query) {
dropdown.innerHTML = '<div class="lt-typeahead-loading">Searching…</div>';
dropdown.classList.add('is-open');
try {
const results = typeof source === 'function' ? await source(query) : source.filter(i => i.label.toLowerCase().includes(query.toLowerCase()));
_render(results, query);
} catch(e) {
dropdown.innerHTML = '<div class="lt-typeahead-empty">Error loading results</div>';
}
}
function _select(item) {
inputEl.value = item.label;
dropdown.classList.remove('is-open');
if (onSelect) onSelect(item);
bus.emit('typeahead:select', { item });
}
function _moveFocus(dir) {
const els = dropdown.querySelectorAll('.lt-typeahead-item');
if (!els.length) return;
_focusedIdx = Math.max(0, Math.min(_focusedIdx + dir, els.length - 1));
els.forEach((el, i) => el.classList.toggle('is-focused', i === _focusedIdx));
els[_focusedIdx].scrollIntoView({ block: 'nearest' });
}
inputEl.addEventListener('input', () => {
clearTimeout(_debTimer);
const q = inputEl.value.trim();
if (q.length < minChars) { dropdown.classList.remove('is-open'); return; }
_debTimer = setTimeout(() => _search(q), debounceMs);
});
inputEl.addEventListener('keydown', e => {
if (!dropdown.classList.contains('is-open')) return;
if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); }
if (e.key === 'Enter') { e.preventDefault(); if (_focusedIdx >= 0 && _items[_focusedIdx]) _select(_items[_focusedIdx]); }
if (e.key === 'Escape') { dropdown.classList.remove('is-open'); }
if (e.key === 'Tab') { dropdown.classList.remove('is-open'); }
});
inputEl.addEventListener('blur', () => setTimeout(() => dropdown.classList.remove('is-open'), 150));
document.addEventListener('click', e => { if (!wrap.contains(e.target)) dropdown.classList.remove('is-open'); });
return { search: q => _search(q) };
},
};
/* ================================================================
MODULE 45 — COOKIE UTILITY
lt.cookie.set(name, value, days?, opts?)
lt.cookie.get(name)
lt.cookie.del(name)
================================================================ */
const cookie = {
set(name, value, days = 0, opts = {}) {
let str = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
if (days) { const d = new Date(); d.setDate(d.getDate() + days); str += `; expires=${d.toUTCString()}`; }
str += `; path=${opts.path || '/'}`;
if (opts.sameSite) str += `; SameSite=${opts.sameSite}`;
if (opts.secure || location.protocol === 'https:') str += '; Secure';
document.cookie = str;
},
get(name) {
const key = encodeURIComponent(name) + '=';
const found = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith(key));
return found ? decodeURIComponent(found.slice(key.length)) : null;
},
del(name, opts = {}) { cookie.set(name, '', -1, opts); },
getAll() {
const out = {};
document.cookie.split(';').forEach(c => {
const [k, ...v] = c.trim().split('=');
if (k) out[decodeURIComponent(k)] = decodeURIComponent(v.join('='));
});
return out;
},
};
/* ================================================================
MODULE 46 — SPLIT PANE
lt.splitPane.init(containerEl, opts)
opts: { minA, minB, initial (0-1), vertical, onResize }
================================================================ */
const splitPane = {
init(container, opts = {}) {
const { minA = 80, minB = 80, initial = 0.5, vertical = false, onResize = null } = opts;
const panes = container.querySelectorAll('.lt-split-pane');
const divider = container.querySelector('.lt-split-divider');
if (!panes[0] || !panes[1] || !divider) return;
const dim = vertical ? 'height' : 'width';
const client = vertical ? 'clientY' : 'clientX';
let dragging = false;
let startPos, startSizeA;
function _setRatio(ratio) {
const total = vertical ? container.clientHeight : container.clientWidth;
const divSize = vertical ? divider.offsetHeight : divider.offsetWidth;
const available = total - divSize;
const sizeA = Math.max(minA, Math.min(available - minB, ratio * available));
panes[0].style[dim] = sizeA + 'px';
panes[0].style.flex = 'none';
panes[1].style.flex = '1';
if (onResize) onResize(sizeA, available - sizeA);
}
// Pointer events (handles both mouse and touch)
divider.addEventListener('pointerdown', e => {
e.preventDefault();
dragging = true;
divider.setPointerCapture(e.pointerId);
divider.classList.add('is-dragging');
startPos = e[client];
startSizeA = vertical ? panes[0].offsetHeight : panes[0].offsetWidth;
});
divider.addEventListener('pointermove', e => {
if (!dragging) return;
const delta = e[client] - startPos;
const total = vertical ? container.clientHeight : container.clientWidth;
const divSize = vertical ? divider.offsetHeight : divider.offsetWidth;
const newSize = Math.max(minA, Math.min(total - divSize - minB, startSizeA + delta));
panes[0].style[dim] = newSize + 'px';
panes[0].style.flex = 'none';
panes[1].style.flex = '1';
if (onResize) onResize(newSize, total - divSize - newSize);
});
divider.addEventListener('pointerup', () => { dragging = false; divider.classList.remove('is-dragging'); });
_setRatio(initial);
return { setRatio: _setRatio };
},
};
/* ================================================================
MODULE 47 — TOAST QUEUE (enhanced dispatch)
Wraps existing toast module with max-stack + progress bars
================================================================ */
const _toastMaxStack = 5;
const _toastQueue = [];
let _toastActive = 0;
const _origToast = Object.assign({}, toast);
function _toastEnqueue(type, msg, dur = 4000) {
if (_toastActive >= _toastMaxStack) {
_toastQueue.push({ type, msg, dur });
return;
}
_toastActive++;
const id = _origToast[type] ? _origToast[type](msg, dur) : _origToast.info(msg, dur);
// Add progress bar to the toast element
setTimeout(() => {
const toastEl = document.querySelector(`#lt-toast-container .lt-toast:last-child`);
if (toastEl && !toastEl.querySelector('.lt-toast-progress')) {
const bar = document.createElement('div');
bar.className = 'lt-toast-progress';
bar.style.animationDuration = dur + 'ms';
toastEl.appendChild(bar);
}
}, 20);
setTimeout(() => {
_toastActive = Math.max(0, _toastActive - 1);
if (_toastQueue.length) {
const next = _toastQueue.shift();
_toastEnqueue(next.type, next.msg, next.dur);
}
}, dur + 400);
return id;
}
// Override toast methods to use queue
['success','error','warning','info'].forEach(t => {
if (toast[t]) {
const orig = toast[t].bind(toast);
toast[t] = (msg, dur) => _toastEnqueue(t, msg, dur || 4000);
}
});
/* ================================================================
PUBLIC API
---------------------------------------------------------------- */
global.lt = {
@@ -1491,6 +2114,17 @@
/* v1.3 responsive */
viewport,
mobileNav,
/* v1.1 new features */
theme,
notif,
rightDrawer,
contextMenu,
offline,
ws,
combobox,
typeahead,
cookie,
splitPane,
};
}(window));