From 818af137f300efd3a383119c4bac12508be56752 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 8 Sep 2026 14:18:54 -0400 Subject: [PATCH] Add touch-event fallback to lt.sortable for kanban drag-and-drop (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lt.sortable only wired dragstart/dragend/dragover/drop — the native HTML5 Drag-and-Drop API. iOS Safari doesn't implement HTML5 DnD on arbitrary elements at all, and mobile Chrome's support is poor, so kanban card status-drag was effectively unusable via touch, despite README's "Touch-friendly controls" claim. Added touchstart/touchmove/touchend/touchcancel handling that mirrors the existing mouse-based behavior: a small movement threshold (8px) distinguishes a tap/scroll from drag intent, the dragged card is repositioned via fixed positioning to follow the finger (reparented to document.body to avoid clipping by an overflow:hidden ancestor), and elementFromPoint resolves the hover target for the same placeholder-insertion logic dragover already uses, including cross-column moves via the shared group check. touchmove/touchend/touchcancel are registered on document rather than the sortable list itself: since touch events keep targeting their touchstart element for the whole gesture regardless of DOM mutations, and the dragged item gets reparented to document.body mid-drag, a listener on the original list would stop receiving bubbled events once that reparenting happens. lt.sortable lives in base.js, the shared web_template copy used by other LotusGuild apps (per its own header comment) — this fix should be contributed upstream too, not just kept local to this repo. Verified with a jsdom harness (stubbing getBoundingClientRect and elementFromPoint against known layouts) covering: sub-threshold movement not starting a drag, same-column reorder, cross-column move with correct final DOM parent and order, and touchcancel cleanly resetting state. This caught a real bug during development — an earlier version listened on the list element for touchmove/touchend, which silently stopped receiving events after the drag-start reparenting. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- assets/js/base.js | 95 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/assets/js/base.js b/assets/js/base.js index 8925325..3547921 100644 --- a/assets/js/base.js +++ b/assets/js/base.js @@ -2475,6 +2475,101 @@ list.addEventListener('drop', e => { e.preventDefault(); }); + // Touch fallback — iOS Safari doesn't implement HTML5 drag-and-drop on + // arbitrary elements at all, and mobile Chrome's support is poor, so + // kanban drag was effectively unusable via touch without this. Touch + // events for a given touch point are always dispatched to the element + // touchstart fired on (per spec), so per-list local state here is safe; + // cross-list moves are resolved via elementFromPoint against the live + // finger position, same as dragover does via e.target above. + const DRAG_THRESHOLD = 8; // px of movement before a touch starts a drag + let _touchItem = null, _touchDragging = false; + let _touchStartX = 0, _touchStartY = 0, _touchOffsetX = 0, _touchOffsetY = 0; + + function _touchTargetList(x, y) { + const el = document.elementFromPoint(x, y); + const found = el ? el.closest('[data-sortable-group]') : null; + return found && (found === list || _sameGroup(found)) ? found : null; + } + + list.addEventListener('touchstart', e => { + const item = e.target.closest('[data-sortable-item]'); + if (!item || !list.contains(item)) return; + if (handle && !e.target.closest(handle)) return; + const t = e.touches[0]; + _touchItem = item; + _touchDragging = false; + _touchStartX = t.clientX; + _touchStartY = t.clientY; + }, { passive: true }); + + // touchmove/touchend/touchcancel are registered on document, not list: + // once the dragged item is reparented to document.body below, it's no + // longer a descendant of list, so events targeting it (touch events + // keep targeting their touchstart element for the whole gesture) would + // stop bubbling to a listener on list. + document.addEventListener('touchmove', e => { + if (!_touchItem) return; + const t = e.touches[0]; + + if (!_touchDragging) { + if (Math.abs(t.clientX - _touchStartX) < DRAG_THRESHOLD && Math.abs(t.clientY - _touchStartY) < DRAG_THRESHOLD) return; + // Drag intent confirmed — take over from here, blocking page scroll. + _touchDragging = true; + _srtDragging = _touchItem; + _srtSrcList = list; + _srtPlaceholder = _makePlaceholder(_touchItem); + _touchItem.classList.add('is-dragging'); + const rect = _touchItem.getBoundingClientRect(); + _touchOffsetX = _touchStartX - rect.left; + _touchOffsetY = _touchStartY - rect.top; + _touchItem.parentNode.insertBefore(_srtPlaceholder, _touchItem); + _touchItem.style.position = 'fixed'; + _touchItem.style.zIndex = '1000'; + _touchItem.style.width = rect.width + 'px'; + _touchItem.style.pointerEvents = 'none'; + document.body.appendChild(_touchItem); // avoid clipping by an overflow:hidden ancestor + } + + e.preventDefault(); + _touchItem.style.left = (t.clientX - _touchOffsetX) + 'px'; + _touchItem.style.top = (t.clientY - _touchOffsetY) + 'px'; + + const targetList = _touchTargetList(t.clientX, t.clientY); + if (!targetList) return; + const overEl = document.elementFromPoint(t.clientX, t.clientY); + const over = overEl ? overEl.closest('[data-sortable-item]') : null; + if (over && over !== _srtDragging && targetList.contains(over)) { + const rect = over.getBoundingClientRect(); + targetList.insertBefore(_srtPlaceholder, t.clientY < rect.top + rect.height / 2 ? over : over.nextSibling); + } else if (!targetList.contains(_srtPlaceholder)) { + targetList.appendChild(_srtPlaceholder); + } + }, { passive: false }); + + function _touchEnd() { + if (_touchDragging && _srtDragging) { + _srtDragging.classList.remove('is-dragging'); + _srtDragging.style.position = ''; + _srtDragging.style.zIndex = ''; + _srtDragging.style.width = ''; + _srtDragging.style.pointerEvents = ''; + _srtDragging.style.left = ''; + _srtDragging.style.top = ''; + if (_srtPlaceholder && _srtPlaceholder.parentNode) { + _srtPlaceholder.parentNode.insertBefore(_srtDragging, _srtPlaceholder); + _srtPlaceholder.remove(); + } + if (onSort) onSort(_getItems(), _srtDragging); + bus.emit('sortable:change', { list, items: _getItems(), moved: _srtDragging }); + } + _touchItem = null; _touchDragging = false; + _srtDragging = null; _srtPlaceholder = null; _srtSrcList = null; + } + + document.addEventListener('touchend', _touchEnd); + document.addEventListener('touchcancel', _touchEnd); + return { refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); }, getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()),