FN-8355: preserve mobile model list scrolling

Keep filtered mobile model dropdown lists scrollable when the virtual keyboard repositions the viewport.

- Allow the portaled list flex child to shrink and retain overflow ownership.
- Cover desktop and mobile scroll contracts, filtering, keyboard repositioning, and no-result states.
- Add a patch changeset for the mobile dropdown fix.

Files changed:
 .changeset/fn-8355-mobile-model-dropdown-scroll.md |  7 ++
 .../app/components/CustomModelDropdown.css         |  4 ++
 .../__tests__/CustomModelDropdown.test.tsx         | 79 ++++++++++++++++++++++
 3 files changed, 90 insertions(+)

Fusion-Task-Id: FN-8355

Fusion-Task-Lineage: 4e9dff76-f347-45ff-8ef6-abd7eea90d20

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 19:04:07 -07:00
parent 927f2baf7d
commit 0400c02f08
3 changed files with 90 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix mobile model dropdown so the list stays scrollable after searching.
category: fix
dev: CustomModelDropdown portaled list retains touch-scroll ownership after filter + visualViewport reposition.

View File

@@ -231,9 +231,13 @@ FN-7760 requires the portaled model list to remain the touch-scroll owner on mob
FNXC:ModelDropdown 2026-07-15-00:00:
FN-8212 refines FN-8193 by removing the scroller's top padding. A sticky provider header anchors at the list top, so top padding exposed scrolling model rows in a seam between the fixed header stack and that header. Bottom token spacing preserves the list's trailing rhythm.
FNXC:ModelDropdown 2026-07-16-00:00:
FN-8355 requires the portaled list to remain the touch-scroll owner after filtering and a virtual-keyboard visualViewport reposition. Permit this flex child to shrink below its content height; otherwise the fixed search/header stack can leave its overflow clipped by the dropdown instead of scrollable on iOS and Android.
*/
.model-combobox-list {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;

View File

@@ -342,6 +342,7 @@ describe("CustomModelDropdown", () => {
});
it.each([
{ width: 1024, height: 800, query: "desktop", expectedMaxHeight: "320px" },
{ width: 390, height: 844, query: "(max-width: 640px)", expectedMaxHeight: "360px" },
{ width: 700, height: 900, query: "(max-width: 768px)", expectedMaxHeight: "420px" },
])("keeps the portaled model list touch-scrollable at $query", async ({ width, height, query, expectedMaxHeight }) => {
@@ -403,6 +404,7 @@ describe("CustomModelDropdown", () => {
expect(portal.style.maxHeight).toBe(expectedMaxHeight);
expect(list).toBeInstanceOf(HTMLElement);
expect(within(portal).getAllByRole("option").length).toBeGreaterThan(20);
expect(listRule).toContain("min-height: 0;");
expect(listRule).toContain("overflow-y: auto;");
expect(listRule).toContain("overflow-x: hidden;");
expect(listRule).toContain("-webkit-overflow-scrolling: touch;");
@@ -1270,6 +1272,83 @@ describe("CustomModelDropdown", () => {
};
};
it("keeps the filtered mobile list scrollable after visualViewport keyboard repositioning", async () => {
const user = userEvent.setup();
const css = readFileSync(resolve(__dirname, "../CustomModelDropdown.css"), "utf-8");
const listRule = css.match(/\.model-combobox-list\s*\{[^}]*\}/)?.[0] ?? "";
const overflowingModels = Array.from({ length: 30 }, (_, index) => ({
provider: index % 2 === 0 ? "anthropic" : "openai",
id: `mobile-model-${index}`,
name: `Mobile Model ${index}`,
reasoning: false,
contextWindow: 128000,
}));
const { simulateChange, cleanup: vvCleanup } = setupVisualViewportMock({
width: 375,
height: 667,
offsetTop: 0,
offsetLeft: 0,
});
vi.spyOn(window, "innerWidth", "get").mockReturnValue(375);
vi.spyOn(window, "innerHeight", "get").mockReturnValue(667);
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: query === "(max-width: 640px)" || query === "(max-width: 768px)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
} as MediaQueryList));
const restore = setupBoundingRectMock({
top: 200,
left: 20,
bottom: 236,
width: 335,
height: 36,
right: 355,
x: 20,
y: 200,
} as DOMRect);
try {
render(<CustomModelDropdown label="Mobile model" value="" onChange={vi.fn()} models={overflowingModels} />);
await user.click(screen.getByRole("button", { name: "Mobile model" }));
const portal = await screen.findByTestId("model-combobox-portal");
const list = portal.querySelector<HTMLElement>(".model-combobox-list");
expect(list).not.toBeNull();
expect(within(list!).getAllByRole("option")).toHaveLength(31);
await user.type(screen.getByPlaceholderText("Filter models…"), "mobile");
expect(within(list!).getAllByRole("option")).toHaveLength(31);
// Simulate the virtual keyboard shrinking the visual viewport after search.
simulateChange({ height: 160, offsetTop: 507 });
await waitFor(() => expect(parseFloat(portal.style.maxHeight)).toBe(160));
// JSDOM does not lay out overflow, so assert the CSS scroll contract and its runtime owner directly.
expect(listRule).toContain("min-height: 0;");
expect(listRule).toContain("overflow-y: auto;");
expect(listRule).toContain("-webkit-overflow-scrolling: touch;");
expect(listRule).toContain("touch-action: pan-y;");
expect(listRule).not.toContain("min-height: 160");
list!.scrollTop = 96;
expect(list!.scrollTop).toBe(96);
// The empty/no-match state retains the same list scroll owner rather than replacing it.
await user.clear(screen.getByPlaceholderText("Filter models…"));
await user.type(screen.getByPlaceholderText("Filter models…"), "no-matching-mobile-model");
expect(portal.querySelector(".model-combobox-no-results")).not.toBeNull();
expect(portal.querySelector(".model-combobox-list")).toBe(list);
} finally {
restore();
vvCleanup();
}
});
it("uses visualViewport dimensions for positioning when available", async () => {
const user = userEvent.setup();
const onChange = vi.fn();