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