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(); });
|
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 {
|
return {
|
||||||
refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); },
|
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()),
|
getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()),
|
||||||
|
|||||||
Reference in New Issue
Block a user