Drag-reorder works on touch via svelte-dnd-action

HTML5 native drag-and-drop doesn't fire on touchscreens — mobile
users couldn't reorder the list at all. Swapped the manual DnD
wiring (dragstart/dragover/drop) for svelte-dnd-action, which uses
Pointer Events and handles mouse, touch, AND keyboard reorder
uniformly. Linear-quality reorder UX for ~11 KB gzipped.

Replacement details:
  - bun add svelte-dnd-action (0.9.69)
  - Home.svelte: ~70 lines of manual handler code deleted, replaced
    with ~30 lines wiring up `use:dndzone` + `onconsider` +
    `onfinalize`. The midpoint-position math for sort_position is
    unchanged (finalize gives us the new neighbour list directly).
  - ActivityRow.svelte: the drag handle's `onpointerdown` flips a
    parent-owned dragDisabled flag to false — the library then
    takes over. Standard "handle-only drag" recipe; clicks on the
    title/buttons inside the card don't initiate drag because
    dragDisabled stays true everywhere else.
  - dndItems is a buffer copy of `filtered` that the library
    mutates during a drag. An $effect re-syncs it from `filtered`
    between drags (so new activities still float to the top, etc).
  - Shadow item (the library's placeholder while dragging) is
    rendered at 30% opacity so the drop target is visible without
    flashing.

Accessibility wins for free:
  - Keyboard reorder: focus an item, press Space to "pick up",
    arrow keys to move, Space to drop. Screen readers get
    polite-live announcements of each move from the library.
  - Touch reorder works on iOS Safari and Android Chrome.

96 tests still pass; typecheck clean; build ok.
Bundle: 122 KB → 154 KB (gzipped 42 → 53 KB, ~+11 KB).
This commit is contained in:
Ole-Morten Duesund 2026-05-25 16:59:43 +02:00
commit 59e2f95767
4 changed files with 89 additions and 92 deletions

View file

@ -18,17 +18,16 @@
/** Called when the row updates its own activity (e.g. after a heart
* toggle). The parent should patch its list so the change persists. */
onChanged?: (a: Activity) => void;
/** Whether to render a drag handle that initiates HTML5 DnD on this row.
* Parent owns the actual dragstart/dragend wiring — the handle just
* forwards events upward. */
/** Render a drag handle on this row. The parent owns the actual drag
* wiring via svelte-dnd-action; the handle's pointerdown/touchstart
* just flips the parent's dragDisabled to false (one drag at a time). */
draggable?: boolean;
onDragHandleStart?: (e: DragEvent) => void;
onDragHandleEnd?: () => void;
onDragHandleStart?: () => void;
}
let {
activity, privateCleartext = null,
onDeleted, onEdit, onChanged,
draggable = false, onDragHandleStart, onDragHandleEnd,
draggable = false, onDragHandleStart,
}: Props = $props();
// Fallback decrypt for the case where Home hasn't pre-computed yet (e.g.
@ -205,15 +204,14 @@
<article class="card" aria-labelledby={`act-${activity.id}-h`}
style={draggable ? 'display: flex; align-items: flex-start; gap: 0.75rem;' : ''}>
{#if draggable}
<!-- Drag handle. Only the handle is `draggable` so clicking the title
or buttons doesn't initiate a drag. Native HTML5 DnD; touch users
currently can't reorder (separate UI concern). -->
<!-- Drag handle. The parent uses svelte-dnd-action and keeps
dragDisabled=true by default; pressing the handle flips it false
for this gesture. Works for mouse, touch, and pen via Pointer
Events. Clicks elsewhere on the card don't initiate drag. -->
<button
type="button"
draggable="true"
class="drag-handle"
ondragstart={onDragHandleStart}
ondragend={onDragHandleEnd}
onpointerdown={onDragHandleStart}
aria-label="Dra for å endre rekkefølge"
title="Dra for å endre rekkefølge"
>⋮⋮</button>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
import { api } from '../lib/api';
import { session } from '../lib/session.svelte';
import {
@ -101,82 +102,68 @@
);
// --- Drag-and-drop reordering --------------------------------------------
// HTML5 native DnD. Drag handle is on each row; only logged-in users can
// drag (anonymous list is read-only). Touch users currently can't drag
// — adding a touch polyfill is a separate concern.
let dragId: string | null = $state(null);
let dragOverId: string | null = $state(null);
let dragOverHalf: 'above' | 'below' | null = $state(null);
// svelte-dnd-action handles touch, mouse, AND keyboard reorder uniformly.
// We hand it the `filtered` array and let it dispatch `consider`/`finalize`
// CustomEvents as the user drags.
//
// Drag-handle-only behaviour: the library doesn't have a built-in "handle"
// option, so we use the standard recipe — keep dragDisabled=true by
// default, flip to false on pointerdown/touchstart on the handle, flip
// back after finalize. Clicks on the title/buttons inside a card don't
// initiate drag because dragDisabled is true everywhere else.
function onDragStart(id: string, e: DragEvent) {
if (!session.user) { e.preventDefault(); return; }
dragId = id;
e.dataTransfer?.setData('text/plain', id);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
}
function onDragOver(targetId: string, e: DragEvent) {
if (!dragId || dragId === targetId) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
const t = e.currentTarget as HTMLElement;
const rect = t.getBoundingClientRect();
dragOverId = targetId;
dragOverHalf = e.clientY - rect.top < rect.height / 2 ? 'above' : 'below';
}
function onDragLeaveRow(targetId: string) {
if (dragOverId === targetId) {
dragOverId = null;
dragOverHalf = null;
}
}
function onDragEnd() {
dragId = null;
dragOverId = null;
dragOverHalf = null;
/** Items the library is currently working with — a copy of `filtered` it
* can mutate during the drag for the visual transition. Re-derived from
* `filtered` between drags. */
let dndItems: Activity[] = $state([]);
/** Disable drag globally unless the user has just tapped a drag handle. */
let dragDisabled = $state(true);
$effect(() => {
// Keep dndItems in sync with the source-of-truth `filtered` array
// whenever it changes outside of an active drag (e.g. after a new
// activity is created, or the search filter shrinks the list).
if (dragDisabled) dndItems = filtered.slice();
});
function activateDrag() {
if (!session.user) return;
dragDisabled = false;
}
async function onDrop(targetId: string, e: DragEvent) {
e.preventDefault();
const src = dragId;
const half = dragOverHalf;
onDragEnd();
if (!src || src === targetId || !half) return;
function handleConsider(e: CustomEvent<{ items: Activity[] }>) {
// Library asks us to render this transient ordering during the drag.
dndItems = e.detail.items;
}
// Compute the new position via midpoint between neighbours of the drop
// slot. The current `filtered` is the on-screen order; find the target's
// index and adjust.
const idx = filtered.findIndex((a) => a.id === targetId);
async function handleFinalize(e: CustomEvent<{ items: Activity[]; info: { id: string } }>) {
const next = e.detail.items;
const movedId = e.detail.info.id;
dndItems = next;
dragDisabled = true;
// Find where the moved item landed and compute a midpoint position
// between its (new) neighbours' sort_positions.
const idx = next.findIndex((a) => a.id === movedId);
if (idx < 0) return;
const target = filtered[idx]!;
let leftPos: number | null = null;
let rightPos: number | null = null;
if (half === 'above') {
// Drop above target → between (idx - 1) and target. Skip the source
// itself if it was at idx-1 (would compute relative to itself).
let prev = idx - 1;
if (prev >= 0 && filtered[prev]!.id === src) prev -= 1;
leftPos = prev >= 0 ? filtered[prev]!.sort_position : null;
rightPos = target.sort_position;
} else {
// Drop below target → between target and (idx + 1).
let next = idx + 1;
if (next < filtered.length && filtered[next]!.id === src) next += 1;
leftPos = target.sort_position;
rightPos = next < filtered.length ? filtered[next]!.sort_position : null;
}
const moved = next[idx]!;
const prev = idx > 0 ? next[idx - 1] : null;
const nextItem = idx < next.length - 1 ? next[idx + 1] : null;
const newPos =
leftPos !== null && rightPos !== null ? (leftPos + rightPos) / 2 :
leftPos !== null ? leftPos + 1.0 :
rightPos !== null ? rightPos - 1.0 :
prev && nextItem ? (prev.sort_position + nextItem.sort_position) / 2 :
prev ? prev.sort_position + 1.0 :
nextItem ? nextItem.sort_position - 1.0 :
0;
// Optimistic: update the local row immediately.
// Optimistic update on the source-of-truth `activities` array so the
// visual ordering doesn't snap back when the effect above re-syncs.
const before = activities;
activities = activities.map((a) =>
a.id === src ? { ...a, sort_position: newPos } as Activity : a,
a.id === movedId ? ({ ...a, sort_position: newPos } as Activity) : a,
);
try {
await api.sortActivity(src, newPos);
await api.sortActivity(movedId, newPos);
} catch {
activities = before;
error = 'Kunne ikke endre rekkefølge.';
@ -251,17 +238,29 @@
</p>
{/if}
{:else}
<div role="list" aria-label="Aktivitetsliste">
{#each filtered as a (a.id)}
<div role="listitem"
ondragover={(e) => onDragOver(a.id, e)}
ondragleave={() => onDragLeaveRow(a.id)}
ondrop={(e) => onDrop(a.id, e)}
style={`position: relative;${dragId === a.id ? ' opacity: 0.4;' : ''}`}
>
{#if dragOverId === a.id && dragOverHalf === 'above'}
<div aria-hidden="true" style="position: absolute; top: -2px; left: 0; right: 0; height: 3px; background: var(--accent); border-radius: 2px;"></div>
{/if}
<!--
svelte-dnd-action's dndzone owns the list while a drag is active.
We always render via dndItems (kept in sync with `filtered` between
drags by the $effect above) so the visual stays consistent.
-->
<div
use:dndzone={{
items: dndItems,
dragDisabled,
flipDurationMs: 180,
// Type "shadow item" is the library's placeholder while dragging;
// we render it transparently to keep layout stable.
dropTargetStyle: {},
type: 'activities',
}}
onconsider={handleConsider}
onfinalize={handleFinalize}
aria-label="Aktivitetsliste"
>
{#each dndItems as a (a.id)}
<!-- The library tags the shadow item via SHADOW_ITEM_MARKER_PROPERTY_NAME;
dim it slightly so the drop target stays visible without flashing. -->
<div style={(a as unknown as Record<string, unknown>)[SHADOW_ITEM_MARKER_PROPERTY_NAME] ? 'opacity: 0.3;' : ''}>
<ActivityRow
activity={a}
privateCleartext={a.visibility === 'private' ? (privateCleartext.get(a.id) ?? null) : null}
@ -269,12 +268,8 @@
onEdit={(act) => (editing = act)}
onChanged={onChanged}
draggable={!!session.user && !publicOnly}
onDragHandleStart={(e) => onDragStart(a.id, e)}
onDragHandleEnd={onDragEnd}
onDragHandleStart={activateDrag}
/>
{#if dragOverId === a.id && dragOverHalf === 'below'}
<div aria-hidden="true" style="position: absolute; bottom: -2px; left: 0; right: 0; height: 3px; background: var(--accent); border-radius: 2px;"></div>
{/if}
</div>
{/each}
</div>