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);
|
return optionsList.findIndex((opt) => opt.value === value);
|
||||||
}, [optionsList, 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 getPreferredDropdownHeight = useCallback(() => {
|
||||||
const viewportHeight = window.innerHeight;
|
const { height: viewportHeight } = getEffectiveViewport();
|
||||||
const supportsMatchMedia = typeof window.matchMedia === "function";
|
const supportsMatchMedia = typeof window.matchMedia === "function";
|
||||||
const isSmallMobile = supportsMatchMedia ? window.matchMedia("(max-width: 640px)").matches : false;
|
const isSmallMobile = supportsMatchMedia ? window.matchMedia("(max-width: 640px)").matches : false;
|
||||||
const isMobile = supportsMatchMedia ? window.matchMedia("(max-width: 768px)").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 Math.min(viewportHeight * 0.7, 420);
|
||||||
}
|
}
|
||||||
return 320;
|
return 320;
|
||||||
}, []);
|
}, [getEffectiveViewport]);
|
||||||
|
|
||||||
const updateDropdownPosition = useCallback(() => {
|
const updateDropdownPosition = useCallback(() => {
|
||||||
const trigger = triggerRef.current;
|
const trigger = triggerRef.current;
|
||||||
if (!trigger) return;
|
if (!trigger) return;
|
||||||
|
|
||||||
const rect = trigger.getBoundingClientRect();
|
const rect = trigger.getBoundingClientRect();
|
||||||
const viewportWidth = window.innerWidth;
|
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
const horizontalPadding = 16;
|
const horizontalPadding = 16;
|
||||||
const verticalPadding = 16;
|
const verticalPadding = 16;
|
||||||
const gap = 4;
|
const gap = 4;
|
||||||
const preferredHeight = getPreferredDropdownHeight();
|
const preferredHeight = getPreferredDropdownHeight();
|
||||||
|
|
||||||
// Calculate space below and above the trigger
|
// Calculate space below and above the trigger, relative to the visible viewport.
|
||||||
const spaceBelow = viewportHeight - rect.bottom;
|
// On mobile with a virtual keyboard, offsetTop/offsetLeft shift the origin.
|
||||||
const spaceAbove = rect.top;
|
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 availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160);
|
||||||
const availableAbove = Math.max(spaceAbove - 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 dropdownWidth = Math.min(rect.width, viewportWidth - horizontalPadding * 2);
|
||||||
const left = Math.min(
|
const left = Math.min(
|
||||||
Math.max(rect.left, horizontalPadding),
|
Math.max(triggerLeft, horizontalPadding),
|
||||||
viewportWidth - horizontalPadding - dropdownWidth,
|
viewportWidth - horizontalPadding - dropdownWidth,
|
||||||
);
|
) + offsetLeft;
|
||||||
const top = openUpward
|
const top = openUpward
|
||||||
? Math.max(verticalPadding, rect.top - maxHeight - gap)
|
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
|
||||||
: Math.min(rect.bottom + gap, viewportHeight - verticalPadding - maxHeight);
|
: Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight);
|
||||||
|
|
||||||
setDropdownPosition({
|
setDropdownPosition({
|
||||||
top,
|
top,
|
||||||
@@ -236,7 +262,7 @@ export function CustomModelDropdown({
|
|||||||
width: dropdownWidth,
|
width: dropdownWidth,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
});
|
});
|
||||||
}, [getPreferredDropdownHeight]);
|
}, [getEffectiveViewport, getPreferredDropdownHeight]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPortalRoot(document.body);
|
setPortalRoot(document.body);
|
||||||
@@ -263,6 +289,8 @@ export function CustomModelDropdown({
|
|||||||
}, [isOpen, updateDropdownPosition]);
|
}, [isOpen, updateDropdownPosition]);
|
||||||
|
|
||||||
// Keep portaled dropdown anchored during viewport and container scrolling.
|
// Keep portaled dropdown anchored during viewport and container scrolling.
|
||||||
|
// Also reposition when the visual viewport changes (mobile virtual keyboard,
|
||||||
|
// pinch-zoom, etc.).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
@@ -271,9 +299,20 @@ export function CustomModelDropdown({
|
|||||||
window.addEventListener("resize", handleReposition);
|
window.addEventListener("resize", handleReposition);
|
||||||
window.addEventListener("scroll", handleReposition, true);
|
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 () => {
|
return () => {
|
||||||
window.removeEventListener("resize", handleReposition);
|
window.removeEventListener("resize", handleReposition);
|
||||||
window.removeEventListener("scroll", handleReposition, true);
|
window.removeEventListener("scroll", handleReposition, true);
|
||||||
|
if (vv) {
|
||||||
|
vv.removeEventListener("resize", handleReposition);
|
||||||
|
vv.removeEventListener("scroll", handleReposition);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [isOpen, updateDropdownPosition]);
|
}, [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