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