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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user