fix(ui,preview): panels chase the keyboard by frame, and the preview is finally mobile

The panel measured itself once, a fixed delay into the keyboard's animation,
so it landed against a viewport that was still moving - and in a sidedock
drawer no resize event fires at all, so it never moved. It now re-places every
frame until its own position holds still, which needs neither a duration to
guess nor an event to wait for. Its input taking focus is another trigger:
that is what opens the keyboard in the first place.

The preview had no viewport meta, so a 390px window still laid out at 980px:
every "mobile" screenshot was a desktop one at a phone's aspect, and
coarse-pointer rules could not apply. Which is why the tap targets looked
fine here and were not.
This commit is contained in:
hh committed 2026-09-05 00:47:21 +02:00
1 parent b70331f7bd
commit f7ccd21d7b
2 files changed
+39 -10

No files matched your search

+3
View File
@@ -2,6 +2,9 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- Without this a phone-sized window still lays out at 980px, so a
"mobile" screenshot is not one and coarse-pointer rules never apply. -->
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>beaver-calendar preview</title>
<link rel="stylesheet" href="/app.css" />
<link rel="stylesheet" href="/theme.css" />
+36 -10
View File
@@ -27,8 +27,9 @@
const view = useView();
// iOS animates the keyboard in; the viewport only tells the truth after.
const KEYBOARD_SETTLE_MS = 350;
// The keyboard animates both ways: rather than guess how long that takes,
// the panel re-places every frame until its position holds still.
const STEADY_FRAMES = 4;
let panel = $state<HTMLElement | null>(null);
let at = $state({ top: 0, left: 0 });
@@ -74,14 +75,38 @@
if (!node.contains(event.target as Node)) onclose();
};
const reflow = (): void => measure();
/**
* Re-place every frame until the position holds still. The keyboard
* animates both ways, and in a sidedock nothing fires an event at all -
* a frame loop needs neither a duration to guess nor an event to wait for.
*/
let frame: number | null = null;
const settle = (): void => {
if (frame !== null) return;
let last = Number.NaN;
let steady = 0;
const tick = (): void => {
measure();
steady = at.top === last ? steady + 1 : 0;
last = at.top;
if (steady >= STEADY_FRAMES) {
frame = null;
return;
}
frame = win.requestAnimationFrame(tick);
};
frame = win.requestAnimationFrame(tick);
};
document.addEventListener("pointerdown", outside, true);
window.addEventListener("resize", reflow);
// The keyboard changes the visual viewport without resizing the window,
// and its animation takes a moment to settle.
// The keyboard changes the visual viewport without resizing the window.
const vv = win.visualViewport;
vv?.addEventListener("resize", reflow);
vv?.addEventListener("scroll", reflow);
const settle = win.setTimeout(reflow, KEYBOARD_SETTLE_MS);
vv?.addEventListener("resize", settle);
vv?.addEventListener("scroll", settle);
// The panel's own input takes focus a beat before the keyboard is up, and
// swapping fields re-opens it.
node.addEventListener("focusin", settle);
settle();
// A panel whose body grew (an option list opened) re-places itself. Only
// the content height is watched: measure() sets max-height, so observing
// the panel itself would feed its own change back in.
@@ -97,9 +122,10 @@
destroy() {
document.removeEventListener("pointerdown", outside, true);
window.removeEventListener("resize", reflow);
vv?.removeEventListener("resize", reflow);
vv?.removeEventListener("scroll", reflow);
win.clearTimeout(settle);
vv?.removeEventListener("resize", settle);
vv?.removeEventListener("scroll", settle);
node.removeEventListener("focusin", settle);
if (frame !== null) win.cancelAnimationFrame(frame);
watcher?.disconnect();
win.removeEventListener("keydown", keys, true);
panel = null;