import * as React from 'react'; import { Animated, Dimensions, Easing, EmitterSubscription, findNodeHandle, I18nManager, Keyboard, KeyboardEvent as RNKeyboardEvent, LayoutRectangle, NativeEventSubscription, Platform, ScrollView, ScrollViewProps, StyleProp, StyleSheet, View, ViewStyle, Pressable, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import MenuItem from './MenuItem'; import { useInternalTheme } from '../../core/theming'; import type { MD3Elevation, ThemeProp } from '../../types'; import { ElevationLevels } from '../../types'; import { addEventListener } from '../../utils/addEventListener'; import { BackHandler } from '../../utils/BackHandler/BackHandler'; import Portal from '../Portal/Portal'; import Surface from '../Surface'; export type Props = { /** * Whether the Menu is currently visible. */ visible: boolean; /** * The anchor to open the menu from. In most cases, it will be a button that opens the menu. */ anchor: React.ReactNode | { x: number; y: number }; /** * Whether the menu should open at the top of the anchor or at its bottom. * Applied only when anchor is a node, not an x/y position. */ anchorPosition?: 'top' | 'bottom'; /** * Extra margin to add at the top of the menu to account for translucent status bar on Android. * If you are using Expo, we assume translucent status bar and set a height for status bar automatically. * Pass `0` or a custom value to and customize it. * This is automatically handled on iOS. */ statusBarHeight?: number; /** * Callback called when Menu is dismissed. The `visible` prop needs to be updated when this is called. */ onDismiss?: () => void; /** * Accessibility label for the overlay. This is read by the screen reader when the user taps outside the menu. */ overlayAccessibilityLabel?: string; /** * Content of the `Menu`. */ children: React.ReactNode; /** * Style of menu's inner content. */ contentStyle?: Animated.WithAnimatedValue>; style?: StyleProp; /** * Elevation level of the menu's content. Shadow styles are calculated based on this value. Default `backgroundColor` is taken from the corresponding `theme.colors.elevation` property. By default equals `2`. * @supported Available in v5.x with theme version 3 */ elevation?: MD3Elevation; /** * Mode of the menu's content. * - `elevated` - Surface with a shadow and background color corresponding to set `elevation` value. * - `flat` - Surface without a shadow, with the background color corresponding to set `elevation` value. * * @supported Available in v5.x with theme version 3 */ mode?: 'flat' | 'elevated'; /** * @optional */ theme?: ThemeProp; /** * Inner ScrollView prop */ keyboardShouldPersistTaps?: ScrollViewProps['keyboardShouldPersistTaps']; /** * testID to be used on tests. */ testID?: string; }; // Minimum padding between the edge of the screen and the menu const SCREEN_INDENT = 8; // From https://material.io/design/motion/speed.html#duration const ANIMATION_DURATION = 250; // From the 'Standard easing' section of https://material.io/design/motion/speed.html#easing const EASING = Easing.bezier(0.4, 0, 0.2, 1); const WINDOW_LAYOUT = Dimensions.get('window'); const DEFAULT_ELEVATION: MD3Elevation = 2; export const ELEVATION_LEVELS_MAP = Object.values( ElevationLevels ) as ElevationLevels[]; const DEFAULT_MODE = 'elevated'; const focusFirstDOMNode = (el: View | null | undefined) => { if (el && Platform.OS === 'web') { // When in the browser, we want to focus the first focusable item on toggle // For example, when menu is shown, focus the first item in the menu // And when menu is dismissed, send focus back to the button to resume tabbing const node: any = findNodeHandle(el); const focusableNode = node.querySelector( // This is a rough list of selectors that can be focused 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); focusableNode?.focus(); } }; const isCoordinate = (anchor: any): anchor is { x: number; y: number } => !React.isValidElement(anchor) && typeof anchor?.x === 'number' && typeof anchor?.y === 'number'; const isBrowser = () => Platform.OS === 'web' && 'document' in global; /** * Menus display a list of choices on temporary elevated surfaces. Their placement varies based on the element that opens them. * * ## Usage * ```js * import * as React from 'react'; * import { View } from 'react-native'; * import { Button, Menu, Divider, PaperProvider } from 'react-native-paper'; * * const MyComponent = () => { * const [visible, setVisible] = React.useState(false); * * const openMenu = () => setVisible(true); * * const closeMenu = () => setVisible(false); * * return ( * * * Show menu}> * {}} title="Item 1" /> * {}} title="Item 2" /> * * {}} title="Item 3" /> * * * * ); * }; * * export default MyComponent; * ``` * * ### Note * When using `Menu` within a React Native's `Modal` component, you need to wrap all * `Modal` contents within a `PaperProvider` in order for the menu to show. This * wrapping is not necessary if you use Paper's `Modal` instead. */ const Menu = ({ visible, statusBarHeight, overlayAccessibilityLabel = 'Close menu', testID = 'menu', anchor, onDismiss, anchorPosition, contentStyle, style, elevation = DEFAULT_ELEVATION, mode = DEFAULT_MODE, children, theme: themeOverrides, keyboardShouldPersistTaps, }: Props) => { const theme = useInternalTheme(themeOverrides); const insets = useSafeAreaInsets(); const [rendered, setRendered] = React.useState(visible); const [left, setLeft] = React.useState(0); const [top, setTop] = React.useState(0); const [menuLayout, setMenuLayout] = React.useState({ width: 0, height: 0 }); const [anchorLayout, setAnchorLayout] = React.useState({ width: 0, height: 0, }); const [windowLayout, setWindowLayout] = React.useState({ width: WINDOW_LAYOUT.width, height: WINDOW_LAYOUT.height, }); const opacityAnimationRef = React.useRef(new Animated.Value(0)); const scaleAnimationRef = React.useRef(new Animated.ValueXY({ x: 0, y: 0 })); const keyboardHeightRef = React.useRef(0); const prevVisible = React.useRef(null); const anchorRef = React.useRef(null); const menuRef = React.useRef(null); const prevRendered = React.useRef(false); const keyboardDidShow = React.useCallback((e: RNKeyboardEvent) => { const keyboardHeight = e.endCoordinates.height; keyboardHeightRef.current = keyboardHeight; }, []); const keyboardDidHide = React.useCallback(() => { keyboardHeightRef.current = 0; }, []); const keyboardDidShowListenerRef: React.MutableRefObject< EmitterSubscription | undefined > = React.useRef(); const keyboardDidHideListenerRef: React.MutableRefObject< EmitterSubscription | undefined > = React.useRef(); const backHandlerSubscriptionRef: React.MutableRefObject< NativeEventSubscription | undefined > = React.useRef(); const dimensionsSubscriptionRef: React.MutableRefObject< NativeEventSubscription | undefined > = React.useRef(); const handleDismiss = React.useCallback(() => { if (visible) { onDismiss?.(); } }, [onDismiss, visible]); const handleKeypress = React.useCallback( (e: KeyboardEvent) => { if (e.key === 'Escape') { onDismiss?.(); } }, [onDismiss] ); const removeListeners = React.useCallback(() => { backHandlerSubscriptionRef.current?.remove(); dimensionsSubscriptionRef.current?.remove(); isBrowser() && document.removeEventListener('keyup', handleKeypress); }, [handleKeypress]); const attachListeners = React.useCallback(() => { backHandlerSubscriptionRef.current = addEventListener( BackHandler, 'hardwareBackPress', handleDismiss ); dimensionsSubscriptionRef.current = addEventListener( Dimensions, 'change', handleDismiss ); Platform.OS === 'web' && document.addEventListener('keyup', handleKeypress); }, [handleDismiss, handleKeypress]); const measureMenuLayout = () => new Promise((resolve) => { if (menuRef.current) { menuRef.current.measureInWindow((x, y, width, height) => { resolve({ x, y, width, height }); }); } }); const measureAnchorLayout = React.useCallback( () => new Promise((resolve) => { if (isCoordinate(anchor)) { resolve({ x: anchor.x, y: anchor.y, width: 0, height: 0 }); return; } if (anchorRef.current) { anchorRef.current.measureInWindow((x, y, width, height) => { resolve({ x, y, width, height }); }); } }), [anchor] ); const show = React.useCallback(async () => { const windowLayoutResult = Dimensions.get('window'); const [menuLayoutResult, anchorLayoutResult] = await Promise.all([ measureMenuLayout(), measureAnchorLayout(), ]); // When visible is true for first render // native views can be still not rendered and // measureMenuLayout/measureAnchorLayout functions // return wrong values e.g { x:0, y: 0, width: 0, height: 0 } // so we have to wait until views are ready // and rerun this function to show menu if ( !windowLayoutResult.width || !windowLayoutResult.height || !menuLayoutResult.width || !menuLayoutResult.height || (!anchorLayoutResult.width && !isCoordinate(anchor)) || (!anchorLayoutResult.height && !isCoordinate(anchor)) ) { requestAnimationFrame(show); return; } setLeft(anchorLayoutResult.x); setTop(anchorLayoutResult.y); setAnchorLayout({ height: anchorLayoutResult.height, width: anchorLayoutResult.width, }); setMenuLayout({ height: menuLayoutResult.height, width: menuLayoutResult.width, }); setWindowLayout({ height: windowLayoutResult.height - keyboardHeightRef.current, width: windowLayoutResult.width, }); attachListeners(); const { animation } = theme; Animated.parallel([ Animated.timing(scaleAnimationRef.current, { toValue: { x: menuLayoutResult.width, y: menuLayoutResult.height }, duration: ANIMATION_DURATION * animation.scale, easing: EASING, useNativeDriver: true, }), Animated.timing(opacityAnimationRef.current, { toValue: 1, duration: ANIMATION_DURATION * animation.scale, easing: EASING, useNativeDriver: true, }), ]).start(({ finished }) => { if (finished) { focusFirstDOMNode(menuRef.current); prevRendered.current = true; } }); }, [anchor, attachListeners, measureAnchorLayout, theme]); const hide = React.useCallback(() => { removeListeners(); const { animation } = theme; Animated.timing(opacityAnimationRef.current, { toValue: 0, duration: ANIMATION_DURATION * animation.scale, easing: EASING, useNativeDriver: true, }).start(({ finished }) => { if (finished) { setMenuLayout({ width: 0, height: 0 }); setRendered(false); prevRendered.current = false; focusFirstDOMNode(anchorRef.current); } }); }, [removeListeners, theme]); const updateVisibility = React.useCallback( async (display: boolean) => { // Menu is rendered in Portal, which updates items asynchronously // We need to do the same here so that the ref is up-to-date await Promise.resolve().then(() => { if (display && !prevRendered.current) { show(); return; } if (!display && prevRendered.current) { hide(); } return; }); }, [hide, show] ); React.useEffect(() => { const opacityAnimation = opacityAnimationRef.current; const scaleAnimation = scaleAnimationRef.current; keyboardDidShowListenerRef.current = Keyboard.addListener( 'keyboardDidShow', keyboardDidShow ); keyboardDidHideListenerRef.current = Keyboard.addListener( 'keyboardDidHide', keyboardDidHide ); return () => { removeListeners(); keyboardDidShowListenerRef.current?.remove(); keyboardDidHideListenerRef.current?.remove(); scaleAnimation.removeAllListeners(); opacityAnimation?.removeAllListeners(); }; }, [removeListeners, keyboardDidHide, keyboardDidShow]); React.useEffect(() => { if (prevVisible.current !== visible) { prevVisible.current = visible; if (visible !== rendered) { setRendered(visible); } } }, [visible, rendered]); React.useEffect(() => { updateVisibility(rendered); }, [rendered, updateVisibility]); // I don't know why but on Android measure function is wrong by 24 const additionalVerticalValue = Platform.select({ android: statusBarHeight ?? insets.top, default: 0, }); // We need to translate menu while animating scale to imitate transform origin for scale animation const positionTransforms = []; let leftTransformation = left; let topTransformation = !isCoordinate(anchorRef.current) && anchorPosition === 'bottom' ? top + anchorLayout.height : top; // Check if menu fits horizontally and if not align it to right. if (left <= windowLayout.width - menuLayout.width - SCREEN_INDENT) { positionTransforms.push({ translateX: scaleAnimationRef.current.x.interpolate({ inputRange: [0, menuLayout.width], outputRange: [-(menuLayout.width / 2), 0], }), }); // Check if menu position has enough space from left side if (leftTransformation < SCREEN_INDENT) { leftTransformation = SCREEN_INDENT; } } else { positionTransforms.push({ translateX: scaleAnimationRef.current.x.interpolate({ inputRange: [0, menuLayout.width], outputRange: [menuLayout.width / 2, 0], }), }); leftTransformation += anchorLayout.width - menuLayout.width; const right = leftTransformation + menuLayout.width; // Check if menu position has enough space from right side if (right > windowLayout.width - SCREEN_INDENT) { leftTransformation = windowLayout.width - SCREEN_INDENT - menuLayout.width; } } // If the menu is larger than available vertical space, // calculate the height of scrollable view let scrollableMenuHeight = 0; // Check if the menu should be scrollable if ( // Check if the menu overflows from bottom side topTransformation >= windowLayout.height - menuLayout.height - SCREEN_INDENT - additionalVerticalValue && // And bottom side of the screen has more space than top side topTransformation <= windowLayout.height - topTransformation ) { // Scrollable menu should be below the anchor (expands downwards) scrollableMenuHeight = windowLayout.height - topTransformation - SCREEN_INDENT - additionalVerticalValue; } else if ( // Check if the menu overflows from bottom side topTransformation >= windowLayout.height - menuLayout.height - SCREEN_INDENT - additionalVerticalValue && // And top side of the screen has more space than bottom side topTransformation >= windowLayout.height - top && // And menu overflows from top side topTransformation <= menuLayout.height - anchorLayout.height + SCREEN_INDENT - additionalVerticalValue ) { // Scrollable menu should be above the anchor (expands upwards) scrollableMenuHeight = topTransformation + anchorLayout.height - SCREEN_INDENT + additionalVerticalValue; } // Scrollable menu max height scrollableMenuHeight = scrollableMenuHeight > windowLayout.height - 2 * SCREEN_INDENT ? windowLayout.height - 2 * SCREEN_INDENT : scrollableMenuHeight; // Menu is typically positioned below the element that generates it // So first check if it fits below the anchor (expands downwards) if ( // Check if menu fits vertically topTransformation <= windowLayout.height - menuLayout.height - SCREEN_INDENT - additionalVerticalValue || // Or if the menu overflows from bottom side (topTransformation >= windowLayout.height - menuLayout.height - SCREEN_INDENT - additionalVerticalValue && // And bottom side of the screen has more space than top side topTransformation <= windowLayout.height - topTransformation) ) { positionTransforms.push({ translateY: scaleAnimationRef.current.y.interpolate({ inputRange: [0, menuLayout.height], outputRange: [-((scrollableMenuHeight || menuLayout.height) / 2), 0], }), }); // Check if menu position has enough space from top side if (topTransformation < SCREEN_INDENT) { topTransformation = SCREEN_INDENT; } } else { positionTransforms.push({ translateY: scaleAnimationRef.current.y.interpolate({ inputRange: [0, menuLayout.height], outputRange: [(scrollableMenuHeight || menuLayout.height) / 2, 0], }), }); topTransformation += anchorLayout.height - (scrollableMenuHeight || menuLayout.height); const bottom = topTransformation + (scrollableMenuHeight || menuLayout.height) + additionalVerticalValue; // Check if menu position has enough space from bottom side if (bottom > windowLayout.height - SCREEN_INDENT) { topTransformation = scrollableMenuHeight === windowLayout.height - 2 * SCREEN_INDENT ? -SCREEN_INDENT * 2 : windowLayout.height - menuLayout.height - SCREEN_INDENT - additionalVerticalValue; } } const shadowMenuContainerStyle = { opacity: opacityAnimationRef.current, transform: [ { scaleX: scaleAnimationRef.current.x.interpolate({ inputRange: [0, menuLayout.width], outputRange: [0, 1], }), }, { scaleY: scaleAnimationRef.current.y.interpolate({ inputRange: [0, menuLayout.height], outputRange: [0, 1], }), }, ], borderRadius: theme.roundness, ...(!theme.isV3 && { elevation: 8 }), ...(scrollableMenuHeight ? { height: scrollableMenuHeight } : {}), }; const positionStyle = { top: isCoordinate(anchor) ? topTransformation : topTransformation + additionalVerticalValue, ...(I18nManager.getConstants().isRTL ? { right: leftTransformation } : { left: leftTransformation }), }; const pointerEvents = visible ? 'box-none' : 'none'; return ( (anchorRef.current = ref)} collapsable={false}> {isCoordinate(anchor) ? null : anchor} {rendered ? ( (menuRef.current = ref)} collapsable={false} accessibilityViewIsModal={visible} style={[styles.wrapper, positionStyle, style]} pointerEvents={pointerEvents} onAccessibilityEscape={onDismiss} testID={`${testID}-view`} > {(scrollableMenuHeight && ( {children} )) || {children}} ) : null} ); }; Menu.Item = MenuItem; const styles = StyleSheet.create({ wrapper: { position: 'absolute', }, shadowMenuContainer: { opacity: 0, paddingVertical: 8, }, pressableOverlay: { ...Platform.select({ web: { cursor: 'auto', }, }), ...StyleSheet.absoluteFillObject, width: '100%', }, }); export default Menu;