UNPKG

@fto-consult/expo-ui

Version:

Bibliothèque de composants UI Expo,react-native

579 lines (527 loc) • 18.6 kB
import React,{BaseComponent as AppComponent} from '$react'; import View from "$ecomponents/View"; import { ScrollView } from 'react-native'; import { Platform, StyleSheet, Animated, Dimensions, Easing, I18nManager, TouchableWithoutFeedback, findNodeHandle, } from 'react-native'; import BackHandler from "$ecomponents/BackHandler"; import PropTypes from "prop-types"; import { withTheme,Surface,Portal} from 'react-native-paper'; import { NativeModules} from 'react-native'; import {defaultDecimal,extendObj} from "$cutils"; import theme,{StylePropTypes} from "$theme"; import APP from "$capp/instance"; import MenuItem from "./Item"; import { MIN_WIDTH } from './utils'; const RESIZE_PAGE = APP.EVENTS.RESIZE_PAGE; const estimatedStatusBarHeight = NativeModules.NativeUnimoduleProxy?.modulesConstants?.ExponentConstants ?.statusBarHeight ?? 0; const APPROX_STATUSBAR_HEIGHT = Platform.select({ android: estimatedStatusBarHeight, ios: Platform.Version < 11 ? estimatedStatusBarHeight : 0, }); // 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); class _Menu extends AppComponent { static Item = MenuItem; static defaultProps = { statusBarHeight: APPROX_STATUSBAR_HEIGHT, overlayAccessibilityLabel: 'Close menu', }; constructor(props){ super(props); extendObj(this.state,{ top: 0, left: 0, menuLayout: { width: 0, height: 0 }, anchorLayout: { width: 0, height: 0 }, opacityAnimation: new Animated.Value(0), scaleAnimation: new Animated.ValueXY({ x: 0, y: 0 }), }); Object.defineProperties(this._events,{ RESIZE_PAGE : { value : this.handleDismiss.bind(this), } }); } componentDidUpdate(prevProps) { if (prevProps.visible !== this.props.visible) { this.updateVisibility(); } } componentWillUnmount() { super.componentWillUnmount(); this.removeListeners(); this.menu = null; this.anchor = null; } anchor = null; backHandlerSubscription; measureMenuLayout = () => new Promise((resolve) => { if (this.menu) { this.menu.measureInWindow((x, y, width, height) => { resolve({ x, y, width:Math.max(width,defaultDecimal(this.props.minWidth)), height }); }); } }); measureAnchorLayout = () => new Promise((resolve) => { if (this.anchor) { this.anchor.measureInWindow((x, y, width, height) => { resolve({ x, y, width, height }); }); } }); updateVisibility = async () => { // _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(); if (this.props.visible) { this.show(); } else { this.hide(); } }; isBrowser = () => Platform.OS === 'web' && 'document' in global; focusFirstDOMNode = (el) => { if (el && this.isBrowser()) { // 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 = 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(); } }; handleDismiss = () => { this.hide(()=>{ if (this.props.visible) { this.props.onDismiss(); } }); return true; }; handleKeypress = (e) => { if (e.key === 'Escape') { this.props.onDismiss(); } }; attachListeners = () => { this.backHandlerSubscription = BackHandler.addEventListener( 'hardwareBackPress', this.handleDismiss ); APP.on(RESIZE_PAGE,this._events.RESIZE_PAGE); this.isBrowser() && document.addEventListener('keyup', this.handleKeypress); }; removeListeners = () => { if (this.backHandlerSubscription?.remove) { this.backHandlerSubscription.remove(); } else { BackHandler.removeEventListener('hardwareBackPress', this.handleDismiss); } APP.off(RESIZE_PAGE,this._events.RESIZE_PAGE); this.clearEvents(); this.isBrowser() && document.removeEventListener('keyup', this.handleKeypress); }; show = async () => { if(!this._isMounted()) return; const windowLayout = Dimensions.get('window'); const [menuLayout, anchorLayout] = await Promise.all([ this.measureMenuLayout(), this.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 ( !windowLayout.width || !windowLayout.height || !menuLayout.width || !menuLayout.height || (!anchorLayout.width) || (!anchorLayout.height) ) { requestAnimationFrame(this.show); return; } this.setState( () => ({ left: anchorLayout.x, top: anchorLayout.y, anchorLayout: { height: anchorLayout.height, width: anchorLayout.width, }, menuLayout: { width: menuLayout.width, height: menuLayout.height, }, }), () => { this.attachListeners(); const animation = theme.animation; Animated.parallel([ Animated.timing(this.state.scaleAnimation, { toValue: { x: menuLayout.width, y: menuLayout.height }, duration: ANIMATION_DURATION * animation.scale, easing: EASING, useNativeDriver: true, }), Animated.timing(this.state.opacityAnimation, { toValue: 1, duration: ANIMATION_DURATION * animation.scale, easing: EASING, useNativeDriver: true, }), ]).start(({ finished }) => { if (finished) { this.focusFirstDOMNode(this.menu); } }); } ); }; hide = (cb) => { this.removeListeners(); if(!this._isMounted()) return; const animation = theme.animation; Animated.timing(this.state.opacityAnimation, { toValue: 0, duration: ANIMATION_DURATION * animation.scale, easing: EASING, useNativeDriver: true, }).start(({ finished }) => { if (finished) { this.setState({ menuLayout: { width: 0, height: 0 }},(e)=>{if(typeof cb ==='function') cb();}); this.state.scaleAnimation.setValue({ x: 0, y: 0 }); this.focusFirstDOMNode(this.anchor); } }); }; render() { const { visible, anchor, contentStyle, style, children, statusBarHeight, onDismiss, withScrollView:canHandleScroll, overlayAccessibilityLabel, sameWidth, minWidth:customMinWidth, } = this.props; const testID = defaultStr(this.props.testID,"RN_MainMenuComponent"); const { menuLayout, anchorLayout, opacityAnimation, scaleAnimation, } = this.state; const minWidth = defaultDecimal(customMinWidth); const rendered = this.props.visible; let { left, top } = this.state; // I don't know why but on Android measure function is wrong by 24 const additionalVerticalValue = Platform.select({ android: statusBarHeight, default: 0, }); const scaleTransforms = [ { scaleX: scaleAnimation.x.interpolate({ inputRange: [0, menuLayout.width], outputRange: [0, 1], }), }, { scaleY: scaleAnimation.y.interpolate({ inputRange: [0, menuLayout.height], outputRange: [0, 1], }), }, ]; const windowLayout = Dimensions.get('window'); // We need to translate menu while animating scale to imitate transform origin for scale animation const positionTransforms = []; // Check if menu fits horizontally and if not align it to right. if (left <= windowLayout.width - menuLayout.width - SCREEN_INDENT) { positionTransforms.push({ translateX: scaleAnimation.x.interpolate({ inputRange: [0, menuLayout.width], outputRange: [-(menuLayout.width / 2), 0], }), }); // Check if menu position has enough space from left side if (left < SCREEN_INDENT) { left = SCREEN_INDENT; } } else { positionTransforms.push({ translateX: scaleAnimation.x.interpolate({ inputRange: [0, menuLayout.width], outputRange: [menuLayout.width / 2, 0], }), }); left += anchorLayout.width - menuLayout.width; const right = left + menuLayout.width; // Check if menu position has enough space from right side if (right > windowLayout.width - SCREEN_INDENT) { left = windowLayout.width - SCREEN_INDENT - menuLayout.width; } } const withScrollView = canHandleScroll !== false? true : false; // 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 top >= windowLayout.height - menuLayout.height - SCREEN_INDENT - additionalVerticalValue && // And bottom side of the screen has more space than top side top <= windowLayout.height - top ) { // Scrollable menu should be below the anchor (expands downwards) if(withScrollView){ scrollableMenuHeight = windowLayout.height - top - SCREEN_INDENT - additionalVerticalValue; } } else if ( // Check if the menu overflows from bottom side top >= windowLayout.height - menuLayout.height - SCREEN_INDENT - additionalVerticalValue && // And top side of the screen has more space than bottom side top >= windowLayout.height - top && // And menu overflows from top side top <= menuLayout.height - anchorLayout.height + SCREEN_INDENT - additionalVerticalValue ) { // Scrollable menu should be above the anchor (expands upwards) if(withScrollView){ scrollableMenuHeight = top + anchorLayout.height - SCREEN_INDENT + additionalVerticalValue; } } // Scrollable menu max height if(withScrollView){ 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 top <= windowLayout.height - menuLayout.height - SCREEN_INDENT - additionalVerticalValue || // Or if the menu overflows from bottom side (top >= windowLayout.height - menuLayout.height - SCREEN_INDENT - additionalVerticalValue && // And bottom side of the screen has more space than top side top <= windowLayout.height - top) ) { positionTransforms.push({ translateY: scaleAnimation.y.interpolate({ inputRange: [0, menuLayout.height], outputRange: [-((scrollableMenuHeight || menuLayout.height) / 2), 0], }), }); // Check if menu position has enough space from top side if (top < SCREEN_INDENT) { top = SCREEN_INDENT; } } else { positionTransforms.push({ translateY: scaleAnimation.y.interpolate({ inputRange: [0, menuLayout.height], outputRange: [(scrollableMenuHeight || menuLayout.height) / 2, 0], }), }); top += anchorLayout.height - (scrollableMenuHeight || menuLayout.height); const bottom = top + (scrollableMenuHeight || menuLayout.height) + additionalVerticalValue; // Check if menu position has enough space from bottom side if (bottom > windowLayout.height - SCREEN_INDENT) { top = scrollableMenuHeight === windowLayout.height - 2 * SCREEN_INDENT ? -SCREEN_INDENT * 2 : windowLayout.height - menuLayout.height - SCREEN_INDENT - additionalVerticalValue; } } const shadowMenuContainerStyle = { opacity: opacityAnimation, transform: scaleTransforms, borderRadius: theme.roundness, ...(scrollableMenuHeight && withScrollView ? { height: scrollableMenuHeight } : {}), }; //- (sameWidth ? anchorLayout.height : 0) const positionStyle = { top: top + additionalVerticalValue, ...(I18nManager.isRTL ? { right: left } : { left }), }; if(sameWidth){ const bottom = windowLayout.height - SCREEN_INDENT - menuLayout.height - anchorLayout.height; if(bottom >= top - SCREEN_INDENT){ positionStyle.top += anchorLayout.height; } } if(positionStyle.left < SCREEN_INDENT){ positionStyle.left = SCREEN_INDENT; } if(positionStyle.top < SCREEN_INDENT){ positionStyle.top = SCREEN_INDENT; } const maxMenuHeight = windowLayout.height - top - SCREEN_INDENT; const maxHeight = maxMenuHeight >=0 ? Math.max(Math.max(maxMenuHeight,menuLayout.height),150) : windowLayout.height - SCREEN_INDENT*2; const contentContainerStyle = maxMenuHeight > SCREEN_INDENT ? {maxHeight} : undefined; const hiddenStyle = !rendered ? {display:'none',width:0,opacity:0} : null; return ( <View testID = {testID} ref={(ref) => { this.anchor = ref; }} collapsable={false} style = {{backgroundColor:'transparent'}} > {anchor} {true ? ( <Portal> {rendered ? <TouchableWithoutFeedback testID={testID+"_Menu_TouchableWithoutFeedBack"} aria-label={overlayAccessibilityLabel} //role="button" onPress={onDismiss} style = {[hiddenStyle]} > <View style={[StyleSheet.absoluteFill,{flex:1,backgroundColor:'transparent'}]} testID={testID+"_Backdrop"} /> </TouchableWithoutFeedback>:null} <View testID = {testID+"_MenuContentContainer"} ref={(ref) => { this.menu = ref; }} collapsable={false} accessibilityViewIsModal={visible} style={[styles.wrapper, positionStyle, style,hiddenStyle]} onAccessibilityEscape={onDismiss} > {rendered?<Animated.View style={{ transform: positionTransforms }} testID={testID+"_Animated"}> <Surface elevation = {5} testID= {testID+"_MenuContent"} style={ [ styles.shadowMenuContainer, shadowMenuContainerStyle, contentStyle, {backgroundColor : theme.colors.surface}, minWidth && {minWidth : Math.max(minWidth,MIN_WIDTH)}, sameWidth && anchorLayout.width ? {width:Math.max(anchorLayout.width,minWidth,MIN_WIDTH)} : undefined, ] } > {((scrollableMenuHeight|| contentContainerStyle) && (<ScrollView contentContainerStyle={[styles.contentContainerStyle,contentContainerStyle]} testID={testID+"_ScrollView"}>{children}</ScrollView> )) || children} </Surface> </Animated.View> : null} </View> </Portal> ) : null} </View> ); } } const styles = StyleSheet.create({ wrapper: { position: 'absolute', }, shadowMenuContainer: { opacity: 0, paddingVertical: 8, elevation: 8, }, contentContainerStyle : { paddingBottom : 5, } }); const Menu = withTheme(_Menu); export default Menu; Menu.propTypes = { minWidth : PropTypes.number,///la longueur minimale du menu withScrollView : PropTypes.bool, //si le contenu est scrollable /** * Whether the _Menu is currently visible. */ visible: PropTypes.bool, /** * The anchor to open the menu from. In most cases, it will be a button that opens the menu. */ anchor: PropTypes.any, /** * 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: PropTypes.number, /** * Callback called when _Menu is dismissed. The `visible` prop needs to be updated when this is called. */ onDismiss: PropTypes.func, /** * Accessibility label for the overlay. This is read by the screen reader when the user taps outside the menu. */ overlayAccessibilityLabel: PropTypes.string, /** * Content of the `_Menu`. */ children: PropTypes.node, /** * Style of menu's inner content. */ contentStyle : StylePropTypes, style : StylePropTypes, }