From 51f2dcd104da59109b7b68e4a961913a44100ab0 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 17:11:11 +0200 Subject: [PATCH] fix(dnd): single-gesture drag + working keyboard, drop the handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the previous DnD wiring, both stemming from the handle-gates-dragDisabled pattern: 1. **Two-step desktop drag.** The handle's pointerdown flipped dragDisabled to false, but by then the gesture was already in progress and the library wasn't watching. Users had to click the handle, release, then mousedown+drag the card — two gestures for one action. 2. **Keyboard reorder didn't work.** svelte-dnd-action's Space-to- lift + arrow-keys-to-move handling is gated by dragDisabled. By keeping it true except during a handle-press, the library never processed keyboard interactions. Fix: dragDisabled becomes a pure function of auth state. The library's built-in distance threshold prevents accidental drags from clicks; form controls and links inside cards aren't draggable targets. Result: - Desktop: mousedown on the card, move → drag starts. - Touch: tap-and-drag on the card → drag starts (after threshold). - Keyboard: Tab to a row, Space to lift, arrows to move, Space to drop. Screen-reader announcements come from the library. Plus: the drag-handle ⋮⋮ icon is gone entirely. The library listens on the whole card, so the handle was only ever a visual hint. `cursor: grab` on the card carries that affordance for the ~5px of weight the handle was eating. Net diff: −12 / +18, including a small CSS adjustment to scope `cursor: grab` to dndzone children only (so the same card style on permalink / public-list / tag pages stays with default cursor). Also: the public landing ("/") now sorts strictly by created_at DESC regardless of viewer. Personal sort applies on /home but not on the public root — the landing is the canonical newest-first view, not the viewer's curated one. 96 tests still pass; typecheck clean; build ok. --- frontend/src/components/ActivityRow.svelte | 24 +-------- frontend/src/components/Home.svelte | 59 ++++++++++++---------- frontend/src/styles.css | 32 +++--------- 3 files changed, 41 insertions(+), 74 deletions(-) diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte index 480fda5..6ea08f4 100644 --- a/frontend/src/components/ActivityRow.svelte +++ b/frontend/src/components/ActivityRow.svelte @@ -18,16 +18,10 @@ /** 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; - /** 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?: () => void; } let { activity, privateCleartext = null, onDeleted, onEdit, onChanged, - draggable = false, onDragHandleStart, }: Props = $props(); // Fallback decrypt for the case where Home hasn't pre-computed yet (e.g. @@ -201,22 +195,7 @@

{/snippet} -
- {#if draggable} - - - {/if} -
+
{#if activity.visibility === 'private'} {#if decrypted}

@@ -331,5 +310,4 @@ {copiedAt ? 'Kopiert!' : 'Del'}

-
diff --git a/frontend/src/components/Home.svelte b/frontend/src/components/Home.svelte index d9ce8a0..583865e 100644 --- a/frontend/src/components/Home.svelte +++ b/frontend/src/components/Home.svelte @@ -89,16 +89,22 @@ ].some((s) => s.toLowerCase().includes(needle)); } - // The list is now unified — one column, sorted by sort_position. The - // server's COALESCE(custom, -created_at) handles both manually-positioned - // and not-yet-touched rows. Bookmark/friends/private/semi/public are all - // intermingled; their vis-badges tell the viewer which is which. + // The list is unified — one column. On the public landing ("/", which + // anonymous and logged-in users both see), the order is strictly + // newest-first regardless of any personal sort the viewer may have + // applied on /home. On the authenticated dashboard, sort by the + // viewer's effective sort_position (custom if dragged, else + // -created_at, both surfaced by the server). const filtered = $derived( activities .filter((a) => !publicOnly || a.visibility !== 'private') .filter((a) => matchesQuery(a, query)) .slice() - .sort((a, b) => a.sort_position - b.sort_position), + .sort((a, b) => + publicOnly + ? b.created_at - a.created_at + : a.sort_position - b.sort_position, + ), ); // --- Drag-and-drop reordering -------------------------------------------- @@ -106,33 +112,34 @@ // 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. + // dragDisabled is a pure function of auth state — NOT a handle toggle. + // An earlier version gated it on handle pointerdown, which had two bugs: + // 1. Desktop required two gestures (handle to "enable", then card to + // drag) because pointerdown flipped the flag too late for the + // already-in-progress mouse gesture. + // 2. Keyboard reorder relies on dragDisabled being false at focus time + // — gating it broke Space/arrow-key lifting. + // The library's built-in distance threshold prevents accidental drags + // from clicks. Form controls and links inside cards aren't draggable. + // The drag-handle icon stays as a visual affordance only. - /** 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. */ + /** Items the library mutates during the drag. Re-synced from `filtered` + * between drags via the $effect below. */ let dndItems: Activity[] = $state([]); - /** Disable drag globally unless the user has just tapped a drag handle. */ - let dragDisabled = $state(true); + /** Drag is disabled for anonymous viewers and on the public landing. */ + const dragDisabled = $derived(!session.user || publicOnly); + + /** Set to true while a drag is in progress, so the resync effect doesn't + * fight the library by overwriting its in-flight item ordering. */ + let dragActive = $state(false); $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(); + if (!dragActive) dndItems = filtered.slice(); }); - function activateDrag() { - if (!session.user) return; - dragDisabled = false; - } - function handleConsider(e: CustomEvent<{ items: Activity[] }>) { // Library asks us to render this transient ordering during the drag. + dragActive = true; dndItems = e.detail.items; } @@ -140,7 +147,7 @@ const next = e.detail.items; const movedId = e.detail.info.id; dndItems = next; - dragDisabled = true; + dragActive = false; // Find where the moved item landed and compute a midpoint position // between its (new) neighbours' sort_positions. @@ -267,8 +274,6 @@ onDeleted={onDeleted} onEdit={(act) => (editing = act)} onChanged={onChanged} - draggable={!!session.user && !publicOnly} - onDragHandleStart={activateDrag} /> {/each} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4f8781f..6aebe7b 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -294,30 +294,14 @@ nav.top h1::after { override the ::before-emoji default that .vis-badge applies otherwise. */ .vis-badge.bookmarked::before { content: none; } -/* Drag handle on each activity row in the unified list view. The button - itself is what `draggable="true"` is bound to so users have a clear - target; the rest of the card stays selectable. */ -.drag-handle { - min-height: 24px !important; - min-width: 28px !important; - padding: 0 0.3rem !important; - cursor: grab; - background: transparent !important; - border: 1px solid transparent !important; - color: var(--muted); - font-size: 1.1rem; - line-height: 1; - user-select: none; - align-self: stretch; - display: flex; - align-items: center; - justify-content: center; -} -.drag-handle:hover { - background: var(--accent-soft) !important; - border-color: var(--border) !important; -} -.drag-handle:active { cursor: grabbing; } +/* Reorder affordance for the unified activity list. svelte-dnd-action + wraps each item in a div with role="listitem"; the card becomes + grabbable on the empty regions while buttons and links inside keep + their native cursor. */ +[role="listitem"] article.card { cursor: grab; } +[role="listitem"] article.card:active { cursor: grabbing; } +[role="listitem"] article.card button, +[role="listitem"] article.card a { cursor: pointer; } .error { color: var(--danger); margin-top: 0.5rem; }