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:
|
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.
|
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 {
|
.model-combobox-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ describe("CustomModelDropdown", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
{ width: 1024, height: 800, query: "desktop", expectedMaxHeight: "320px" },
|
||||||
{ width: 390, height: 844, query: "(max-width: 640px)", expectedMaxHeight: "360px" },
|
{ width: 390, height: 844, query: "(max-width: 640px)", expectedMaxHeight: "360px" },
|
||||||
{ width: 700, height: 900, query: "(max-width: 768px)", expectedMaxHeight: "420px" },
|
{ width: 700, height: 900, query: "(max-width: 768px)", expectedMaxHeight: "420px" },
|
||||||
])("keeps the portaled model list touch-scrollable at $query", async ({ width, height, query, expectedMaxHeight }) => {
|
])("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(portal.style.maxHeight).toBe(expectedMaxHeight);
|
||||||
expect(list).toBeInstanceOf(HTMLElement);
|
expect(list).toBeInstanceOf(HTMLElement);
|
||||||
expect(within(portal).getAllByRole("option").length).toBeGreaterThan(20);
|
expect(within(portal).getAllByRole("option").length).toBeGreaterThan(20);
|
||||||
|
expect(listRule).toContain("min-height: 0;");
|
||||||
expect(listRule).toContain("overflow-y: auto;");
|
expect(listRule).toContain("overflow-y: auto;");
|
||||||
expect(listRule).toContain("overflow-x: hidden;");
|
expect(listRule).toContain("overflow-x: hidden;");
|
||||||
expect(listRule).toContain("-webkit-overflow-scrolling: touch;");
|
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 () => {
|
it("uses visualViewport dimensions for positioning when available", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
|
|||||||
Reference in New Issue
Block a user