diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx index b862a5de88..8482a148ca 100644 --- a/src/components/Menu/Menu.tsx +++ b/src/components/Menu/Menu.tsx @@ -117,6 +117,8 @@ const EASING = Easing.bezier(0.4, 0, 0.2, 1); const WINDOW_LAYOUT = Dimensions.get('window'); +const getKeyboardHeight = (fallback: number) => + Keyboard.metrics?.()?.height ?? fallback; const DEFAULT_ELEVATION: Elevation = 2; const DEFAULT_MODE = 'elevated'; @@ -380,7 +382,9 @@ const Menu = ({ }); setWindowLayout({ - height: windowLayoutResult.height - keyboardHeightRef.current, + height: + windowLayoutResult.height - + getKeyboardHeight(keyboardHeightRef.current), width: windowLayoutResult.width, }); @@ -461,6 +465,8 @@ const Menu = ({ 'keyboardDidHide', keyboardDidHide ); + // iOS does not replay keyboardDidShow for a keyboard that is already open. + keyboardHeightRef.current = getKeyboardHeight(keyboardHeightRef.current); return () => { removeListeners(); diff --git a/src/components/__tests__/Menu.test.tsx b/src/components/__tests__/Menu.test.tsx index 7b02890f78..5b1f9f5207 100644 --- a/src/components/__tests__/Menu.test.tsx +++ b/src/components/__tests__/Menu.test.tsx @@ -1,3 +1,6 @@ +import { Dimensions, Keyboard, StyleSheet, View } from 'react-native'; + +import { expect, it, jest } from '@jest/globals'; import { Dimensions, Platform, StyleSheet, View } from 'react-native'; import { @@ -341,3 +344,120 @@ it('renders menu with mode "flat"', async () => { expect(styles).not.toHaveProperty('shadowColor'); expect(styles).not.toHaveProperty('shadowOpacity'); }); + +it('accounts for a keyboard that is already open when the menu mounts', async () => { + const testID = 'keyboard-aware-menu'; + const dimensionsSpy = jest.spyOn(Dimensions, 'get').mockReturnValue({ + width: 400, + height: 800, + scale: 2, + fontScale: 2, + }); + const keyboardMetricsSpy = jest.spyOn(Keyboard, 'metrics').mockReturnValue({ + screenX: 0, + screenY: 500, + width: 400, + height: 300, + }); + + let measureCalls = 0; + const measureSpy = jest + .spyOn(View.prototype, 'measureInWindow') + .mockImplementation((fn) => { + measureCalls += 1; + if (measureCalls % 2 === 1) { + // Menu content is tall enough to overflow the remaining window. + fn(100, 100, 200, 400); + } else { + fn(100, 100, 80, 32); + } + }); + + function makeMenu(visible: boolean) { + return ( + + + Open menu + + } + testID={testID} + > + + + + + ); + } + + const { rerender } = await render(makeMenu(false)); + + await act(async () => { + await rerender(makeMenu(true)); + await Promise.resolve(); + }); + + await waitFor(() => { + // eslint-disable-next-line no-restricted-syntax -- layout height is not otherwise exposed. + const styles = StyleSheet.flatten(screen.getByTestId(testID).props.style); + // Available height is window (800) minus keyboard (300) minus top (100) minus SCREEN_INDENT (8). + expect(styles).toEqual(expect.objectContaining({ height: 392 })); + }); + + measureSpy.mockRestore(); + keyboardMetricsSpy.mockRestore(); + dimensionsSpy.mockRestore(); +}); +it('does not crash when Keyboard.metrics is unavailable (react-native-web)', async () => { + const testID = 'no-metrics-menu'; + const original = Keyboard.metrics; + // @ts-expect-error simulate react-native-web, where metrics doesn't exist + Keyboard.metrics = undefined; + + const dimensionsSpy = jest.spyOn(Dimensions, 'get').mockReturnValue({ + width: 400, + height: 800, + scale: 2, + fontScale: 2, + }); + const measureSpy = jest + .spyOn(View.prototype, 'measureInWindow') + .mockImplementation((fn) => fn(100, 100, 80, 32)); + + function makeMenu(visible: boolean) { + return ( + + Open menu} + testID={testID} + > + + + + ); + } + + try { + const { rerender, toJSON } = await render(makeMenu(false)); + + await act(async () => { + await rerender(makeMenu(true)); + await Promise.resolve(); + }); + + await waitFor(() => { + const json = JSON.stringify(toJSON()); + expect(json).toContain('"left":100'); + expect(json).toContain('"top":100'); + }); + } finally { + Keyboard.metrics = original; + measureSpy.mockRestore(); + dimensionsSpy.mockRestore(); + } +});