import { themeGet } from "@styled-system/theme-get" import { MeasuredView } from "../../utils/MeasuredView" import { EventEmitter } from "events" import _ from "lodash" import { Color, EyeOpenedIcon, Flex, Spinner, Text, useTheme, XCircleIcon } from "palette" import React, { useEffect, useImperativeHandle, useRef, useState } from "react" import { LayoutAnimation, Platform, TextInput, TextInputProps, TextStyle, TouchableOpacity, TouchableWithoutFeedback, View, } from "react-native" import styled from "styled-components/native" import { EyeClosedIcon } from "../../svgs/EyeClosedIcon" import { InputTitle } from "./InputTitle" const DEFAULT_FONT_SIZE = 16 export const INPUT_HEIGHT = 50 export const INPUT_HEIGHT_MULTILINE = 100 export const inputEvents = new EventEmitter() export const emitInputClearEvent = () => { inputEvents.emit("clear") } export interface InputProps extends Omit { containerStyle?: React.ComponentProps["style"] description?: string descriptionColor?: Color error?: string icon?: JSX.Element loading?: boolean disabled?: boolean optional?: boolean required?: boolean title?: string showLimit?: boolean fontSize?: number /** * This placeholder is fixed to the right side of the input */ fixedRightPlaceholder?: string /** * The placeholder can be an array of string, specifically for android, because of a bug. * On ios, the longest string will always be picked, as ios can add ellipsis. * On android, the longest string **that fits** will be picked, as android doesn't use ellipsis. * The way to use it is to put the longest string first, and the shortest string last. * * Check `HACKS.md` for more info. * * @example * const placeholders = [ * "Wow this is a great and very long placeholder", * "Wow this is a great and long placeholder", * "Wow this is a great placeholder", * "Wow", * ] * ... * */ placeholder?: string | string[] enableClearButton?: boolean canHidePassword?: boolean inputTextStyle?: TextStyle addClearListener?: boolean onClear?(): void renderLeftHandSection?(): JSX.Element } export type Input = TextInput /** * Input component */ export const Input = React.forwardRef( ( { containerStyle, description, descriptionColor, disabled, error, icon, loading, optional, required, enableClearButton, title, renderLeftHandSection, secureTextEntry = false, textContentType, canHidePassword, inputTextStyle, fixedRightPlaceholder, placeholder, multiline, maxLength, showLimit, addClearListener = false, fontSize = DEFAULT_FONT_SIZE, ...rest }, ref ) => { const { color, theme } = useTheme() const [focused, setFocused] = useState(false) const [showPassword, setShowPassword] = useState(!secureTextEntry) const [value, setValue] = useState(rest.value ?? rest.defaultValue ?? "") const input = useRef() const localClear = () => { input.current?.clear() localOnChangeText("") rest.onClear?.() } useImperativeHandle(ref, () => input.current!) const fontFamily = theme.fonts.sans.regular useEffect(() => { if (!addClearListener) { return } inputEvents.addListener("clear", localClear) return () => { inputEvents.removeListener("clear", localClear) } }, []) useEffect(() => { /* to make the font work for secure text inputs, see https://github.com/facebook/react-native/issues/30123#issuecomment-711076098 */ input.current?.setNativeProps({ style: { fontFamily }, }) }, [fontFamily]) const renderShowPasswordIcon = () => { if (!secureTextEntry) { return } return ( { setShowPassword(!showPassword) }} accessibilityLabel={showPassword ? "hide password button" : "show password button"} hitSlop={{ bottom: 40, right: 40, left: 0, top: 40 }} > {!showPassword ? : } ) } const localOnChangeText = (text: string) => { setValue(text) rest.onChangeText?.(text) } const [placeholderWidths, setPlaceholderWidths] = useState([]) const [inputWidth, setInputWidth] = useState(0) const placeholderMeasuringHack = Platform.OS === "android" && _.isArray(placeholder) ? ( <> {placeholder.map((placeholderString, index) => ( setPlaceholderWidths((widths) => { widths[index] = width return widths }) } > {placeholderString} ))} ) : null const actualPlaceholder = () => { if (placeholder === undefined) { return placeholder } // ios works well. just return the longest placeholder if (Platform.OS === "ios") { return _.isArray(placeholder) ? placeholder[0] : placeholder } // if it's android and we only have one string, return that string if (_.isString(placeholder)) { return placeholder } // otherwise, find a placeholder that has longest width that fits in the inputtext const longestFittingStringIndex = placeholderWidths.findIndex((placeholderWidth) => { return placeholderWidth <= inputWidth - 10 /* 10px left margin that the StyleInput has */ }) if (longestFittingStringIndex > -1) { return placeholder[longestFittingStringIndex] } // otherwise just return the shortest placeholder return placeholder[placeholder.length - 1] } return ( {title} {!!maxLength && !!showLimit && ( {maxLength - value.length} )} {!!description && ( {description} )} input.current?.focus()}> {renderLeftHandSection?.()} {!!icon && ( {icon} )} {placeholderMeasuringHack} { const newWidth = event.nativeEvent.layout.width if (newWidth > inputWidth) { requestAnimationFrame(() => setInputWidth(newWidth)) } else { setInputWidth(newWidth) } }} ref={input} placeholderTextColor={color("black60")} style={{ flex: 1, fontSize, ...inputTextStyle }} numberOfLines={multiline ? undefined : 1} secureTextEntry={!showPassword} textAlignVertical={multiline ? "top" : "center"} placeholder={actualPlaceholder()} value={value} {...(rest as any)} onChangeText={localOnChangeText} onFocus={(e) => { if (Platform.OS === "android") { LayoutAnimation.configureNext( LayoutAnimation.create(60, "easeInEaseOut", "opacity") ) } setFocused(true) rest.onFocus?.(e) }} onBlur={(e) => { if (Platform.OS === "android") { LayoutAnimation.configureNext( LayoutAnimation.create(60, "easeInEaseOut", "opacity") ) } setFocused(false) rest.onBlur?.(e) }} /> {!!fixedRightPlaceholder && value === "" && ( {fixedRightPlaceholder} )} {renderShowPasswordIcon()} {loading ? ( ) : ( !!(value !== undefined && value !== "" && enableClearButton) && ( { localClear() }} hitSlop={{ bottom: 40, right: 40, left: 0, top: 40 }} accessibilityLabel="Clear input button" > ) )} {!!error && ( {error} )} ) } ) export interface InputStatus { disabled?: boolean error?: boolean focused?: boolean } /** * func to compute border color */ export const computeBorderColor = (inputStatus: InputStatus): Color => { const { disabled, error, focused } = inputStatus if (disabled) { return "black30" } if (error) { return "red100" } if (focused) { return "black60" } return "black30" } const StyledInput = styled(TextInput)` padding: ${themeGet("space.1")}px; font-family: ${themeGet("fonts.sans.regular")}; ` StyledInput.displayName = "StyledInput"