mirror of
https://github.com/aculix/negotium.git
synced 2026-09-11 07:28:17 +00:00
fix: pointer-events reordering with touch and keyboard support
HTML5 drag events never fire on touch, so reordering only ever worked with
a mouse.
Pointer events handle mouse, touch and pen in one path. The entry
condition has to differ: a mouse drag starts after 8px of movement, but a
touch drag can't use movement, because a vertical swipe on a list usually
means scroll. Touch starts on a 400ms long press instead, and moving
before that cancels it and lets the page scroll. touch-action applies only
while a drag is live, plus a non-passive touchmove blocker, since
touch-action can't stop a gesture already in flight.
Alt+Up and Alt+Down reorder from the keyboard. Focus is put back on the
moved row by hand, since list reconciliation drops it and the first
version only worked once.
Also removes dataTransfer.setData('text/html', event.target), which passed
a DOM node where a string was required.
This commit is contained in:
+127
-22
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { fly, fade } from 'svelte/transition';
|
import { fly, fade } from 'svelte/transition';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
@@ -95,33 +95,138 @@
|
|||||||
if (event.key === 'Delete') {
|
if (event.key === 'Delete') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
deleteTask(taskId);
|
deleteTask(taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reordering must not be pointer-only. The keyed each block moves the
|
||||||
|
// existing DOM node, so focus travels with the row.
|
||||||
|
if (event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
||||||
|
event.preventDefault();
|
||||||
|
moveTask(taskId, event.key === 'ArrowUp' ? -1 : 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragStart(event, index) {
|
// Reordering runs on Pointer Events rather than HTML5 drag-and-drop, which
|
||||||
draggedItem = index;
|
// never fired on touch at all — so a documented feature was desktop-only.
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
//
|
||||||
event.dataTransfer.setData('text/html', event.target);
|
// Touch and mouse need different entry conditions. A mouse drag begins once
|
||||||
|
// the pointer has moved past a small threshold. A touch drag cannot, because
|
||||||
|
// a vertical swipe on a list is far more likely to mean "scroll"; it begins
|
||||||
|
// on a long press instead, and any movement before that cancels the intent
|
||||||
|
// and lets the page scroll normally.
|
||||||
|
const DRAG_THRESHOLD_PX = 8;
|
||||||
|
const LONG_PRESS_MS = 400;
|
||||||
|
|
||||||
|
let drag = null;
|
||||||
|
|
||||||
|
function blockTouchScroll(event) {
|
||||||
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragOver(event, index) {
|
function beginDrag() {
|
||||||
event.preventDefault();
|
if (!drag || drag.active) return;
|
||||||
draggedOverIndex = index;
|
drag.active = true;
|
||||||
}
|
draggedItem = drag.index;
|
||||||
|
draggedOverIndex = drag.index;
|
||||||
|
|
||||||
function handleDragEnd(event) {
|
try {
|
||||||
event.preventDefault();
|
drag.row.setPointerCapture(drag.pointerId);
|
||||||
|
} catch {
|
||||||
if (draggedItem !== null && draggedOverIndex !== null && draggedItem !== draggedOverIndex) {
|
// Capture is an optimisation; the gesture still works without it.
|
||||||
setTasks(taskOps.reorderTask(tasks, draggedItem, draggedOverIndex));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (drag.pointerType !== 'mouse') {
|
||||||
|
// touch-action alone cannot stop a gesture already in flight.
|
||||||
|
document.addEventListener('touchmove', blockTouchScroll, { passive: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
if (!drag) return;
|
||||||
|
|
||||||
|
clearTimeout(drag.timer);
|
||||||
|
document.removeEventListener('touchmove', blockTouchScroll);
|
||||||
|
try {
|
||||||
|
drag.row.releasePointerCapture(drag.pointerId);
|
||||||
|
} catch {
|
||||||
|
// Already released, or never captured.
|
||||||
|
}
|
||||||
|
|
||||||
|
drag = null;
|
||||||
draggedItem = null;
|
draggedItem = null;
|
||||||
draggedOverIndex = null;
|
draggedOverIndex = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragLeave() {
|
function targetIndexFor(clientY) {
|
||||||
draggedOverIndex = null;
|
const rows = [...drag.row.parentElement.children];
|
||||||
|
for (let i = 0; i < rows.length; i += 1) {
|
||||||
|
const rect = rows[i].getBoundingClientRect();
|
||||||
|
if (clientY < rect.top + rect.height / 2) return i;
|
||||||
|
}
|
||||||
|
return rows.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event, index) {
|
||||||
|
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||||
|
// Let the checkbox and delete button have their clicks.
|
||||||
|
if (event.target.closest('button')) return;
|
||||||
|
|
||||||
|
drag = {
|
||||||
|
index,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
pointerType: event.pointerType,
|
||||||
|
startY: event.clientY,
|
||||||
|
row: event.currentTarget,
|
||||||
|
active: false,
|
||||||
|
timer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.pointerType !== 'mouse') {
|
||||||
|
drag.timer = setTimeout(beginDrag, LONG_PRESS_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event) {
|
||||||
|
if (!drag) return;
|
||||||
|
|
||||||
|
if (!drag.active) {
|
||||||
|
const moved = Math.abs(event.clientY - drag.startY);
|
||||||
|
if (drag.pointerType === 'mouse') {
|
||||||
|
if (moved > DRAG_THRESHOLD_PX) beginDrag();
|
||||||
|
} else if (moved > DRAG_THRESHOLD_PX) {
|
||||||
|
// Moved before the long press landed: this is a scroll, not a drag.
|
||||||
|
endDrag();
|
||||||
|
}
|
||||||
|
if (!drag?.active) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
draggedOverIndex = targetIndexFor(event.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp() {
|
||||||
|
if (!drag) return;
|
||||||
|
|
||||||
|
if (drag.active && draggedOverIndex !== null && draggedOverIndex !== drag.index) {
|
||||||
|
setTasks(taskOps.reorderTask(tasks, drag.index, draggedOverIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
endDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveTask(taskId, offset) {
|
||||||
|
const from = tasks.findIndex(task => task.id === taskId);
|
||||||
|
const to = from + offset;
|
||||||
|
if (from === -1 || to < 0 || to >= tasks.length) return;
|
||||||
|
|
||||||
|
setTasks(taskOps.reorderTask(tasks, from, to));
|
||||||
|
|
||||||
|
// Reconciling the keyed list drops focus, which would make Alt+Arrow a
|
||||||
|
// one-shot: the second press would land on nothing. Put it back on the
|
||||||
|
// task that moved so the key can be held down.
|
||||||
|
await tick();
|
||||||
|
const row = [...document.querySelectorAll('.task-item')]
|
||||||
|
.find(el => el.dataset.taskId === String(taskId));
|
||||||
|
row?.querySelector('.checkbox')?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function tomorrowKey() {
|
function tomorrowKey() {
|
||||||
@@ -310,16 +415,16 @@
|
|||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<li
|
<li
|
||||||
class="task-item"
|
class="task-item"
|
||||||
|
data-task-id={task.id}
|
||||||
class:completed={task.completed}
|
class:completed={task.completed}
|
||||||
class:dragging={draggedItem === index}
|
class:dragging={draggedItem === index}
|
||||||
class:drag-over={draggedOverIndex === index}
|
class:drag-over={draggedOverIndex === index && draggedItem !== index}
|
||||||
draggable="true"
|
|
||||||
in:fly={{ y: -10, duration: 300, delay: index * 30, easing: cubicOut }}
|
in:fly={{ y: -10, duration: 300, delay: index * 30, easing: cubicOut }}
|
||||||
out:fly={{ x: 30, opacity: 0, duration: 250, delay: index * 20, easing: cubicOut }}
|
out:fly={{ x: 30, opacity: 0, duration: 250, delay: index * 20, easing: cubicOut }}
|
||||||
ondragstart={(e) => handleDragStart(e, index)}
|
onpointerdown={(e) => handlePointerDown(e, index)}
|
||||||
ondragover={(e) => handleDragOver(e, index)}
|
onpointermove={handlePointerMove}
|
||||||
ondragend={handleDragEnd}
|
onpointerup={handlePointerUp}
|
||||||
ondragleave={handleDragLeave}
|
onpointercancel={endDrag}
|
||||||
onkeydown={(e) => handleTaskKeydown(e, task.id)}
|
onkeydown={(e) => handleTaskKeydown(e, task.id)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -276,6 +276,10 @@ body {
|
|||||||
.task-item.dragging {
|
.task-item.dragging {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
|
/* Only while a drag is actually in flight, so an ordinary touch on a row
|
||||||
|
still scrolls the page. */
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item.drag-over::before {
|
.task-item.drag-over::before {
|
||||||
|
|||||||
Reference in New Issue
Block a user