From 75c7f397ba8d6938287d29acf0ed256b5f08dd86 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:56:57 +0300 Subject: [PATCH] Fix opening phone keyboard closing the dropdown --- .gitignore | 3 +- .../useCloseOnScrollClip.browser.test.tsx | 50 +++++++++++++++++++ .../elements/useCloseOnScrollClip.ts | 25 +++++++++- .../2026-09-18-keyboard-closing-selects.md | 4 ++ 4 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 changelog/2026-09-18-keyboard-closing-selects.md diff --git a/.gitignore b/.gitignore index 0391350e5..0125500da 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ notepad.txt *.mp4 *.mkv -*.webm \ No newline at end of file +*.webm +*.mov \ No newline at end of file diff --git a/app/components/elements/useCloseOnScrollClip.browser.test.tsx b/app/components/elements/useCloseOnScrollClip.browser.test.tsx index 142c8ee7f..ba617fa22 100644 --- a/app/components/elements/useCloseOnScrollClip.browser.test.tsx +++ b/app/components/elements/useCloseOnScrollClip.browser.test.tsx @@ -8,6 +8,7 @@ const PAGE_HEIGHT = 5000; afterEach(() => { window.scrollTo(0, 0); + closeKeyboard(); }); function Overlay({ @@ -66,6 +67,29 @@ function ScrollingOverlay({ const settle = () => new Promise((resolve) => setTimeout(resolve, 150)); +const KEYBOARD_HEIGHT = 300; + +/** Shrinks the visual viewport the way the virtual keyboard opening does. */ +function openKeyboard() { + const viewport = window.visualViewport; + invariant(viewport); + + const shrunk = viewport.height - KEYBOARD_HEIGHT; + Object.defineProperty(viewport, "height", { + configurable: true, + get: () => shrunk, + }); + viewport.dispatchEvent(new Event("resize")); +} + +function closeKeyboard() { + const viewport = window.visualViewport; + invariant(viewport); + + Reflect.deleteProperty(viewport, "height"); + viewport.dispatchEvent(new Event("resize")); +} + describe("useCloseOnScrollClip", () => { test("closes once scrolling clips a popover that was fully visible", async () => { const close = vi.fn(); @@ -100,6 +124,32 @@ describe("useCloseOnScrollClip", () => { expect(close).not.toHaveBeenCalled(); }); + test("never closes over the scroll the virtual keyboard opening causes", async () => { + const close = vi.fn(); + await render(); + await settle(); + + openKeyboard(); + window.scrollTo(0, 250); + await settle(); + + expect(close).not.toHaveBeenCalled(); + }); + + test("forgets a scroll the keyboard only lands after", async () => { + const close = vi.fn(); + await render(); + await settle(); + + window.scrollTo(0, 250); + // the scroll can reach the page before the keyboard has shrunk the viewport + window.dispatchEvent(new Event("scroll")); + openKeyboard(); + await settle(); + + expect(close).not.toHaveBeenCalled(); + }); + test("never closes over a scroll of the popover's own content", async () => { const close = vi.fn(); const screen = await render( diff --git a/app/components/elements/useCloseOnScrollClip.ts b/app/components/elements/useCloseOnScrollClip.ts index 553daa8bb..5b130cc5b 100644 --- a/app/components/elements/useCloseOnScrollClip.ts +++ b/app/components/elements/useCloseOnScrollClip.ts @@ -1,6 +1,8 @@ import * as React from "react"; const VISIBLE_RATIO_THRESHOLD = 0.98; +/** A visual viewport shorter than the window by more than this is the virtual keyboard, not collapsing browser chrome. */ +const KEYBOARD_MIN_HEIGHT = 150; /** * Closes an open popover once scrolling clips it against the sticky header @@ -8,7 +10,11 @@ const VISIBLE_RATIO_THRESHOLD = 0.98; * * Only scrolling may close: a popover clipped by its own content growing (the * moment before anchor positioning flips it into view), one too tall to ever - * fit fully, or one measured before it is shown must not close itself. + * fit fully, or one measured before it is shown must not close itself. The + * virtual keyboard opening is not scrolling either, even though the browser + * scrolls the page to keep the focused field in view as it does: a popover + * left under the keyboard beats one that closes as its own search input is + * focused. */ export function useCloseOnScrollClip( isOpen: boolean, @@ -37,6 +43,7 @@ export function useCloseOnScrollClip( if (event.target instanceof Node && element.contains(event.target)) { return; } + if (keyboardIsOpen()) return; scrolledSinceFullyVisible = true; }; window.addEventListener("scroll", onScroll, { @@ -44,6 +51,14 @@ export function useCloseOnScrollClip( passive: true, }); + // the keyboard can land after the scroll it causes, which then has to be forgotten + const onViewportResize = () => { + if (keyboardIsOpen()) { + scrolledSinceFullyVisible = false; + } + }; + window.visualViewport?.addEventListener("resize", onViewportResize); + const observer = new IntersectionObserver( (entries) => { const entry = entries.at(-1); @@ -64,7 +79,15 @@ export function useCloseOnScrollClip( return () => { window.removeEventListener("scroll", onScroll, { capture: true }); + window.visualViewport?.removeEventListener("resize", onViewportResize); observer.disconnect(); }; }, [isOpen, elementRef]); } + +function keyboardIsOpen() { + const viewport = window.visualViewport; + if (!viewport) return false; + + return window.innerHeight - viewport.height > KEYBOARD_MIN_HEIGHT; +} diff --git a/changelog/2026-09-18-keyboard-closing-selects.md b/changelog/2026-09-18-keyboard-closing-selects.md new file mode 100644 index 000000000..216d35457 --- /dev/null +++ b/changelog/2026-09-18-keyboard-closing-selects.md @@ -0,0 +1,4 @@ +--- +type: bug +--- +Opening the phone keyboard no longer closes the dropdown you are typing into