Public: anyone can read this; the crew can change it.

A card can be moved with a thumb: touch dragging, and a fallback that picks a position #46

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/tempo:scout/21-a-card-can-be-moved-with-a-thumb-touch-d → main 3 commits fork

  • Merged. scout/21-a-card-can-be-moved-with-a-thumb-touch-d was merged into main as bb63342.

Review

Sign in to review this change.
4 files changed +341 −26
README.md +12 −7
@@ -37,8 +37,11 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
3737 - **Cards**: type a title and press Enter to save it; drag a card to
3838 reorder or move it between columns, or focus it (click, or Tab) and
3939 press the arrow keys — the keyboard does everything dragging does.
40 A card also has a "Move to" dropdown and a delete button, both plain
41 forms that work with JavaScript off.
40 On a touchscreen, press and hold a card (~300ms) to pick it up, then
41 carry it with your thumb; a short swipe still scrolls the board, and
42 holding a card near the left or right edge scrolls it into view. A
43 card also has a "Move to" dropdown and a Top/Bottom position, both
44 plain forms that work with JavaScript off.
4245 - **Columns**: add, rename, delete, and move them left/right with the
4346 ◀ ▶ buttons next to each name.
4447 - **Filter**: the search box hides non-matching cards instantly (no
@@ -137,8 +140,10 @@ anonymous browser's.
137140 ## Dependencies
138141
139142 The app itself has none. `happy-dom` is a dev-only dependency, used by
140 `src/drag.test.ts` (and `src/theme.test.ts`, `src/boards.test.ts`) to give
141 `static/board.js` and `static/boards.js` a real DOM outside a browser so
142 their event handlers and `localStorage` calls run against the real thing
143 — hand-rolling that much of the DOM to test a few dozen lines of script
144 would be more code, and less trustworthy, than the thing it's testing.
143 `src/drag.test.ts`, `src/touch.test.ts` (and `src/theme.test.ts`,
144 `src/boards.test.ts`) to give `static/board.js` and `static/boards.js` a
145 real DOM outside a browser so their dragstart/dragover/drop/dragend,
146 pointerdown/pointermove/pointerup, and `localStorage` calls run against
147 the real thing — hand-rolling that much of the DOM to test a few dozen
148 lines of script would be more code, and less trustworthy, than the
149 thing it's testing.
src/pages/board.ts +10 −7
@@ -44,7 +44,7 @@ const styles = `
4444 button.ghost, a.ghost { background: transparent; color: inherit; border: 1px solid #8884; padding: .35rem .7rem; border-radius: .4rem; }
4545 .tools { position: fixed; top: .75rem; right: .75rem; display: flex; gap: .4rem; z-index: 10; }
4646 .tools button { padding: .3rem .55rem; font-size: 1rem; line-height: 1; }
47 .board { display: flex; gap: 1rem; align-items: flex-start; overflow-x: auto; padding-bottom: 1rem; }
47 .board { display: flex; gap: 1rem; align-items: flex-start; overflow-x: auto; max-width: 100%; padding-bottom: 1rem; }
4848 .column { background: #8881; border-radius: .6rem; padding: .6rem; width: 16rem; flex: none; }
4949 .column.drop-target { outline: 2px dashed var(--accent); }
5050 .column header { display: flex; align-items: center; gap: .3rem; margin-bottom: .5rem; }
@@ -57,9 +57,9 @@ const styles = `
5757 .card:focus-visible { outline: 2px solid var(--accent); }
5858 .card.dragging { opacity: .4; }
5959 .card .edit input { width: 100%; border: 0; background: transparent; padding: .2rem; }
60 .card .row { display: flex; gap: .25rem; margin-top: .3rem; align-items: center; }
61 .card .row select { flex: 1; font-size: .8rem; padding: .15rem; }
62 .card .row button { padding: .15rem .4rem; font-size: .8rem; }
60 .card .row { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .3rem; align-items: center; }
61 .card .row select { flex: 1; font-size: .8rem; padding: .4rem; min-height: 32px; }
62 .card .row button { padding: .4rem .6rem; font-size: .8rem; min-height: 32px; min-width: 32px; }
6363 .add-card { display: flex; gap: .3rem; margin-top: .5rem; }
6464 .add-card input { flex: 1; }
6565 .new-column { flex: none; width: 14rem; }
@@ -190,7 +190,10 @@ export function boardPage({ board, columns, cards, query, who, error }: BoardDat
190190 <div class="row">
191191 <form class="move" method="post" action="/b/${board.id}/cards/${c.id}/move">
192192 <select name="column_id" aria-label="Move to column">${columnOptions(c.column_id)}</select>
193 <input type="hidden" name="index" value="999999">
193 <select name="index" aria-label="Position in column">
194 <option value="0">Top</option>
195 <option value="999999" selected>Bottom</option>
196 </select>
194197 <button>Move</button>
195198 </form>
196199 <form class="delete" method="post" action="/b/${board.id}/cards/${c.id}/delete">
@@ -239,7 +242,7 @@ export function boardPage({ board, columns, cards, query, who, error }: BoardDat
239242 </form>
240243 ${doorLink(who, `/b/${board.id}`)}
241244 </header>
242 <p class="hint">Share this board with its URL. Drag a card, or focus it (Tab) and press the arrow keys, to move it — no mouse needed. Type a title and press Enter to save it. ${keepHtml(board, who)}</p>
245 <p class="hint">Share this board with its URL. Drag a card, focus it (Tab) and press the arrow keys, or press and hold it on a touchscreen, to move it — no mouse needed. Type a title and press Enter to save it. ${keepHtml(board, who)}</p>
243246 <div class="toolbar">
244247 <form class="filter-form" method="get" action="/b/${board.id}">
245248 <input type="search" id="filter" name="q" value="${escape(query)}" placeholder="Filter cards">
@@ -275,6 +278,6 @@ const boardShortcuts: [string, string][] = [
275278
276279 // Named by content — a change to static/board.js gets a new hash here,
277280 // so the year-long cache on /static/ never serves a stale script.
278 export const BOARD_JS = "board.2031175319.js";
281 export const BOARD_JS = "board.7793265334.js";
279282 export const THEME_JS = "theme.1025564933.js";
280283 export const BOARDS_JS = "boards.608180649.js";
src/touch.test.ts added +207
@@ -0,0 +1,207 @@
1 /**
2 * Drives static/board.js's touch path the way a phone would: real
3 * pointerdown/pointermove/pointerup events on a real DOM (happy-dom, whose
4 * PointerEvent constructor accepts pointerId/pointerType/clientX/clientY
5 * directly), against the real server, so a card actually moves in the
6 * database — not just in a mock.
7 *
8 * See drag.test.ts for why fetch is wrapped rather than the window's own,
9 * and why happy-dom's Window is used un-registered.
10 *
11 * The board is built directly against db.ts, not POST /boards: the
12 * board-creation bucket is shared, process-wide state (see limits.ts,
13 * and the same note in server.test.ts), and every test file in this
14 * suite runs against the same one.
15 */
16 import { expect, test } from "bun:test";
17 import { Window } from "happy-dom";
18 import { mkdtempSync } from "node:fs";
19 import { tmpdir } from "node:os";
20 import { join } from "node:path";
21
22 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
23 process.env.PORT = "0";
24 process.env.FORGE_APP_IDENTITY_KEY = "test-key";
25 const { server } = await import("./server.ts");
26 const db = await import("./db.ts");
27 const base = `http://${server.hostname}:${server.port}`;
28
29 const post = (path: string, body: Record<string, string> = {}) =>
30 fetch(base + path, { method: "POST", body: new URLSearchParams(body) });
31
32 function newBoard(): { id: string; columns: string[] } {
33 const created = db.createBoard("Touch test");
34 const columns = db.listColumns(created.id).map((c) => c.id);
35 return { id: created.id, columns };
36 }
37
38 const board = newBoard();
39
40 async function cardTitled(title: string, columnId = board.columns[0]): Promise<void> {
41 await post(`/b/${board.id}/columns/${columnId}/cards`, { title });
42 }
43
44 type TouchWindow = Window & { PointerEvent: typeof PointerEvent };
45
46 async function load(boardId: string): Promise<{ window: Window; document: Document; requests: string[] }> {
47 const html = await (await fetch(`${base}/b/${boardId}`)).text();
48 const [, scriptSrc] = html.match(/<script src="(\/static\/board[^"]+)"/)!;
49 const script = await (await fetch(base + scriptSrc)).text();
50
51 const window = new Window({ url: `${base}/b/${boardId}` });
52 const document = window.document;
53 document.open();
54 document.write(html);
55 document.close();
56
57 const requests: string[] = [];
58 const boundFetch = (path: string, init: RequestInit) => {
59 requests.push(new URL(path, base).toString());
60 return fetch(new URL(path, base), init);
61 };
62 new Function("document", "fetch", "location", "history", "CSS", script)(
63 document,
64 boundFetch,
65 window.location,
66 window.history,
67 window.CSS,
68 );
69 return { window, document, requests };
70 }
71
72 function firePointer(
73 window: Window,
74 target: Element,
75 type: string,
76 opts: { pointerId?: number; pointerType?: string; clientX?: number; clientY?: number } = {},
77 ): Event {
78 const PointerEventCtor = (window as unknown as TouchWindow).PointerEvent;
79 const event = new PointerEventCtor(type, {
80 bubbles: true,
81 cancelable: true,
82 pointerId: 1,
83 pointerType: "touch",
84 clientX: 0,
85 clientY: 0,
86 ...opts,
87 });
88 target.dispatchEvent(event as unknown as never);
89 return event as unknown as Event;
90 }
91
92 test("a long press then a move over another column drops the card there through commitMove", async () => {
93 await cardTitled("Card-Press");
94 const { window, document, requests } = await load(board.id);
95
96 const first = document.querySelector(`[data-title="Card-Press"]`) as unknown as HTMLElement & {
97 dataset: DOMStringMap;
98 };
99 const targetList = document.querySelector(`[data-column-id="${board.columns[1]}"] .cards`)!;
100
101 firePointer(window, first as unknown as Element, "pointerdown", { clientX: 10, clientY: 10 });
102 expect(first.className).not.toContain("dragging");
103
104 await new Promise((resolve) => setTimeout(resolve, 350));
105 expect(first.className).toContain("dragging");
106
107 firePointer(window, targetList as unknown as Element, "pointermove", { clientX: 10, clientY: 10 });
108 expect(targetList.contains(first as never)).toBe(true);
109 expect(document.querySelector(`[data-column-id="${board.columns[1]}"]`)!.className).toContain("drop-target");
110
111 firePointer(window, targetList as unknown as Element, "pointerup", { clientX: 10, clientY: 10 });
112 expect(first.className).not.toContain("dragging");
113
114 const moveRequests = requests.filter((r) => r.includes("/move"));
115 expect(moveRequests.length).toBe(1);
116
117 await new Promise((resolve) => setTimeout(resolve, 20));
118 const page = await (await fetch(`${base}/b/${board.id}`)).text();
119 expect(page).toMatch(new RegExp(`data-column-id="${board.columns[1]}"[\\s\\S]*data-title="Card-Press"`));
120
121 for (const r of requests) expect(new URL(r).origin).toBe(new URL(base).origin);
122
123 await window.close();
124 });
125
126 test("a short swipe (movement before the press threshold) posts nothing and leaves the DOM as it was", async () => {
127 await cardTitled("Card-Swipe");
128 const { window, document, requests } = await load(board.id);
129
130 const first = document.querySelector(`[data-title="Card-Swipe"]`) as unknown as HTMLElement;
131 const originalParent = first.parentElement;
132
133 firePointer(window, first as unknown as Element, "pointerdown", { clientX: 0, clientY: 0 });
134 firePointer(window, first as unknown as Element, "pointermove", { clientX: 40, clientY: 40 });
135 firePointer(window, first as unknown as Element, "pointerup", { clientX: 40, clientY: 40 });
136
137 await new Promise((resolve) => setTimeout(resolve, 350));
138
139 expect(first.className).not.toContain("dragging");
140 expect(first.parentElement).toBe(originalParent);
141 expect(requests.length).toBe(0);
142
143 await window.close();
144 });
145
146 test("a mouse pointer sequence posts nothing — the mouse keeps using the HTML5 drag path", async () => {
147 await cardTitled("Card-Mouse");
148 const { window, document, requests } = await load(board.id);
149
150 const first = document.querySelector(`[data-title="Card-Mouse"]`) as unknown as HTMLElement;
151 const targetList = document.querySelector(`[data-column-id="${board.columns[1]}"] .cards`)!;
152
153 firePointer(window, first as unknown as Element, "pointerdown", { pointerType: "mouse", clientX: 0, clientY: 0 });
154 await new Promise((resolve) => setTimeout(resolve, 350));
155 expect(first.className).not.toContain("dragging");
156
157 firePointer(window, targetList as unknown as Element, "pointermove", { pointerType: "mouse", clientX: 10, clientY: 10 });
158 firePointer(window, targetList as unknown as Element, "pointerup", { pointerType: "mouse", clientX: 10, clientY: 10 });
159
160 expect(requests.length).toBe(0);
161 expect(targetList.contains(first as never)).toBe(false);
162
163 await window.close();
164 });
165
166 test("the move form's Top control reorders a card above its column's existing cards", async () => {
167 await cardTitled("Card-Top-A");
168 await cardTitled("Card-Top-B");
169 const { window, document } = await load(board.id);
170
171 const second = document.querySelector(`[data-title="Card-Top-B"]`) as unknown as HTMLElement;
172 const moveForm = second.querySelector("form.move") as unknown as HTMLFormElement & {
173 column_id: HTMLSelectElement;
174 index: HTMLSelectElement;
175 };
176 moveForm.column_id.value = board.columns[0];
177 moveForm.index.value = "0";
178 moveForm.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }) as unknown as never);
179
180 await new Promise((resolve) => setTimeout(resolve, 20));
181 const page = await (await fetch(`${base}/b/${board.id}`)).text();
182 expect(page).toMatch(
183 new RegExp(`data-column-id="${board.columns[0]}"[\\s\\S]*data-title="Card-Top-B"[\\s\\S]*data-title="Card-Top-A"`),
184 );
185
186 await window.close();
187 });
188
189 test("the move form's Bottom control posts the end of the target column, unlike Top's index 0", async () => {
190 await cardTitled("Card-Bottom");
191 const { window, document } = await load(board.id);
192
193 const card = document.querySelector(`[data-title="Card-Bottom"]`) as unknown as HTMLElement;
194 const moveForm = card.querySelector("form.move") as unknown as HTMLFormElement & {
195 column_id: HTMLSelectElement;
196 index: HTMLSelectElement;
197 };
198 moveForm.column_id.value = board.columns[1];
199 moveForm.index.value = "999999";
200 moveForm.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }) as unknown as never);
201
202 await new Promise((resolve) => setTimeout(resolve, 20));
203 const page = await (await fetch(`${base}/b/${board.id}`)).text();
204 expect(page).toMatch(new RegExp(`data-column-id="${board.columns[1]}"[\\s\\S]*data-title="Card-Bottom"`));
205
206 await window.close();
207 });
static/board.2031175319.js → static/board.7793265334.js renamed +112 −12
@@ -85,6 +85,17 @@
8585 function clearDropTargets() {
8686 for (const col of columnsList()) col.classList.remove("drop-target");
8787 }
88 // A card being dropped into a list goes just above the first sibling whose
89 // midpoint is below the pointer — shared by the mouse's dragover and
90 // touch's pointermove below.
91 function placeInList(el, list, clientY) {
92 const after = cardsOf(list.closest(".column")).find((sib) => {
93 if (sib === el) return false;
94 const box = sib.getBoundingClientRect();
95 return clientY < box.top + box.height / 2;
96 });
97 list.insertBefore(el, after || null);
98 }
8899 root.addEventListener("dragstart", (e) => {
89100 const card = e.target.closest(".card");
90101 if (!card) return;
@@ -115,17 +126,97 @@
115126 e.preventDefault();
116127 clearDropTargets();
117128 list.closest(".column").classList.add("drop-target");
118 const after = cardsOf(list.closest(".column")).find((el) => {
119 if (el === dragging.el) return false;
120 const box = el.getBoundingClientRect();
121 return e.clientY < box.top + box.height / 2;
122 });
123 list.insertBefore(dragging.el, after || null);
129 placeInList(dragging.el, list, e.clientY);
124130 });
125131 }
126132 }
127133 attachDropListeners();
128134
135 // ---- touch: a long press (no real drag events on a phone) picks a card
136 // up, pointermove carries it, and pointerup drops it through the same
137 // commitMove the mouse uses. Bound to touch/pen only (pointerType !==
138 // "mouse") so the mouse keeps using dragstart/dragover/drop above
139 // untouched — two paths committing the same move is the bug to avoid.
140 const LONG_PRESS_MS = 300;
141 const MOVE_SLOP = 10; // px of movement before the press threshold that cancels a hold and lets the page scroll instead
142 const EDGE_ZONE = 48; // px from either edge of .board that auto-scrolls while a card is held
143 let press = null; // { el, from, pointerId, startX, startY, timer, active }
144 let autoScrollTimer = null;
145 let autoScrollDx = 0;
146
147 function stopAutoScroll() {
148 clearInterval(autoScrollTimer);
149 autoScrollTimer = null;
150 autoScrollDx = 0;
151 }
152 function updateAutoScroll(clientX) {
153 const board = root.querySelector(".board");
154 if (!board) return;
155 const box = board.getBoundingClientRect();
156 const dx = clientX - box.left < EDGE_ZONE ? -10 : box.right - clientX < EDGE_ZONE ? 10 : 0;
157 autoScrollDx = dx;
158 if (dx !== 0 && autoScrollTimer === null) autoScrollTimer = setInterval(() => (board.scrollLeft += autoScrollDx), 16);
159 else if (dx === 0) stopAutoScroll();
160 }
161
162 function endPress(commit) {
163 const p = press;
164 press = null;
165 clearTimeout(p.timer);
166 stopAutoScroll();
167 if (!p.active) return;
168 p.el.classList.remove("dragging");
169 p.el.style.touchAction = "";
170 clearDropTargets();
171 if (commit) commitMove(p.el, p.from);
172 }
173
174 root.addEventListener("pointerdown", (e) => {
175 if (e.pointerType === "mouse") return;
176 // A press starting on a control (the title input, a select, a button)
177 // is that control's own gesture, not a drag.
178 if (e.target.closest("input, select, button, textarea, a")) return;
179 const card = e.target.closest(".card");
180 if (!card) return;
181 const timer = setTimeout(() => {
182 if (!press) return;
183 press.active = true;
184 card.classList.add("dragging");
185 // Only while held: before this, touch-action stays "auto" so an
186 // ordinary swipe still scrolls the board, same as before this held.
187 card.style.touchAction = "none";
188 }, LONG_PRESS_MS);
189 press = { el: card, from: card.dataset.columnId, pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, timer, active: false };
190 });
191
192 root.addEventListener("pointermove", (e) => {
193 if (!press || e.pointerId !== press.pointerId) return;
194 if (!press.active) {
195 // Movement before the card is picked up is a scroll or a stray
196 // finger tremor, never a drag — cancel the pending hold and leave
197 // the event alone so the browser's own scroll keeps going.
198 if (Math.hypot(e.clientX - press.startX, e.clientY - press.startY) > MOVE_SLOP) endPress(false);
199 return;
200 }
201 e.preventDefault();
202 const list = e.target.closest(".cards");
203 if (list) {
204 clearDropTargets();
205 list.closest(".column").classList.add("drop-target");
206 placeInList(press.el, list, e.clientY);
207 }
208 updateAutoScroll(e.clientX);
209 });
210
211 root.addEventListener("pointerup", (e) => {
212 if (!press || e.pointerId !== press.pointerId) return;
213 endPress(true);
214 });
215 root.addEventListener("pointercancel", (e) => {
216 if (!press || e.pointerId !== press.pointerId) return;
217 endPress(false);
218 });
219
129220 // ---- keyboard: arrow keys move the focused card, no drag required ----
130221 root.addEventListener("keydown", (e) => {
131222 const card = e.target.closest(".card");
@@ -180,7 +271,9 @@
180271 const fromColumnId = card.dataset.columnId;
181272 const target = root.querySelector(`.column[data-column-id="${CSS.escape(form.column_id.value)}"]`);
182273 if (!target) return;
183 target.querySelector(".cards").appendChild(card);
274 const list = target.querySelector(".cards");
275 if (form.index.value === "0") list.insertBefore(card, list.firstChild);
276 else list.appendChild(card);
184277 commitMove(card, fromColumnId);
185278 return;
186279 }
@@ -265,13 +358,20 @@
265358 if (col.dataset.columnId === columnId) opt.selected = true;
266359 select.appendChild(opt);
267360 }
268 const hidden = document.createElement("input");
269 hidden.type = "hidden";
270 hidden.name = "index";
271 hidden.value = "999999";
361 const indexSelect = document.createElement("select");
362 indexSelect.name = "index";
363 indexSelect.setAttribute("aria-label", "Position in column");
364 const top = document.createElement("option");
365 top.value = "0";
366 top.textContent = "Top";
367 const bottom = document.createElement("option");
368 bottom.value = "999999";
369 bottom.textContent = "Bottom";
370 bottom.selected = true;
371 indexSelect.append(top, bottom);
272372 const moveBtn = document.createElement("button");
273373 moveBtn.textContent = "Move";
274 moveForm.append(select, hidden, moveBtn);
374 moveForm.append(select, indexSelect, moveBtn);
275375
276376 const deleteForm = document.createElement("form");
277377 deleteForm.className = "delete";

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs