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:
parent
8295a35c94
commit
59e2f95767
4 changed files with 89 additions and 92 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue