fix(dnd): single-gesture drag + working keyboard, drop the handle

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.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 17:11:11 +02:00
commit 51f2dcd104
3 changed files with 41 additions and 74 deletions

View file

@ -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 @@
</p>
{/snippet}
<article class="card" aria-labelledby={`act-${activity.id}-h`}
style={draggable ? 'display: flex; align-items: flex-start; gap: 0.75rem;' : ''}>
{#if draggable}
<!-- 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"
class="drag-handle"
onpointerdown={onDragHandleStart}
aria-label="Dra for å endre rekkefølge"
title="Dra for å endre rekkefølge"
>⋮⋮</button>
{/if}
<div style={draggable ? 'flex: 1; min-width: 0;' : ''}>
<article class="card" aria-labelledby={`act-${activity.id}-h`}>
{#if activity.visibility === 'private'}
{#if decrypted}
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
@ -331,5 +310,4 @@
{copiedAt ? 'Kopiert!' : 'Del'}
</button>
</div>
</div>
</article>

View file

@ -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}
/>
</div>
{/each}

View file

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