UNPKG

react-native-ui-lib

Version:

[![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct.svg)](https://stand-with-ukraine.pp.ua)

807 lines (800 loc) • 27 kB
import _get from "lodash/get"; import _isUndefined from "lodash/isUndefined"; import _isFunction from "lodash/isFunction"; import _isPlainObject from "lodash/isPlainObject"; import _isString from "lodash/isString"; import _invoke from "lodash/invoke"; import _isEmpty from "lodash/isEmpty"; // @ts-nocheck // TODO: hideUnderline should be true by default // TODO: enableErrors should be false by default // TODO: enableErrors should derived from errorMessage prop // TODO: use forwardRef to allow access to inner TextInput API // TODO: add trailing/leading icon props // TODO: support margin modifiers import PropTypes from 'prop-types'; import React from 'react'; import { StyleSheet, Animated, TextInput as RNTextInput } from 'react-native'; import { TextInputPropTypes, ImagePropTypes } from 'deprecated-react-native-prop-types'; import memoize from 'memoize-one'; import { Constants } from "../../commons/new"; import { Colors, Typography, Spacings } from "../../style"; import BaseInput from "../baseInput"; import Modal from "../modal"; import TextArea from "../textArea"; import View from "../view"; import Image from "../image"; import Text from "../text"; import TouchableOpacity from "../touchableOpacity"; const COLOR_BY_STATE = { default: Colors.grey10, focus: Colors.grey10, error: Colors.grey10, disabled: Colors.grey50 }; const UNDERLINE_COLOR_BY_STATE = { default: Colors.grey50, focus: Colors.$outlinePrimary, error: Colors.red30 }; const PLACEHOLDER_COLOR_BY_STATE = { default: Colors.grey30, focus: Colors.$textPrimary }; const CHAR_COUNTER_COLOR_BY_STATE = { default: Colors.grey30, error: Colors.red30 }; const LABEL_TYPOGRAPHY = Typography.text80; const ICON_SIZE = 24; const ICON_LEFT_PADDING = 6; const FLOATING_PLACEHOLDER_SCALE = 0.875; /** * @description: A wrapper for TextInput component with extra functionality like floating placeholder and validations (This is an uncontrolled component) * @modifiers: Typography * @extends: TextInput * @extendsLink: https://reactnative.dev/docs/textinput * @gif: https://media.giphy.com/media/xULW8su8Cs5Z9Fq4PS/giphy.gif, https://media.giphy.com/media/3ohc1dhDcLS9FvWLJu/giphy.gif, https://media.giphy.com/media/oNUSOxnHdMP5ZnKYsh/giphy.gif * @example: https://github.com/wix/react-native-ui-lib/blob/master/demo/src/screens/componentScreens/TextFieldScreen/BasicTextFieldScreen.js */ export default class TextField extends BaseInput { static displayName = 'TextFieldOld'; static propTypes = { ...TextInputPropTypes, ...BaseInput.propTypes, /** * should placeholder have floating behavior */ floatingPlaceholder: PropTypes.bool, /** * floating placeholder color as a string or object of states, ex. {default: 'black', error: 'red', focus: 'blue', disabled: 'grey'} */ floatingPlaceholderColor: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), /** * Custom style for floating placeholder */ floatingPlaceholderStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]), /** * This text will appear as a placeholder when the textInput becomes focused, only when passing floatingPlaceholder * as well (NOT for expandable textInputs) */ helperText: PropTypes.string, /** * hide text input underline, by default false */ hideUnderline: PropTypes.bool, /** * underline color as a string or object of states, ex. {default: 'black', error: 'red', focus: 'blue', disabled: 'grey'} */ underlineColor: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), /** * the color of all text when the input is disabled (if undefined will not apply color) */ disabledColor: PropTypes.string, /** * should text input be align to center */ centered: PropTypes.bool, /** * input error message, should be empty if no error exists */ error: PropTypes.string, /** * should the input component support error messages */ enableErrors: PropTypes.bool, /** * input error message's text color */ errorColor: PropTypes.string, /** * should the input expand to another text area modal */ expandable: PropTypes.bool, /** * Render custom expandable input (requires expandable to be true) */ renderExpandableInput: PropTypes.elementType, /** * allow custom rendering of expandable content when clicking on the input (useful for pickers) * accept props and state as params, ex. (props, state) => {...} * use toggleExpandableModal(false) method to toggle off the expandable content */ renderExpandable: PropTypes.elementType, /** * Callback for the modal toggle. Pass with renderExpandable to control the modal toggle */ onToggleExpandableModal: PropTypes.func, /** * The picker modal top bar props */ topBarProps: PropTypes.shape(Modal.TopBar.propTypes), /** * transform function executed on value and return transformed value */ transformer: PropTypes.func, /** * Pass to render a prefix text as part of the input (doesn't work with floatingPlaceholder) */ prefix: PropTypes.string, /** * The prefix style */ prefixStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]), /** * Fixed title that will displayed above the input (note: floatingPlaceholder MUST be 'false') */ title: PropTypes.string, /** * The title's color as a string or object of states, ex. {default: 'black', error: 'red', focus: 'blue', disabled: 'grey'} */ titleColor: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), /** * Additional styles for the title (not including 'color') */ titleStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]), /** * should the input display a character counter (only when passing 'maxLength') */ showCharacterCounter: PropTypes.bool, /** * should float the placeholder when focused (instead of when typing) */ floatOnFocus: PropTypes.bool, /** * should the errors be displayed at the top */ useTopErrors: PropTypes.bool, /** * Icon asset source for showing on the right side, appropriate for dropdown icon and such */ rightIconSource: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), /** * Pass to style the right icon source */ rightIconStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), /** * Props for the right button {iconSource, onPress, style} */ rightButtonProps: PropTypes.shape({ iconSource: ImagePropTypes.source, iconColor: PropTypes.string, onPress: PropTypes.func, style: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), accessibilityLabel: PropTypes.string }), /** * Pass to render a leading icon to the TextInput value. Accepts Image props (doesn't work with floatingPlaceholder) */ leadingIcon: PropTypes.shape(ImagePropTypes) }; static defaultProps = { enableErrors: true, validateOnBlur: true }; constructor(props) { super(props); this.state = { ...this.state, value: props.value, // for floatingPlaceholder functionality floatingPlaceholderState: new Animated.Value(this.shouldFloatPlaceholder(props.value) ? 1 : 0), showExpandableModal: false, floatingPlaceholderTranslate: 0, charCountColor: CHAR_COUNTER_COLOR_BY_STATE.default }; this.generatePropsWarnings(props); } UNSAFE_componentWillReceiveProps(nextProps) { if (nextProps.value !== this.props.value) { this.setState({ value: nextProps.value }, () => { this.updateFloatingPlaceholderState(); if (nextProps.validateOnChange) { this.validate(); } }); } } componentDidUpdate(_prevProps, prevState) { if (_isEmpty(prevState.value) !== _isEmpty(this.state.value) || prevState.focused !== this.state.focused) { this.updateFloatingPlaceholderState(); } } onPlaceholderLayout = event => { const { width } = event.nativeEvent.layout; const translate = width / 2 - width * FLOATING_PLACEHOLDER_SCALE / 2; this.setState({ floatingPlaceholderTranslate: translate / FLOATING_PLACEHOLDER_SCALE }); }; /** Actions */ generatePropsWarnings(props) { if (props.maxLength === 0) { console.warn('Setting maxLength to zero will block typing in this input'); } if (props.showCharacterCounter && !props.maxLength) { console.warn('In order to use showCharacterCount please pass \'maxLength\' prop'); } } generateStyles() { this.styles = createStyles(this.getThemeProps(), this.getTopPaddings()); } getAccessibilityInfo() { const { floatingPlaceholder, placeholder } = this.getThemeProps(); const accessibilityState = this.isDisabled() ? { disabled: true } : undefined; let accessibilityLabel = floatingPlaceholder ? this.props.accessibilityLabel || placeholder : ''; if (this.isRequiredField()) { accessibilityLabel = `${accessibilityLabel}. Mandatory`; } return { accessibilityLabel, // on Android accessibilityStates cause issues with expandable input accessibilityState: Constants.isIOS ? accessibilityState : undefined }; } toggleExpandableModal = value => { this.setState({ showExpandableModal: value }); _invoke(this.props, 'onToggleExpandableModal', value); }; updateFloatingPlaceholderState = withoutAnimation => { if (withoutAnimation) { this.state.floatingPlaceholderState.setValue(this.shouldFloatPlaceholder() ? 1 : 0); } else { Animated.spring(this.state.floatingPlaceholderState, { toValue: this.shouldFloatPlaceholder() ? 1 : 0, duration: 150, useNativeDriver: true }).start(); } }; getPlaceholderText = memoize((placeholder, helperText) => { // HACK: passing whitespace instead of undefined. Issue fixed in RN56 const text = this.shouldFakePlaceholder() ? this.shouldShowHelperText() ? helperText : ' ' : this.shouldShowTopError() && this.shouldShowHelperText() ? helperText : this.getRequiredPlaceholder(placeholder); return text; }); getStateColor(colorProp = {}) { const { focused } = this.state; const error = this.getErrorMessage(); const { disabledColor } = this.getThemeProps(); if (_isString(colorProp)) { return colorProp || Colors.grey10; } else if (_isPlainObject(colorProp)) { const mergedColorState = { ...COLOR_BY_STATE, ...colorProp }; if (this.isDisabled()) { return disabledColor || mergedColorState.disabled; } else if (error) { return mergedColorState.error; } else if (focused) { return mergedColorState.focus; } else { return mergedColorState.default; } } return colorProp || Colors.grey10; } getCharCount() { const { value } = this.state; if (value) { return value.length; } return 0; } setCharCountColor(key) { this.maxReached = key === Constants.backspaceKey ? false : this.isCounterLimit(); const color = this.state.focused && this.maxReached ? CHAR_COUNTER_COLOR_BY_STATE.error : CHAR_COUNTER_COLOR_BY_STATE.default; if (color !== this.state.charCountColor) { this.setState({ charCountColor: color }); } } getCharCountColor() { const { charCountColor } = this.state; const { disabledColor } = this.getThemeProps(); return this.isDisabled() && disabledColor ? disabledColor : charCountColor; } getTopPaddings() { return this.shouldFakePlaceholder() ? this.shouldShowTopError() ? undefined : 25 : undefined; } getTopErrorsPosition() { return !this.props.title && this.shouldShowTopError() ? { top: Constants.isIOS ? -25 : -27 } : undefined; } isDisabled() { return this.props.editable === false; } isCounterLimit() { const { maxLength } = this.getThemeProps(); const counter = this.getCharCount(); return counter === 0 ? false : maxLength <= counter; } hasText(value) { return !_isEmpty(value || this.state.value); } shouldShowHelperText() { const { focused } = this.state; const { helperText } = this.props; return focused && helperText; } shouldFloatOnFocus() { const { focused } = this.state; const { floatOnFocus } = this.getThemeProps(); return focused && floatOnFocus; } shouldFloatPlaceholder(text) { return this.hasText(text) || this.shouldShowHelperText() || this.shouldFloatOnFocus(); } shouldFakePlaceholder() { const { floatingPlaceholder, centered, leadingIcon, prefix } = this.getThemeProps(); return !leadingIcon && !prefix && Boolean(floatingPlaceholder && !centered && !this.shouldShowTopError()); } shouldShowError() { const { enableErrors } = this.getThemeProps(); const error = this.getErrorMessage(); return enableErrors && error; } getErrorMessage() { return this.props?.error || this.state?.error; } shouldShowTopError() { const { useTopErrors } = this.getThemeProps(); return this.shouldShowError() && useTopErrors; } shouldDisplayRightButton() { const { rightButtonProps, expandable } = this.getThemeProps(); return !expandable && rightButtonProps && rightButtonProps.iconSource; } shouldRenderTitle() { const { floatingPlaceholder, title } = this.getThemeProps(); return !floatingPlaceholder && title; } onPressRightButton = () => { _invoke(this.props, 'rightButtonProps.onPress'); }; /** Renders */ renderPlaceholder() { const { floatingPlaceholderState, floatingPlaceholderTranslate } = this.state; const { placeholder, placeholderTextColor, floatingPlaceholderColor, floatingPlaceholderStyle } = this.getThemeProps(); const typography = this.getTypography(); const placeholderColor = this.getStateColor(placeholderTextColor || PLACEHOLDER_COLOR_BY_STATE.default); if (this.shouldFakePlaceholder()) { return <View absF left> <Animated.Text pointerEvents="none" numberOfLines={1} suppressHighlighting accessible={false} onLayout={this.onPlaceholderLayout} style={[this.styles.placeholder, this.getTopErrorsPosition(), typography, // TODO: we need to exclude completely any dependency on line height // in this component since it always breaks alignments { lineHeight: undefined }, { transform: [{ scale: floatingPlaceholderState.interpolate({ inputRange: [0, 1], outputRange: [1, FLOATING_PLACEHOLDER_SCALE] }) }, { translateY: floatingPlaceholderState.interpolate({ inputRange: [0, 1], outputRange: [25, 0] }) }, { translateX: floatingPlaceholderState.interpolate({ inputRange: [0, 1], outputRange: [0, -floatingPlaceholderTranslate] }) }], color: this.shouldFloatPlaceholder() ? this.getStateColor(floatingPlaceholderColor || PLACEHOLDER_COLOR_BY_STATE) : placeholderColor }, floatingPlaceholderStyle]}> {this.getRequiredPlaceholder(placeholder)} </Animated.Text> </View>; } } renderPrefix() { const { prefix, prefixStyle } = this.props; if (prefix) { const typography = this.getTypography(); return <Text style={[this.styles.prefix, typography, { lineHeight: undefined }, prefixStyle]}>{prefix}</Text>; } } renderTitle() { const { title, titleColor, titleStyle } = this.getThemeProps(); const color = this.getStateColor(titleColor || PLACEHOLDER_COLOR_BY_STATE); if (this.shouldRenderTitle()) { return <Text style={[{ color }, this.styles.topLabel, this.styles.label, titleStyle]}>{title}</Text>; } } renderCharCounter() { const { maxLength, showCharacterCounter } = this.getThemeProps(); if (maxLength && showCharacterCounter) { const counter = this.getCharCount(); const color = this.getCharCountColor(); return <Text style={[{ color }, this.styles.bottomLabel, this.styles.label]} accessibilityLabel={`${counter} out of ${maxLength} max characters`}> {counter} / {maxLength} </Text>; } } renderError(visible) { const { enableErrors, useTopErrors, errorColor } = this.getThemeProps(); const textColor = errorColor ? { color: errorColor } : undefined; const positionStyle = useTopErrors ? this.styles.topLabel : this.styles.bottomLabel; const error = this.getErrorMessage(); if (visible && enableErrors) { return <Text style={[this.styles.errorMessage, this.styles.label, positionStyle, textColor]} accessible={!_isEmpty(error) && !useTopErrors}> {error} </Text>; } } renderExpandableModal() { const { renderExpandable, topBarProps } = this.getThemeProps(); const { showExpandableModal } = this.state; if (_isFunction(renderExpandable) && showExpandableModal) { return renderExpandable(this.getThemeProps(), this.state); } const textInputProps = TextField.extractOwnProps(this.props, ['error', 'testID']); return <Modal animationType={'slide'} visible={showExpandableModal} onRequestClose={() => this.toggleExpandableModal(false)}> <Modal.TopBar {...topBarProps} onCancel={() => this.toggleExpandableModal(false)} onDone={this.onDoneEditingExpandableInput} /> <View style={this.styles.expandableModalContent}> <TextArea ref={textarea => { this.expandableInput = textarea; }} {...textInputProps} value={this.state.value} /> </View> </Modal>; } renderExpandableInput() { const { renderExpandableInput, testID } = this.getThemeProps(); if (_isFunction(renderExpandableInput)) { return renderExpandableInput(this.getThemeProps(), this.toggleExpandableModal); } return <TouchableOpacity style={this.styles.expandableInput} activeOpacity={1} onPress={() => !this.isDisabled() && this.toggleExpandableModal(true)} testID={`${testID}.expandable`} // {...this.extractAccessibilityProps()} {...this.getAccessibilityInfo()}> {this.renderTextInput()} </TouchableOpacity>; } renderTextInput() { const { value } = this.state; // value set on state for floatingPlaceholder functionality const { style, placeholderTextColor, multiline, // hideUnderline, numberOfLines, expandable, rightIconSource, color, placeholder, helperText, ...others } = this.getThemeProps(); const typography = this.getTypography(); const { lineHeight, ...typographyStyle } = typography; const textColor = this.getStateColor(color || this.extractColorValue()); const hasRightElement = this.shouldDisplayRightButton() || rightIconSource; const shouldUseMultiline = multiline /* || expandable */; const inputStyle = [hasRightElement && this.styles.rightElement, this.styles.input, // hideUnderline && this.styles.inputWithoutUnderline, { ...typographyStyle }, // Constants.isAndroid && {lineHeight}, expandable && { maxHeight: lineHeight * (Constants.isAndroid ? 3 : 3.3) }, Constants.isRTL && { minHeight: lineHeight + 3 }, Constants.isIOS && shouldUseMultiline && { paddingTop: 0 }, // fix for iOS topPadding in multiline TextInput { color: textColor }, style]; const placeholderText = this.getPlaceholderText(placeholder, helperText); const placeholderColor = this.getStateColor(placeholderTextColor || PLACEHOLDER_COLOR_BY_STATE.default); const isEditable = !this.isDisabled() && !expandable; return <RNTextInput {...this.getAccessibilityInfo()} pointerEvents={expandable ? 'none' : undefined} {...others} value={value} placeholder={placeholderText} placeholderTextColor={placeholderColor} underlineColorAndroid="transparent" style={inputStyle} multiline={shouldUseMultiline} numberOfLines={numberOfLines} onKeyPress={this.onKeyPress} onChangeText={this.onChangeText} onChange={this.onChange} onFocus={this.onFocus} onBlur={this.onBlur} ref={input => { this.input = input; }} editable={isEditable} />; } renderRightButton() { if (this.shouldDisplayRightButton()) { const { rightButtonProps } = this.getThemeProps(); const { style, iconSource, iconColor, accessibilityLabel, ...others } = rightButtonProps; return <TouchableOpacity {...others} accessibilityLabel={accessibilityLabel} style={[this.styles.rightButton, this.getTopErrorsPosition(), style]} onPress={this.onPressRightButton}> <Image pointerEvents="none" source={iconSource} resizeMode={'contain'} style={[this.styles.rightButtonImage, { tintColor: iconColor || Colors.$iconPrimary }]} /> </TouchableOpacity>; } } renderRightIcon() { const { rightIconSource, rightIconStyle } = this.getThemeProps(); if (rightIconSource) { return <View style={[this.styles.rightButton, this.getTopErrorsPosition()]} pointerEvents="none"> <Image source={rightIconSource} resizeMode={'center'} style={[this.styles.rightButtonImage, rightIconStyle]} /> </View>; } } render() { const { expandable, containerStyle, underlineColor, useTopErrors, hideUnderline, leadingIcon } = this.getThemeProps(); const underlineStateColor = this.getStateColor(underlineColor || UNDERLINE_COLOR_BY_STATE); return <View style={[this.styles.container, containerStyle]} collapsable={false}> {this.shouldShowTopError() ? this.renderError(useTopErrors) : this.renderTitle()} <View style={[this.styles.innerContainer, hideUnderline && this.styles.innerContainerWithoutUnderline, { borderColor: underlineStateColor }, { paddingTop: this.getTopPaddings() }]}> {leadingIcon && <Image {...leadingIcon} style={[this.styles.leadingIcon, leadingIcon.style]} />} {this.renderPrefix()} {this.renderPlaceholder()} {expandable ? this.renderExpandableInput() : this.renderTextInput()} {this.renderRightButton()} {this.renderRightIcon()} {expandable && this.renderExpandableModal()} </View> {!_isUndefined(this.getErrorMessage()) && useTopErrors && <View style={this.styles.accessibilityDummyErrorMessage} accessible accessibilityLabel={this.getErrorMessage()} />} <View row> <View flex>{this.renderError(!useTopErrors)}</View> {this.renderCharCounter()} </View> </View>; } /** Events */ onDoneEditingExpandableInput = () => { const expandableInputValue = _get(this.expandableInput, 'state.value'); this.setState({ value: expandableInputValue }); this.state.floatingPlaceholderState.setValue(expandableInputValue ? 1 : 0); _invoke(this.props, 'onChangeText', expandableInputValue); this.toggleExpandableModal(false); }; onKeyPress = event => { this.lastKey = event.nativeEvent.key; this.setCharCountColor(this.lastKey); _invoke(this.props, 'onKeyPress', event); }; onChangeText = text => { // when character count exceeds maxLength text will be empty string. // HACK: To avoid setting state value to '' we check the source of that deletion if (text === '' && this.lastKey && this.lastKey !== Constants.backspaceKey) { return; } const { transformer, validateOnChange } = this.props; let transformedText = text; if (_isFunction(transformer)) { transformedText = transformer(text); } _invoke(this.props, 'onChangeText', transformedText); this.setState({ value: transformedText }, () => { if (validateOnChange) { setImmediate(this.validate); } }); }; } function createStyles({ centered, multiline }, rightItemTopPadding = 0) { const itemTopPadding = Constants.isIOS ? rightItemTopPadding - 3 : rightItemTopPadding - 1; return StyleSheet.create({ container: {}, innerContainer: { // flexGrow: 1, // create bugs with lineHeight flexDirection: 'row', justifyContent: centered ? 'center' : undefined, borderBottomWidth: 1, borderColor: Colors.grey70, paddingBottom: Constants.isIOS ? 10 : 5 }, innerContainerWithoutUnderline: { borderBottomWidth: 0, paddingBottom: 0 }, input: { flexGrow: 1, textAlign: centered ? 'center' : Constants.isRTL ? 'right' : 'left', backgroundColor: 'transparent', // marginBottom: Constants.isIOS ? 10 : 5, padding: 0, // for Android // textAlignVertical: 'top', // for Android borderColor: 'transparent', // borderColor & borderWidth is a fix for collapsing issue on Android borderWidth: Constants.isAndroid ? 1 : undefined // for Android }, expandableInput: { flexGrow: 1, flexDirection: 'row', alignItems: 'center' }, // inputWithoutUnderline: { // marginBottom: undefined // }, expandableModalContent: { flex: 1, paddingTop: 15, paddingHorizontal: 20 }, prefix: { color: Colors.grey30, marginRight: Spacings.s1, textAlignVertical: 'center' }, placeholder: { textAlign: 'left' }, errorMessage: { color: Colors.red30, textAlign: centered ? 'center' : 'left' }, topLabel: { marginBottom: Constants.isIOS ? multiline ? 1 : 5 : 7 }, bottomLabel: { marginTop: 9 }, label: { ...LABEL_TYPOGRAPHY, height: LABEL_TYPOGRAPHY.lineHeight }, rightElement: { paddingRight: ICON_SIZE + ICON_LEFT_PADDING }, rightButton: { position: 'absolute', right: 0, alignSelf: 'flex-start', paddingTop: itemTopPadding }, rightButtonImage: { width: ICON_SIZE, height: ICON_SIZE }, leadingIcon: { alignSelf: 'center' }, accessibilityDummyErrorMessage: { position: 'absolute', bottom: 0, left: 0, width: 1, height: 1 } }); }