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 (
+
+
+
+ );
+ }
+
+ 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 (
+
+
+
+ );
+ }
+
+ 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();
+ }
+});