react-native-paper
Version:
Material design for React Native
510 lines (496 loc) • 21.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = exports.ELEVATION_LEVELS_MAP = void 0;
var React = _interopRequireWildcard(require("react"));
var _reactNative = require("react-native");
var _reactNativeSafeAreaContext = require("react-native-safe-area-context");
var _MenuItem = _interopRequireDefault(require("./MenuItem"));
var _theming = require("../../core/theming");
var _types = require("../../types");
var _addEventListener = require("../../utils/addEventListener");
var _BackHandler = require("../../utils/BackHandler/BackHandler");
var _Portal = _interopRequireDefault(require("../Portal/Portal"));
var _Surface = _interopRequireDefault(require("../Surface"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
// 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 = _reactNative.Easing.bezier(0.4, 0, 0.2, 1);
const WINDOW_LAYOUT = _reactNative.Dimensions.get('window');
const DEFAULT_ELEVATION = 2;
const ELEVATION_LEVELS_MAP = Object.values(_types.ElevationLevels);
exports.ELEVATION_LEVELS_MAP = ELEVATION_LEVELS_MAP;
const DEFAULT_MODE = 'elevated';
const focusFirstDOMNode = el => {
if (el && _reactNative.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 = (0, _reactNative.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 === null || focusableNode === void 0 ? void 0 : focusableNode.focus();
}
};
const isCoordinate = anchor => ! /*#__PURE__*/React.isValidElement(anchor) && typeof (anchor === null || anchor === void 0 ? void 0 : anchor.x) === 'number' && typeof (anchor === null || anchor === void 0 ? void 0 : anchor.y) === 'number';
const isBrowser = () => _reactNative.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 (
* <PaperProvider>
* <View
* style={{
* paddingTop: 50,
* flexDirection: 'row',
* justifyContent: 'center',
* }}>
* <Menu
* visible={visible}
* onDismiss={closeMenu}
* anchor={<Button onPress={openMenu}>Show menu</Button>}>
* <Menu.Item onPress={() => {}} title="Item 1" />
* <Menu.Item onPress={() => {}} title="Item 2" />
* <Divider />
* <Menu.Item onPress={() => {}} title="Item 3" />
* </Menu>
* </View>
* </PaperProvider>
* );
* };
*
* 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 = _ref => {
let {
visible,
statusBarHeight,
overlayAccessibilityLabel = 'Close menu',
testID = 'menu',
anchor,
onDismiss,
anchorPosition,
contentStyle,
style,
elevation = DEFAULT_ELEVATION,
mode = DEFAULT_MODE,
children,
theme: themeOverrides,
keyboardShouldPersistTaps
} = _ref;
const theme = (0, _theming.useInternalTheme)(themeOverrides);
const insets = (0, _reactNativeSafeAreaContext.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 _reactNative.Animated.Value(0));
const scaleAnimationRef = React.useRef(new _reactNative.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 => {
const keyboardHeight = e.endCoordinates.height;
keyboardHeightRef.current = keyboardHeight;
}, []);
const keyboardDidHide = React.useCallback(() => {
keyboardHeightRef.current = 0;
}, []);
const keyboardDidShowListenerRef = React.useRef();
const keyboardDidHideListenerRef = React.useRef();
const backHandlerSubscriptionRef = React.useRef();
const dimensionsSubscriptionRef = React.useRef();
const handleDismiss = React.useCallback(() => {
if (visible) {
onDismiss === null || onDismiss === void 0 ? void 0 : onDismiss();
}
}, [onDismiss, visible]);
const handleKeypress = React.useCallback(e => {
if (e.key === 'Escape') {
onDismiss === null || onDismiss === void 0 ? void 0 : onDismiss();
}
}, [onDismiss]);
const removeListeners = React.useCallback(() => {
var _backHandlerSubscript, _dimensionsSubscripti;
(_backHandlerSubscript = backHandlerSubscriptionRef.current) === null || _backHandlerSubscript === void 0 ? void 0 : _backHandlerSubscript.remove();
(_dimensionsSubscripti = dimensionsSubscriptionRef.current) === null || _dimensionsSubscripti === void 0 ? void 0 : _dimensionsSubscripti.remove();
isBrowser() && document.removeEventListener('keyup', handleKeypress);
}, [handleKeypress]);
const attachListeners = React.useCallback(() => {
backHandlerSubscriptionRef.current = (0, _addEventListener.addEventListener)(_BackHandler.BackHandler, 'hardwareBackPress', handleDismiss);
dimensionsSubscriptionRef.current = (0, _addEventListener.addEventListener)(_reactNative.Dimensions, 'change', handleDismiss);
_reactNative.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 = _reactNative.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;
_reactNative.Animated.parallel([_reactNative.Animated.timing(scaleAnimationRef.current, {
toValue: {
x: menuLayoutResult.width,
y: menuLayoutResult.height
},
duration: ANIMATION_DURATION * animation.scale,
easing: EASING,
useNativeDriver: true
}), _reactNative.Animated.timing(opacityAnimationRef.current, {
toValue: 1,
duration: ANIMATION_DURATION * animation.scale,
easing: EASING,
useNativeDriver: true
})]).start(_ref2 => {
let {
finished
} = _ref2;
if (finished) {
focusFirstDOMNode(menuRef.current);
prevRendered.current = true;
}
});
}, [anchor, attachListeners, measureAnchorLayout, theme]);
const hide = React.useCallback(() => {
removeListeners();
const {
animation
} = theme;
_reactNative.Animated.timing(opacityAnimationRef.current, {
toValue: 0,
duration: ANIMATION_DURATION * animation.scale,
easing: EASING,
useNativeDriver: true
}).start(_ref3 => {
let {
finished
} = _ref3;
if (finished) {
setMenuLayout({
width: 0,
height: 0
});
setRendered(false);
prevRendered.current = false;
focusFirstDOMNode(anchorRef.current);
}
});
}, [removeListeners, theme]);
const updateVisibility = React.useCallback(async display => {
// 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 = _reactNative.Keyboard.addListener('keyboardDidShow', keyboardDidShow);
keyboardDidHideListenerRef.current = _reactNative.Keyboard.addListener('keyboardDidHide', keyboardDidHide);
return () => {
var _keyboardDidShowListe, _keyboardDidHideListe;
removeListeners();
(_keyboardDidShowListe = keyboardDidShowListenerRef.current) === null || _keyboardDidShowListe === void 0 ? void 0 : _keyboardDidShowListe.remove();
(_keyboardDidHideListe = keyboardDidHideListenerRef.current) === null || _keyboardDidHideListe === void 0 ? void 0 : _keyboardDidHideListe.remove();
scaleAnimation.removeAllListeners();
opacityAnimation === null || opacityAnimation === void 0 ? void 0 : 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 = _reactNative.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,
...(_reactNative.I18nManager.getConstants().isRTL ? {
right: leftTransformation
} : {
left: leftTransformation
})
};
const pointerEvents = visible ? 'box-none' : 'none';
return /*#__PURE__*/React.createElement(_reactNative.View, {
ref: ref => anchorRef.current = ref,
collapsable: false
}, isCoordinate(anchor) ? null : anchor, rendered ? /*#__PURE__*/React.createElement(_Portal.default, null, /*#__PURE__*/React.createElement(_reactNative.Pressable, {
accessibilityLabel: overlayAccessibilityLabel,
accessibilityRole: "button",
onPress: onDismiss,
style: styles.pressableOverlay
}), /*#__PURE__*/React.createElement(_reactNative.View, {
ref: ref => menuRef.current = ref,
collapsable: false,
accessibilityViewIsModal: visible,
style: [styles.wrapper, positionStyle, style],
pointerEvents: pointerEvents,
onAccessibilityEscape: onDismiss,
testID: `${testID}-view`
}, /*#__PURE__*/React.createElement(_reactNative.Animated.View, {
pointerEvents: pointerEvents,
style: {
transform: positionTransforms
}
}, /*#__PURE__*/React.createElement(_Surface.default, _extends({
mode: mode,
pointerEvents: pointerEvents,
style: [styles.shadowMenuContainer, shadowMenuContainerStyle, theme.isV3 && {
backgroundColor: theme.colors.elevation[ELEVATION_LEVELS_MAP[elevation]]
}, contentStyle]
}, theme.isV3 && {
elevation
}, {
testID: `${testID}-surface`,
theme: theme
}), scrollableMenuHeight && /*#__PURE__*/React.createElement(_reactNative.ScrollView, {
keyboardShouldPersistTaps: keyboardShouldPersistTaps
}, children) || /*#__PURE__*/React.createElement(React.Fragment, null, children))))) : null);
};
Menu.Item = _MenuItem.default;
const styles = _reactNative.StyleSheet.create({
wrapper: {
position: 'absolute'
},
shadowMenuContainer: {
opacity: 0,
paddingVertical: 8
},
pressableOverlay: {
..._reactNative.Platform.select({
web: {
cursor: 'auto'
}
}),
..._reactNative.StyleSheet.absoluteFillObject,
width: '100%'
}
});
var _default = Menu;
exports.default = _default;
//# sourceMappingURL=Menu.js.map