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:
7
.changeset/fn-8355-mobile-model-dropdown-scroll.md
Normal file
7
.changeset/fn-8355-mobile-model-dropdown-scroll.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user