FN-7760: fix mobile model dropdown list not scrolling via touch

Adds an explicit touch-scroll contract to the portaled model combobox list so it remains scrollable on mobile despite the global mobile lockdown that hides body/root overflow and defaults gestures to vertical panning.

- Add -webkit-overflow-scrolling: touch, overscroll-behavior: contain, and touch-action: pan-y to .model-combobox-list so the fixed-position dropdown owns its own touch scrolling on mobile.
- Add regression tests covering the mobile scroll contract for CustomModelDropdown.
- Add a patch changeset documenting the fix for release notes.

Files changed:
 .changeset/fn-7760-mobile-model-dropdown-scroll.md |  7 +++
 packages/dashboard/app/components/CustomModelDropdown.css         |  7 +++
 packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx | 70 ++++++++++++++++++++++
 3 files changed, 84 insertions(+)

Fusion-Task-Id: FN-7760

Fusion-Task-Lineage: e135ff47-275b-4606-aef2-c9859aeaa418

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 23:29:47 -07:00
parent fed5d3d951
commit 150227f808
3 changed files with 84 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix mobile model drop-down lists so they scroll by touch.
category: fix
dev: Adds the CustomModelDropdown portaled list mobile scroll contract.

View File

@@ -130,10 +130,17 @@
border-bottom: 1px solid var(--border);
}
/*
FNXC:ModelDropdown 2026-07-09-00:00:
FN-7760 requires the portaled model list to remain the touch-scroll owner on mobile. The global mobile lockdown keeps body/root overflow hidden and defaults touch gestures to vertical panning, so this fixed-position scroller needs its own iOS momentum scrolling, contained overscroll, and explicit vertical pan contract.
*/
.model-combobox-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
touch-action: pan-y;
padding: 4px 0;
}

View File

@@ -76,6 +76,76 @@ describe("CustomModelDropdown", () => {
expect(hostSurface.contains(portal)).toBe(false);
});
it.each([
{ 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 }) => {
const user = userEvent.setup();
const onChange = vi.fn();
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 ? "openai" : "anthropic",
id: `model-${index}`,
name: `Model ${index}`,
reasoning: index % 3 === 0,
contextWindow: 128000 + index,
}));
vi.spyOn(window, "innerWidth", "get").mockReturnValue(width);
vi.spyOn(window, "innerHeight", "get").mockReturnValue(height);
vi.spyOn(window, "matchMedia").mockImplementation((mediaQuery: string) => ({
matches: mediaQuery === query || (width <= 640 && mediaQuery === "(max-width: 768px)"),
media: mediaQuery,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
} as MediaQueryList));
render(
<CustomModelDropdown
label="Executor Model"
value=""
onChange={onChange}
models={overflowingModels}
favoriteModels={["openai/model-0", "anthropic/model-1"]}
onToggleModelFavorite={vi.fn()}
/>,
);
const trigger = screen.getByRole("button", { name: "Executor Model" });
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
x: 24,
y: 80,
width: 320,
height: 36,
top: 80,
right: 344,
bottom: 116,
left: 24,
toJSON: () => ({}),
});
await user.click(trigger);
const portal = await screen.findByTestId("model-combobox-portal");
const list = portal.querySelector(".model-combobox-list");
expect(portal.classList.contains("model-combobox-dropdown--portal")).toBe(true);
expect(portal.style.maxHeight).toBe(expectedMaxHeight);
expect(list).toBeInstanceOf(HTMLElement);
expect(within(portal).getAllByRole("option").length).toBeGreaterThan(20);
expect(listRule).toContain("overflow-y: auto;");
expect(listRule).toContain("overflow-x: hidden;");
expect(listRule).toContain("-webkit-overflow-scrolling: touch;");
expect(listRule).toContain("overscroll-behavior: contain;");
expect(listRule).toContain("touch-action: pan-y;");
expect(listRule).not.toContain("touch-action: none");
});
it("supports an explicit No change sentinel while keeping Use default available", async () => {
const user = userEvent.setup();
const onChange = vi.fn();