From f7ccd21d7b4d743d078cf4f199ff29f690f97280 Mon Sep 17 00:00:00 2001 From: h Date: Sat, 5 Sep 2026 00:47:21 +0200 Subject: [PATCH] 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. --- preview/index.html | 3 +++ src/ui/anchored-panel.svelte | 46 ++++++++++++++++++++++++++++-------- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/preview/index.html b/preview/index.html index b8dc2cb..b059205 100644 --- a/preview/index.html +++ b/preview/index.html @@ -2,6 +2,9 @@ + + beaver-calendar preview diff --git a/src/ui/anchored-panel.svelte b/src/ui/anchored-panel.svelte index 22506b7..0a8ab0a 100644 --- a/src/ui/anchored-panel.svelte +++ b/src/ui/anchored-panel.svelte @@ -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(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;