fix: resolve DOMContentLoaded crash + wire kanban/pagination

- Fix lt.clipboard.init() → initCopyButtons() (crashed init handler
  before context menu, split pane, lightbox, theme btn could register)
- Add lt.pagination module (Module 55) with ellipsis rendering,
  prev/next, onChange callback; wire demo-pagination nav
- Upgrade lt.sortable for cross-list group dragging (shared module-level
  drag state enables kanban card movement between columns)
- Wire kanban columns (open/pending/inprogress/closed) to lt.sortable
  with group:'kanban' for drag-and-drop between columns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 23:19:49 -04:00
parent 0c2e136cae
commit 3847513594
2 changed files with 126 additions and 66 deletions
+18 -14
View File
@@ -415,7 +415,7 @@
<span class="lt-frame-bl"></span> <span class="lt-frame-bl"></span>
<span class="lt-frame-br"></span> <span class="lt-frame-br"></span>
<div class="lt-section-header">Open</div> <div class="lt-section-header">Open</div>
<div class="lt-section-body"> <div class="lt-section-body" id="kanban-col-open" style="min-height:60px">
<div class="lt-card lt-mb-md lt-row-p1"> <div class="lt-card lt-mb-md lt-row-p1">
<div class="lt-flex lt-flex-between lt-mb-md"> <div class="lt-flex lt-flex-between lt-mb-md">
@@ -443,7 +443,7 @@
<span class="lt-frame-bl"></span> <span class="lt-frame-bl"></span>
<span class="lt-frame-br"></span> <span class="lt-frame-br"></span>
<div class="lt-section-header">Pending</div> <div class="lt-section-header">Pending</div>
<div class="lt-section-body"> <div class="lt-section-body" id="kanban-col-pending" style="min-height:60px">
<div class="lt-card lt-row-p2"> <div class="lt-card lt-row-p2">
<div class="lt-flex lt-flex-between lt-mb-md"> <div class="lt-flex lt-flex-between lt-mb-md">
<span class="lt-p2">P2</span> <span class="lt-p2">P2</span>
@@ -460,7 +460,7 @@
<span class="lt-frame-bl"></span> <span class="lt-frame-bl"></span>
<span class="lt-frame-br"></span> <span class="lt-frame-br"></span>
<div class="lt-section-header">In Progress</div> <div class="lt-section-header">In Progress</div>
<div class="lt-section-body"> <div class="lt-section-body" id="kanban-col-inprogress" style="min-height:60px">
<div class="lt-card lt-row-p2 lt-item-running"> <div class="lt-card lt-row-p2 lt-item-running">
<div class="lt-flex lt-flex-between lt-mb-md"> <div class="lt-flex lt-flex-between lt-mb-md">
<span class="lt-p2">P2</span> <span class="lt-p2">P2</span>
@@ -477,7 +477,7 @@
<span class="lt-frame-bl"></span> <span class="lt-frame-bl"></span>
<span class="lt-frame-br"></span> <span class="lt-frame-br"></span>
<div class="lt-section-header">Closed</div> <div class="lt-section-header">Closed</div>
<div class="lt-section-body"> <div class="lt-section-body" id="kanban-col-closed" style="min-height:60px">
<div class="lt-card lt-row-p4" style="opacity:0.6"> <div class="lt-card lt-row-p4" style="opacity:0.6">
<div class="lt-flex lt-flex-between lt-mb-md"> <div class="lt-flex lt-flex-between lt-mb-md">
<span class="lt-p4">P4</span> <span class="lt-p4">P4</span>
@@ -724,15 +724,7 @@
<div class="lt-breadcrumb-sep"></div> <div class="lt-breadcrumb-sep"></div>
<div class="lt-breadcrumb-item active">NODE-07</div> <div class="lt-breadcrumb-item active">NODE-07</div>
</nav> </nav>
<nav class="lt-pagination" aria-label="pagination"> <nav class="lt-pagination" id="demo-pagination" aria-label="pagination"></nav>
<button class="lt-page-btn" disabled>&laquo;</button>
<button class="lt-page-btn" disabled>1</button>
<button class="lt-page-btn active">2</button>
<button class="lt-page-btn">3</button>
<button class="lt-page-btn">4</button>
<button class="lt-page-btn">5</button>
<button class="lt-page-btn">&raquo;</button>
</nav>
</div> </div>
<!-- ACCORDION --> <!-- ACCORDION -->
@@ -1573,7 +1565,7 @@ Storage array link-down on `compute-storage-01`.
lt.tooltip.init(); lt.tooltip.init();
// Clipboard copy buttons ([data-copy]) // Clipboard copy buttons ([data-copy])
lt.clipboard.init(); lt.clipboard.initCopyButtons();
// Alert dismiss buttons // Alert dismiss buttons
lt.alerts.init(); lt.alerts.init();
@@ -1691,6 +1683,18 @@ Storage array link-down on `compute-storage-01`.
}, },
}); });
// Kanban drag-and-drop — all four columns share group "kanban"
['kanban-col-open','kanban-col-pending','kanban-col-inprogress','kanban-col-closed'].forEach(id => {
const col = document.getElementById(id);
if (col) lt.sortable.init(col, { group: 'kanban', onSort: () => {} });
});
// Pagination demo (50 items, 10 per page)
lt.pagination.init('#demo-pagination', {
total: 50, perPage: 10, page: 2,
onChange: p => lt.toast.info('Page ' + p),
});
// Countdown demo — SLA expires 2 hours from now // Countdown demo — SLA expires 2 hours from now
const slaTarget = new Date(Date.now() + 2 * 60 * 60 * 1000); const slaTarget = new Date(Date.now() + 2 * 60 * 60 * 1000);
lt.timer.countdown(document.getElementById('demo-countdown'), slaTarget, { lt.timer.countdown(document.getElementById('demo-countdown'), slaTarget, {
+107 -51
View File
@@ -2228,90 +2228,86 @@
MODULE 50 — SORTABLE (drag-to-reorder lists/kanban) MODULE 50 — SORTABLE (drag-to-reorder lists/kanban)
lt.sortable.init(listEl, opts) lt.sortable.init(listEl, opts)
opts: { handle (selector), onSort(newOrder, movedEl), group } opts: { handle (selector), onSort(newOrder, movedEl), group }
Returns draggable list; emits 'sortable:change' on bus Supports cross-list dragging when group matches.
Returns { refresh(), getOrder() }; emits 'sortable:change' on bus
================================================================ */ ================================================================ */
// Shared cross-instance drag state (enables group/cross-column dragging)
let _srtDragging = null, _srtPlaceholder = null, _srtSrcList = null;
const sortable = { const sortable = {
init(list, opts = {}) { init(list, opts = {}) {
const { handle = null, onSort = null, group = null, animation = 200 } = opts; const { handle = null, onSort = null, group = null } = opts;
list.setAttribute('data-sortable-group', group || ''); list.setAttribute('data-sortable-group', group || '');
let _dragging = null, _placeholder = null, _startIdx = -1;
function _idx(el) { return Array.from(list.children).indexOf(el); } function _mark(child) {
function _makePlaceholder(el) {
const ph = document.createElement(el.tagName);
ph.className = 'lt-sortable-placeholder';
ph.style.height = el.offsetHeight + 'px';
ph.style.width = el.offsetWidth + 'px';
return ph;
}
function _getItems() { return Array.from(list.querySelectorAll(':scope > [data-sortable-item]')); }
// Mark all children as sortable items
Array.from(list.children).forEach(child => {
child.setAttribute('data-sortable-item', ''); child.setAttribute('data-sortable-item', '');
child.setAttribute('draggable', handle ? 'false' : 'true'); child.setAttribute('draggable', handle ? 'false' : 'true');
if (handle) { if (handle) {
const h = child.querySelector(handle); const h = child.querySelector(handle);
if (h) { h.setAttribute('draggable', 'true'); h.style.cursor = 'grab'; } if (h) { h.setAttribute('draggable', 'true'); h.style.cursor = 'grab'; }
} else { child.style.cursor = 'grab'; } } else { child.style.cursor = 'grab'; }
}); }
function _getItems() { return Array.from(list.querySelectorAll(':scope > [data-sortable-item]')); }
function _makePlaceholder(el) {
const ph = document.createElement(el.tagName);
ph.className = 'lt-sortable-placeholder';
ph.style.height = el.offsetHeight + 'px';
ph.style.width = '100%';
return ph;
}
function _sameGroup(otherList) {
if (!group) return false;
return otherList.getAttribute('data-sortable-group') === group;
}
// Mark all current children
Array.from(list.children).forEach(_mark);
list.addEventListener('dragstart', e => { list.addEventListener('dragstart', e => {
const item = e.target.closest('[data-sortable-item]'); const item = e.target.closest('[data-sortable-item]');
if (!item || !list.contains(item)) return; if (!item || !list.contains(item)) return;
_dragging = item; _srtDragging = item;
_startIdx = _idx(item); _srtSrcList = list;
_placeholder = _makePlaceholder(item); _srtPlaceholder = _makePlaceholder(item);
requestAnimationFrame(() => { item.classList.add('is-dragging'); }); requestAnimationFrame(() => { item.classList.add('is-dragging'); });
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', ''); // Firefox compat e.dataTransfer.setData('text/plain', '');
}); });
list.addEventListener('dragend', () => { list.addEventListener('dragend', () => {
if (!_dragging) return; if (!_srtDragging) return;
_dragging.classList.remove('is-dragging'); _srtDragging.classList.remove('is-dragging');
_dragging.style.opacity = ''; if (_srtPlaceholder && _srtPlaceholder.parentNode) {
if (_placeholder && _placeholder.parentNode) { _srtPlaceholder.parentNode.insertBefore(_srtDragging, _srtPlaceholder);
_placeholder.parentNode.insertBefore(_dragging, _placeholder); _srtPlaceholder.remove();
_placeholder.remove();
} }
const endIdx = _idx(_dragging); if (onSort) onSort(_getItems(), _srtDragging);
if (endIdx !== _startIdx && onSort) onSort(_getItems(), _dragging); bus.emit('sortable:change', { list, items: _getItems(), moved: _srtDragging });
bus.emit('sortable:change', { list, items: _getItems(), moved: _dragging }); _srtDragging = null; _srtPlaceholder = null; _srtSrcList = null;
_dragging = null; _placeholder = null;
}); });
list.addEventListener('dragover', e => { list.addEventListener('dragover', e => {
if (!_srtDragging) return;
// Allow drop only within same list or same group
if (list !== _srtSrcList && !_sameGroup(_srtSrcList)) return;
e.preventDefault(); e.dataTransfer.dropEffect = 'move'; e.preventDefault(); e.dataTransfer.dropEffect = 'move';
if (!_srtPlaceholder) _srtPlaceholder = _makePlaceholder(_srtDragging);
const over = e.target.closest('[data-sortable-item]'); const over = e.target.closest('[data-sortable-item]');
if (!over || over === _dragging || !list.contains(over)) return; if (over && over !== _srtDragging && list.contains(over)) {
if (!_placeholder) return;
const rect = over.getBoundingClientRect(); const rect = over.getBoundingClientRect();
const mid = rect.top + rect.height / 2; list.insertBefore(_srtPlaceholder, e.clientY < rect.top + rect.height / 2 ? over : over.nextSibling);
over.parentNode.insertBefore(_placeholder, e.clientY < mid ? over : over.nextSibling); } else if (!list.contains(_srtPlaceholder)) {
}); list.appendChild(_srtPlaceholder);
list.addEventListener('dragenter', e => {
const over = e.target.closest('[data-sortable-item]');
if (over && over !== _dragging && list.contains(over) && !_placeholder) {
_placeholder = _makePlaceholder(_dragging);
} }
}); });
list.addEventListener('drop', e => { e.preventDefault(); }); list.addEventListener('drop', e => { e.preventDefault(); });
return { return {
refresh() { refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); },
Array.from(list.children).forEach(child => {
if (!child.hasAttribute('data-sortable-item')) {
child.setAttribute('data-sortable-item', '');
child.setAttribute('draggable', handle ? 'false' : 'true');
if (!handle) child.style.cursor = 'grab';
}
});
},
getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()), getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()),
}; };
}, },
@@ -2621,6 +2617,65 @@
}, },
}; };
/* ================================================================
MODULE 55 — PAGINATION
lt.pagination.init(navEl, opts)
opts: { total, perPage, page, onChange(page) }
Renders page buttons inside navEl; re-renders on page change.
================================================================ */
const pagination = {
init(nav, opts = {}) {
if (typeof nav === 'string') nav = document.querySelector(nav);
if (!nav) return null;
let { total = 0, perPage = 10, page = 1, onChange = null, maxBtns = 7 } = opts;
function _pages() { return Math.max(1, Math.ceil(total / perPage)); }
function render() {
const pages = _pages();
let html = '';
// Prev
html += `<button class="lt-page-btn" ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}">&laquo;</button>`;
// Page buttons with ellipsis
const half = Math.floor((maxBtns - 2) / 2);
let start = Math.max(2, page - half);
let end = Math.min(pages - 1, page + half);
if (end - start < maxBtns - 3) {
if (start === 2) end = Math.min(pages - 1, start + maxBtns - 3);
else start = Math.max(2, end - maxBtns + 3);
}
html += `<button class="lt-page-btn${page === 1 ? ' active' : ''}" data-page="1">1</button>`;
if (start > 2) html += `<button class="lt-page-btn" disabled>…</button>`;
for (let i = start; i <= end; i++) {
html += `<button class="lt-page-btn${page === i ? ' active' : ''}" data-page="${i}">${i}</button>`;
}
if (end < pages - 1) html += `<button class="lt-page-btn" disabled>…</button>`;
if (pages > 1) html += `<button class="lt-page-btn${page === pages ? ' active' : ''}" data-page="${pages}">${pages}</button>`;
// Next
html += `<button class="lt-page-btn" ${page >= pages ? 'disabled' : ''} data-page="${page + 1}">&raquo;</button>`;
nav.innerHTML = html;
}
nav.addEventListener('click', e => {
const btn = e.target.closest('.lt-page-btn');
if (!btn || btn.disabled || btn.classList.contains('active')) return;
const p = parseInt(btn.dataset.page, 10);
if (!p || p < 1 || p > _pages()) return;
page = p;
render();
if (onChange) onChange(page);
});
render();
return {
setTotal(n) { total = n; page = 1; render(); },
setPage(p) { page = Math.max(1, Math.min(_pages(), p)); render(); },
getPage() { return page; },
getPages() { return _pages(); },
};
},
};
/* ================================================================ /* ================================================================
PUBLIC API PUBLIC API
---------------------------------------------------------------- */ ---------------------------------------------------------------- */
@@ -2683,6 +2738,7 @@
lightbox, lightbox,
auth, auth,
markdown, markdown,
pagination,
sidebarSubmenus: { init: initSidebarSubmenus }, sidebarSubmenus: { init: initSidebarSubmenus },
}; };