react-native-ui-lib
Version:
<p align="center"> <img src="https://user-images.githubusercontent.com/1780255/105469025-56759000-5ca0-11eb-993d-3568c1fd54f4.png" height="250px" style="display:block"/> </p> <p align="center">UI Toolset & Components Library for React Native</p> <p a
630 lines (552 loc) • 16.4 kB
JavaScript
import _pt from "prop-types";
import _ from 'lodash';
import React, { Component, isValidElement } from 'react';
import { Animated, StyleSheet, AccessibilityInfo, findNodeHandle } from 'react-native';
import { Typography, Spacings, Colors, BorderRadiuses, Shadows } from "../../style";
import { Constants, asBaseComponent } from "../../commons/new";
import View from "../view";
import Text from "../text";
import Image from "../image";
import Modal from "../modal";
import TouchableOpacity from "../touchableOpacity";
const sideTip = require("./assets/hintTipSide.png");
const middleTip = require("./assets/hintTipMiddle.png");
const DEFAULT_COLOR = Colors.primary;
const DEFAULT_HINT_OFFSET = Spacings.s4;
const DEFAULT_EDGE_MARGINS = Spacings.s5;
var TARGET_POSITIONS;
(function (TARGET_POSITIONS) {
TARGET_POSITIONS["LEFT"] = "left";
TARGET_POSITIONS["RIGHT"] = "right";
TARGET_POSITIONS["CENTER"] = "center";
})(TARGET_POSITIONS || (TARGET_POSITIONS = {}));
var HintPositions; // TODO: unify with FeatureHighlightFrame
(function (HintPositions) {
HintPositions["TOP"] = "top";
HintPositions["BOTTOM"] = "bottom";
})(HintPositions || (HintPositions = {}));
/**
* @description: Hint component for displaying a tooltip over wrapped component
* @example: https://github.com/wix/react-native-ui-lib/blob/master/demo/src/screens/componentScreens/HintsScreen.tsx
* @notes: You can either wrap a component or pass a specific targetFrame
* @gif: https://github.com/wix/react-native-ui-lib/blob/master/demo/showcase/Hint/Hint.gif?raw=true
*/
class Hint extends Component {
static propTypes = {
/**
* Control the visibility of the hint
*/
visible: _pt.bool,
/**
* The hint background color
*/
color: _pt.string,
/**
* The hint message
*/
message: _pt.oneOfType([_pt.string, _pt.element]),
/**
* The hint's position
*/
position: _pt.oneOf(["top", "bottom"]),
/**
* Provide custom target position instead of wrapping a child
*/
targetFrame: _pt.shape({
x: _pt.number,
y: _pt.number,
width: _pt.number,
height: _pt.number
}),
/**
* Show side tips instead of the middle tip
*/
useSideTip: _pt.bool,
/**
* The hint's border radius
*/
borderRadius: _pt.number,
/**
* Hint margins from screen edges
*/
edgeMargins: _pt.number,
/**
* Hint offset from target
*/
offset: _pt.number,
/**
* Callback for Hint press
*/
onPress: _pt.func,
/**
* Callback for the background press
*/
onBackgroundPress: _pt.func,
/**
* Color for background overlay (require onBackgroundPress)
*/
backdropColor: _pt.string,
/**
* The hint container width
*/
containerWidth: _pt.number,
/**
* Custom content element to render inside the hint container
*/
customContent: _pt.element,
/**
* Remove all hint's paddings
*/
removePaddings: _pt.bool,
/**
* Enable shadow (for hint with white background only)
*/
enableShadow: _pt.bool,
/**
* The hint's test identifier
*/
testID: _pt.string
};
static displayName = 'Hint';
static defaultProps = {
position: HintPositions.BOTTOM
};
static positions = HintPositions;
targetRef = null;
hintRef = null;
animationDuration = 170;
state = {
targetLayoutInWindow: undefined,
targetLayout: this.props.targetFrame,
hintUnmounted: !this.props.visible
};
visibleAnimated = new Animated.Value(Number(!!this.props.visible));
componentDidUpdate(prevProps) {
if (prevProps.visible !== this.props.visible) {
this.animateHint();
}
}
animateHint = () => {
Animated.timing(this.visibleAnimated, {
toValue: Number(!!this.props.visible),
duration: this.animationDuration,
useNativeDriver: true
}).start(this.toggleAnimationEndedToRemoveHint);
};
toggleAnimationEndedToRemoveHint = () => {
this.setState({
hintUnmounted: !this.props.visible
});
};
focusAccessibilityOnHint = () => {
const {
message
} = this.props;
const targetRefTag = findNodeHandle(this.targetRef);
const hintRefTag = findNodeHandle(this.hintRef);
if (targetRefTag && _.isString(message)) {
AccessibilityInfo.setAccessibilityFocus(targetRefTag);
} else if (hintRefTag) {
AccessibilityInfo.setAccessibilityFocus(hintRefTag);
}
};
setTargetRef = ref => {
this.targetRef = ref;
this.focusAccessibilityOnHint();
};
setHintRef = ref => {
this.hintRef = ref;
this.focusAccessibilityOnHint();
};
onTargetLayout = ({
nativeEvent: {
layout
}
}) => {
if (!_.isEqual(this.state.targetLayout, layout)) {
this.setState({
targetLayout: layout
});
}
if (!this.state.targetLayoutInWindow || this.props.onBackgroundPress) {
setTimeout(() => {
this.targetRef?.measureInWindow((x, y, width, height) => {
const targetLayoutInWindow = {
x,
y,
width,
height
};
this.setState({
targetLayoutInWindow
});
});
});
}
};
getAccessibilityInfo() {
const {
visible,
message
} = this.props;
if (visible && _.isString(message)) {
return {
accessible: true,
accessibilityLabel: `hint: ${message}`
};
}
}
get containerWidth() {
const {
containerWidth = Constants.screenWidth
} = this.props;
return containerWidth;
}
get targetLayout() {
const {
onBackgroundPress,
targetFrame
} = this.props;
const {
targetLayout,
targetLayoutInWindow
} = this.state;
if (targetFrame) {
return targetFrame;
}
return onBackgroundPress ? targetLayoutInWindow : targetLayout;
}
get showHint() {
return !!this.targetLayout;
}
get tipSize() {
return this.useSideTip ? {
width: 14,
height: 7
} : {
width: 20,
height: 7
};
}
get hintOffset() {
const {
offset = DEFAULT_HINT_OFFSET
} = this.props;
return offset;
}
get edgeMargins() {
const {
edgeMargins = DEFAULT_EDGE_MARGINS
} = this.props;
return edgeMargins;
}
get useSideTip() {
const {
useSideTip
} = this.props;
if (!_.isUndefined(useSideTip)) {
return useSideTip;
}
return this.getTargetPositionOnScreen() !== TARGET_POSITIONS.CENTER;
}
getTargetPositionOnScreen() {
if (this.targetLayout?.x && this.targetLayout?.width) {
const targetMidPosition = this.targetLayout.x + this.targetLayout.width / 2;
if (targetMidPosition > this.containerWidth * (2 / 3)) {
return TARGET_POSITIONS.RIGHT;
} else if (targetMidPosition < this.containerWidth * (1 / 3)) {
return TARGET_POSITIONS.LEFT;
}
}
return TARGET_POSITIONS.CENTER;
}
getContainerPosition() {
if (this.targetLayout) {
return {
top: this.targetLayout.y,
left: this.targetLayout.x
};
}
}
getHintPosition() {
const {
position
} = this.props;
const hintPositionStyle = {
alignItems: 'center'
};
if (this.targetLayout?.x) {
hintPositionStyle.left = -this.targetLayout.x;
}
if (position === HintPositions.TOP) {
hintPositionStyle.bottom = 0;
} else if (this.targetLayout?.height) {
hintPositionStyle.top = this.targetLayout.height;
}
const targetPositionOnScreen = this.getTargetPositionOnScreen();
if (targetPositionOnScreen === TARGET_POSITIONS.RIGHT) {
hintPositionStyle.alignItems = Constants.isRTL ? 'flex-start' : 'flex-end';
} else if (targetPositionOnScreen === TARGET_POSITIONS.LEFT) {
hintPositionStyle.alignItems = Constants.isRTL ? 'flex-end' : 'flex-start';
}
return hintPositionStyle;
}
getHintPadding() {
const paddings = {
paddingVertical: this.hintOffset,
paddingHorizontal: this.edgeMargins
};
if (this.useSideTip && this.targetLayout?.x) {
const targetPositionOnScreen = this.getTargetPositionOnScreen();
if (targetPositionOnScreen === TARGET_POSITIONS.LEFT) {
paddings.paddingLeft = this.targetLayout.x;
} else if (targetPositionOnScreen === TARGET_POSITIONS.RIGHT && this.targetLayout?.width) {
paddings.paddingRight = this.containerWidth - this.targetLayout.x - this.targetLayout.width;
}
}
return paddings;
}
getHintAnimatedStyle = () => {
const {
position
} = this.props;
const translateY = position === HintPositions.TOP ? -10 : 10;
return {
opacity: this.visibleAnimated,
transform: [{
translateY: this.visibleAnimated.interpolate({
inputRange: [0, 1],
outputRange: [translateY, 0]
})
}]
};
};
getTipPosition() {
const {
position
} = this.props;
const tipPositionStyle = {};
if (position === HintPositions.TOP) {
tipPositionStyle.bottom = this.hintOffset - this.tipSize.height;
!this.useSideTip ? tipPositionStyle.bottom += 1 : undefined;
} else {
tipPositionStyle.top = this.hintOffset - this.tipSize.height;
}
const layoutWidth = this.targetLayout?.width || 0;
if (this.targetLayout?.x) {
const targetMidWidth = layoutWidth / 2;
const tipMidWidth = this.tipSize.width / 2;
const leftPosition = this.useSideTip ? this.targetLayout.x : this.targetLayout.x + targetMidWidth - tipMidWidth;
const rightPosition = this.useSideTip ? this.containerWidth - this.targetLayout.x - layoutWidth : this.containerWidth - this.targetLayout.x - targetMidWidth - tipMidWidth;
const targetPositionOnScreen = this.getTargetPositionOnScreen();
switch (targetPositionOnScreen) {
case TARGET_POSITIONS.LEFT:
tipPositionStyle.left = Constants.isRTL ? rightPosition : leftPosition;
break;
case TARGET_POSITIONS.RIGHT:
tipPositionStyle.right = Constants.isRTL ? leftPosition : rightPosition;
break;
case TARGET_POSITIONS.CENTER:
default:
tipPositionStyle.left = this.targetLayout.x + targetMidWidth - tipMidWidth;
break;
}
}
return tipPositionStyle;
} // renderOverlay() {
// const {targetLayoutInWindow} = this.state;
// const {onBackgroundPress} = this.props;
// if (targetLayoutInWindow) {
// const containerPosition = this.getContainerPosition();
// return (
// <View
// style={[
// styles.overlay,
// {
// top: containerPosition.top - targetLayoutInWindow.y,
// left: containerPosition.left - targetLayoutInWindow.x,
// },
// ]}
// pointerEvents="box-none"
// >
// {onBackgroundPress && (
// <TouchableWithoutFeedback style={[StyleSheet.absoluteFillObject]} onPress={onBackgroundPress}>
// <View flex />
// </TouchableWithoutFeedback>
// )}
// </View>
// );
// }
// }
renderHintTip() {
const {
position,
color = DEFAULT_COLOR
} = this.props;
const source = this.useSideTip ? sideTip : middleTip;
const flipVertically = position === HintPositions.TOP;
const flipHorizontally = this.getTargetPositionOnScreen() === TARGET_POSITIONS.RIGHT;
const flipStyle = {
transform: [{
scaleY: flipVertically ? -1 : 1
}, {
scaleX: flipHorizontally ? -1 : 1
}]
};
return <Image tintColor={color} source={source} style={[styles.hintTip, this.getTipPosition(), flipStyle]} />;
}
renderContent() {
const {
message,
messageStyle,
icon,
iconStyle,
borderRadius,
color = DEFAULT_COLOR,
customContent,
removePaddings,
enableShadow,
visible,
testID
} = this.props;
return <View testID={`${testID}.message`} row centerV style={[styles.hint, !removePaddings && styles.hintPaddings, visible && enableShadow && styles.containerShadow, {
backgroundColor: color
}, !_.isUndefined(borderRadius) && {
borderRadius
}]} ref={this.setHintRef}>
{customContent}
{!customContent && icon && <Image source={icon} style={[styles.icon, iconStyle]} />}
{!customContent && <Text style={[styles.hintMessage, messageStyle]}>{message}</Text>}
</View>;
}
renderHint() {
const {
onPress,
testID
} = this.props;
const opacity = onPress ? 0.9 : 1.0;
if (this.showHint) {
return <View animated style={[{
width: this.containerWidth
}, styles.animatedContainer, this.getHintPosition(), this.getHintPadding(), this.getHintAnimatedStyle()]} pointerEvents="box-none" testID={testID}>
<TouchableOpacity activeOpacity={opacity} onPress={onPress}>
{this.renderContent()}
</TouchableOpacity>
{this.renderHintTip()}
</View>;
}
}
renderHintContainer() {
const {
style,
...others
} = this.props;
return <View {...others} // this view must be collapsable, don't pass testID or backgroundColor etc'.
collapsable testID={undefined} style={[styles.container, style, this.getContainerPosition()]}>
{this.renderHint()}
</View>;
}
renderMockChildren() {
const {
children
} = this.props;
if (children && React.isValidElement(children)) {
const layout = { ...this.getContainerPosition(),
width: this.targetLayout?.width,
height: this.targetLayout?.height
};
return <View style={[styles.mockChildrenContainer, layout]}>
{React.cloneElement(children, {
collapsable: false,
key: 'mock',
style: [children.props.style, styles.mockChildren]
})}
</View>;
}
}
renderChildren() {
const {
targetFrame
} = this.props;
if (!targetFrame && isValidElement(this.props.children)) {
return React.cloneElement(this.props.children, {
key: 'clone',
collapsable: false,
onLayout: this.onTargetLayout,
ref: this.setTargetRef,
...this.getAccessibilityInfo()
});
}
}
render() {
const {
onBackgroundPress,
backdropColor,
testID
} = this.props;
if (!this.props.visible && this.state.hintUnmounted) {
return this.props.children || null;
}
return <>
{this.renderChildren()}
{onBackgroundPress ? <Modal visible={this.showHint} animationType={backdropColor ? 'fade' : 'none'} overlayBackgroundColor={backdropColor} transparent onBackgroundPress={onBackgroundPress} onRequestClose={onBackgroundPress} testID={`${testID}.modal`}>
{this.renderMockChildren()}
{this.renderHintContainer()}
</Modal> : // this.renderOverlay(),
this.renderHintContainer()}
</>;
}
}
const styles = StyleSheet.create({
container: {
position: 'absolute'
},
mockChildrenContainer: {
position: 'absolute'
},
mockChildren: {
margin: undefined,
marginVertical: undefined,
marginHorizontal: undefined,
marginTop: undefined,
marginRight: undefined,
marginBottom: undefined,
marginLeft: undefined,
top: undefined,
left: undefined,
right: undefined,
bottom: undefined
},
// overlay: {
// position: 'absolute',
// width: Constants.screenWidth,
// height: Constants.screenHeight
// },
animatedContainer: {
position: 'absolute'
},
hintTip: {
position: 'absolute'
},
hint: {
maxWidth: Math.min(Constants.screenWidth - 2 * Spacings.s4, 400),
borderRadius: BorderRadiuses.br60,
backgroundColor: DEFAULT_COLOR
},
hintPaddings: {
paddingHorizontal: Spacings.s5,
paddingTop: Spacings.s3,
paddingBottom: Spacings.s4
},
containerShadow: { ...Shadows.sh30.bottom
},
hintMessage: { ...Typography.text70,
color: Colors.white,
flexShrink: 1
},
icon: {
marginRight: Spacings.s4,
tintColor: Colors.white
}
});
export default asBaseComponent(Hint);