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:
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 & 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 & 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, { initial: 0.4, minA: 120 })</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WebSocket & Offline -->
|
||||||
|
<div class="lt-section">
|
||||||
|
<div class="lt-section-header">
|
||||||
|
<span class="lt-section-title">// WEBSOCKET & 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 => {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user