feat(FN-757): harden mobile viewport positioning and add regression tests
- Fix mobile visual viewport positioning to handle dynamic viewport changes (address bar, keyboard) - Improve CustomModelDropdown with better mobile scroll/resize handling - Add comprehensive visual viewport regression tests for CustomModelDropdown - Refactor build-exe tests for cleaner structure and coverage
This commit is contained in:
@@ -178,8 +178,31 @@ export function CustomModelDropdown({
|
||||
return optionsList.findIndex((opt) => opt.value === value);
|
||||
}, [optionsList, value]);
|
||||
|
||||
/**
|
||||
* Get the effective visible viewport dimensions, preferring
|
||||
* `window.visualViewport` when available (accounts for mobile virtual
|
||||
* keyboards, pinch-zoom, etc.) and falling back to `window` dimensions.
|
||||
*/
|
||||
const getEffectiveViewport = useCallback(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (vv && vv.height > 0 && vv.width > 0) {
|
||||
return {
|
||||
width: vv.width,
|
||||
height: vv.height,
|
||||
offsetTop: vv.offsetTop,
|
||||
offsetLeft: vv.offsetLeft,
|
||||
};
|
||||
}
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getPreferredDropdownHeight = useCallback(() => {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const { height: viewportHeight } = getEffectiveViewport();
|
||||
const supportsMatchMedia = typeof window.matchMedia === "function";
|
||||
const isSmallMobile = supportsMatchMedia ? window.matchMedia("(max-width: 640px)").matches : false;
|
||||
const isMobile = supportsMatchMedia ? window.matchMedia("(max-width: 768px)").matches : false;
|
||||
@@ -192,23 +215,26 @@ export function CustomModelDropdown({
|
||||
return Math.min(viewportHeight * 0.7, 420);
|
||||
}
|
||||
return 320;
|
||||
}, []);
|
||||
}, [getEffectiveViewport]);
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
||||
const horizontalPadding = 16;
|
||||
const verticalPadding = 16;
|
||||
const gap = 4;
|
||||
const preferredHeight = getPreferredDropdownHeight();
|
||||
|
||||
// Calculate space below and above the trigger
|
||||
const spaceBelow = viewportHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
// Calculate space below and above the trigger, relative to the visible viewport.
|
||||
// On mobile with a virtual keyboard, offsetTop/offsetLeft shift the origin.
|
||||
const triggerBottom = rect.bottom - offsetTop;
|
||||
const triggerTop = rect.top - offsetTop;
|
||||
const triggerLeft = rect.left - offsetLeft;
|
||||
const spaceBelow = viewportHeight - triggerBottom;
|
||||
const spaceAbove = triggerTop;
|
||||
const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160);
|
||||
const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160);
|
||||
|
||||
@@ -223,12 +249,12 @@ export function CustomModelDropdown({
|
||||
|
||||
const dropdownWidth = Math.min(rect.width, viewportWidth - horizontalPadding * 2);
|
||||
const left = Math.min(
|
||||
Math.max(rect.left, horizontalPadding),
|
||||
Math.max(triggerLeft, horizontalPadding),
|
||||
viewportWidth - horizontalPadding - dropdownWidth,
|
||||
);
|
||||
) + offsetLeft;
|
||||
const top = openUpward
|
||||
? Math.max(verticalPadding, rect.top - maxHeight - gap)
|
||||
: Math.min(rect.bottom + gap, viewportHeight - verticalPadding - maxHeight);
|
||||
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
|
||||
: Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight);
|
||||
|
||||
setDropdownPosition({
|
||||
top,
|
||||
@@ -236,7 +262,7 @@ export function CustomModelDropdown({
|
||||
width: dropdownWidth,
|
||||
maxHeight,
|
||||
});
|
||||
}, [getPreferredDropdownHeight]);
|
||||
}, [getEffectiveViewport, getPreferredDropdownHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
setPortalRoot(document.body);
|
||||
@@ -263,6 +289,8 @@ export function CustomModelDropdown({
|
||||
}, [isOpen, updateDropdownPosition]);
|
||||
|
||||
// Keep portaled dropdown anchored during viewport and container scrolling.
|
||||
// Also reposition when the visual viewport changes (mobile virtual keyboard,
|
||||
// pinch-zoom, etc.).
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
@@ -271,9 +299,20 @@ export function CustomModelDropdown({
|
||||
window.addEventListener("resize", handleReposition);
|
||||
window.addEventListener("scroll", handleReposition, true);
|
||||
|
||||
// Listen for visual viewport changes (virtual keyboard open/close, zoom)
|
||||
const vv = window.visualViewport;
|
||||
if (vv) {
|
||||
vv.addEventListener("resize", handleReposition);
|
||||
vv.addEventListener("scroll", handleReposition);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleReposition);
|
||||
window.removeEventListener("scroll", handleReposition, true);
|
||||
if (vv) {
|
||||
vv.removeEventListener("resize", handleReposition);
|
||||
vv.removeEventListener("scroll", handleReposition);
|
||||
}
|
||||
};
|
||||
}, [isOpen, updateDropdownPosition]);
|
||||
|
||||
|
||||
@@ -560,4 +560,417 @@ describe("CustomModelDropdown", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mobile Visual Viewport Positioning", () => {
|
||||
// Helper to mock getBoundingClientRect on Element.prototype
|
||||
const setupBoundingRectMock = (rectValues: DOMRect) => {
|
||||
const originalGetBCR = Element.prototype.getBoundingClientRect;
|
||||
Element.prototype.getBoundingClientRect = vi.fn(() => rectValues as DOMRect);
|
||||
return () => {
|
||||
Element.prototype.getBoundingClientRect = originalGetBCR;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets up a mock visualViewport on window.
|
||||
* Returns cleanup function to restore the original state.
|
||||
*/
|
||||
const setupVisualViewportMock = (vv: {
|
||||
width: number;
|
||||
height: number;
|
||||
offsetTop: number;
|
||||
offsetLeft: number;
|
||||
}) => {
|
||||
const listeners: Record<string, Array<() => void>> = {};
|
||||
const mockVV = {
|
||||
width: vv.width,
|
||||
height: vv.height,
|
||||
offsetTop: vv.offsetTop,
|
||||
offsetLeft: vv.offsetLeft,
|
||||
addEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
listeners[event] ??= [];
|
||||
listeners[event].push(handler);
|
||||
}),
|
||||
removeEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
listeners[event] = (listeners[event] ?? []).filter((h) => h !== handler);
|
||||
}),
|
||||
dispatchEvent: vi.fn(),
|
||||
};
|
||||
|
||||
const originalVV = window.visualViewport;
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: mockVV,
|
||||
});
|
||||
|
||||
return {
|
||||
mockVV,
|
||||
listeners,
|
||||
/**
|
||||
* Simulate a visual viewport change event (e.g. keyboard open/close).
|
||||
* Updates the mock dimensions and fires all registered listeners.
|
||||
*/
|
||||
simulateChange: (newVV: { width?: number; height?: number; offsetTop?: number; offsetLeft?: number }) => {
|
||||
Object.assign(mockVV, {
|
||||
width: newVV.width ?? mockVV.width,
|
||||
height: newVV.height ?? mockVV.height,
|
||||
offsetTop: newVV.offsetTop ?? mockVV.offsetTop,
|
||||
offsetLeft: newVV.offsetLeft ?? mockVV.offsetLeft,
|
||||
});
|
||||
// Fire resize listeners (component subscribes to "resize" on visualViewport)
|
||||
for (const handler of listeners["resize"] ?? []) {
|
||||
handler();
|
||||
}
|
||||
},
|
||||
cleanup: () => {
|
||||
if (originalVV === undefined) {
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
} else {
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: originalVV,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it("uses visualViewport dimensions for positioning when available", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
// Simulate a mobile phone with virtual keyboard open:
|
||||
// window is 375×812, but visual viewport shrinks to 375×350
|
||||
const { cleanup: vvCleanup } = setupVisualViewportMock({
|
||||
width: 375,
|
||||
height: 350,
|
||||
offsetTop: 462, // 812 - 350 = 462 (keyboard pushed viewport up)
|
||||
offsetLeft: 0,
|
||||
});
|
||||
|
||||
vi.spyOn(window, "innerWidth", "get").mockReturnValue(375);
|
||||
vi.spyOn(window, "innerHeight", "get").mockReturnValue(812);
|
||||
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));
|
||||
|
||||
// Trigger button in the middle of the page at y=500
|
||||
// In the visible viewport: triggerTop = 500 - 462 = 38, triggerBottom = 536 - 462 = 74
|
||||
const restore = setupBoundingRectMock({
|
||||
top: 500,
|
||||
left: 20,
|
||||
bottom: 536,
|
||||
width: 335,
|
||||
height: 36,
|
||||
right: 355,
|
||||
x: 20,
|
||||
y: 500,
|
||||
} as DOMRect);
|
||||
|
||||
try {
|
||||
render(
|
||||
<CustomModelDropdown
|
||||
label="Executor Model"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
models={MOCK_MODELS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Executor Model" }));
|
||||
|
||||
const portal = await screen.findByTestId("model-combobox-portal");
|
||||
const top = parseFloat(portal.style.top);
|
||||
const left = parseFloat(portal.style.left);
|
||||
const maxHeight = parseFloat(portal.style.maxHeight);
|
||||
|
||||
// The dropdown should open downward from the trigger
|
||||
// triggerBottom relative to viewport = 74, plus gap = 4, plus offsetTop = 462 → top = 540
|
||||
expect(top).toBe(540);
|
||||
// left = max(20 - 0, 16) + 0 = 20
|
||||
expect(left).toBe(20);
|
||||
// maxHeight capped by visual viewport height: min(350 - 74 - 16 - 4, 210) = min(256, 210) = 210
|
||||
// (0.6 * 350 = 210)
|
||||
expect(maxHeight).toBe(210);
|
||||
|
||||
// Verify the dropdown bottom (top + maxHeight) stays within visual viewport
|
||||
const vvBottom = 462 + 350; // offsetTop + height = 812
|
||||
expect(top + maxHeight).toBeLessThanOrEqual(vvBottom - 16);
|
||||
} finally {
|
||||
restore();
|
||||
vvCleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("repositions when visual viewport changes (keyboard opens)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
// Start with no keyboard: visual viewport = full window
|
||||
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));
|
||||
|
||||
// Trigger at top area of viewport
|
||||
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="Executor Model"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
models={MOCK_MODELS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Executor Model" }));
|
||||
const portal = await screen.findByTestId("model-combobox-portal");
|
||||
|
||||
const initialTop = parseFloat(portal.style.top);
|
||||
const initialMaxHeight = parseFloat(portal.style.maxHeight);
|
||||
|
||||
// Now simulate keyboard opening: visual viewport shrinks to 250px, pushed up
|
||||
await waitFor(() => {
|
||||
simulateChange({
|
||||
height: 250,
|
||||
offsetTop: 417, // 667 - 250 = 417
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for repositioning to take effect
|
||||
await waitFor(() => {
|
||||
const newTop = parseFloat(portal.style.top);
|
||||
const newMaxHeight = parseFloat(portal.style.maxHeight);
|
||||
// At least one value should have changed
|
||||
expect(newTop !== initialTop || newMaxHeight !== initialMaxHeight).toBe(true);
|
||||
});
|
||||
|
||||
// Verify the new position is within the shrunken visual viewport
|
||||
const newTop = parseFloat(portal.style.top);
|
||||
const newMaxHeight = parseFloat(portal.style.maxHeight);
|
||||
const vvBottom = 417 + 250;
|
||||
expect(newTop + newMaxHeight).toBeLessThanOrEqual(vvBottom + 1); // allow 1px for rounding
|
||||
expect(newTop).toBeGreaterThanOrEqual(16 - 1); // minimum vertical padding
|
||||
} finally {
|
||||
restore();
|
||||
vvCleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps dropdown to stay within visual viewport on small mobile screen", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
// Small mobile: 320px wide, 568px tall viewport
|
||||
const { cleanup: vvCleanup } = setupVisualViewportMock({
|
||||
width: 320,
|
||||
height: 568,
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
});
|
||||
|
||||
vi.spyOn(window, "innerWidth", "get").mockReturnValue(320);
|
||||
vi.spyOn(window, "innerHeight", "get").mockReturnValue(568);
|
||||
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));
|
||||
|
||||
// Trigger near right edge
|
||||
const restore = setupBoundingRectMock({
|
||||
top: 100,
|
||||
left: 200,
|
||||
bottom: 136,
|
||||
width: 110,
|
||||
height: 36,
|
||||
right: 310,
|
||||
x: 200,
|
||||
y: 100,
|
||||
} as DOMRect);
|
||||
|
||||
try {
|
||||
render(
|
||||
<CustomModelDropdown
|
||||
label="Executor Model"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
models={MOCK_MODELS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Executor Model" }));
|
||||
const portal = await screen.findByTestId("model-combobox-portal");
|
||||
|
||||
const left = parseFloat(portal.style.left);
|
||||
const width = parseFloat(portal.style.width);
|
||||
|
||||
// Dropdown must fit within viewport (320px) with 16px padding on each side
|
||||
expect(left).toBeGreaterThanOrEqual(16);
|
||||
expect(left + width).toBeLessThanOrEqual(320 - 16);
|
||||
} finally {
|
||||
restore();
|
||||
vvCleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to window dimensions when visualViewport is unavailable", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
// Remove visualViewport to simulate older browsers
|
||||
const originalVV = window.visualViewport;
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
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: 100,
|
||||
left: 50,
|
||||
bottom: 136,
|
||||
width: 300,
|
||||
height: 36,
|
||||
right: 350,
|
||||
x: 50,
|
||||
y: 100,
|
||||
} as DOMRect);
|
||||
|
||||
try {
|
||||
render(
|
||||
<CustomModelDropdown
|
||||
label="Executor Model"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
models={MOCK_MODELS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Executor Model" }));
|
||||
const portal = await screen.findByTestId("model-combobox-portal");
|
||||
|
||||
// Should still render and be positioned correctly using window dimensions
|
||||
const top = parseFloat(portal.style.top);
|
||||
const left = parseFloat(portal.style.left);
|
||||
const maxHeight = parseFloat(portal.style.maxHeight);
|
||||
|
||||
expect(top).toBe(140); // rect.bottom + gap = 136 + 4
|
||||
expect(left).toBe(50); // trigger left
|
||||
expect(maxHeight).toBeGreaterThan(0);
|
||||
|
||||
// Must stay within window bounds
|
||||
expect(top).toBeGreaterThanOrEqual(16);
|
||||
expect(top + maxHeight).toBeLessThanOrEqual(667 - 16);
|
||||
} finally {
|
||||
restore();
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: originalVV,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("subscribes to visualViewport resize events when available", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
const { mockVV, cleanup: vvCleanup } = setupVisualViewportMock({
|
||||
width: 375,
|
||||
height: 667,
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
});
|
||||
|
||||
const restore = setupBoundingRectMock({
|
||||
top: 100,
|
||||
left: 50,
|
||||
bottom: 136,
|
||||
width: 300,
|
||||
height: 36,
|
||||
right: 350,
|
||||
x: 50,
|
||||
y: 100,
|
||||
} as DOMRect);
|
||||
|
||||
try {
|
||||
render(
|
||||
<CustomModelDropdown
|
||||
label="Executor Model"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
models={MOCK_MODELS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Executor Model" }));
|
||||
await screen.findByTestId("model-combobox-portal");
|
||||
|
||||
// Verify that visualViewport.addEventListener was called for "resize" and "scroll"
|
||||
expect(mockVV.addEventListener).toHaveBeenCalledWith("resize", expect.any(Function));
|
||||
expect(mockVV.addEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));
|
||||
} finally {
|
||||
restore();
|
||||
vvCleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user