v1.1: Complete remaining 8 feature modules + 7 CSS component sections

JS modules added:
- lt.sidebarSubmenus — nested nav groups with expand/collapse, auto-opens active
- lt.infiniteScroll  — IntersectionObserver-based (scroll fallback), loading indicator
- lt.wizard          — multi-step form with indicators, validation hook, getData()
- lt.sortable        — HTML5 drag-to-reorder lists with placeholder ghost + bus event
- lt.timer           — countdown (urgent threshold + onExpire) + stopwatch (pause/reset)
- lt.lightbox        — full-screen image viewer, prev/next, ESC, caption, loop
- lt.auth            — JWT token management: setToken, refresh (auto + manual),
                       401 retry, onExpire hook, patches lt.api with Bearer header
- lt.markdown        — micro-renderer (no deps); auto-delegates to window.marked /
                       markdownit if present; renders headings/bold/italic/code/
                       links/lists/blockquotes/tables/HR

CSS sections added (69–75):
- Infinite scroll sentinel + loading indicator
- Wizard step indicators (connectors, active/complete/error states, nav footer)
- Sortable item dragging + placeholder ghost
- Countdown/timer display + urgency blink animation
- Image lightbox overlay (close/prev/next controls, caption, counter)
- Sidebar submenu groups (chevron, expand/collapse, active sub-link)
- Markdown output styling (.lt-markdown — all block elements themed)

HTML demos for all 8 new components added and wired

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 22:42:16 -04:00
parent 0eb91f1937
commit 6ad4cb2354
3 changed files with 1128 additions and 0 deletions
+581
View File
@@ -1257,6 +1257,7 @@
observeLazy('[data-lazy]');
/* v1.3 */
initMobileNav();
initSidebarSubmenus();
/* Boot */
const bootEl = document.getElementById('lt-boot');
if (bootEl) runBoot(bootEl.dataset.appName || document.title);
@@ -2070,6 +2071,578 @@
}
});
/* ================================================================
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.addEventListener('click', () => group.classList.toggle('is-open'));
// 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');
}
});
}
/* ================================================================
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();
}
scrollRoot.addEventListener('scroll', throttle(_onScroll, 150), { passive: true });
return { reset() { _done = false; _loading = false; }, stop() { scrollRoot.removeEventListener('scroll', _onScroll); } };
}
},
};
/* ================================================================
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);
s.setAttribute('aria-hidden', i !== idx ? 'true' : 'false');
});
// 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);
}
async function _next() {
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); }
}
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 }
Returns draggable list; emits 'sortable:change' on bus
================================================================ */
const sortable = {
init(list, opts = {}) {
const { handle = null, onSort = null, group = null, animation = 200 } = opts;
list.setAttribute('data-sortable-group', group || '');
let _dragging = null, _placeholder = null, _startIdx = -1;
function _idx(el) { return Array.from(list.children).indexOf(el); }
function _makePlaceholder(el) {
const ph = document.createElement(el.tagName);
ph.className = 'lt-sortable-placeholder';
ph.style.height = el.offsetHeight + 'px';
ph.style.width = el.offsetWidth + 'px';
return ph;
}
function _getItems() { return Array.from(list.querySelectorAll(':scope > [data-sortable-item]')); }
// Mark all children as sortable items
Array.from(list.children).forEach(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'; }
});
list.addEventListener('dragstart', e => {
const item = e.target.closest('[data-sortable-item]');
if (!item || !list.contains(item)) return;
_dragging = item;
_startIdx = _idx(item);
_placeholder = _makePlaceholder(item);
requestAnimationFrame(() => { item.classList.add('is-dragging'); });
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', ''); // Firefox compat
});
list.addEventListener('dragend', () => {
if (!_dragging) return;
_dragging.classList.remove('is-dragging');
_dragging.style.opacity = '';
if (_placeholder && _placeholder.parentNode) {
_placeholder.parentNode.insertBefore(_dragging, _placeholder);
_placeholder.remove();
}
const endIdx = _idx(_dragging);
if (endIdx !== _startIdx && onSort) onSort(_getItems(), _dragging);
bus.emit('sortable:change', { list, items: _getItems(), moved: _dragging });
_dragging = null; _placeholder = null;
});
list.addEventListener('dragover', e => {
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
const over = e.target.closest('[data-sortable-item]');
if (!over || over === _dragging || !list.contains(over)) return;
if (!_placeholder) return;
const rect = over.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
over.parentNode.insertBefore(_placeholder, e.clientY < mid ? over : over.nextSibling);
});
list.addEventListener('dragenter', e => {
const over = e.target.closest('[data-sortable-item]');
if (over && over !== _dragging && list.contains(over) && !_placeholder) {
_placeholder = _makePlaceholder(_dragging);
}
});
list.addEventListener('drop', e => { e.preventDefault(); });
return {
refresh() {
Array.from(list.children).forEach(child => {
if (!child.hasAttribute('data-sortable-item')) {
child.setAttribute('data-sortable-item', '');
child.setAttribute('draggable', handle ? 'false' : 'true');
if (!handle) child.style.cursor = 'grab';
}
});
},
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) dom.classList.add(urgentClass);
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;
function _getCaption(img) {
if (typeof caption === 'function') return caption(img);
return img.getAttribute(caption) || '';
}
function _buildOverlay() {
if (_overlay) return;
_overlay = document.createElement('div');
_overlay.className = 'lt-lightbox-overlay';
_overlay.setAttribute('role', 'dialog');
_overlay.setAttribute('aria-modal', 'true');
_overlay.setAttribute('aria-label', 'Image viewer');
_overlay.innerHTML = `
<button class="lt-lightbox-close" aria-label="Close">&times;</button>
<button class="lt-lightbox-prev" aria-label="Previous">&#8249;</button>
<button class="lt-lightbox-next" aria-label="Next">&#8250;</button>
<div class="lt-lightbox-img-wrap">
<img class="lt-lightbox-img" src="" alt="">
</div>
<div class="lt-lightbox-caption"></div>
<div class="lt-lightbox-counter"></div>
`;
document.body.appendChild(_overlay);
_overlay.querySelector('.lt-lightbox-close').addEventListener('click', lightbox.close);
_overlay.querySelector('.lt-lightbox-prev').addEventListener('click', () => lightbox.prev());
_overlay.querySelector('.lt-lightbox-next').addEventListener('click', () => lightbox.next());
_overlay.addEventListener('click', e => { if (e.target === _overlay) lightbox.close(); });
document.addEventListener('keydown', _lbKey);
}
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();
_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(() => 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();
},
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 to inject Authorization header and auto-refresh
const _origApiFetch = apiFetch;
async function apiFetch(method, url, body) {
if (_authAccess) {
if (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 token refresh
if (resp.status === 401 && _authRefresh) {
const ok = await auth.refresh();
if (ok) {
opts.headers['Authorization'] = 'Bearer ' + _authAccess;
try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); }
}
}
let data;
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status);
return data;
}
// Re-point api methods to patched apiFetch
api.get = url => apiFetch('GET', url);
api.post = (u, b) => apiFetch('POST', u, b);
api.put = (u, b) => apiFetch('PUT', u, b);
api.patch = (u, b) => apiFetch('PATCH', u, b);
api.delete = (u, b) => apiFetch('DELETE', u, b);
/* ================================================================
MODULE 54 — MARKDOWN RENDERER
lt.markdown.render(mdString) → HTML string (sanitized)
lt.markdown.init(selector) → renders all matching el's .textContent
Uses a built-in micro-renderer (no deps) for common syntax.
For full GFM, swap in marked.js: window.marked && marked.parse()
================================================================ */
const markdown = {
render(md) {
// Delegate to window.marked if available
if (global.marked) return global.marked.parse(md);
if (global.markdownit) return global.markdownit().render(md);
// Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr
let html = escHtml(md)
// Fenced code blocks
.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => `<pre class="lt-code-block"><code class="lt-tok tok-${lang || 'plain'}">${code.trim()}</code></pre>`)
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Headings
.replace(/^######\s(.+)$/gm, '<h6>$1</h6>')
.replace(/^#####\s(.+)$/gm, '<h5>$1</h5>')
.replace(/^####\s(.+)$/gm, '<h4>$1</h4>')
.replace(/^###\s(.+)$/gm, '<h3>$1</h3>')
.replace(/^##\s(.+)$/gm, '<h2>$1</h2>')
.replace(/^#\s(.+)$/gm, '<h1>$1</h1>')
// Bold / italic
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/_(.+?)_/g, '<em>$1</em>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
// Images
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">')
// Blockquote
.replace(/^&gt;\s(.+)$/gm, '<blockquote>$1</blockquote>')
// Horizontal rule
.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '<hr>')
// Unordered list items
.replace(/^[-*+]\s(.+)$/gm, '<li>$1</li>')
.replace(/(<li>[\s\S]+?<\/li>\n?)+/g, m => `<ul>${m}</ul>`)
// Ordered list items
.replace(/^\d+\.\s(.+)$/gm, '<li>$1</li>')
// Paragraphs (double newline)
.replace(/\n{2,}/g, '</p><p>')
.replace(/\n/g, '<br>');
return `<p>${html}</p>`
.replace(/<p>(<(?:pre|ul|ol|h[1-6]|blockquote|hr)[^>]*>)/g, '$1')
.replace(/(<\/(?:pre|ul|ol|h[1-6]|blockquote|hr)>)<\/p>/g, '$1');
},
init(selector) {
document.querySelectorAll(selector).forEach(el => {
const raw = el.getAttribute('data-markdown') || el.textContent;
el.innerHTML = markdown.render(raw);
el.classList.add('lt-markdown');
});
},
};
/* ================================================================
PUBLIC API
---------------------------------------------------------------- */
@@ -2125,6 +2698,14 @@
typeahead,
cookie,
splitPane,
infiniteScroll,
wizard,
sortable,
timer,
lightbox,
auth,
markdown,
sidebarSubmenus: { init: initSidebarSubmenus },
};
}(window));