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 @@
-
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; }