feat(web): drag to reorder in the list view
The board has had it since it was written; the list has not, and
POST /tasks/{id}/move was already tested and working.
Where the board shows the target column, a list has to show the target
gap — the whole decision is "above this one or below it" — so a line is
drawn where the row would land, chosen by which half of the row the
pointer is in rather than by counting rows, which is what keeps it where
the eye expects it when rows have different heights.
The move sends both neighbours. The API's after_id is the task above,
which reads as the opposite of "after" in a vertical list, so the locals
here are named for where they end up on screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
committed by
Kristian Wind
co-authored by
Claude Opus 5
parent
d288606827
commit
c2a664d891
@@ -0,0 +1,167 @@
|
||||
<script>
|
||||
/**
|
||||
* A list of tasks that can be reordered by dragging.
|
||||
*
|
||||
* The board view drags cards between columns; this drags rows within one. Both
|
||||
* use the native HTML drag-and-drop API for the same reason — it works with a
|
||||
* trackpad, a mouse and the browser's own affordances without reimplementing
|
||||
* any of them.
|
||||
*
|
||||
* Where the board shows the target *column*, a list has to show the target
|
||||
* *gap*: the whole decision here is "above this one or below it", so a line is
|
||||
* drawn where the row would land.
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import TaskRow from './TaskRow.svelte';
|
||||
|
||||
let { tasks, projectId, sectionId = '', canEdit = true } = $props();
|
||||
|
||||
let draggingId = $state(null);
|
||||
/** The row the line is drawn at, and which side of it. */
|
||||
let overId = $state(null);
|
||||
let overBelow = $state(false);
|
||||
|
||||
function onDragStart(event, task) {
|
||||
if (!canEdit) return;
|
||||
draggingId = task.id;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox refuses to start a drag unless something is set on the transfer.
|
||||
event.dataTransfer.setData('text/plain', task.id);
|
||||
}
|
||||
|
||||
function onDragOver(event, task) {
|
||||
if (!canEdit || !draggingId || draggingId === task.id) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
|
||||
// Which half of the row the pointer is in decides the gap. Comparing against
|
||||
// the row's own midpoint rather than counting rows is what keeps the line
|
||||
// where the eye expects it when rows have different heights.
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
overId = task.id;
|
||||
overBelow = event.clientY > box.top + box.height / 2;
|
||||
}
|
||||
|
||||
function clearOver() {
|
||||
overId = null;
|
||||
}
|
||||
|
||||
async function onDrop(event, target) {
|
||||
event.preventDefault();
|
||||
const id = draggingId;
|
||||
const below = overBelow;
|
||||
draggingId = null;
|
||||
overId = null;
|
||||
if (!id || !canEdit || id === target.id) return;
|
||||
|
||||
const ordered = [...tasks].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const without = ordered.filter((t) => t.id !== id);
|
||||
const at = without.findIndex((t) => t.id === target.id);
|
||||
if (at < 0) return;
|
||||
|
||||
// The gap the row lands in, named by where they end up on screen — the API's
|
||||
// after_id is the task above, which is the opposite of what "after" reads as
|
||||
// in a vertical list.
|
||||
const index = below ? at + 1 : at;
|
||||
const above = without[index - 1];
|
||||
const beneath = without[index];
|
||||
|
||||
const task = app.get(id);
|
||||
if (!task) return;
|
||||
const previous = { ...task };
|
||||
|
||||
// Optimistic: the row is in its new place before the request goes out. The
|
||||
// midpoint is a local guess — the server owns the real value and sends it
|
||||
// back, and if the gap has run out of precision it respaces the section and
|
||||
// tries again.
|
||||
const order =
|
||||
above && beneath
|
||||
? (above.sort_order + beneath.sort_order) / 2
|
||||
: above
|
||||
? above.sort_order + 1024
|
||||
: (beneath?.sort_order ?? 1024) - 1024;
|
||||
|
||||
app.replace(id, { ...task, sort_order: order });
|
||||
|
||||
try {
|
||||
app.replace(
|
||||
id,
|
||||
await api.moveTask(id, {
|
||||
project_id: projectId,
|
||||
section_id: sectionId,
|
||||
after_id: above?.id ?? '',
|
||||
before_id: beneath?.id ?? ''
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
app.replace(id, previous);
|
||||
app.toast('Kunne ikke flytte opgaven.');
|
||||
}
|
||||
}
|
||||
|
||||
let ordered = $derived([...tasks].sort((a, b) => a.sort_order - b.sort_order));
|
||||
</script>
|
||||
|
||||
{#each ordered as task (task.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="sortable"
|
||||
class:dragging={draggingId === task.id}
|
||||
class:drop-above={overId === task.id && !overBelow}
|
||||
class:drop-below={overId === task.id && overBelow}
|
||||
draggable={canEdit}
|
||||
ondragstart={(e) => onDragStart(e, task)}
|
||||
ondragend={() => {
|
||||
draggingId = null;
|
||||
overId = null;
|
||||
}}
|
||||
ondragover={(e) => onDragOver(e, task)}
|
||||
ondragleave={clearOver}
|
||||
ondrop={(e) => onDrop(e, task)}
|
||||
>
|
||||
<TaskRow {task} />
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.sortable {
|
||||
position: relative;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
/* Only the handle-less body drags; the checkbox stays clickable because the
|
||||
drag needs a few pixels of movement before the browser calls it a drag. */
|
||||
.sortable[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.sortable.dragging {
|
||||
opacity: 0.4;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* A line in the gap rather than a highlighted row: the row is not the target,
|
||||
the space between two rows is. */
|
||||
.sortable::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.sortable.drop-above::before {
|
||||
top: -1px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sortable.drop-below::before {
|
||||
bottom: -1px;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user