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
+320
View File
@@ -4168,6 +4168,326 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
.lt-ws-status[data-state="disconnected"] .lt-dot { background: var(--accent-red); }
/* ----------------------------------------------------------------
69. INFINITE SCROLL SENTINEL / LOADING
---------------------------------------------------------------- */
.lt-infinite-sentinel { height: 1px; width: 100%; }
.lt-infinite-loading {
display: flex;
justify-content: center;
padding: 1.5rem;
}
/* ----------------------------------------------------------------
70. WIZARD / MULTI-STEP FORM
---------------------------------------------------------------- */
/* Step container — hide non-active steps */
[data-wizard-step] { display: none; }
[data-wizard-step].is-active { display: block; }
/* Progress indicator bar */
.lt-wizard-steps {
display: flex;
align-items: center;
gap: 0;
margin-bottom: 1.5rem;
overflow-x: auto;
padding-bottom: 2px;
}
.lt-wizard-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
flex: 1;
min-width: 60px;
position: relative;
cursor: default;
}
/* Connector line between steps */
.lt-wizard-step::before {
content: '';
position: absolute;
top: 14px;
left: calc(-50% + 14px);
right: calc(50% + 14px);
height: 1px;
background: var(--border-dim);
z-index: 0;
}
.lt-wizard-step:first-child::before { display: none; }
.lt-wizard-step.is-complete::before { background: var(--accent-cyan); }
/* Step number circle */
.lt-wizard-num {
width: 28px; height: 28px;
border-radius: 50%;
background: var(--bg-secondary);
border: 1.5px solid var(--border-color);
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 700;
display: flex; align-items: center; justify-content: center;
position: relative;
z-index: 1;
transition: all 0.2s;
}
.lt-wizard-step.is-active .lt-wizard-num {
background: var(--accent-orange-dim);
border-color: var(--accent-orange);
color: var(--accent-orange);
box-shadow: var(--box-glow-orange);
}
.lt-wizard-step.is-complete .lt-wizard-num {
background: var(--accent-cyan-dim);
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.lt-wizard-step.is-complete .lt-wizard-num::after {
content: '✓';
position: absolute;
}
.lt-wizard-step.is-error .lt-wizard-num {
background: var(--accent-red-dim);
border-color: var(--accent-red);
color: var(--accent-red);
}
.lt-wizard-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
white-space: nowrap;
text-align: center;
}
.lt-wizard-step.is-active .lt-wizard-label { color: var(--accent-orange); }
.lt-wizard-step.is-complete .lt-wizard-label { color: var(--text-secondary); }
/* Counter badge */
.lt-wizard-counter {
font-size: 0.68rem;
color: var(--text-muted);
}
.lt-wizard-counter strong { color: var(--accent-cyan); }
/* Nav footer */
.lt-wizard-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-dim);
gap: 0.75rem;
}
/* ----------------------------------------------------------------
71. SORTABLE LIST
---------------------------------------------------------------- */
[data-sortable-item] { transition: opacity 0.15s; }
[data-sortable-item].is-dragging {
opacity: 0.35;
cursor: grabbing;
}
.lt-sortable-placeholder {
background: var(--accent-cyan-dim);
border: 1px dashed var(--accent-cyan-border);
border-radius: 2px;
pointer-events: none;
}
/* ----------------------------------------------------------------
72. COUNTDOWN / TIMER
---------------------------------------------------------------- */
.lt-countdown {
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
font-size: 1.25rem;
font-weight: 700;
color: var(--accent-cyan);
letter-spacing: 0.05em;
}
.lt-countdown.lt-text-red { text-shadow: var(--glow-red); }
.lt-countdown.lt-text-cyan { text-shadow: var(--glow-cyan); }
/* SLA urgency animation */
.lt-countdown-urgent {
animation: lt-countdown-blink 1s step-end infinite;
}
@keyframes lt-countdown-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@media (prefers-reduced-motion: reduce) {
.lt-countdown-urgent { animation: none; }
}
/* ----------------------------------------------------------------
73. IMAGE LIGHTBOX
---------------------------------------------------------------- */
.lt-lightbox-overlay {
position: fixed;
inset: 0;
background: rgba(3,5,8,0.96);
z-index: calc(var(--z-modal) + 10); /* above everything */
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.lt-lightbox-overlay.is-open {
opacity: 1;
pointer-events: auto;
}
.lt-lightbox-img-wrap {
max-width: 90vw;
max-height: 85vh;
display: flex;
align-items: center;
justify-content: center;
}
.lt-lightbox-img {
max-width: 100%;
max-height: 85vh;
object-fit: contain;
box-shadow: 0 0 60px rgba(0,212,255,0.12);
display: block;
}
.lt-lightbox-close,
.lt-lightbox-prev,
.lt-lightbox-next {
position: fixed;
background: rgba(10,14,23,0.75);
border: 1px solid var(--border-color);
color: var(--text-secondary);
font-size: 1.5rem;
cursor: pointer;
transition: var(--transition-fast);
display: flex; align-items: center; justify-content: center;
border-radius: 2px;
}
.lt-lightbox-close { top: 1rem; right: 1rem; width: 40px; height: 40px; }
.lt-lightbox-prev { left: 1rem; top: 50%; transform: translateY(-50%); width: 40px; height: 60px; }
.lt-lightbox-next { right: 1rem; top: 50%; transform: translateY(-50%); width: 40px; height: 60px; }
.lt-lightbox-close:hover,
.lt-lightbox-prev:hover,
.lt-lightbox-next:hover { color: var(--accent-cyan); border-color: var(--accent-cyan-border); box-shadow: var(--box-glow-cyan); }
.lt-lightbox-caption {
position: fixed;
bottom: 3rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.78rem;
color: var(--text-secondary);
max-width: 60vw;
text-align: center;
}
.lt-lightbox-counter {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.68rem;
color: var(--text-muted);
letter-spacing: 0.1em;
}
@media (pointer: coarse) {
.lt-lightbox-prev { width: 56px; height: 80px; }
.lt-lightbox-next { width: 56px; height: 80px; }
.lt-lightbox-close { width: 48px; height: 48px; }
}
/* ----------------------------------------------------------------
74. SIDEBAR SUBMENUS
---------------------------------------------------------------- */
.lt-sidebar-group {
margin-bottom: 0.25rem;
}
.lt-sidebar-group-label {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 0.75rem;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
cursor: pointer;
user-select: none;
transition: color 0.15s;
border-radius: 2px;
}
.lt-sidebar-group-label:hover { color: var(--text-secondary); }
.lt-sidebar-group-label .chevron {
font-size: 0.5rem;
transition: transform 0.2s ease;
opacity: 0.6;
}
.lt-sidebar-group.is-open .lt-sidebar-group-label .chevron { transform: rotate(90deg); }
/* Submenu items */
.lt-sidebar-submenu {
display: none;
flex-direction: column;
padding-left: 0.75rem;
border-left: 1px solid var(--border-dim);
margin-left: 0.75rem;
margin-top: 2px;
margin-bottom: 4px;
}
.lt-sidebar-group.is-open .lt-sidebar-submenu { display: flex; }
.lt-sidebar-sub-link {
padding: 0.3rem 0.5rem;
font-size: 0.72rem;
color: var(--text-muted);
text-decoration: none;
border-radius: 2px;
transition: var(--transition-fast);
display: flex;
align-items: center;
gap: 0.4rem;
white-space: nowrap;
}
.lt-sidebar-sub-link:hover { color: var(--accent-cyan); background: var(--accent-cyan-dim); }
.lt-sidebar-sub-link.active,
.lt-sidebar-sub-link[aria-current="page"] {
color: var(--accent-orange);
background: var(--accent-orange-dim);
}
@media (pointer: coarse) {
.lt-sidebar-group-label { padding: 0.5rem 0.75rem; }
.lt-sidebar-sub-link { padding: 0.4rem 0.5rem; min-height: 36px; }
}
/* ----------------------------------------------------------------
75. MARKDOWN OUTPUT STYLING
---------------------------------------------------------------- */
.lt-markdown h1 { font-size: 1.25rem; color: var(--accent-cyan); border-bottom: 1px solid var(--border-dim); padding-bottom: 0.3rem; margin: 1rem 0 0.5rem; }
.lt-markdown h2 { font-size: 1.05rem; color: var(--text-primary); margin: 0.9rem 0 0.4rem; }
.lt-markdown h3 { font-size: 0.9rem; color: var(--text-secondary); margin: 0.75rem 0 0.35rem; }
.lt-markdown h4, .lt-markdown h5, .lt-markdown h6 { font-size: 0.8rem; color: var(--text-muted); margin: 0.5rem 0 0.25rem; }
.lt-markdown p { font-size: 0.82rem; line-height: 1.7; color: var(--text-secondary); margin: 0.5rem 0; }
.lt-markdown ul, .lt-markdown ol { padding-left: 1.25rem; margin: 0.5rem 0; }
.lt-markdown li { font-size: 0.8rem; color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.2rem; }
.lt-markdown ul li::marker { color: var(--accent-cyan); }
.lt-markdown ol li::marker { color: var(--accent-orange); }
.lt-markdown code { font-family: var(--font-mono); font-size: 0.78rem; color: var(--accent-green); background: var(--bg-secondary); padding: 1px 5px; border-radius: 2px; }
.lt-markdown pre { margin: 0.75rem 0; overflow-x: auto; }
.lt-markdown blockquote { border-left: 3px solid var(--accent-cyan-border); padding: 0.25rem 0.75rem; margin: 0.5rem 0; background: var(--accent-cyan-dim); color: var(--text-muted); font-style: italic; }
.lt-markdown hr { border: none; border-top: 1px solid var(--border-dim); margin: 1rem 0; }
.lt-markdown a { color: var(--accent-cyan); text-decoration: none; }
.lt-markdown a:hover { text-decoration: underline; }
.lt-markdown strong { color: var(--text-primary); }
.lt-markdown img { max-width: 100%; border: 1px solid var(--border-dim); }
.lt-markdown table { width: 100%; border-collapse: collapse; font-size: 0.78rem; margin: 0.75rem 0; }
.lt-markdown th { background: var(--bg-secondary); color: var(--accent-cyan); padding: 0.4rem 0.6rem; border: 1px solid var(--border-dim); text-align: left; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; }
.lt-markdown td { padding: 0.35rem 0.6rem; border: 1px solid var(--border-dim); color: var(--text-secondary); }
.lt-markdown tr:hover td { background: var(--bg-secondary); }
/* ----------------------------------------------------------------
68. PRINT ENHANCEMENTS
(Extends section 25)
+227
View File
@@ -1256,6 +1256,191 @@
</div>
</div>
<!-- Wizard / Multi-Step Form -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// WIZARD / MULTI-STEP FORM</span>
</div>
<div class="lt-section-body">
<div id="demo-wizard">
<!-- Step indicators -->
<div class="lt-wizard-steps">
<div class="lt-wizard-step is-active" data-wizard-indicator>
<div class="lt-wizard-num">1</div>
<div class="lt-wizard-label">Details</div>
</div>
<div class="lt-wizard-step" data-wizard-indicator>
<div class="lt-wizard-num">2</div>
<div class="lt-wizard-label">Assign</div>
</div>
<div class="lt-wizard-step" data-wizard-indicator>
<div class="lt-wizard-num">3</div>
<div class="lt-wizard-label">Review</div>
</div>
</div>
<!-- Counter -->
<p class="lt-wizard-counter">Step <strong data-wizard-current>1</strong> of <strong data-wizard-total>3</strong></p>
<!-- Steps -->
<div data-wizard-step="1" class="is-active" aria-hidden="false">
<div class="lt-grid lt-grid-2" style="gap:1rem;margin-top:1rem">
<div class="lt-form-group"><label class="lt-label">Ticket Title</label><input type="text" name="title" class="lt-input lt-w-full" placeholder="Brief description…"></div>
<div class="lt-form-group"><label class="lt-label">Priority</label><select name="priority" class="lt-select lt-w-full"><option value="p1">P1 Critical</option><option value="p2">P2 High</option><option value="p3" selected>P3 Medium</option><option value="p4">P4 Low</option></select></div>
</div>
</div>
<div data-wizard-step="2" aria-hidden="true">
<div class="lt-form-group" style="margin-top:1rem"><label class="lt-label">Assign To</label><input type="text" name="assignee" class="lt-input lt-w-full" placeholder="Username…"></div>
<div class="lt-form-group"><label class="lt-label">Due Date</label><input type="date" name="due" class="lt-input"></div>
</div>
<div data-wizard-step="3" aria-hidden="true">
<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon"></div><div class="lt-empty-state-title">Review &amp; Submit</div><div class="lt-empty-state-body">Check the details above and click Submit when ready.</div></div>
</div>
<!-- Nav -->
<div class="lt-wizard-nav">
<button class="lt-btn lt-btn-sm" data-wizard-prev disabled>← Back</button>
<button class="lt-btn lt-btn-primary lt-btn-sm" data-wizard-next>Next →</button>
<button class="lt-btn lt-btn-primary lt-btn-sm" data-wizard-done style="display:none">Submit ✓</button>
</div>
</div>
</div>
</div>
<!-- Sortable List -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// DRAG-TO-REORDER (SORTABLE)</span>
</div>
<div class="lt-section-body">
<div class="lt-grid lt-grid-2" style="gap:1.5rem">
<div>
<p style="font-size:0.72rem;color:var(--text-muted);margin-bottom:0.75rem">Drag items to reorder. Uses native HTML5 drag API with pointer-events fallback.</p>
<ul id="demo-sortable" style="list-style:none;padding:0;display:flex;flex-direction:column;gap:4px">
<li data-id="p1" style="padding:0.5rem 0.75rem;background:var(--bg-card);border:1px solid var(--border-dim);display:flex;align-items:center;gap:0.5rem"><span style="color:var(--text-muted);cursor:grab"></span> P1 — Storage link-down</li>
<li data-id="p2" style="padding:0.5rem 0.75rem;background:var(--bg-card);border:1px solid var(--border-dim);display:flex;align-items:center;gap:0.5rem"><span style="color:var(--text-muted);cursor:grab"></span> P2 — Switch port flapping</li>
<li data-id="p3" style="padding:0.5rem 0.75rem;background:var(--bg-card);border:1px solid var(--border-dim);display:flex;align-items:center;gap:0.5rem"><span style="color:var(--text-muted);cursor:grab"></span> P3 — SFP+ replacement</li>
<li data-id="p4" style="padding:0.5rem 0.75rem;background:var(--bg-card);border:1px solid var(--border-dim);display:flex;align-items:center;gap:0.5rem"><span style="color:var(--text-muted);cursor:grab"></span> P4 — SSL cert renewal</li>
</ul>
<p id="demo-sort-order" style="font-size:0.68rem;color:var(--text-muted);margin-top:0.5rem">Order: p1, p2, p3, p4</p>
</div>
<div>
<p style="font-size:0.72rem;color:var(--text-muted);margin-bottom:0.75rem">API:</p>
<pre class="lt-code-block" style="font-size:0.7rem">const s = lt.sortable.init(listEl, {
onSort: (items) => console.log(s.getOrder())
});</pre>
</div>
</div>
</div>
</div>
<!-- Countdown / Timer -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// COUNTDOWN &amp; TIMER</span>
</div>
<div class="lt-section-body">
<div class="lt-grid lt-grid-3" style="gap:1rem">
<div class="lt-stat-card">
<div class="lt-stat-label">SLA Countdown</div>
<div class="lt-countdown lt-num" id="demo-countdown">--:--:--</div>
<div style="font-size:0.68rem;color:var(--text-muted);margin-top:0.25rem">Goes urgent (red) at &lt;5 min</div>
</div>
<div class="lt-stat-card">
<div class="lt-stat-label">Stopwatch</div>
<div class="lt-countdown lt-num" id="demo-stopwatch">00:00:00</div>
<div class="lt-flex lt-gap-xs" style="margin-top:0.5rem">
<button class="lt-btn lt-btn-sm" id="sw-pause">Pause</button>
<button class="lt-btn lt-btn-sm" id="sw-reset">Reset</button>
</div>
</div>
<div class="lt-stat-card">
<div class="lt-stat-label">API</div>
<pre class="lt-code-block" style="font-size:0.62rem;margin:0">lt.timer.countdown(el, date, {
urgent: 300,
onExpire: () => {}
});
lt.timer.stopwatch(el);</pre>
</div>
</div>
</div>
</div>
<!-- Image Lightbox -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// IMAGE LIGHTBOX</span>
</div>
<div class="lt-section-body">
<p style="font-size:0.78rem;color:var(--text-secondary);margin-bottom:1rem">Click any image to open full-screen viewer. Keyboard: ←/→ navigate, Esc closes.</p>
<div class="lt-flex lt-gap-md lt-wrap">
<img class="lt-lightbox-demo" src="https://picsum.photos/seed/lt1/320/180" alt="Server rack overview" style="width:160px;height:90px;object-fit:cover;border:1px solid var(--border-dim)" loading="lazy">
<img class="lt-lightbox-demo" src="https://picsum.photos/seed/lt2/320/180" alt="Network switch closeup" style="width:160px;height:90px;object-fit:cover;border:1px solid var(--border-dim)" loading="lazy">
<img class="lt-lightbox-demo" src="https://picsum.photos/seed/lt3/320/180" alt="Datacenter floor view" style="width:160px;height:90px;object-fit:cover;border:1px solid var(--border-dim)" loading="lazy">
</div>
<p style="font-size:0.72rem;color:var(--text-muted);margin-top:0.5rem"><code>lt.lightbox.init('.lt-lightbox-demo', { caption: 'alt', loop: true })</code></p>
</div>
</div>
<!-- Sidebar Submenus -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// SIDEBAR SUBMENUS</span>
</div>
<div class="lt-section-body">
<div style="width:220px;background:var(--bg-secondary);border:1px solid var(--border-dim);padding:0.5rem">
<a href="#" class="lt-sidebar-link active" style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.75rem;font-size:0.78rem;color:var(--accent-orange);text-decoration:none">⊞ Dashboard</a>
<a href="#" class="lt-sidebar-link" style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.75rem;font-size:0.78rem;color:var(--text-secondary);text-decoration:none">🎫 Tickets</a>
<div class="lt-sidebar-group is-open">
<div class="lt-sidebar-group-label">Admin <span class="chevron"></span></div>
<div class="lt-sidebar-submenu">
<a href="#" class="lt-sidebar-sub-link active" aria-current="page">⚙ Templates</a>
<a href="#" class="lt-sidebar-sub-link">🔀 Workflow</a>
<a href="#" class="lt-sidebar-sub-link">📋 Audit Log</a>
<a href="#" class="lt-sidebar-sub-link">🔑 API Keys</a>
</div>
</div>
<div class="lt-sidebar-group">
<div class="lt-sidebar-group-label">Reports <span class="chevron"></span></div>
<div class="lt-sidebar-submenu">
<a href="#" class="lt-sidebar-sub-link">📊 SLA Report</a>
<a href="#" class="lt-sidebar-sub-link">📈 Volume Trends</a>
</div>
</div>
</div>
</div>
</div>
<!-- Markdown Renderer -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// MARKDOWN RENDERER</span>
</div>
<div class="lt-section-body">
<div class="lt-grid lt-grid-2" style="gap:1.5rem">
<div>
<div class="lt-section-label" style="margin-bottom:0.5rem">Source</div>
<pre class="lt-code-block" style="font-size:0.7rem"># Incident Report
**Severity**: P1 Critical
## Summary
Storage array link-down on `compute-storage-01`.
- Affected: 3 production services
- Duration: 2h 33m
- Root cause: NIC hardware failure
> Resolved. Monitoring continues.
[View Ticket](#001)</pre>
</div>
<div>
<div class="lt-section-label" style="margin-bottom:0.5rem">Rendered</div>
<div id="demo-markdown" class="lt-markdown" style="background:var(--bg-card);border:1px solid var(--border-dim);padding:1rem" data-markdown="# Incident Report&#10;**Severity**: P1 Critical&#10;&#10;## Summary&#10;Storage array link-down on &#96;compute-storage-01&#96;.&#10;&#10;- Affected: 3 production services&#10;- Duration: 2h 33m&#10;- Root cause: NIC hardware failure&#10;&#10;&gt; Resolved. Monitoring continues.&#10;&#10;[View Ticket](#001)"></div>
</div>
</div>
<p style="font-size:0.72rem;color:var(--text-muted);margin-top:0.75rem">Built-in micro-renderer (no deps). Drops in <code>window.marked</code> or <code>window.markdownit</code> automatically if present.</p>
</div>
</div>
</main><!-- /.lt-main -->
@@ -1490,6 +1675,48 @@
// Demo notification badge initial count
lt.notif.set('#lt-notif-bell', 3);
// Wizard demo
lt.wizard.init(document.getElementById('demo-wizard'), {
onComplete: data => lt.toast.success('Ticket submitted: ' + (data.title || 'untitled')),
validate: (step, data) => {
if (step === 1 && !data.title?.trim()) { lt.toast.error('Title is required'); return false; }
return true;
},
});
// Sortable demo
const sortableList = lt.sortable.init(document.getElementById('demo-sortable'), {
onSort: (items) => {
document.getElementById('demo-sort-order').textContent = 'Order: ' + items.map(el => el.dataset.id).join(', ');
},
});
// Countdown demo — SLA expires 2 hours from now
const slaTarget = new Date(Date.now() + 2 * 60 * 60 * 1000);
lt.timer.countdown(document.getElementById('demo-countdown'), slaTarget, {
urgent: 300,
urgentClass: 'lt-text-red lt-countdown-urgent',
onExpire: () => lt.toast.error('SLA BREACHED'),
});
// Stopwatch demo
const sw = lt.timer.stopwatch(document.getElementById('demo-stopwatch'));
let swRunning = true;
document.getElementById('sw-pause').addEventListener('click', function() {
if (swRunning) { sw.pause(); this.textContent = 'Resume'; } else { sw.resume(); this.textContent = 'Pause'; }
swRunning = !swRunning;
});
document.getElementById('sw-reset').addEventListener('click', () => { sw.reset(); swRunning = true; document.getElementById('sw-pause').textContent = 'Pause'; });
// Lightbox demo
lt.lightbox.init('.lt-lightbox-demo');
// Markdown demo
lt.markdown.init('#demo-markdown');
// Sidebar submenus (re-init for demo sidebar)
lt.sidebarSubmenus.init(document.querySelector('.lt-section-body'));
// Tab bar switching
document.querySelectorAll('.lt-tab-bar').forEach(bar => {
bar.addEventListener('click', e => {
+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));