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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 22:29:55 -04:00
parent db67f0c92b
commit 0eb91f1937
3 changed files with 1941 additions and 1 deletions
+927
View File
@@ -3266,3 +3266,930 @@ input[type="range"].lt-range::-moz-range-thumb {
/* Monospace table-style number alignment */ /* Monospace table-style number alignment */
.lt-num { font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; } .lt-num { font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; }
/* ================================================================
v1.1 COMPONENT EXTENSIONS
51. Light Theme
52. Theme Toggle Button
53. Skeleton Loader Variants
54. Empty State Enhancements
55. Nav Notification Badge
56. Right-Side Drawer
57. Sticky Table
58. Multi-Select / Combobox
59. Context Menu
60. Offline Banner
61. Timeline / Activity Feed
62. Avatar
63. Split Pane
64. Chart Container
65. Toast Queue
66. Autocomplete / Typeahead
67. WebSocket Status
68. Print Enhancements
================================================================ */
/* ----------------------------------------------------------------
51. LIGHT THEME
---------------------------------------------------------------- */
html[data-theme="light"] {
--bg-primary: #eef1f6;
--bg-secondary: #e2e6ed;
--bg-tertiary: #d5dae3;
--bg-card: #ffffff;
--bg-terminal: #f7f9fc;
--bg-overlay: rgba(238,241,246,0.96);
--bg-input: #ffffff;
--text-primary: #1a2035;
--text-secondary: #3a4a6a;
--text-muted: #647898;
--text-dim: #8fa0b8;
--border-color: rgba(0,100,180,0.20);
--border-color-hi: var(--accent-cyan);
--border-color-dim: rgba(0,100,180,0.10);
--border-dim: rgba(0,100,180,0.10);
/* Slightly muted glows for light bg */
--glow-orange: 0 0 4px rgba(255,107,0,0.6), 0 0 10px rgba(255,107,0,0.3);
--glow-cyan: 0 0 4px rgba(0,150,200,0.6), 0 0 10px rgba(0,150,200,0.3);
--glow-green: 0 0 4px rgba(0,180,80,0.5), 0 0 10px rgba(0,180,80,0.25);
--glow-red: 0 0 4px rgba(220,0,50,0.5), 0 0 10px rgba(220,0,50,0.25);
--glow-amber: 0 0 4px rgba(200,130,0,0.5), 0 0 10px rgba(200,130,0,0.25);
color-scheme: light;
}
/* Hide CRT overlays in light mode */
html[data-theme="light"] body::before,
html[data-theme="light"] body::after { display: none; }
/* Soften dot grid */
html[data-theme="light"] body {
background-image: radial-gradient(circle, rgba(0,80,160,0.10) 1px, transparent 1px);
}
/* Nav / header adjustments */
html[data-theme="light"] .lt-header {
background: rgba(238,241,246,0.96);
border-bottom-color: var(--border-color);
}
html[data-theme="light"] .lt-nav-link,
html[data-theme="light"] .lt-nav-link:hover { color: var(--text-secondary); }
html[data-theme="light"] .lt-nav-link.active { color: var(--accent-orange); }
html[data-theme="light"] .lt-sidebar { background: var(--bg-secondary); border-color: var(--border-color); }
html[data-theme="light"] .lt-card,
html[data-theme="light"] .lt-frame { background: var(--bg-card); border-color: var(--border-color); }
html[data-theme="light"] .lt-section { background: var(--bg-card); border-color: var(--border-color); }
html[data-theme="light"] .lt-input,
html[data-theme="light"] .lt-select,
html[data-theme="light"] .lt-textarea { background: var(--bg-input); border-color: var(--border-color); color: var(--text-primary); }
html[data-theme="light"] .lt-table th { background: var(--bg-secondary); }
html[data-theme="light"] .lt-table tr:hover td { background: rgba(0,100,200,0.04); }
html[data-theme="light"] .lt-nav-drawer { background: var(--bg-card); }
html[data-theme="light"] code,
html[data-theme="light"] .lt-code-block { background: var(--bg-secondary); color: var(--text-primary); }
/* ----------------------------------------------------------------
52. THEME TOGGLE BUTTON
---------------------------------------------------------------- */
.lt-theme-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px; height: 32px;
border-radius: 4px;
border: 1px solid var(--border-color);
background: transparent;
color: var(--accent-cyan);
font-size: 1rem;
cursor: pointer;
transition: var(--transition-fast);
flex-shrink: 0;
}
.lt-theme-btn:hover {
background: var(--accent-cyan-dim);
border-color: var(--accent-cyan-border);
box-shadow: var(--box-glow-cyan);
}
.lt-theme-btn:focus-visible {
outline: 1px solid var(--accent-cyan);
outline-offset: 2px;
}
@media (pointer: coarse) {
.lt-theme-btn { width: 44px; height: 44px; font-size: 1.2rem; }
}
/* ----------------------------------------------------------------
53. SKELETON LOADER VARIANTS
(Extends existing .lt-skeleton in section 19)
---------------------------------------------------------------- */
/* Override section 19 shimmer with improved cyan-tinted version */
.lt-skeleton {
background: linear-gradient(
90deg,
var(--bg-secondary) 25%,
rgba(0,212,255,0.05) 50%,
var(--bg-secondary) 75%
);
background-size: 200% 100%;
animation: lt-shimmer 1.6s ease-in-out infinite;
border-radius: 2px;
display: block;
}
@keyframes lt-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.lt-skeleton { animation: none; background: var(--bg-secondary); }
}
@media (pointer: coarse) {
.lt-skeleton { animation: none; }
}
/* Size variants */
.lt-skeleton-text { height: 0.8rem; width: 100%; margin-bottom: 0.4rem; }
.lt-skeleton-title { height: 1.1rem; width: 55%; margin-bottom: 0.6rem; }
.lt-skeleton-avatar { height: 2.25rem; width: 2.25rem; border-radius: 50%; flex-shrink: 0; }
.lt-skeleton-btn { height: 1.9rem; width: 5.5rem; }
.lt-skeleton-badge { height: 1.1rem; width: 4rem; border-radius: 999px; }
.lt-skeleton-line-sm { height: 0.7rem; width: 40%; margin-bottom: 0.3rem; }
.lt-skeleton-line-lg { height: 0.8rem; width: 80%; margin-bottom: 0.4rem; }
/* Card skeleton */
.lt-skeleton-card {
background: var(--bg-card);
border: 1px solid var(--border-dim);
clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.lt-skeleton-card-header {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.4rem;
}
/* Table row skeleton */
.lt-skeleton-row {
display: grid;
grid-template-columns: 1.5rem 1fr 2.5fr 1fr 1fr 1fr;
gap: 1rem;
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--border-dim);
align-items: center;
}
@media (max-width: 639px) {
.lt-skeleton-row { grid-template-columns: 1fr 2fr; gap: 0.5rem; }
}
/* ----------------------------------------------------------------
54. EMPTY STATE ENHANCEMENTS
(Extends existing .lt-empty in section 19)
---------------------------------------------------------------- */
.lt-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
text-align: center;
gap: 0.6rem;
}
.lt-empty-state-icon {
font-size: 2.5rem;
opacity: 0.35;
margin-bottom: 0.25rem;
line-height: 1;
}
.lt-empty-state-title {
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.lt-empty-state-body {
color: var(--text-muted);
font-size: 0.72rem;
max-width: 30ch;
line-height: 1.7;
}
.lt-empty-state .lt-btn { margin-top: 0.5rem; }
/* Small inline variant */
.lt-empty-state--sm {
padding: 1.5rem 1rem;
}
.lt-empty-state--sm .lt-empty-state-icon { font-size: 1.5rem; }
.lt-empty-state--sm .lt-empty-state-title { font-size: 0.72rem; }
/* ----------------------------------------------------------------
55. NAV NOTIFICATION BADGE
---------------------------------------------------------------- */
.lt-notif-wrap {
position: relative;
display: inline-flex;
}
.lt-notif-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 1rem;
height: 1rem;
padding: 0 0.2rem;
background: var(--accent-red);
color: #fff;
font-size: 0.58rem;
font-weight: 700;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
border: 1.5px solid var(--bg-primary);
pointer-events: none;
animation: lt-notif-pulse 2.5s ease-in-out infinite;
}
.lt-notif-badge[data-count="0"],
.lt-notif-badge:empty { display: none; }
@keyframes lt-notif-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(255,45,85,0.7); }
50% { box-shadow: 0 0 0 5px rgba(255,45,85,0); }
}
@media (pointer: coarse) { .lt-notif-badge { animation: none; } }
/* ----------------------------------------------------------------
56. RIGHT-SIDE DRAWER
---------------------------------------------------------------- */
.lt-drawer-right {
position: fixed;
top: var(--header-height);
right: 0;
height: calc(100vh - var(--header-height));
width: 400px;
max-width: 92vw;
background: var(--bg-card);
border-left: 1px solid var(--border-color);
clip-path: polygon(10px 0, 100% 0, 100% 100%, 0 100%, 0 10px);
transform: translateX(100%);
transition: transform 0.25s cubic-bezier(0.4,0,0.2,1);
z-index: calc(var(--z-overlay) + 2); /* 10001 — same level as nav overlay */
display: flex;
flex-direction: column;
overflow: hidden;
}
.lt-drawer-right.is-open { transform: translateX(0); }
.lt-drawer-right-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.lt-drawer-right-title {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent-cyan);
text-shadow: var(--glow-cyan);
}
.lt-drawer-right-close {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
background: none;
border: 1px solid transparent;
color: var(--text-muted);
font-size: 1rem;
cursor: pointer;
border-radius: 2px;
transition: var(--transition-fast);
}
.lt-drawer-right-close:hover { color: var(--accent-red); border-color: var(--accent-red); }
.lt-drawer-right-close:focus-visible { outline: 1px solid var(--accent-cyan); outline-offset: 2px; }
.lt-drawer-right-body {
flex: 1;
overflow-y: auto;
padding: 1rem;
overscroll-behavior: contain;
}
.lt-drawer-right-footer {
padding: 0.75rem 1rem;
border-top: 1px solid var(--border-dim);
display: flex;
gap: 0.5rem;
justify-content: flex-end;
flex-shrink: 0;
}
/* Overlay for right drawer */
.lt-drawer-right-overlay {
position: fixed;
inset: 0;
background: rgba(3,5,8,0.65);
z-index: calc(var(--z-overlay) + 1); /* 10000 */
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
}
.lt-drawer-right-overlay.is-open { opacity: 1; pointer-events: auto; }
@media (max-width: 479px) {
.lt-drawer-right { width: 100vw; max-width: 100vw; top: 0; height: 100vh; border-left: none; border-top: 1px solid var(--border-color); }
}
@media (pointer: coarse) {
.lt-drawer-right-close { width: 44px; height: 44px; }
}
/* ----------------------------------------------------------------
57. STICKY TABLE
---------------------------------------------------------------- */
.lt-table-sticky-wrap {
max-height: 420px;
overflow-y: auto;
overflow-x: auto;
overscroll-behavior: contain;
border: 1px solid var(--border-dim);
border-radius: 2px;
}
.lt-table-sticky-wrap .lt-table { margin: 0; border: none; }
.lt-table-sticky-wrap .lt-table thead th {
position: sticky;
top: 0;
z-index: 2;
background: var(--bg-tertiary);
backdrop-filter: blur(6px);
border-bottom: 2px solid var(--border-color);
box-shadow: 0 2px 8px rgba(3,5,8,0.5);
}
/* Custom scrollbar inside sticky wrap */
.lt-table-sticky-wrap::-webkit-scrollbar { width: 6px; height: 6px; }
.lt-table-sticky-wrap::-webkit-scrollbar-track { background: var(--bg-primary); }
.lt-table-sticky-wrap::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
/* ----------------------------------------------------------------
58. MULTI-SELECT / COMBOBOX
---------------------------------------------------------------- */
.lt-combobox {
position: relative;
display: block;
}
.lt-combobox-input-wrap {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
min-height: 36px;
padding: 4px 8px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
cursor: text;
transition: border-color 0.15s;
}
.lt-combobox-input-wrap:focus-within {
border-color: var(--accent-cyan);
box-shadow: var(--box-glow-cyan);
}
.lt-combobox-input-wrap .lt-combobox-input {
flex: 1;
min-width: 80px;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.8rem;
padding: 2px 0;
}
/* Selected tag chips inside the input */
.lt-combobox-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
background: var(--accent-cyan-dim);
border: 1px solid var(--accent-cyan-border);
color: var(--accent-cyan);
font-size: 0.7rem;
border-radius: 2px;
white-space: nowrap;
}
.lt-combobox-tag-remove {
background: none; border: none; cursor: pointer;
color: var(--text-muted); font-size: 0.75rem; padding: 0; line-height: 1;
display: flex; align-items: center;
}
.lt-combobox-tag-remove:hover { color: var(--accent-red); }
/* Dropdown list */
.lt-combobox-dropdown {
position: absolute;
top: calc(100% + 2px);
left: 0; right: 0;
background: var(--bg-card);
border: 1px solid var(--border-color);
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%);
z-index: var(--z-dropdown);
max-height: 200px;
overflow-y: auto;
display: none;
}
.lt-combobox-dropdown.is-open { display: block; }
.lt-combobox-option {
padding: 0.4rem 0.75rem;
font-size: 0.78rem;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background 0.1s;
}
.lt-combobox-option:hover,
.lt-combobox-option.is-focused { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.lt-combobox-option.is-selected::before {
content: '✓';
color: var(--accent-green);
font-size: 0.7rem;
width: 0.8rem;
flex-shrink: 0;
}
.lt-combobox-option:not(.is-selected)::before { content: ''; width: 0.8rem; display: inline-block; flex-shrink: 0; }
.lt-combobox-empty {
padding: 0.6rem 0.75rem;
font-size: 0.72rem;
color: var(--text-muted);
text-align: center;
}
/* ----------------------------------------------------------------
59. CONTEXT MENU
---------------------------------------------------------------- */
.lt-context-menu {
position: fixed;
background: var(--bg-card);
border: 1px solid var(--border-color);
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%);
min-width: 160px;
z-index: var(--z-tooltip);
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
padding: 4px 0;
display: none;
}
.lt-context-menu.is-open { display: block; }
.lt-context-menu-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.75rem;
font-size: 0.78rem;
color: var(--text-secondary);
cursor: pointer;
transition: background 0.1s;
user-select: none;
white-space: nowrap;
}
.lt-context-menu-item:hover { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.lt-context-menu-item.is-danger:hover { background: var(--accent-red-dim); color: var(--accent-red); }
.lt-context-menu-item .icon { width: 1rem; text-align: center; opacity: 0.7; font-size: 0.75rem; }
.lt-context-menu-item kbd {
margin-left: auto;
font-size: 0.6rem;
color: var(--text-muted);
background: var(--bg-tertiary);
border: 1px solid var(--border-dim);
padding: 1px 4px;
border-radius: 2px;
}
.lt-context-menu-divider {
height: 1px;
background: var(--border-dim);
margin: 4px 0;
}
.lt-context-menu-label {
padding: 0.25rem 0.75rem;
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
pointer-events: none;
}
/* ----------------------------------------------------------------
60. OFFLINE BANNER
---------------------------------------------------------------- */
.lt-offline-banner {
position: fixed;
top: var(--header-height);
left: 0; right: 0;
padding: 0.4rem 1rem;
background: var(--accent-red-dim);
border-top: 1px solid var(--accent-red);
border-bottom: 1px solid var(--accent-red);
color: var(--accent-red);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.1em;
text-align: center;
z-index: var(--z-fixed);
transform: translateY(-100%);
transition: transform 0.25s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.lt-offline-banner.is-visible { transform: translateY(0); }
.lt-offline-banner .lt-dot--red {
animation: lt-pulse 1s ease-in-out infinite;
}
@keyframes lt-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s ease; }
/* ----------------------------------------------------------------
61. TIMELINE / ACTIVITY FEED
---------------------------------------------------------------- */
.lt-timeline {
display: flex;
flex-direction: column;
gap: 0;
padding-left: 1.25rem;
border-left: 1px solid var(--border-dim);
margin-left: 0.5rem;
}
.lt-timeline-item {
position: relative;
padding: 0 0 1.25rem 1.25rem;
}
.lt-timeline-item:last-child { padding-bottom: 0; }
/* Connector dot */
.lt-timeline-item::before {
content: '';
position: absolute;
left: -5px;
top: 4px;
width: 8px; height: 8px;
border-radius: 50%;
background: var(--accent-cyan);
box-shadow: 0 0 6px var(--accent-cyan);
border: 1.5px solid var(--bg-primary);
}
.lt-timeline-item--orange::before { background: var(--accent-orange); box-shadow: var(--glow-orange); }
.lt-timeline-item--green::before { background: var(--accent-green); box-shadow: var(--glow-green); }
.lt-timeline-item--red::before { background: var(--accent-red); box-shadow: var(--glow-red); }
.lt-timeline-item--dim::before { background: var(--text-muted); box-shadow: none; }
.lt-timeline-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.68rem;
color: var(--text-muted);
margin-bottom: 0.2rem;
flex-wrap: wrap;
}
.lt-timeline-actor { color: var(--accent-cyan); }
.lt-timeline-time { margin-left: auto; white-space: nowrap; }
.lt-timeline-body { font-size: 0.78rem; color: var(--text-secondary); line-height: 1.5; }
.lt-timeline-body code { font-size: 0.72rem; color: var(--accent-green); }
/* ----------------------------------------------------------------
62. AVATAR
---------------------------------------------------------------- */
.lt-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--bg-tertiary);
border: 1.5px solid var(--border-color);
color: var(--accent-cyan);
font-weight: 700;
font-size: 0.75rem;
text-transform: uppercase;
overflow: hidden;
flex-shrink: 0;
user-select: none;
}
.lt-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Sizes */
.lt-avatar--xs { width: 1.5rem; height: 1.5rem; font-size: 0.55rem; }
.lt-avatar--sm { width: 2rem; height: 2rem; font-size: 0.65rem; }
.lt-avatar { width: 2.5rem; height: 2.5rem; font-size: 0.75rem; } /* default */
.lt-avatar--lg { width: 3.5rem; height: 3.5rem; font-size: 1rem; }
.lt-avatar--xl { width: 5rem; height: 5rem; font-size: 1.4rem; }
/* Color variants */
.lt-avatar--orange { background: var(--accent-orange-dim); border-color: var(--accent-orange-border); color: var(--accent-orange); }
.lt-avatar--green { background: var(--accent-green-dim); border-color: var(--accent-green-border); color: var(--accent-green); }
.lt-avatar--red { background: var(--accent-red-dim); border-color: var(--accent-red); color: var(--accent-red); }
.lt-avatar--purple { background: var(--accent-purple-dim); border-color: var(--accent-purple); color: var(--accent-purple); }
/* Avatar group (overlapping row) */
.lt-avatar-group { display: flex; }
.lt-avatar-group .lt-avatar {
margin-left: -0.5rem;
border: 2px solid var(--bg-primary);
transition: transform 0.15s;
}
.lt-avatar-group .lt-avatar:first-child { margin-left: 0; }
.lt-avatar-group .lt-avatar:hover { transform: translateY(-2px) scale(1.08); z-index: 1; }
/* Status ring */
.lt-avatar-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.lt-avatar-status {
position: absolute;
bottom: 1px; right: 1px;
width: 9px; height: 9px;
border-radius: 50%;
border: 1.5px solid var(--bg-primary);
background: var(--text-muted);
}
.lt-avatar-status--online { background: var(--accent-green); box-shadow: 0 0 4px var(--accent-green); }
.lt-avatar-status--away { background: var(--accent-amber); }
.lt-avatar-status--busy { background: var(--accent-red); }
/* ----------------------------------------------------------------
63. SPLIT PANE
---------------------------------------------------------------- */
.lt-split {
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
.lt-split--vertical { flex-direction: column; }
.lt-split-pane {
overflow: auto;
flex: 1;
min-width: 0;
min-height: 0;
}
.lt-split-divider {
flex: 0 0 5px;
background: var(--border-dim);
cursor: col-resize;
transition: background 0.15s;
position: relative;
user-select: none;
touch-action: none;
}
.lt-split-divider::after {
content: '⠿';
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
color: var(--text-muted);
font-size: 0.6rem;
pointer-events: none;
}
.lt-split-divider:hover,
.lt-split-divider.is-dragging { background: var(--accent-cyan-border); }
.lt-split--vertical .lt-split-divider { cursor: row-resize; }
/* On mobile, stack vertically and hide divider */
@media (max-width: 767px) {
.lt-split:not(.lt-split--vertical) { flex-direction: column; }
.lt-split:not(.lt-split--vertical) .lt-split-divider { display: none; }
.lt-split:not(.lt-split--vertical) .lt-split-pane { flex: 0 0 auto; }
}
/* ----------------------------------------------------------------
64. CHART CONTAINER
---------------------------------------------------------------- */
.lt-chart-wrap {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border-dim);
clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%);
padding: 1rem;
overflow: hidden;
}
.lt-chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.lt-chart-title {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-secondary);
}
.lt-chart-legend {
display: flex;
gap: 1rem;
font-size: 0.68rem;
color: var(--text-muted);
flex-wrap: wrap;
}
.lt-chart-legend-item {
display: flex;
align-items: center;
gap: 0.35rem;
}
.lt-chart-legend-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.lt-chart-body {
position: relative;
width: 100%;
}
/* Sparkline — inline mini chart */
.lt-sparkline {
display: inline-block;
vertical-align: middle;
}
/* Axis labels */
.lt-chart-axis {
display: flex;
justify-content: space-between;
font-size: 0.62rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
/* Loading / no-data states */
.lt-chart-wrap.is-loading .lt-chart-body { min-height: 120px; }
.lt-chart-wrap.is-loading .lt-chart-body::after {
content: 'LOADING DATA...';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.68rem;
color: var(--text-muted);
letter-spacing: 0.1em;
}
/* ----------------------------------------------------------------
65. TOAST QUEUE (enhanced multi-toast stack)
---------------------------------------------------------------- */
/* Toast container already exists in section 14; these are addons */
#lt-toast-container {
/* Stack from bottom — newest on top */
display: flex;
flex-direction: column-reverse;
gap: 0.5rem;
}
.lt-toast + .lt-toast { margin-top: 0; } /* gap handles spacing */
/* Progress bar on toast (auto-dismiss countdown) */
.lt-toast-progress {
position: absolute;
bottom: 0; left: 0;
height: 2px;
background: currentColor;
opacity: 0.4;
animation: lt-toast-drain linear forwards;
}
@keyframes lt-toast-drain {
from { width: 100%; }
to { width: 0; }
}
/* Pause on hover */
.lt-toast:hover .lt-toast-progress { animation-play-state: paused; }
/* ----------------------------------------------------------------
66. AUTOCOMPLETE / TYPEAHEAD
---------------------------------------------------------------- */
.lt-typeahead {
position: relative;
display: block;
}
.lt-typeahead-dropdown {
position: absolute;
top: calc(100% + 2px);
left: 0; right: 0;
background: var(--bg-card);
border: 1px solid var(--border-color);
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%);
z-index: var(--z-dropdown);
max-height: 220px;
overflow-y: auto;
display: none;
}
.lt-typeahead-dropdown.is-open { display: block; }
.lt-typeahead-item {
padding: 0.4rem 0.75rem;
font-size: 0.78rem;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background 0.1s;
}
.lt-typeahead-item:hover,
.lt-typeahead-item.is-focused { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.lt-typeahead-item mark {
background: none;
color: var(--accent-orange);
font-weight: 700;
}
.lt-typeahead-item .icon {
font-size: 0.75rem;
opacity: 0.6;
width: 1rem;
text-align: center;
flex-shrink: 0;
}
.lt-typeahead-empty {
padding: 0.6rem 0.75rem;
font-size: 0.72rem;
color: var(--text-muted);
text-align: center;
letter-spacing: 0.05em;
}
/* Loading state in dropdown */
.lt-typeahead-loading {
padding: 0.6rem 0.75rem;
font-size: 0.72rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.5rem;
}
.lt-typeahead-loading::before {
content: '';
width: 10px; height: 10px;
border: 1.5px solid var(--border-color);
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
/* ----------------------------------------------------------------
67. WEBSOCKET STATUS INDICATOR
---------------------------------------------------------------- */
.lt-ws-status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
padding: 2px 8px;
border: 1px solid var(--border-dim);
border-radius: 2px;
}
.lt-ws-status .lt-dot { flex-shrink: 0; }
.lt-ws-status[data-state="connected"] { color: var(--accent-green); border-color: var(--accent-green-border); }
.lt-ws-status[data-state="connecting"] { color: var(--accent-amber); border-color: var(--accent-amber-dim); }
.lt-ws-status[data-state="disconnected"] { color: var(--accent-red); border-color: var(--accent-red-dim); }
.lt-ws-status[data-state="connected"] .lt-dot { background: var(--accent-green); box-shadow: var(--glow-green); }
.lt-ws-status[data-state="connecting"] .lt-dot { background: var(--accent-amber); box-shadow: var(--glow-amber); animation: lt-pulse 0.8s ease-in-out infinite; }
.lt-ws-status[data-state="disconnected"] .lt-dot { background: var(--accent-red); }
/* ----------------------------------------------------------------
68. PRINT ENHANCEMENTS
(Extends section 25)
---------------------------------------------------------------- */
@media print {
.lt-sidebar, .lt-nav, .lt-header-right,
.lt-btn-group, .lt-menu-btn,
.lt-nav-drawer, .lt-nav-drawer-overlay,
.lt-offline-banner, .lt-context-menu,
[data-modal-open], [data-tooltip] { display: none !important; }
.lt-section, .lt-card, .lt-frame {
break-inside: avoid;
box-shadow: none;
clip-path: none;
border: 1px solid #999;
}
.lt-table { border-collapse: collapse; }
.lt-table th, .lt-table td { border: 1px solid #ccc; }
.lt-table thead th { background: #eee !important; color: black !important; }
a[href]::after { content: " (" attr(href) ")"; font-size: 0.7em; color: #666; }
a[href^="#"]::after, a[href^="javascript"]::after { content: ""; }
.lt-code-block { white-space: pre-wrap; word-break: break-all; }
.lt-page-header { border-bottom: 2px solid #333; padding-bottom: 0.5rem; margin-bottom: 1rem; }
}
+379
View File
@@ -111,6 +111,16 @@
</div> </div>
<div class="lt-header-right"> <div class="lt-header-right">
<!-- WebSocket status indicator -->
<div class="lt-ws-status" id="lt-ws-indicator" data-state="disconnected" aria-label="WebSocket status">
<span class="lt-dot"></span><span>Offline</span>
</div>
<!-- Theme toggle -->
<button class="lt-theme-btn" id="lt-theme-btn" aria-label="Switch to light mode" title="Switch to light mode"></button>
<!-- Notifications with badge -->
<span class="lt-notif-wrap" id="lt-notif-bell" aria-label="Notifications">
<button class="lt-btn lt-btn-sm" style="padding:0 0.5rem;min-height:32px;" aria-label="Open notifications">🔔</button>
</span>
<!-- Current user --> <!-- Current user -->
<span class="lt-header-user">operator</span> <span class="lt-header-user">operator</span>
<!-- Admin badge (only show for admins) --> <!-- Admin badge (only show for admins) -->
@@ -118,6 +128,45 @@
</div> </div>
</header> </header>
<!-- Right-side detail drawer -->
<div id="lt-detail-drawer" class="lt-drawer-right" aria-hidden="true" role="dialog" aria-label="Detail panel" data-overlay="lt-detail-overlay">
<div class="lt-drawer-right-header">
<span class="lt-drawer-right-title">// TICKET DETAIL</span>
<button class="lt-drawer-right-close" data-drawer-close aria-label="Close detail panel"></button>
</div>
<div class="lt-drawer-right-body">
<div class="lt-kv-grid">
<div class="lt-kv-row"><span class="lt-kv-label">ID</span><span class="lt-kv-value lt-text-cyan">#123456789</span></div>
<div class="lt-kv-row"><span class="lt-kv-label">Status</span><span class="lt-kv-value"><span class="lt-badge lt-badge-open">Open</span></span></div>
<div class="lt-kv-row"><span class="lt-kv-label">Priority</span><span class="lt-kv-value"><span class="lt-badge lt-badge-p1">P1 Critical</span></span></div>
<div class="lt-kv-row"><span class="lt-kv-label">Assignee</span><span class="lt-kv-value" style="display:flex;align-items:center;gap:0.5rem"><span class="lt-avatar lt-avatar--sm lt-avatar--orange">JD</span>jdoe</span></div>
<div class="lt-kv-row"><span class="lt-kv-label">Created</span><span class="lt-kv-value">2026-03-10</span></div>
</div>
<div class="lt-divider-label" style="margin:1rem 0">Description</div>
<p style="font-size:0.78rem;color:var(--text-secondary);line-height:1.6">Storage array link-down on compute-storage-01. Affects prod write path. Investigate RAID controller firmware.</p>
<div class="lt-divider-label" style="margin:1rem 0">Activity</div>
<div class="lt-timeline">
<div class="lt-timeline-item lt-timeline-item--orange">
<div class="lt-timeline-meta"><span class="lt-timeline-actor">jdoe</span> assigned ticket<span class="lt-timeline-time">2h ago</span></div>
<div class="lt-timeline-body">Assigned to self, escalated to P1.</div>
</div>
<div class="lt-timeline-item">
<div class="lt-timeline-meta"><span class="lt-timeline-actor">sysbot</span> auto-created<span class="lt-timeline-time">3h ago</span></div>
<div class="lt-timeline-body">Alert triggered: <code>node_network_up = 0</code></div>
</div>
<div class="lt-timeline-item lt-timeline-item--dim">
<div class="lt-timeline-meta"><span class="lt-timeline-actor">monitor</span> detected<span class="lt-timeline-time">3h ago</span></div>
<div class="lt-timeline-body">NIC link-down detected on large1:enp35s0.</div>
</div>
</div>
</div>
<div class="lt-drawer-right-footer">
<button class="lt-btn lt-btn-sm" onclick="lt.rightDrawer.close('lt-detail-drawer')">Close</button>
<button class="lt-btn lt-btn-primary lt-btn-sm">Save Changes</button>
</div>
</div>
<div id="lt-detail-overlay" class="lt-drawer-right-overlay"></div>
<!-- =========================================================== <!-- ===========================================================
MAIN CONTENT AREA MAIN CONTENT AREA
=========================================================== --> =========================================================== -->
@@ -933,6 +982,280 @@
</div><!-- /.lt-frame-inner (v1.2 additions) --> </div><!-- /.lt-frame-inner (v1.2 additions) -->
</div><!-- /.lt-frame (v1.2 additions) --> </div><!-- /.lt-frame (v1.2 additions) -->
<!-- ═══════════════════════════════════════════════════════════
v1.1 COMPONENT SHOWCASE
═══════════════════════════════════════════════════════════ -->
<!-- Theme Toggle -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// THEME TOGGLE</span>
</div>
<div class="lt-section-body">
<p style="font-size:0.78rem;color:var(--text-secondary);margin-bottom:1rem">Dark/light mode with OS preference detection and localStorage persistence.</p>
<div class="lt-flex lt-gap-sm lt-align-center lt-wrap">
<button class="lt-btn lt-btn-primary" onclick="lt.theme.toggle()">Toggle Theme</button>
<button class="lt-btn" onclick="lt.theme.set('dark')">Force Dark</button>
<button class="lt-btn" onclick="lt.theme.set('light')">Force Light</button>
<code style="font-size:0.72rem;color:var(--accent-cyan)">lt.theme.toggle() | .set('light') | .get()</code>
</div>
</div>
</div>
<!-- Skeleton Loaders -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// SKELETON LOADERS</span>
</div>
<div class="lt-section-body">
<div class="lt-grid lt-grid-3" style="gap:1rem;align-items:start">
<div class="lt-skeleton-card">
<div class="lt-skeleton-card-header">
<span class="lt-skeleton lt-skeleton-avatar"></span>
<div style="flex:1"><span class="lt-skeleton lt-skeleton-title"></span><span class="lt-skeleton lt-skeleton-line-sm"></span></div>
</div>
<span class="lt-skeleton lt-skeleton-text"></span>
<span class="lt-skeleton lt-skeleton-line-lg"></span>
<span class="lt-skeleton lt-skeleton-text" style="width:70%"></span>
<div class="lt-flex lt-gap-sm" style="margin-top:0.25rem">
<span class="lt-skeleton lt-skeleton-btn"></span>
<span class="lt-skeleton lt-skeleton-badge"></span>
</div>
</div>
<div style="grid-column:span 2">
<div class="lt-skeleton-row"><span class="lt-skeleton" style="height:1rem"></span><span class="lt-skeleton lt-skeleton-text"></span><span class="lt-skeleton" style="height:0.8rem;width:80%"></span><span class="lt-skeleton lt-skeleton-badge"></span><span class="lt-skeleton" style="height:0.8rem;width:60%"></span><span class="lt-skeleton lt-skeleton-btn"></span></div>
<div class="lt-skeleton-row" style="opacity:0.65"><span class="lt-skeleton" style="height:1rem"></span><span class="lt-skeleton lt-skeleton-text"></span><span class="lt-skeleton" style="height:0.8rem;width:65%"></span><span class="lt-skeleton lt-skeleton-badge"></span><span class="lt-skeleton" style="height:0.8rem;width:50%"></span><span class="lt-skeleton lt-skeleton-btn"></span></div>
<div class="lt-skeleton-row" style="opacity:0.35"><span class="lt-skeleton" style="height:1rem"></span><span class="lt-skeleton lt-skeleton-text"></span><span class="lt-skeleton" style="height:0.8rem;width:55%"></span><span class="lt-skeleton lt-skeleton-badge"></span><span class="lt-skeleton" style="height:0.8rem;width:40%"></span><span class="lt-skeleton lt-skeleton-btn"></span></div>
</div>
</div>
</div>
</div>
<!-- Empty States -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// EMPTY STATES</span>
</div>
<div class="lt-section-body">
<div class="lt-grid lt-grid-3" style="gap:1rem">
<div class="lt-card"><div class="lt-empty-state"><div class="lt-empty-state-icon">📭</div><div class="lt-empty-state-title">No Tickets Found</div><div class="lt-empty-state-body">No tickets match your current filters.</div><button class="lt-btn lt-btn-sm lt-btn-primary">Clear Filters</button></div></div>
<div class="lt-card"><div class="lt-empty-state"><div class="lt-empty-state-icon">🔌</div><div class="lt-empty-state-title">No Workers Online</div><div class="lt-empty-state-body">All workers are offline or unreachable.</div><button class="lt-btn lt-btn-sm" onclick="lt.toast.warning('Checking workers…')">Retry</button></div></div>
<div class="lt-card"><div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">🗂</div><div class="lt-empty-state-title">No Results</div><div class="lt-empty-state-body">Try a different search term.</div></div></div>
</div>
</div>
</div>
<!-- Avatars & Notification Badges -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// AVATARS &amp; NOTIFICATION BADGES</span>
</div>
<div class="lt-section-body">
<div class="lt-flex lt-gap-lg lt-wrap lt-align-center" style="margin-bottom:1rem">
<span class="lt-avatar lt-avatar--xs lt-avatar--orange">AB</span>
<span class="lt-avatar lt-avatar--sm lt-avatar--green">CD</span>
<span class="lt-avatar lt-avatar--purple">EF</span>
<span class="lt-avatar lt-avatar--lg lt-avatar--red">GH</span>
<div class="lt-avatar-wrap"><span class="lt-avatar lt-avatar--orange">JD</span><span class="lt-avatar-status lt-avatar-status--online"></span></div>
<div class="lt-avatar-wrap"><span class="lt-avatar">SK</span><span class="lt-avatar-status lt-avatar-status--away"></span></div>
<div class="lt-avatar-wrap"><span class="lt-avatar lt-avatar--red">MR</span><span class="lt-avatar-status lt-avatar-status--busy"></span></div>
<div class="lt-avatar-group">
<span class="lt-avatar lt-avatar--sm lt-avatar--orange">AA</span>
<span class="lt-avatar lt-avatar--sm lt-avatar--green">BB</span>
<span class="lt-avatar lt-avatar--sm lt-avatar--purple">CC</span>
<span class="lt-avatar lt-avatar--sm" style="background:var(--bg-tertiary);color:var(--text-muted);font-size:0.6rem">+4</span>
</div>
</div>
<div class="lt-flex lt-gap-md lt-align-center lt-wrap">
<span class="lt-notif-wrap" id="demo-notif-btn"><button class="lt-btn lt-btn-sm">🔔 Alerts</button></span>
<button class="lt-btn lt-btn-sm" onclick="lt.notif.inc('#demo-notif-btn')">+1 Badge</button>
<button class="lt-btn lt-btn-sm" onclick="lt.notif.clear('#demo-notif-btn')">Clear</button>
<button class="lt-btn lt-btn-sm" onclick="lt.notif.inc('#lt-notif-bell')">+1 Header Bell</button>
</div>
</div>
</div>
<!-- Timeline -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// TIMELINE / ACTIVITY FEED</span>
</div>
<div class="lt-section-body">
<div class="lt-grid lt-grid-2" style="gap:1.5rem">
<div class="lt-timeline">
<div class="lt-timeline-item lt-timeline-item--red">
<div class="lt-timeline-meta"><span class="lt-timeline-actor">alertmanager</span> fired<span class="lt-timeline-time lt-num">14:22:05</span></div>
<div class="lt-timeline-body">CRITICAL: Storage array link-down on <code>compute-storage-01</code>. Ticket auto-created.</div>
</div>
<div class="lt-timeline-item lt-timeline-item--orange">
<div class="lt-timeline-meta"><span class="lt-timeline-actor">jdoe</span> assigned<span class="lt-timeline-time lt-num">14:24:11</span></div>
<div class="lt-timeline-body">Escalated to P1 Critical. Paged on-call team.</div>
</div>
<div class="lt-timeline-item">
<div class="lt-timeline-meta"><span class="lt-timeline-actor">jdoe</span> commented<span class="lt-timeline-time lt-num">14:31:44</span></div>
<div class="lt-timeline-body">Confirmed NIC failure. Ordered replacement hardware. ETA 2h.</div>
</div>
<div class="lt-timeline-item lt-timeline-item--green">
<div class="lt-timeline-meta"><span class="lt-timeline-actor">jdoe</span> resolved<span class="lt-timeline-time lt-num">16:55:00</span></div>
<div class="lt-timeline-body">Hardware replaced. Link restored. Monitoring for 30 min.</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:0.75rem">
<div class="lt-stat-card"><div class="lt-stat-label">Resolution Time</div><div class="lt-stat-value lt-text-green lt-num">2h 33m</div></div>
<div class="lt-stat-card"><div class="lt-stat-label">Events</div><div class="lt-stat-value lt-num">4</div></div>
<div class="lt-stat-card"><div class="lt-stat-label">SLA Status</div><div class="lt-stat-value lt-text-orange">Within SLA</div></div>
</div>
</div>
</div>
</div>
<!-- Right Drawer -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// RIGHT-SIDE DRAWER</span>
</div>
<div class="lt-section-body">
<p style="font-size:0.78rem;color:var(--text-secondary);margin-bottom:1rem">Detail/inspect panel from the right. Focus trap, ESC close, overlay backdrop, return-focus.</p>
<div class="lt-flex lt-gap-sm lt-wrap">
<button class="lt-btn lt-btn-primary" data-drawer-open="lt-detail-drawer">Open Detail Panel</button>
<code style="font-size:0.72rem;color:var(--accent-cyan)">lt.rightDrawer.open('id') | .close() | .toggle()</code>
</div>
</div>
</div>
<!-- Context Menu -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// CONTEXT MENU</span>
</div>
<div class="lt-section-body">
<p style="font-size:0.78rem;color:var(--text-secondary);margin-bottom:1rem">Right-click any element with <code>data-context-menu</code> or trigger programmatically.</p>
<div class="lt-flex lt-gap-sm lt-wrap lt-align-center">
<div data-context-menu="demo-ctx" class="lt-card" style="padding:0.75rem 1.25rem;cursor:context-menu;border-style:dashed;display:inline-block">
Right-click this card
</div>
<button class="lt-btn lt-btn-sm" id="demo-ctx-btn">Show Context Menu</button>
</div>
</div>
</div>
<!-- Combobox + Typeahead -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// COMBOBOX &amp; TYPEAHEAD</span>
</div>
<div class="lt-section-body">
<div class="lt-grid lt-grid-2" style="gap:1.5rem">
<div>
<label class="lt-label">Assign Workers (multi-select)</label>
<div class="lt-combobox" id="demo-combobox">
<div class="lt-combobox-input-wrap">
<input type="text" class="lt-combobox-input" id="demo-combobox-input" placeholder="Search workers…" autocomplete="off">
</div>
<div class="lt-combobox-dropdown"></div>
</div>
</div>
<div>
<label class="lt-label">Search Tickets (typeahead)</label>
<div class="lt-typeahead" id="demo-typeahead">
<input type="text" class="lt-input lt-w-full" id="demo-typeahead-input" placeholder="Type to search…" autocomplete="off">
<div class="lt-typeahead-dropdown"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Sticky Table -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// STICKY TABLE HEADERS</span>
</div>
<div class="lt-section-body">
<div class="lt-table-sticky-wrap">
<table class="lt-table lt-table-responsive">
<thead><tr><th>ID</th><th>Priority</th><th>Title</th><th>Status</th><th>Assignee</th></tr></thead>
<tbody>
<tr><td data-label="ID"><a href="#" class="lt-text-cyan">#001</a></td><td data-label="Priority"><span class="lt-badge lt-badge-p1">P1</span></td><td data-label="Title">Link-down on compute-storage-01</td><td data-label="Status"><span class="lt-badge lt-badge-open">Open</span></td><td data-label="Assignee">jdoe</td></tr>
<tr><td data-label="ID"><a href="#" class="lt-text-cyan">#002</a></td><td data-label="Priority"><span class="lt-badge lt-badge-p2">P2</span></td><td data-label="Title">Switch port flapping USW-Pro-24</td><td data-label="Status"><span class="lt-badge lt-badge-in-progress">In Progress</span></td><td data-label="Assignee">smith</td></tr>
<tr><td data-label="ID"><a href="#" class="lt-text-cyan">#003</a></td><td data-label="Priority"><span class="lt-badge lt-badge-p3">P3</span></td><td data-label="Title">Scheduled SFP+ replacement</td><td data-label="Status"><span class="lt-badge lt-badge-pending">Pending</span></td><td data-label="Assignee">ops-bot</td></tr>
<tr><td data-label="ID"><a href="#" class="lt-text-cyan">#004</a></td><td data-label="Priority"><span class="lt-badge lt-badge-p4">P4</span></td><td data-label="Title">SSL cert renewal wiki</td><td data-label="Status"><span class="lt-badge lt-badge-open">Open</span></td><td data-label="Assignee">admin</td></tr>
<tr><td data-label="ID"><a href="#" class="lt-text-cyan">#005</a></td><td data-label="Priority"><span class="lt-badge lt-badge-p1">P1</span></td><td data-label="Title">RAID controller firmware</td><td data-label="Status"><span class="lt-badge lt-badge-open">Open</span></td><td data-label="Assignee">jdoe</td></tr>
<tr><td data-label="ID"><a href="#" class="lt-text-cyan">#006</a></td><td data-label="Priority"><span class="lt-badge lt-badge-p2">P2</span></td><td data-label="Title">Backup job failure nas-01</td><td data-label="Status"><span class="lt-badge lt-badge-closed">Closed</span></td><td data-label="Assignee">backup-bot</td></tr>
<tr><td data-label="ID"><a href="#" class="lt-text-cyan">#007</a></td><td data-label="Priority"><span class="lt-badge lt-badge-p3">P3</span></td><td data-label="Title">Prometheus alert rule tuning</td><td data-label="Status"><span class="lt-badge lt-badge-in-progress">In Progress</span></td><td data-label="Assignee">ops-team</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Chart Containers -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// CHART CONTAINERS</span>
</div>
<div class="lt-section-body">
<div class="lt-grid lt-grid-2" style="gap:1rem">
<div class="lt-chart-wrap">
<div class="lt-chart-header">
<span class="lt-chart-title">Ticket Volume (7d)</span>
<div class="lt-chart-legend">
<span class="lt-chart-legend-item"><span class="lt-chart-legend-dot" style="background:var(--accent-cyan)"></span>Open</span>
<span class="lt-chart-legend-item"><span class="lt-chart-legend-dot" style="background:var(--accent-green)"></span>Closed</span>
</div>
</div>
<div class="lt-chart-body" style="min-height:120px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:0.72rem;letter-spacing:0.1em">[ Plug in Chart.js / D3 here ]</div>
<div class="lt-chart-axis"><span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span><span>Sun</span></div>
</div>
<div class="lt-chart-wrap is-loading">
<div class="lt-chart-header"><span class="lt-chart-title">Worker Uptime</span></div>
<div class="lt-chart-body"></div>
</div>
</div>
</div>
</div>
<!-- Split Pane -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// SPLIT PANE</span>
</div>
<div class="lt-section-body">
<div class="lt-split" id="demo-split" style="height:200px;border:1px solid var(--border-dim)">
<div class="lt-split-pane" style="padding:1rem;overflow:auto">
<div class="lt-section-label" style="margin-bottom:0.5rem">Panel A</div>
<p style="font-size:0.78rem;color:var(--text-secondary)">Drag the divider to resize. Stacks vertically on mobile.</p>
</div>
<div class="lt-split-divider" title="Drag to resize"></div>
<div class="lt-split-pane" style="padding:1rem;overflow:auto">
<div class="lt-section-label" style="margin-bottom:0.5rem">Panel B</div>
<p style="font-size:0.78rem;color:var(--text-secondary)">Both panels maintain independent scrolling.</p>
</div>
</div>
<p style="font-size:0.72rem;color:var(--text-muted);margin-top:0.5rem"><code>lt.splitPane.init(el, &#123; initial: 0.4, minA: 120 &#125;)</code></p>
</div>
</div>
<!-- WebSocket & Offline -->
<div class="lt-section">
<div class="lt-section-header">
<span class="lt-section-title">// WEBSOCKET &amp; OFFLINE DETECTION</span>
</div>
<div class="lt-section-body">
<div class="lt-flex lt-gap-md lt-wrap lt-align-center" style="margin-bottom:1rem">
<div class="lt-ws-status" data-state="connected"><span class="lt-dot"></span><span>Connected</span></div>
<div class="lt-ws-status" data-state="connecting"><span class="lt-dot"></span><span>Connecting…</span></div>
<div class="lt-ws-status" data-state="disconnected"><span class="lt-dot"></span><span>Disconnected</span></div>
</div>
<div class="lt-flex lt-gap-sm lt-wrap">
<button class="lt-btn lt-btn-sm" onclick="lt.offline.isOnline() ? lt.toast.success('Online ✓') : lt.toast.error('Offline ✗')">Check Online Status</button>
<button class="lt-btn lt-btn-sm" onclick="lt.toast.info('lt.ws.connect(url, { reconnect:true, onMessage: fn })')">WS API Hint</button>
</div>
<p style="font-size:0.72rem;color:var(--text-muted);margin-top:0.75rem">Offline banner + body class auto-applied on <code>navigator.onLine</code> change. WS manager has exponential backoff, event emitter, status indicator binding.</p>
</div>
</div>
</main><!-- /.lt-main --> </main><!-- /.lt-main -->
@@ -1111,6 +1434,62 @@
requestAnimationFrame(() => { bar.style.width = bar.dataset.width; }); requestAnimationFrame(() => { bar.style.width = bar.dataset.width; });
}); });
// Theme toggle button
document.getElementById('lt-theme-btn').addEventListener('click', () => lt.theme.toggle());
// Context menu: register demo menu items
lt.contextMenu.register('demo-ctx', [
{ icon: '📋', label: 'Copy ID', kbd: 'C', action: () => lt.toast.info('ID copied') },
{ icon: '👁', label: 'View Details', action: () => lt.rightDrawer.open('lt-detail-drawer') },
{ icon: '✏️', label: 'Edit Ticket', kbd: 'E', action: () => lt.toast.info('Edit…') },
{ divider: true },
{ icon: '🗑', label: 'Delete', danger: true, action: () => lt.toast.error('Deleted') },
]);
// Programmatic context menu button
const demoCtxBtn = document.getElementById('demo-ctx-btn');
if (demoCtxBtn) demoCtxBtn.addEventListener('click', e => {
const r = demoCtxBtn.getBoundingClientRect();
lt.contextMenu.show(r.left, r.bottom + 4, [
{ icon: '📋', label: 'Copy ID', action: () => lt.toast.info('Copied') },
{ icon: '✏️', label: 'Edit', action: () => lt.toast.info('Edit') },
{ divider: true },
{ icon: '🗑', label: 'Delete', danger: true, action: () => lt.toast.error('Deleted') },
]);
});
// Combobox demo
lt.combobox.init(
document.getElementById('demo-combobox-input'),
[
{ value: 'worker-01', label: 'worker-01', icon: '🖥' },
{ value: 'worker-02', label: 'worker-02', icon: '🖥' },
{ value: 'worker-03', label: 'worker-03', icon: '🖥' },
{ value: 'gpu-01', label: 'gpu-01', icon: '⚡' },
{ value: 'gpu-02', label: 'gpu-02', icon: '⚡' },
{ value: 'storage-01',label: 'storage-01',icon: '💾' },
],
{ onChange: vals => console.log('[combobox]', vals) }
);
// Typeahead demo
lt.typeahead.init(
document.getElementById('demo-typeahead-input'),
[
{ value: '001', label: 'Link-down on compute-storage-01', icon: '🔴', meta: 'P1' },
{ value: '002', label: 'Switch port flapping USW-Pro-24', icon: '🟠', meta: 'P2' },
{ value: '003', label: 'Scheduled SFP+ replacement large1',icon: '🔵', meta: 'P3' },
{ value: '004', label: 'SSL cert renewal wiki.lotusguild.org', icon: '🟢', meta: 'P4' },
{ value: '005', label: 'RAID controller firmware update', icon: '🔴', meta: 'P1' },
],
{ onSelect: item => lt.toast.info(`Selected: ${item.label}`) }
);
// Split pane demo
lt.splitPane.init(document.getElementById('demo-split'), { initial: 0.4, minA: 100, minB: 100 });
// Demo notification badge initial count
lt.notif.set('#lt-notif-bell', 3);
// Tab bar switching // Tab bar switching
document.querySelectorAll('.lt-tab-bar').forEach(bar => { document.querySelectorAll('.lt-tab-bar').forEach(bar => {
bar.addEventListener('click', e => { bar.addEventListener('click', e => {
+635 -1
View File
@@ -1447,7 +1447,630 @@
}; };
/* ---------------------------------------------------------------- /* ================================================================
MODULE 37 — THEME TOGGLE
lt.theme.toggle()
lt.theme.set('light'|'dark')
lt.theme.get()
================================================================ */
const _themeKey = 'lt_theme';
function _applyTheme(t) {
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem(_themeKey, t); } catch(_) {}
document.querySelectorAll('.lt-theme-btn').forEach(btn => {
btn.textContent = t === 'light' ? '◐' : '☀';
btn.setAttribute('aria-label', t === 'light' ? 'Switch to dark mode' : 'Switch to light mode');
btn.setAttribute('title', t === 'light' ? 'Switch to dark mode' : 'Switch to light mode');
});
bus.emit('theme:change', { theme: t });
}
const _initTheme = (function() {
let saved;
try { saved = localStorage.getItem(_themeKey); } catch(_) {}
return saved || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
})();
_applyTheme(_initTheme);
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', e => {
let saved; try { saved = localStorage.getItem(_themeKey); } catch(_) {}
if (!saved) _applyTheme(e.matches ? 'light' : 'dark');
});
const theme = {
toggle: () => _applyTheme(document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light'),
set: t => _applyTheme(t),
get: () => document.documentElement.getAttribute('data-theme') || 'dark',
};
/* ================================================================
MODULE 38 — NOTIFICATION BADGE
lt.notif.set(el, count)
lt.notif.inc(el)
lt.notif.clear(el)
el = CSS selector string or DOM element
================================================================ */
function _notifEl(el) { return typeof el === 'string' ? document.querySelector(el) : el; }
function _notifBadge(el) {
const wrap = _notifEl(el); if (!wrap) return null;
let b = wrap.querySelector(':scope > .lt-notif-badge');
if (!b) {
b = document.createElement('span');
b.className = 'lt-notif-badge';
b.setAttribute('aria-live', 'polite');
b.setAttribute('role', 'status');
wrap.classList.add('lt-notif-wrap');
wrap.appendChild(b);
}
return b;
}
const notif = {
set(el, n) {
const b = _notifBadge(el); if (!b) return;
const label = n > 99 ? '99+' : n > 0 ? String(n) : '';
b.textContent = label;
b.setAttribute('data-count', n);
b.setAttribute('aria-label', n > 0 ? `${n} notification${n !== 1 ? 's' : ''}` : '');
},
inc(el) {
const b = _notifBadge(el); if (!b) return;
const cur = parseInt(b.getAttribute('data-count') || '0', 10);
notif.set(el, cur + 1);
},
clear(el) { notif.set(el, 0); },
};
/* ================================================================
MODULE 39 — RIGHT-SIDE DRAWER
lt.rightDrawer.open(id)
lt.rightDrawer.close(id)
lt.rightDrawer.toggle(id)
================================================================ */
function _rdOpen(id, triggerEl) {
const drawer = typeof id === 'string' ? document.getElementById(id) : id;
if (!drawer) return;
const ovId = drawer.dataset.overlay || id + '-overlay';
const ov = document.getElementById(ovId);
if (_mnOpen) _mnSetOpen(false);
drawer.classList.add('is-open');
drawer.setAttribute('aria-hidden', 'false');
if (ov) ov.classList.add('is-open');
_lockScroll();
if (triggerEl) _modalTriggers.set(drawer, triggerEl);
const first = drawer.querySelector(_FOCUSABLE);
if (first) setTimeout(() => first.focus(), 50);
// ESC to close
drawer._rdKeyHandler = e => { if (e.key === 'Escape') _rdClose(drawer); };
document.addEventListener('keydown', drawer._rdKeyHandler);
// Overlay click
if (ov) ov._rdClick = () => _rdClose(drawer);
if (ov) ov.addEventListener('click', ov._rdClick);
// Close button
drawer.querySelectorAll('[data-drawer-close]').forEach(btn => {
btn._rdHandler = () => _rdClose(drawer);
btn.addEventListener('click', btn._rdHandler);
});
}
function _rdClose(id) {
const drawer = typeof id === 'string' ? document.getElementById(id) : id;
if (!drawer || !drawer.classList.contains('is-open')) return;
const ovId = drawer.dataset.overlay || (drawer.id ? drawer.id + '-overlay' : null);
const ov = ovId ? document.getElementById(ovId) : null;
drawer.classList.remove('is-open');
drawer.setAttribute('aria-hidden', 'true');
if (ov) { ov.classList.remove('is-open'); if (ov._rdClick) { ov.removeEventListener('click', ov._rdClick); delete ov._rdClick; } }
_unlockScroll();
if (drawer._rdKeyHandler) { document.removeEventListener('keydown', drawer._rdKeyHandler); delete drawer._rdKeyHandler; }
const trigger = _modalTriggers.get(drawer);
if (trigger) { trigger.focus(); _modalTriggers.delete(drawer); }
}
const rightDrawer = {
open: (id) => _rdOpen(id, document.activeElement !== document.body ? document.activeElement : null),
close: (id) => _rdClose(id),
toggle: (id) => {
const el = typeof id === 'string' ? document.getElementById(id) : id;
if (el && el.classList.contains('is-open')) _rdClose(el); else _rdOpen(id);
},
};
// data-drawer-open="drawer-id" trigger wiring
document.addEventListener('click', e => {
const btn = e.target.closest('[data-drawer-open]');
if (btn) { e.preventDefault(); _rdOpen(btn.dataset.drawerOpen, btn); }
});
/* ================================================================
MODULE 40 — CONTEXT MENU
lt.contextMenu.register(selector, items)
items = [{ label, icon, kbd, danger, divider, action }]
================================================================ */
let _ctxMenu = null;
let _ctxItems = [];
function _ctxShow(x, y, items) {
if (!_ctxMenu) {
_ctxMenu = document.createElement('div');
_ctxMenu.className = 'lt-context-menu';
_ctxMenu.setAttribute('role', 'menu');
document.body.appendChild(_ctxMenu);
}
_ctxMenu.innerHTML = '';
items.forEach(item => {
if (item.divider) { const d = document.createElement('div'); d.className = 'lt-context-menu-divider'; _ctxMenu.appendChild(d); return; }
if (item.label && !item.action) { const l = document.createElement('div'); l.className = 'lt-context-menu-label'; l.textContent = item.label; _ctxMenu.appendChild(l); return; }
const el = document.createElement('div');
el.className = 'lt-context-menu-item' + (item.danger ? ' is-danger' : '');
el.setAttribute('role', 'menuitem');
el.setAttribute('tabindex', '0');
el.innerHTML = `${item.icon ? `<span class="icon">${escHtml(item.icon)}</span>` : '<span class="icon"></span>'}<span>${escHtml(item.label || '')}</span>${item.kbd ? `<kbd>${escHtml(item.kbd)}</kbd>` : ''}`;
el.addEventListener('click', () => { _ctxHide(); if (item.action) item.action(); });
el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); } });
_ctxMenu.appendChild(el);
});
_ctxMenu.classList.add('is-open');
// Position — keep on screen
const vw = window.innerWidth, vh = window.innerHeight;
const mw = _ctxMenu.offsetWidth || 180, mh = _ctxMenu.offsetHeight || 200;
_ctxMenu.style.left = Math.min(x, vw - mw - 8) + 'px';
_ctxMenu.style.top = Math.min(y, vh - mh - 8) + 'px';
// Focus first item
const first = _ctxMenu.querySelector('[role="menuitem"]');
if (first) setTimeout(() => first.focus(), 20);
}
function _ctxHide() {
if (_ctxMenu) _ctxMenu.classList.remove('is-open');
}
document.addEventListener('click', () => _ctxHide());
document.addEventListener('contextmenu', e => {
const target = e.target.closest('[data-context-menu]');
if (!target) { _ctxHide(); return; }
e.preventDefault();
const menuId = target.dataset.contextMenu;
const items = _ctxItems[menuId];
if (items) _ctxShow(e.clientX, e.clientY, items);
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') _ctxHide(); });
const contextMenu = {
register(id, items) { _ctxItems[id] = items; },
show: (x, y, items) => _ctxShow(x, y, items),
hide: () => _ctxHide(),
};
/* ================================================================
MODULE 41 — OFFLINE DETECTION
lt.offline.onOnline(fn)
lt.offline.onOffline(fn)
lt.offline.isOnline()
================================================================ */
let _offlineBanner = null;
function _offlineGetBanner() {
if (!_offlineBanner) {
_offlineBanner = document.getElementById('lt-offline-banner');
if (!_offlineBanner) {
_offlineBanner = document.createElement('div');
_offlineBanner.id = 'lt-offline-banner';
_offlineBanner.className = 'lt-offline-banner';
_offlineBanner.setAttribute('role', 'alert');
_offlineBanner.setAttribute('aria-live', 'assertive');
_offlineBanner.innerHTML = '<span class="lt-dot lt-dot--red"></span> NO NETWORK CONNECTION — RECONNECTING...';
document.body.appendChild(_offlineBanner);
}
}
return _offlineBanner;
}
const _offlineHandlers = [];
const _onlineHandlers = [];
window.addEventListener('offline', () => {
document.body.classList.add('lt-is-offline');
_offlineGetBanner().classList.add('is-visible');
toast.warning('Connection lost — working offline');
_offlineHandlers.forEach(fn => fn());
});
window.addEventListener('online', () => {
document.body.classList.remove('lt-is-offline');
_offlineGetBanner().classList.remove('is-visible');
toast.success('Connection restored');
_onlineHandlers.forEach(fn => fn());
});
const offline = {
isOnline: () => navigator.onLine,
onOffline: fn => _offlineHandlers.push(fn),
onOnline: fn => _onlineHandlers.push(fn),
};
/* ================================================================
MODULE 42 — WEBSOCKET MANAGER
lt.ws.connect(url, opts)
opts: { protocols, onOpen, onMessage, onClose, onError,
reconnect: true, reconnectDelay: 2000, maxRetries: 10 }
Returns a handle: { send, close, status, on, off }
================================================================ */
const ws = {
connect(url, opts = {}) {
const {
protocols = [],
onOpen = null,
onMessage = null,
onClose = null,
onError = null,
reconnect = true,
reconnectDelay = 2000,
maxRetries = 10,
} = opts;
let _sock = null;
let _retries = 0;
let _closed = false;
let _statusEl = opts.statusEl ? (typeof opts.statusEl === 'string' ? document.querySelector(opts.statusEl) : opts.statusEl) : null;
const _handlers = {};
function _setStatus(state) {
if (_statusEl) {
_statusEl.setAttribute('data-state', state);
_statusEl.querySelector('.lt-dot'); // force repaint
const labels = { connected: 'Connected', connecting: 'Connecting…', disconnected: 'Disconnected' };
const dot = _statusEl.querySelector('.lt-dot');
const span = _statusEl.querySelector('span:last-child');
if (span) span.textContent = labels[state] || state;
}
bus.emit('ws:status', { url, state });
}
function _connect() {
_setStatus('connecting');
try { _sock = protocols.length ? new WebSocket(url, protocols) : new WebSocket(url); }
catch(e) { console.error('[lt.ws] Failed to create WebSocket:', e); return; }
_sock.addEventListener('open', e => {
_retries = 0;
_setStatus('connected');
if (onOpen) onOpen(e);
(_handlers['open'] || []).forEach(fn => fn(e));
});
_sock.addEventListener('message', e => {
let data = e.data;
try { data = JSON.parse(e.data); } catch(_) {}
if (onMessage) onMessage(data, e);
(_handlers['message'] || []).forEach(fn => fn(data, e));
});
_sock.addEventListener('close', e => {
_setStatus('disconnected');
if (onClose) onClose(e);
(_handlers['close'] || []).forEach(fn => fn(e));
if (reconnect && !_closed && _retries < maxRetries) {
_retries++;
const delay = Math.min(reconnectDelay * Math.pow(1.5, _retries - 1), 30000);
setTimeout(_connect, delay);
}
});
_sock.addEventListener('error', e => {
if (onError) onError(e);
(_handlers['error'] || []).forEach(fn => fn(e));
});
}
_connect();
return {
send(data) {
if (!_sock || _sock.readyState !== WebSocket.OPEN) { console.warn('[lt.ws] Not connected'); return false; }
_sock.send(typeof data === 'string' ? data : JSON.stringify(data));
return true;
},
close() { _closed = true; if (_sock) _sock.close(); _setStatus('disconnected'); },
get status() { return _sock ? ['connecting','open','closing','closed'][_sock.readyState] : 'disconnected'; },
on(event, fn) { if (!_handlers[event]) _handlers[event] = []; _handlers[event].push(fn); return this; },
off(event, fn) { if (_handlers[event]) _handlers[event] = _handlers[event].filter(f => f !== fn); return this; },
};
},
};
/* ================================================================
MODULE 43 — MULTI-SELECT COMBOBOX
lt.combobox.init(inputEl, options, opts)
options = [{ value, label, icon? }]
opts: { max, placeholder, onChange }
================================================================ */
const combobox = {
init(inputEl, options = [], opts = {}) {
const wrap = inputEl.closest('.lt-combobox') || inputEl.parentElement;
const inputWrap = wrap.querySelector('.lt-combobox-input-wrap') || wrap;
const dropdown = wrap.querySelector('.lt-combobox-dropdown');
if (!dropdown) return;
const { max = Infinity, placeholder = 'Search…', onChange = null } = opts;
let selected = [];
let focusedIdx = -1;
let filtered = [...options];
function _renderTags() {
wrap.querySelectorAll('.lt-combobox-tag').forEach(t => t.remove());
selected.forEach(v => {
const opt = options.find(o => o.value === v);
if (!opt) return;
const tag = document.createElement('span');
tag.className = 'lt-combobox-tag';
tag.innerHTML = `${escHtml(opt.label)}<button class="lt-combobox-tag-remove" data-value="${escHtml(v)}" aria-label="Remove ${escHtml(opt.label)}">✕</button>`;
inputWrap.insertBefore(tag, inputEl);
});
}
function _renderDropdown(query) {
const q = query.toLowerCase().trim();
filtered = options.filter(o => !selected.includes(o.value) && (!q || o.label.toLowerCase().includes(q)));
dropdown.innerHTML = '';
if (!filtered.length) {
dropdown.innerHTML = `<div class="lt-combobox-empty">${q ? 'No matches' : 'All selected'}</div>`;
return;
}
filtered.forEach((opt, i) => {
const el = document.createElement('div');
el.className = 'lt-combobox-option' + (selected.includes(opt.value) ? ' is-selected' : '');
el.setAttribute('role', 'option');
el.setAttribute('data-value', opt.value);
const hl = q ? opt.label.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>') : escHtml(opt.label);
el.innerHTML = `${opt.icon ? `<span class="icon">${escHtml(opt.icon)}</span>` : ''}<span>${hl}</span>`;
el.addEventListener('mousedown', e => { e.preventDefault(); _toggle(opt.value); });
dropdown.appendChild(el);
});
focusedIdx = -1;
}
function _toggle(value) {
const idx = selected.indexOf(value);
if (idx >= 0) { selected.splice(idx, 1); }
else if (selected.length < max) { selected.push(value); }
_renderTags();
_renderDropdown(inputEl.value);
inputEl.value = '';
inputEl.focus();
if (onChange) onChange([...selected]);
}
function _moveFocus(dir) {
const items = dropdown.querySelectorAll('.lt-combobox-option');
if (!items.length) return;
focusedIdx = Math.max(0, Math.min(focusedIdx + dir, items.length - 1));
items.forEach((el, i) => el.classList.toggle('is-focused', i === focusedIdx));
items[focusedIdx].scrollIntoView({ block: 'nearest' });
}
inputEl.addEventListener('input', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); });
inputEl.addEventListener('focus', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); });
inputEl.addEventListener('keydown', e => {
if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); }
if (e.key === 'Enter') { e.preventDefault(); if (focusedIdx >= 0 && filtered[focusedIdx]) _toggle(filtered[focusedIdx].value); }
if (e.key === 'Escape') { dropdown.classList.remove('is-open'); }
if (e.key === 'Backspace' && !inputEl.value && selected.length) { _toggle(selected[selected.length - 1]); }
});
inputWrap.addEventListener('mousedown', e => {
const rmBtn = e.target.closest('.lt-combobox-tag-remove');
if (rmBtn) { e.preventDefault(); _toggle(rmBtn.dataset.value); return; }
inputEl.focus();
});
document.addEventListener('click', e => { if (!wrap.contains(e.target)) dropdown.classList.remove('is-open'); });
_renderTags();
_renderDropdown('');
return {
getValue: () => [...selected],
setValue: vals => { selected = [...vals]; _renderTags(); _renderDropdown(''); },
clear: () => { selected = []; _renderTags(); _renderDropdown(''); },
};
},
};
/* ================================================================
MODULE 44 — AUTOCOMPLETE / TYPEAHEAD
lt.typeahead.init(inputEl, source, opts)
source: array or async fn(query) => [{value, label, icon?, meta?}]
opts: { minChars, debounceMs, onSelect, maxResults }
================================================================ */
const typeahead = {
init(inputEl, source, opts = {}) {
const wrap = inputEl.closest('.lt-typeahead') || inputEl.parentElement;
const dropdown = wrap.querySelector('.lt-typeahead-dropdown');
if (!dropdown) return;
const { minChars = 1, debounceMs = 150, onSelect = null, maxResults = 10 } = opts;
let _focusedIdx = -1;
let _items = [];
let _debTimer = null;
function _render(items, query) {
_items = items.slice(0, maxResults);
dropdown.innerHTML = '';
if (!_items.length) {
dropdown.innerHTML = `<div class="lt-typeahead-empty">[ NO RESULTS ]</div>`;
return;
}
const q = query.toLowerCase();
_items.forEach((item, i) => {
const el = document.createElement('div');
el.className = 'lt-typeahead-item';
el.setAttribute('role', 'option');
const hl = item.label.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>');
el.innerHTML = `${item.icon ? `<span class="icon">${escHtml(item.icon)}</span>` : ''}<span>${hl}</span>${item.meta ? `<span style="margin-left:auto;color:var(--text-muted);font-size:0.68rem">${escHtml(item.meta)}</span>` : ''}`;
el.addEventListener('mousedown', e => { e.preventDefault(); _select(item); });
dropdown.appendChild(el);
});
_focusedIdx = -1;
}
async function _search(query) {
dropdown.innerHTML = '<div class="lt-typeahead-loading">Searching…</div>';
dropdown.classList.add('is-open');
try {
const results = typeof source === 'function' ? await source(query) : source.filter(i => i.label.toLowerCase().includes(query.toLowerCase()));
_render(results, query);
} catch(e) {
dropdown.innerHTML = '<div class="lt-typeahead-empty">Error loading results</div>';
}
}
function _select(item) {
inputEl.value = item.label;
dropdown.classList.remove('is-open');
if (onSelect) onSelect(item);
bus.emit('typeahead:select', { item });
}
function _moveFocus(dir) {
const els = dropdown.querySelectorAll('.lt-typeahead-item');
if (!els.length) return;
_focusedIdx = Math.max(0, Math.min(_focusedIdx + dir, els.length - 1));
els.forEach((el, i) => el.classList.toggle('is-focused', i === _focusedIdx));
els[_focusedIdx].scrollIntoView({ block: 'nearest' });
}
inputEl.addEventListener('input', () => {
clearTimeout(_debTimer);
const q = inputEl.value.trim();
if (q.length < minChars) { dropdown.classList.remove('is-open'); return; }
_debTimer = setTimeout(() => _search(q), debounceMs);
});
inputEl.addEventListener('keydown', e => {
if (!dropdown.classList.contains('is-open')) return;
if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); }
if (e.key === 'Enter') { e.preventDefault(); if (_focusedIdx >= 0 && _items[_focusedIdx]) _select(_items[_focusedIdx]); }
if (e.key === 'Escape') { dropdown.classList.remove('is-open'); }
if (e.key === 'Tab') { dropdown.classList.remove('is-open'); }
});
inputEl.addEventListener('blur', () => setTimeout(() => dropdown.classList.remove('is-open'), 150));
document.addEventListener('click', e => { if (!wrap.contains(e.target)) dropdown.classList.remove('is-open'); });
return { search: q => _search(q) };
},
};
/* ================================================================
MODULE 45 — COOKIE UTILITY
lt.cookie.set(name, value, days?, opts?)
lt.cookie.get(name)
lt.cookie.del(name)
================================================================ */
const cookie = {
set(name, value, days = 0, opts = {}) {
let str = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
if (days) { const d = new Date(); d.setDate(d.getDate() + days); str += `; expires=${d.toUTCString()}`; }
str += `; path=${opts.path || '/'}`;
if (opts.sameSite) str += `; SameSite=${opts.sameSite}`;
if (opts.secure || location.protocol === 'https:') str += '; Secure';
document.cookie = str;
},
get(name) {
const key = encodeURIComponent(name) + '=';
const found = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith(key));
return found ? decodeURIComponent(found.slice(key.length)) : null;
},
del(name, opts = {}) { cookie.set(name, '', -1, opts); },
getAll() {
const out = {};
document.cookie.split(';').forEach(c => {
const [k, ...v] = c.trim().split('=');
if (k) out[decodeURIComponent(k)] = decodeURIComponent(v.join('='));
});
return out;
},
};
/* ================================================================
MODULE 46 — SPLIT PANE
lt.splitPane.init(containerEl, opts)
opts: { minA, minB, initial (0-1), vertical, onResize }
================================================================ */
const splitPane = {
init(container, opts = {}) {
const { minA = 80, minB = 80, initial = 0.5, vertical = false, onResize = null } = opts;
const panes = container.querySelectorAll('.lt-split-pane');
const divider = container.querySelector('.lt-split-divider');
if (!panes[0] || !panes[1] || !divider) return;
const dim = vertical ? 'height' : 'width';
const client = vertical ? 'clientY' : 'clientX';
let dragging = false;
let startPos, startSizeA;
function _setRatio(ratio) {
const total = vertical ? container.clientHeight : container.clientWidth;
const divSize = vertical ? divider.offsetHeight : divider.offsetWidth;
const available = total - divSize;
const sizeA = Math.max(minA, Math.min(available - minB, ratio * available));
panes[0].style[dim] = sizeA + 'px';
panes[0].style.flex = 'none';
panes[1].style.flex = '1';
if (onResize) onResize(sizeA, available - sizeA);
}
// Pointer events (handles both mouse and touch)
divider.addEventListener('pointerdown', e => {
e.preventDefault();
dragging = true;
divider.setPointerCapture(e.pointerId);
divider.classList.add('is-dragging');
startPos = e[client];
startSizeA = vertical ? panes[0].offsetHeight : panes[0].offsetWidth;
});
divider.addEventListener('pointermove', e => {
if (!dragging) return;
const delta = e[client] - startPos;
const total = vertical ? container.clientHeight : container.clientWidth;
const divSize = vertical ? divider.offsetHeight : divider.offsetWidth;
const newSize = Math.max(minA, Math.min(total - divSize - minB, startSizeA + delta));
panes[0].style[dim] = newSize + 'px';
panes[0].style.flex = 'none';
panes[1].style.flex = '1';
if (onResize) onResize(newSize, total - divSize - newSize);
});
divider.addEventListener('pointerup', () => { dragging = false; divider.classList.remove('is-dragging'); });
_setRatio(initial);
return { setRatio: _setRatio };
},
};
/* ================================================================
MODULE 47 — TOAST QUEUE (enhanced dispatch)
Wraps existing toast module with max-stack + progress bars
================================================================ */
const _toastMaxStack = 5;
const _toastQueue = [];
let _toastActive = 0;
const _origToast = Object.assign({}, toast);
function _toastEnqueue(type, msg, dur = 4000) {
if (_toastActive >= _toastMaxStack) {
_toastQueue.push({ type, msg, dur });
return;
}
_toastActive++;
const id = _origToast[type] ? _origToast[type](msg, dur) : _origToast.info(msg, dur);
// Add progress bar to the toast element
setTimeout(() => {
const toastEl = document.querySelector(`#lt-toast-container .lt-toast:last-child`);
if (toastEl && !toastEl.querySelector('.lt-toast-progress')) {
const bar = document.createElement('div');
bar.className = 'lt-toast-progress';
bar.style.animationDuration = dur + 'ms';
toastEl.appendChild(bar);
}
}, 20);
setTimeout(() => {
_toastActive = Math.max(0, _toastActive - 1);
if (_toastQueue.length) {
const next = _toastQueue.shift();
_toastEnqueue(next.type, next.msg, next.dur);
}
}, dur + 400);
return id;
}
// Override toast methods to use queue
['success','error','warning','info'].forEach(t => {
if (toast[t]) {
const orig = toast[t].bind(toast);
toast[t] = (msg, dur) => _toastEnqueue(t, msg, dur || 4000);
}
});
/* ================================================================
PUBLIC API PUBLIC API
---------------------------------------------------------------- */ ---------------------------------------------------------------- */
global.lt = { global.lt = {
@@ -1491,6 +2114,17 @@
/* v1.3 responsive */ /* v1.3 responsive */
viewport, viewport,
mobileNav, mobileNav,
/* v1.1 new features */
theme,
notif,
rightDrawer,
contextMenu,
offline,
ws,
combobox,
typeahead,
cookie,
splitPane,
}; };
}(window)); }(window));