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
Check · Review · Land · Live done — Landed and live in production.
scout/tempo:scout/21-a-card-can-be-moved-with-a-thumb-touch-d → main 3 commits fork
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 | ||
| 37 | 37 | - **Cards**: type a title and press Enter to save it; drag a card to |
| 38 | 38 | reorder or move it between columns, or focus it (click, or Tab) and |
| 39 | 39 | 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. | |
| 42 | 45 | - **Columns**: add, rename, delete, and move them left/right with the |
| 43 | 46 | ◀ ▶ buttons next to each name. |
| 44 | 47 | - **Filter**: the search box hides non-matching cards instantly (no |
| @@ -137,8 +140,10 @@ anonymous browser's. | ||
| 137 | 140 | ## Dependencies |
| 138 | 141 | |
| 139 | 142 | 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 = ` | ||
| 44 | 44 | button.ghost, a.ghost { background: transparent; color: inherit; border: 1px solid #8884; padding: .35rem .7rem; border-radius: .4rem; } |
| 45 | 45 | .tools { position: fixed; top: .75rem; right: .75rem; display: flex; gap: .4rem; z-index: 10; } |
| 46 | 46 | .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; } | |
| 48 | 48 | .column { background: #8881; border-radius: .6rem; padding: .6rem; width: 16rem; flex: none; } |
| 49 | 49 | .column.drop-target { outline: 2px dashed var(--accent); } |
| 50 | 50 | .column header { display: flex; align-items: center; gap: .3rem; margin-bottom: .5rem; } |
| @@ -57,9 +57,9 @@ const styles = ` | ||
| 57 | 57 | .card:focus-visible { outline: 2px solid var(--accent); } |
| 58 | 58 | .card.dragging { opacity: .4; } |
| 59 | 59 | .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; } | |
| 63 | 63 | .add-card { display: flex; gap: .3rem; margin-top: .5rem; } |
| 64 | 64 | .add-card input { flex: 1; } |
| 65 | 65 | .new-column { flex: none; width: 14rem; } |
| @@ -190,7 +190,10 @@ export function boardPage({ board, columns, cards, query, who, error }: BoardDat | ||
| 190 | 190 | <div class="row"> |
| 191 | 191 | <form class="move" method="post" action="/b/${board.id}/cards/${c.id}/move"> |
| 192 | 192 | <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> | |
| 194 | 197 | <button>Move</button> |
| 195 | 198 | </form> |
| 196 | 199 | <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 | ||
| 239 | 242 | </form> |
| 240 | 243 | ${doorLink(who, `/b/${board.id}`)} |
| 241 | 244 | </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> | |
| 243 | 246 | <div class="toolbar"> |
| 244 | 247 | <form class="filter-form" method="get" action="/b/${board.id}"> |
| 245 | 248 | <input type="search" id="filter" name="q" value="${escape(query)}" placeholder="Filter cards"> |
| @@ -275,6 +278,6 @@ const boardShortcuts: [string, string][] = [ | ||
| 275 | 278 | |
| 276 | 279 | // Named by content — a change to static/board.js gets a new hash here, |
| 277 | 280 | // 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"; | |
| 279 | 282 | export const THEME_JS = "theme.1025564933.js"; |
| 280 | 283 | 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 @@ | ||
| 85 | 85 | function clearDropTargets() { |
| 86 | 86 | for (const col of columnsList()) col.classList.remove("drop-target"); |
| 87 | 87 | } |
| 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 | } | |
| 88 | 99 | root.addEventListener("dragstart", (e) => { |
| 89 | 100 | const card = e.target.closest(".card"); |
| 90 | 101 | if (!card) return; |
| @@ -115,17 +126,97 @@ | ||
| 115 | 126 | e.preventDefault(); |
| 116 | 127 | clearDropTargets(); |
| 117 | 128 | 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); | |
| 124 | 130 | }); |
| 125 | 131 | } |
| 126 | 132 | } |
| 127 | 133 | attachDropListeners(); |
| 128 | 134 | |
| 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 | ||
| 129 | 220 | // ---- keyboard: arrow keys move the focused card, no drag required ---- |
| 130 | 221 | root.addEventListener("keydown", (e) => { |
| 131 | 222 | const card = e.target.closest(".card"); |
| @@ -180,7 +271,9 @@ | ||
| 180 | 271 | const fromColumnId = card.dataset.columnId; |
| 181 | 272 | const target = root.querySelector(`.column[data-column-id="${CSS.escape(form.column_id.value)}"]`); |
| 182 | 273 | 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); | |
| 184 | 277 | commitMove(card, fromColumnId); |
| 185 | 278 | return; |
| 186 | 279 | } |
| @@ -265,13 +358,20 @@ | ||
| 265 | 358 | if (col.dataset.columnId === columnId) opt.selected = true; |
| 266 | 359 | select.appendChild(opt); |
| 267 | 360 | } |
| 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); | |
| 272 | 372 | const moveBtn = document.createElement("button"); |
| 273 | 373 | moveBtn.textContent = "Move"; |
| 274 | moveForm.append(select, hidden, moveBtn); | |
| 374 | moveForm.append(select, indexSelect, moveBtn); | |
| 275 | 375 | |
| 276 | 376 | const deleteForm = document.createElement("form"); |
| 277 | 377 | deleteForm.className = "delete"; |
new above
offline