Add touch-event fallback to lt.sortable for kanban drag-and-drop (#65)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
This commit is contained in:
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user