Make the phone work: touch reordering, reachable menus, and nothing lost offline

Four things that were built for a mouse on a desk and quietly did nothing
useful on a phone. The last one is the serious one.

**A note made offline lost everything written into it.** Its create
reached the server, the server named it something else, and every edit
still queued behind it went on addressing the name only that device ever
knew — 404, five retries, thrown away. The note arrived empty and
untitled, with nothing to say that anything had been lost. The database
was repointed at the new id all along; the queue was not. It is now, by
replacing the id across the whole queued payload rather than in a list of
named fields, because a list of fields is a list to forget to add to. The
same held for a notebook made offline and everything filed into it.

**The order of notes could not be changed on a phone**, because the list
went through the HTML5 drag API, which mobile browsers never fire from
touch. Pointer events instead, which mouse, pen and finger all produce. A
mouse still drags a row from anywhere; a finger drags from a grip,
because pressing the row has to go on scrolling and holding it has to go
on opening the menu. The row under the finger is found with
elementFromPoint — a finger raises no pointerover on anything it did not
press.

**"Move to..." opened off the side of the screen**, and could not be
opened at all without a hover. It is a tap now, unfolding in place where
there is no room beside the menu and flipping sides where there is. The
menu itself is placed by measuring rather than by assuming it is 220px
wide, its rows are tall enough to hit with a thumb, and Escape closes it.

**Installing said nothing when it could not be done.** The entry appeared
only when the browser had offered a prompt, so where installing was
impossible there was no button and no reason. It is always there now and
names the cause — most usefully that a browser will only install a site
served over HTTPS, which is also why the service worker would not have
registered, so one cause was showing up as two mysteries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-12 01:16:41 +02:00
1 parent 6eecf57247
commit 608da16ac2
12 files changed
+743 -147

No files matched your search

+40 -7
View File
@@ -7,25 +7,46 @@ interface BeforeInstallPromptEvent extends Event {
}
export interface PwaInstall {
/** Non-null only while the browser is willing to show an install prompt. */
/** True while the browser is willing to show its own install prompt. */
canInstall: boolean;
/** True when the app is already running as an installed app. */
installed: boolean;
/**
* Why there is no prompt, when there is none. Empty while one is available.
*
* The button used to appear only when the browser offered a prompt, so when
* installing was impossible there was nothing on screen at all — no button, no
* reason, nothing to act on. Every one of these is fixable by whoever is
* holding the phone, which is why it is worth saying.
*/
reason: "" | "installed" | "insecure" | "manual";
promptInstall: () => Promise<void>;
}
/**
* Captures the browser's install offer so the app can present it at a sensible
* moment instead of letting the browser choose one.
*/
const isStandalone = () =>
window.matchMedia?.("(display-mode: standalone)")?.matches ||
// iOS keeps its own flag rather than reporting the display mode.
(navigator as unknown as { standalone?: boolean }).standalone === true;
export function usePwaInstall(): PwaInstall {
const [event, setEvent] = useState<BeforeInstallPromptEvent | null>(null);
const [installed, setInstalled] = useState(isStandalone);
useEffect(() => {
const onPrompt = (e: Event) => {
e.preventDefault();
setEvent(e as BeforeInstallPromptEvent);
};
const onInstalled = () => {
setInstalled(true);
setEvent(null);
};
window.addEventListener("beforeinstallprompt", onPrompt);
return () => window.removeEventListener("beforeinstallprompt", onPrompt);
window.addEventListener("appinstalled", onInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", onPrompt);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);
const promptInstall = async () => {
@@ -37,5 +58,17 @@ export function usePwaInstall(): PwaInstall {
if (outcome === "accepted") setEvent(null);
};
return { canInstall: event !== null, promptInstall };
/* An app can only be installed from a secure origin. Served over plain HTTP
* the service worker never registers either, so this one condition takes the
* install *and* offline with it — which is worth naming rather than leaving
* as two separate mysteries. */
const reason: PwaInstall["reason"] = installed
? "installed"
: event
? ""
: window.isSecureContext
? "manual"
: "insecure";
return { canInstall: event !== null, installed, reason, promptInstall };
}
+128
View File
@@ -0,0 +1,128 @@
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Drag-to-reorder that works with a finger.
*
* The list used to use the HTML5 drag-and-drop API, which mobile browsers never
* fire from touch — so on a phone the arrangement simply could not be changed,
* with nothing on screen to say why. This uses pointer events instead, which
* mouse, pen and finger all produce.
*
* The two devices want different things, so they get different starts:
*
* - **With a mouse**, pressing anywhere on a row and moving a few pixels begins
* the drag. That is what a mouse has always done here.
* - **With a finger**, only the grip begins it. Pressing the row itself has to
* go on scrolling the list, and a long press has to go on opening the menu —
* both would be lost to a drag that started from anywhere.
*/
/** Pixels a mouse must travel before a press counts as a drag rather than a click. */
const MOUSE_THRESHOLD = 5;
export interface ReorderDrag {
/** The row the pointer is over, for the drop indicator. */
overId: string | null;
/** The row being carried, so it can be shown as lifted. */
draggingId: string | null;
/** Spread onto each row; `id` is what gets reordered. */
rowProps: (id: string) => { "data-reorder-id": string; onPointerDown: (e: React.PointerEvent) => void };
/** Spread onto the grip inside a row. */
gripProps: { "data-reorder-grip": string };
}
export function useReorderDrag(
enabled: boolean,
reorder: (dragId: string, targetId: string) => void
): ReorderDrag {
const [overId, setOverId] = useState<string | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
// Everything the pointer handlers need, kept out of state: they run far more
// often than anything needs to be redrawn.
const drag = useRef<{ id: string; x: number; y: number; active: boolean; fromGrip: boolean } | null>(null);
const overRef = useRef<string | null>(null);
const stop = useCallback(() => {
drag.current = null;
overRef.current = null;
setDraggingId(null);
setOverId(null);
document.body.style.removeProperty("user-select");
}, []);
const onPointerDown = useCallback(
(id: string) => (e: React.PointerEvent) => {
if (!enabled || e.button > 0) return;
const fromGrip = !!(e.target as HTMLElement)?.closest?.("[data-reorder-grip]");
// A finger on the row itself is a scroll or a long press, not a drag.
if (e.pointerType !== "mouse" && !fromGrip) return;
drag.current = { id, x: e.clientX, y: e.clientY, active: fromGrip, fromGrip };
if (fromGrip) {
setDraggingId(id);
document.body.style.userSelect = "none";
// The grip owns the gesture from here, so the list must not also scroll.
e.preventDefault();
}
},
[enabled]
);
useEffect(() => {
if (!enabled) return;
const move = (e: PointerEvent) => {
const d = drag.current;
if (!d) return;
if (!d.active) {
if (Math.abs(e.clientX - d.x) + Math.abs(e.clientY - d.y) < MOUSE_THRESHOLD) return;
d.active = true;
setDraggingId(d.id);
document.body.style.userSelect = "none";
}
// Which row is under the finger — asked of the document rather than
// tracked with enter/leave handlers, because a finger generates no
// pointerover on anything it did not press.
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
const row = el?.closest?.("[data-reorder-id]") as HTMLElement | null;
const target = row?.dataset.reorderId ?? null;
const next = target && target !== d.id ? target : null;
if (next !== overRef.current) {
overRef.current = next;
setOverId(next);
}
if (e.cancelable) e.preventDefault();
};
const up = () => {
const d = drag.current;
const target = overRef.current;
if (d?.active && target && target !== d.id) reorder(d.id, target);
stop();
};
window.addEventListener("pointermove", move, { passive: false });
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", stop);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", stop);
};
}, [enabled, reorder, stop]);
// A drag that outlives its list would leave the page unselectable.
useEffect(
() => () => {
document.body.style.removeProperty("user-select");
},
[]
);
return {
overId,
draggingId,
rowProps: (id: string) => ({ "data-reorder-id": id, onPointerDown: onPointerDown(id) }),
gripProps: { "data-reorder-grip": "" },
};
}