diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx index 96dd4096b7..4a354bae91 100644 --- a/src/components/Menu/Menu.tsx +++ b/src/components/Menu/Menu.tsx @@ -117,6 +117,9 @@ 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 +383,9 @@ const Menu = ({ }); setWindowLayout({ - height: windowLayoutResult.height - keyboardHeightRef.current, + height: + windowLayoutResult.height - + getKeyboardHeight(keyboardHeightRef.current), width: windowLayoutResult.width, }); @@ -461,6 +466,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 c35880b48f..8c0d12a520 100644 --- a/src/components/__tests__/Menu.test.tsx +++ b/src/components/__tests__/Menu.test.tsx @@ -1,4 +1,4 @@ -import { Dimensions, StyleSheet, View } from 'react-native'; +import { Dimensions, Keyboard, StyleSheet, View } from 'react-native'; import { expect, it, jest } from '@jest/globals'; import { act, screen, waitFor } from '@testing-library/react-native'; @@ -234,3 +234,70 @@ 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(); +});