diff --git a/docs/assets/screenshots/helper-text.gif b/docs/assets/screenshots/helper-text.gif new file mode 100644 index 0000000000..119dfcc3d6 Binary files /dev/null and b/docs/assets/screenshots/helper-text.gif differ diff --git a/example/src/TextInputExample.js b/example/src/TextInputExample.js index 259372c937..ceb5b6d86e 100644 --- a/example/src/TextInputExample.js +++ b/example/src/TextInputExample.js @@ -1,8 +1,8 @@ /* @flow */ import * as React from 'react'; -import { ScrollView, StyleSheet } from 'react-native'; -import { TextInput, withTheme } from 'react-native-paper'; +import { ScrollView, StyleSheet, View } from 'react-native'; +import { TextInput, HelperText, withTheme } from 'react-native-paper'; import type { Theme } from 'react-native-paper/types'; type Props = { @@ -11,6 +11,7 @@ type Props = { type State = { text: string, + name: string, }; class TextInputExample extends React.Component { @@ -18,14 +19,18 @@ class TextInputExample extends React.Component { state = { text: '', + name: '', }; + _isUsernameValid = () => /^[a-z]*$/.test(this.state.name); + render() { const { theme: { colors: { background }, }, } = this.props; + return ( { style={styles.inputContainerStyle} label="Disabled Input" /> + + this.setState({ name })} + /> + + Error: Only letters are allowed + + ); } diff --git a/src/components/HelperText.js b/src/components/HelperText.js new file mode 100644 index 0000000000..2d827f51af --- /dev/null +++ b/src/components/HelperText.js @@ -0,0 +1,162 @@ +/* @flow */ + +import * as React from 'react'; +import color from 'color'; +import { Animated, StyleSheet } from 'react-native'; +import Text from './Typography/Text'; +import withTheme from '../core/withTheme'; +import type { Theme } from '../types'; + +const AnimatedText = Animated.createAnimatedComponent(Text); + +type Props = { + /** + * Type of the helper text. + */ + type: 'error' | 'info', + /** + * Whether to display the helper text. + */ + visible?: boolean, + /** + * Text content of the HelperText. + */ + children: React.Node, + style?: any, + /** + * @optional + */ + theme: Theme, +}; + +type State = { + shown: Animated.Value, + textHeight: number, +}; + +/** + * Helper text is used in conjuction with input elements to provide additional hints for the user. + * + *
+ * + *
+ * + * ## Usage + * ```js + * import * as React from 'react'; + * import { HelperText, TextInput } from 'react-native-paper'; + * + * class MyComponent extends React.Component { + * state = { + * text: '' + * }; + * + * render(){ + * return ( + * + * this.setState({ text })} + * /> + * + * Email address is invalid! + * + * + * ); + * } + * } + * ``` + */ +class HelperText extends React.PureComponent { + static defaultProps = { + type: 'info', + visible: true, + }; + + state = { + shown: new Animated.Value(this.props.visible ? 1 : 0), + textHeight: 0, + }; + + componentDidUpdate(prevProps) { + if (prevProps.visible !== this.props.visible) { + if (this.props.visible) { + this._animateFocus(); + } else { + this._animateBlur(); + } + } + } + + _animateFocus = () => { + Animated.timing(this.state.shown, { + toValue: 1, + duration: 150, + }).start(); + }; + + _animateBlur = () => { + Animated.timing(this.state.shown, { + toValue: 0, + duration: 180, + }).start(); + }; + + _handleTextLayout = e => + this.setState({ + textHeight: e.nativeEvent.layout.height, + }); + + render() { + const { style, type, visible, theme } = this.props; + const { colors, dark } = theme; + + const textColor = + this.props.type === 'error' + ? colors.error + : color(colors.text) + .alpha(dark ? 0.7 : 0.54) + .rgb() + .string(); + + return ( + + {this.props.children} + + ); + } +} + +const styles = StyleSheet.create({ + text: { + fontSize: 12, + paddingVertical: 4, + }, +}); + +export default withTheme(HelperText); diff --git a/src/components/TextInput.js b/src/components/TextInput.js index ae36b5dba2..5730822409 100644 --- a/src/components/TextInput.js +++ b/src/components/TextInput.js @@ -13,6 +13,13 @@ import type { Theme } from '../types'; const AnimatedText = Animated.createAnimatedComponent(Text); +const MINIMIZED_LABEL_Y_OFFSET = -22; +const MAXIMIZED_LABEL_FONT_SIZE = 16; +const MINIMIZED_LABEL_FONT_SIZE = 12; +const LABEL_WIGGLE_X_OFFSET = 4; +const FOCUS_ANIMATION_DURATION = 150; +const BLUR_ANIMATION_DURATION = 180; + type Props = { /** * If true, user won't be able to interact with the component. @@ -26,6 +33,10 @@ type Props = { * Placeholder for the input. */ placeholder?: string, + /** + * Whether to style the TextInput with error style. + */ + error?: boolean, /** * Callback that is called when the text input's text changes. Changed text is passed as an argument to the callback handler. */ @@ -63,6 +74,7 @@ type Props = { type State = { focused: Animated.Value, + errorShown: Animated.Value, placeholder: ?string, value: ?string, }; @@ -110,11 +122,13 @@ type State = { class TextInput extends React.Component { static defaultProps = { disabled: false, + error: false, multiline: false, }; state = { focused: new Animated.Value(0), + errorShown: new Animated.Value(this.props.error ? 1 : 0), placeholder: '', value: this.props.value, }; @@ -130,6 +144,14 @@ class TextInput extends React.Component { this._setPlaceholder(); } } + + if (prevProps.error !== this.props.error) { + if (this.props.error) { + this._animateErrorShown(); + } else { + this._animateErrorHidden(); + } + } } componentWillUnmount() { @@ -157,10 +179,24 @@ class TextInput extends React.Component { _root: NativeTextInput; + _animateErrorShown = () => { + Animated.timing(this.state.errorShown, { + toValue: 1, + duration: FOCUS_ANIMATION_DURATION, + }).start(this._setPlaceholder); + }; + + _animateErrorHidden = () => { + Animated.timing(this.state.errorShown, { + toValue: 0, + duration: BLUR_ANIMATION_DURATION, + }).start(); + }; + _animateFocus = () => { Animated.timing(this.state.focused, { toValue: 1, - duration: 150, + duration: FOCUS_ANIMATION_DURATION, }).start(this._setPlaceholder); }; @@ -169,7 +205,7 @@ class TextInput extends React.Component { Animated.timing(this.state.focused, { toValue: 0, - duration: 180, + duration: BLUR_ANIMATION_DURATION, }).start(); }; @@ -194,6 +230,15 @@ class TextInput extends React.Component { this.props.onChangeText && this.props.onChangeText(value); }; + _getBottomLineStyle = (color: string, animatedValue: Animated.Value) => ({ + backgroundColor: color, + transform: [{ scaleX: animatedValue }], + opacity: animatedValue.interpolate({ + inputRange: [0, 0.1, 1], + outputRange: [0, 1, 1], + }), + }); + /** * @internal */ @@ -233,6 +278,7 @@ class TextInput extends React.Component { const { disabled, label, + error, underlineColor, style, theme, @@ -241,14 +287,17 @@ class TextInput extends React.Component { const { colors, fonts } = theme; const fontFamily = fonts.regular; - const primaryColor = colors.primary; - const inactiveColor = colors.disabled; + const { + primary: primaryColor, + disabled: inactiveColor, + error: errorColor, + } = colors; let inputTextColor, labelColor, bottomLineColor; if (!disabled) { inputTextColor = colors.text; - labelColor = primaryColor; + labelColor = (error && errorColor) || primaryColor; bottomLineColor = underlineColor || primaryColor; } else { inputTextColor = labelColor = bottomLineColor = inactiveColor; @@ -259,39 +308,40 @@ class TextInput extends React.Component { outputRange: [inactiveColor, labelColor], }); - const translateY = this.state.value - ? -22 + // Wiggle when error appears and label is minimized + const labelTranslateX = + this.state.value && error + ? this.state.errorShown.interpolate({ + inputRange: [0, 0.5, 1], + outputRange: [0, LABEL_WIGGLE_X_OFFSET, 0], + }) + : 0; + + // Move label to top if value is set + const labelTranslateY = this.state.value + ? MINIMIZED_LABEL_Y_OFFSET : this.state.focused.interpolate({ inputRange: [0, 1], - outputRange: [0, -22], + outputRange: [0, MINIMIZED_LABEL_Y_OFFSET], }); - const fontSize = this.state.value - ? 12 + + const labelFontSize = this.state.value + ? MINIMIZED_LABEL_FONT_SIZE : this.state.focused.interpolate({ inputRange: [0, 1], - outputRange: [16, 12], + outputRange: [MAXIMIZED_LABEL_FONT_SIZE, MINIMIZED_LABEL_FONT_SIZE], }); const labelStyle = { color: labelColorAnimation, fontFamily, - fontSize, + fontSize: labelFontSize, transform: [ - { - translateY, - }, + { translateX: labelTranslateX }, + { translateY: labelTranslateY }, ], }; - const bottomLineStyle = { - backgroundColor: bottomLineColor, - transform: [{ scaleX: this.state.focused }], - opacity: this.state.focused.interpolate({ - inputRange: [0, 0.1, 1], - outputRange: [0, 1, 1], - }), - }; - return ( { /> + diff --git a/src/components/__tests__/__snapshots__/BottomNavigation.test.js.snap b/src/components/__tests__/__snapshots__/BottomNavigation.test.js.snap index 4a586398e1..36843a77c6 100644 --- a/src/components/__tests__/__snapshots__/BottomNavigation.test.js.snap +++ b/src/components/__tests__/__snapshots__/BottomNavigation.test.js.snap @@ -1509,6 +1509,7 @@ exports[`renders non-shifting bottom navigation 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -1566,6 +1567,7 @@ exports[`renders non-shifting bottom navigation 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -1766,6 +1768,7 @@ exports[`renders non-shifting bottom navigation 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -1823,6 +1826,7 @@ exports[`renders non-shifting bottom navigation 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -2023,6 +2027,7 @@ exports[`renders non-shifting bottom navigation 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -2080,6 +2085,7 @@ exports[`renders non-shifting bottom navigation 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -2393,6 +2399,7 @@ exports[`renders shifting bottom navigation 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -2554,6 +2561,7 @@ exports[`renders shifting bottom navigation 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -2715,6 +2723,7 @@ exports[`renders shifting bottom navigation 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -2876,6 +2885,7 @@ exports[`renders shifting bottom navigation 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -3037,6 +3047,7 @@ exports[`renders shifting bottom navigation 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", diff --git a/src/components/__tests__/__snapshots__/ListAccordion.test.js.snap b/src/components/__tests__/__snapshots__/ListAccordion.test.js.snap index d43e2b5579..41302a0028 100644 --- a/src/components/__tests__/__snapshots__/ListAccordion.test.js.snap +++ b/src/components/__tests__/__snapshots__/ListAccordion.test.js.snap @@ -115,6 +115,7 @@ exports[`renders list accordion with children 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -289,6 +290,7 @@ exports[`renders list accordion with icons 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -425,6 +427,7 @@ exports[`renders multiline list accordion 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -470,6 +473,7 @@ exports[`renders multiline list accordion 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", diff --git a/src/components/__tests__/__snapshots__/ListItem.test.js.snap b/src/components/__tests__/__snapshots__/ListItem.test.js.snap index 23aa1ee766..94262d3001 100644 --- a/src/components/__tests__/__snapshots__/ListItem.test.js.snap +++ b/src/components/__tests__/__snapshots__/ListItem.test.js.snap @@ -96,6 +96,7 @@ exports[`renders list item with avatar 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -219,6 +220,7 @@ exports[`renders list item with avatar and icon 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -264,6 +266,7 @@ exports[`renders list item with avatar and icon 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -439,6 +442,7 @@ exports[`renders list item with icon 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -537,6 +541,7 @@ exports[`renders list item with title and description 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -582,6 +587,7 @@ exports[`renders list item with title and description 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", diff --git a/src/components/__tests__/__snapshots__/ListSection.test.js.snap b/src/components/__tests__/__snapshots__/ListSection.test.js.snap index b9e09f11af..a6e293e028 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.js.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.js.snap @@ -40,6 +40,7 @@ exports[`renders list section with title 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -170,6 +171,7 @@ exports[`renders list section with title 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -303,6 +305,7 @@ exports[`renders list section with title 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -450,6 +453,7 @@ exports[`renders list section without title 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", @@ -583,6 +587,7 @@ exports[`renders list section without title 1`] = ` "accent": "#ff4081", "background": "#fafafa", "disabled": "rgba(0, 0, 0, 0.26)", + "error": "#ff1744", "paper": "#ffffff", "placeholder": "rgba(0, 0, 0, 0.38)", "primary": "#3f51b5", diff --git a/src/index.js b/src/index.js index b75c6f31da..48e98a9d4c 100644 --- a/src/index.js +++ b/src/index.js @@ -28,6 +28,7 @@ export { default as Divider } from './components/Divider'; export { default as DrawerItem } from './components/DrawerItem'; export { default as DrawerSection } from './components/DrawerSection'; export { default as FAB } from './components/FAB'; +export { default as HelperText } from './components/HelperText'; export { default as ListAccordion } from './components/List/ListAccordion'; export { default as ListItem } from './components/List/ListItem'; export { default as ListSection } from './components/List/ListSection'; diff --git a/src/styles/DefaultTheme.js b/src/styles/DefaultTheme.js index e107c3277c..233f0fb27a 100644 --- a/src/styles/DefaultTheme.js +++ b/src/styles/DefaultTheme.js @@ -1,7 +1,7 @@ /* @flow */ import color from 'color'; -import { indigo500, pinkA200, black, white, grey50 } from './colors'; +import { indigo500, pinkA200, black, white, grey50, redA400 } from './colors'; import fonts from './fonts'; export default { @@ -12,6 +12,7 @@ export default { accent: pinkA200, background: grey50, paper: white, + error: redA400, text: black, disabled: color(black) .alpha(0.26) diff --git a/src/types.js b/src/types.js index a4b90a46d7..7bf17f07f6 100644 --- a/src/types.js +++ b/src/types.js @@ -8,6 +8,7 @@ export type Theme = { background: string, paper: string, accent: string, + error: string, text: string, disabled: string, placeholder: string,