eleven / messenger Public

home: drag-to-reorder saved spaces (replaces the up/down buttons) (#341)

Sorting is now direct manipulation. The visible ↑/↓ buttons are gone;
each row instead reveals a quiet drag affordance on hover and lifts when
grabbed.

Design (via the frontend-design skill), kept inside the existing home
card aesthetic — one expressive moment, quiet everywhere else:
- Idle rows are calm (star + name/url); no separator lines or visible
  controls.
- Hover reveals the affordance: a soft brand-tinted (indigo) row wash and
  an iOS-style 6-dot grip fading in on the RIGHT (so the star stays the
  left anchor), with a grab cursor.
- Grabbing LIFTS the row off the card (panel bg + shadow) and it follows
  the pointer, while the other rows SLIDE aside (FLIP). Drag is confined
  to its own list, so Starred and the rest still sort independently; the
  star toggle moves rows between them.

Pointer-based, so it works on mouse and touch (touch-action:none on the
handle; the grip stays faintly visible where there's no hover to reveal
it). Keyboard a11y is preserved without the visible buttons: the grip is
a focusable control and ↑/↓ reorder the focused row (moveInGroup kept for
this). prefers-reduced-motion drops the slide + settle animations.

Reorder logic is a new pure helper `orderServers` (rebuild the array from
the on-screen order) in serverlist.js, unit-tested.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d7f0c67b46b301d3e1de17b40f2dae2e950abbc7 parent 3eb07bb
4 files changed, +200 −33
  • home/test/serverlist.test.mjs +20 −0
  • home/web/pages/index.html +30 −10
  • home/web/static/home.js +131 −23
  • home/web/static/serverlist.js +19 −0
diff --git a/home/test/serverlist.test.mjs b/home/test/serverlist.test.mjs
index 497dfd8..58f4776 100644
--- a/home/test/serverlist.test.mjs
+++ b/home/test/serverlist.test.mjs
@@ -6,6 +6,7 @@ import {
partitionServers,
toggleStar,
moveInGroup,
+ orderServers,
} from "../web/static/serverlist.js";
let failures = 0;
@@ -85,5 +86,24 @@ await test("moveInGroup at a group boundary is a no-op", () => {
assert.deepEqual(moveInGroup(servers, "b", +1), servers, "bottom of its group can't move down");
});
+await test("orderServers reorders to match the given full order, preserving entries", () => {
+ const servers = [
+ { url: "a", starred: true },
+ { url: "b" },
+ { url: "c", starred: true },
+ ];
+ // A drag result: starred list became [c, a], others [b].
+ const next = orderServers(servers, ["c", "a", "b"]);
+ assert.deepEqual(urls(next), ["c", "a", "b"]);
+ assert.equal(next[0].starred, true, "entry objects (with starred) are preserved");
+ assert.equal(servers[0].url, "a", "input is not mutated");
+});
+
+await test("orderServers ignores unknown urls and keeps unlisted entries at the end", () => {
+ const servers = [{ url: "a" }, { url: "b" }, { url: "c" }];
+ const next = orderServers(servers, ["c", "zzz"]); // 'zzz' unknown; a,b unlisted
+ assert.deepEqual(urls(next), ["c", "a", "b"]);
+});
+
console.log(failures ? `\n${failures} FAILED` : "\nALL PASSED");
process.exit(failures ? 1 : 0);
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index eedf6d9..62d4292 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -50,28 +50,48 @@
#spaces > p.empty { color: var(--muted); font-size: .9rem; margin: 6px 0 0; }
ul.servers { list-style: none; margin: 0; padding: 0; }
ul.servers li {
- display: flex; flex-direction: row; align-items: center; gap: 10px;
- padding: .5rem 0; border-top: 1px solid var(--border);
+ display: flex; align-items: center; gap: 8px;
+ padding: 8px 6px; border-radius: 10px;
+ /* No separator lines: the hover wash (below) is the interactive cue, and the
+ lifted row + the gap it leaves carry the structure while dragging. */
}
- ul.servers li:first-child { border-top: none; }
.srv-main { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
ul.servers li a.name { font-weight: 600; color: var(--ink); text-decoration: none; }
ul.servers li a.name:hover { text-decoration: underline; }
ul.servers li .url { color: var(--muted); font-size: .8rem; overflow-wrap: anywhere; }
/* Star toggle: a quiet outline star that fills gold when on. */
.star {
- background: none; border: none; padding: 2px; cursor: pointer;
+ flex: none; background: none; border: none; padding: 2px; cursor: pointer;
font-size: 18px; line-height: 1; color: var(--muted);
}
.star.on { color: #f2b705; }
.star:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
- /* Up/down reorder within a list. */
- .moves { display: flex; gap: 3px; }
- .move {
- background: var(--quiet); border: none; border-radius: 7px;
- width: 26px; height: 26px; font-size: 13px; line-height: 1; cursor: pointer; color: var(--ink);
+
+ /* Drag-to-reorder. Idle rows are calm; hover reveals the affordance — a soft
+ brand-tinted wash on the row, and a grip that fades in on the right. Grabbing
+ lifts the row off the card (shadow) while the others slide aside. */
+ .drag-handle {
+ flex: none; display: grid; place-items: center; width: 22px; height: 30px;
+ padding: 0; border: none; background: none; color: var(--muted);
+ border-radius: 7px; cursor: grab; touch-action: none;
+ -webkit-tap-highlight-color: transparent;
+ opacity: 0; transition: opacity .12s ease, background .12s ease, color .12s ease;
+ }
+ .drag-handle svg { fill: currentColor; display: block; }
+ .drag-handle:hover { background: var(--quiet); color: var(--ink); }
+ .drag-handle:active { cursor: grabbing; }
+ .drag-handle:focus-visible { opacity: 1; outline: 2px solid var(--accent); outline-offset: 1px; }
+ @media (hover: hover) {
+ ul.servers li:hover { background: color-mix(in srgb, var(--accent) 7%, transparent); }
+ ul.servers li:hover .drag-handle { opacity: .5; }
+ }
+ @media (hover: none) { .drag-handle { opacity: .4; } } /* touch: no hover to reveal it */
+ ul.servers li.dragging {
+ background: var(--panel); box-shadow: 0 10px 28px var(--shadow);
+ position: relative; z-index: 3; cursor: grabbing;
}
- .move:disabled { opacity: .35; cursor: default; }
+ ul.servers li.dragging .drag-handle { opacity: 1; color: var(--ink); }
+ @media (prefers-reduced-motion: reduce) { .drag-handle { transition: none; } }
#space-section { margin-top: 16px; }
#space-section p.lead { color: var(--muted); font-size: .85rem; margin: 0 0 8px; }
#new-space { width: 100%; }
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 47fcc3c..4be2e68 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -17,7 +17,7 @@ import {
apiJSON, beginLogin, runLogin, fetchBlob, putBlob,
} from "/static/backup-core.js";
import {
- partitionServers, toggleStar, moveInGroup,
+ partitionServers, toggleStar, moveInGroup, orderServers,
} from "/static/serverlist.js";
// ---- session state ----------------------------------------------------------
@@ -187,18 +187,27 @@ function sectionHeading(text) {
function serverList(group) {
const ul = document.createElement("ul");
ul.className = "servers";
- group.forEach((s, i) => ul.append(serverRow(s, i, group.length)));
+ for (const s of group) ul.append(serverRow(s));
return ul;
}
-function serverRow(s, idxInGroup, groupLen) {
+// A quiet 6-dot grip (static, in-code SVG — safe for innerHTML). iOS-style, it
+// lives on the right so the star stays the left anchor; it surfaces on hover.
+const GRIP_SVG =
+ '<svg viewBox="0 0 10 16" width="10" height="16" aria-hidden="true">' +
+ '<circle cx="2" cy="3" r="1.25"/><circle cx="8" cy="3" r="1.25"/>' +
+ '<circle cx="2" cy="8" r="1.25"/><circle cx="8" cy="8" r="1.25"/>' +
+ '<circle cx="2" cy="13" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>';
+
+function serverRow(s) {
const li = document.createElement("li");
+ li.dataset.url = s.url;
const star = document.createElement("button");
star.type = "button";
star.className = "star" + (s.starred ? " on" : "");
star.textContent = s.starred ? "★" : "☆";
- star.setAttribute("aria-label", s.starred ? "Unstar " + (s.name || s.url) : "Star " + (s.name || s.url));
+ star.setAttribute("aria-label", (s.starred ? "Unstar " : "Star ") + (s.name || s.url));
star.addEventListener("click", () => { applyServers(toggleStar(vault.obj.servers, s.url)); });
const main = document.createElement("div");
@@ -212,30 +221,129 @@ function serverRow(s, idxInGroup, groupLen) {
url.textContent = s.url;
main.append(name, url);
- const moves = document.createElement("div");
- moves.className = "moves";
- const up = moveButton("↑", "Move up", idxInGroup === 0, () => applyServers(moveInGroup(vault.obj.servers, s.url, -1)));
- const down = moveButton("↓", "Move down", idxInGroup === groupLen - 1, () => applyServers(moveInGroup(vault.obj.servers, s.url, +1)));
- moves.append(up, down);
-
- li.append(star, main, moves);
+ // The drag handle: grab to reorder (pointer), or ↑/↓ when focused (keyboard) —
+ // the a11y fallback that replaces the old visible up/down buttons.
+ const handle = document.createElement("button");
+ handle.type = "button";
+ handle.className = "drag-handle";
+ handle.setAttribute("aria-label", "Reorder " + (s.name || s.url));
+ handle.innerHTML = GRIP_SVG;
+ handle.addEventListener("pointerdown", (e) => startDrag(e, li));
+ handle.addEventListener("keydown", (e) => {
+ const dir = e.key === "ArrowUp" ? -1 : e.key === "ArrowDown" ? 1 : 0;
+ if (!dir) return;
+ e.preventDefault();
+ applyServers(moveInGroup(vault.obj.servers, s.url, dir));
+ focusHandle(s.url); // re-render dropped focus; put it back on the moved row
+ });
+
+ li.append(star, main, handle);
return li;
}
-function moveButton(glyph, label, disabled, onClick) {
- const b = document.createElement("button");
- b.type = "button";
- b.className = "move";
- b.textContent = glyph;
- b.setAttribute("aria-label", label);
- b.disabled = disabled;
- if (!disabled) b.addEventListener("click", onClick);
- return b;
+function focusHandle(url) {
+ for (const li of $("spaces").querySelectorAll("li")) {
+ if (li.dataset.url === url) { li.querySelector(".drag-handle")?.focus(); return; }
+ }
+}
+
+// ---- drag to reorder ----------------------------------------------------------
+// Pointer-based (mouse + touch): the grabbed row lifts and follows the pointer
+// while its siblings slide aside (FLIP). Drag is confined to its own list, so the
+// Starred and other lists sort independently; the star toggle moves rows between
+// them. prefers-reduced-motion drops the slide + settle animations.
+
+let drag = null;
+
+function startDrag(e, li) {
+ if (e.pointerType === "mouse" && e.button !== 0) return;
+ e.preventDefault();
+ const handle = e.currentTarget;
+ const rect = li.getBoundingClientRect();
+ drag = { li, ul: li.parentElement, handle, pointerId: e.pointerId, grab: e.clientY - rect.top, moved: false };
+ handle.setPointerCapture(e.pointerId);
+ li.classList.add("dragging");
+ handle.addEventListener("pointermove", onDragMove);
+ handle.addEventListener("pointerup", endDrag);
+ handle.addEventListener("pointercancel", endDrag);
+}
+
+function onDragMove(e) {
+ if (!drag) return;
+ drag.moved = true;
+ const { li, ul } = drag;
+ // Which sibling should we sit above? The first whose midpoint is below us.
+ let ref = null;
+ for (const r of ul.children) {
+ if (r === li || r.tagName !== "LI") continue;
+ const rc = r.getBoundingClientRect();
+ if (e.clientY < rc.top + rc.height / 2) { ref = r; break; }
+ }
+ const changes = ref ? li.nextElementSibling !== ref : ul.lastElementChild !== li;
+ if (changes) flipReorder(ul, li, () => (ref ? ul.insertBefore(li, ref) : ul.append(li)));
+ // Glue the lifted row under the pointer (clear transform first to read its
+ // natural post-reorder position).
+ li.style.transition = "none";
+ li.style.transform = "";
+ const top = li.getBoundingClientRect().top;
+ li.style.transform = "translateY(" + (e.clientY - drag.grab - top) + "px)";
+}
+
+function endDrag() {
+ if (!drag) return;
+ const { li, handle, pointerId, moved } = drag;
+ handle.releasePointerCapture?.(pointerId);
+ handle.removeEventListener("pointermove", onDragMove);
+ handle.removeEventListener("pointerup", endDrag);
+ handle.removeEventListener("pointercancel", endDrag);
+ li.style.transition = reducedMotion() ? "none" : "transform 160ms ease";
+ li.style.transform = ""; // settle back into the slot the finger left it in
+ li.classList.remove("dragging");
+ drag = null;
+ if (moved) commitOrder();
+}
+
+// flipReorder runs `mutate` (the DOM reorder) and animates every row it displaced
+// from its old position to its new one — the "slide aside" that makes the reorder
+// read as physical rather than a jump. The dragged row is excluded (it's glued to
+// the pointer separately).
+function flipReorder(ul, exclude, mutate) {
+ if (reducedMotion()) { mutate(); return; }
+ const rows = [...ul.children].filter((el) => el.tagName === "LI" && el !== exclude);
+ const before = new Map(rows.map((el) => [el, el.getBoundingClientRect().top]));
+ mutate();
+ for (const el of rows) {
+ const dy = before.get(el) - el.getBoundingClientRect().top;
+ if (!dy) continue;
+ el.style.transition = "none";
+ el.style.transform = "translateY(" + dy + "px)";
+ requestAnimationFrame(() => {
+ el.style.transition = "transform 160ms ease";
+ el.style.transform = "";
+ });
+ }
+}
+
+// commitOrder reads the on-screen order back into the vault and saves — only if it
+// actually changed (a grab-without-move shouldn't write).
+function commitOrder() {
+ const order = [];
+ for (const ul of $("spaces").querySelectorAll("ul.servers")) {
+ for (const li of ul.querySelectorAll("li")) order.push(li.dataset.url);
+ }
+ const cur = vault.obj.servers || [];
+ const next = orderServers(cur, order);
+ if (next.length === cur.length && next.every((s, i) => s.url === cur[i].url)) return;
+ vault.obj.servers = next;
+ persistVault();
+}
+
+function reducedMotion() {
+ return matchMedia("(prefers-reduced-motion: reduce)").matches;
}
-// applyServers swaps in a new servers array (star/reorder returns a fresh one),
-// re-renders immediately (optimistic), then persists to the backup in the
-// background with inline feedback.
+// applyServers swaps in a new servers array (star/keyboard-move returns a fresh
+// one), re-renders immediately (optimistic), then persists in the background.
function applyServers(next) {
vault.obj.servers = next;
showVault();
diff --git a/home/web/static/serverlist.js b/home/web/static/serverlist.js
index b13fe0d..82f50f4 100644
--- a/home/web/static/serverlist.js
+++ b/home/web/static/serverlist.js
@@ -30,10 +30,29 @@ export function toggleStar(servers, url) {
});
}
+// orderServers returns a NEW servers array reordered to match `orderedUrls` (the
+// full top-to-bottom display order across both lists — what a drag leaves behind).
+// Entries whose url isn't listed keep their relative order at the end (defensive);
+// unknown urls are ignored. Entry objects (with their `starred`) are preserved.
+export function orderServers(servers, orderedUrls) {
+ const byUrl = new Map((servers || []).map((s) => [s.url, s]));
+ const out = [];
+ for (const url of orderedUrls) {
+ const s = byUrl.get(url);
+ if (s) {
+ out.push(s);
+ byUrl.delete(url);
+ }
+ }
+ for (const s of servers || []) if (byUrl.has(s.url)) out.push(s);
+ return out;
+}
+
// moveInGroup returns a NEW servers array with the entry at `url` swapped with its
// nearest **same-group** (same starred-ness) neighbour in direction `dir`
// (-1 = up, +1 = down), skipping over entries from the other group. Returns the
// array unchanged if there's no such neighbour (already at an end of its list).
+// Used by the keyboard reorder fallback (↑/↓ on a focused drag handle).
export function moveInGroup(servers, url, dir) {
const arr = (servers || []).slice();
const i = arr.findIndex((s) => s.url === url);