diff --git a/shared/common-adapters/hoc-timers.js b/shared/common-adapters/hoc-timers.js index 4a123e2b916e..417432da7b72 100644 --- a/shared/common-adapters/hoc-timers.js +++ b/shared/common-adapters/hoc-timers.js @@ -78,6 +78,10 @@ function HOCTimers( } } + // TODO forward a ref to `WrappedComponent` when react-redux is patched to + // work with React.forwardRef. + // https://github.com/reduxjs/react-redux/pull/1000 + return TimersComponent } diff --git a/shared/common-adapters/new-input.js b/shared/common-adapters/new-input.js index d7565d2bb516..418ba6a38f76 100644 --- a/shared/common-adapters/new-input.js +++ b/shared/common-adapters/new-input.js @@ -17,6 +17,7 @@ export type _Props = { containerStyle?: StylesCrossPlatform, decoration?: React.Node, error?: boolean, + forwardedRef: React.Ref, hideBorder?: boolean, icon?: IconType, } @@ -32,7 +33,7 @@ type State = { focused: boolean, } -class NewInput extends React.Component { +class ReflessNewInput extends React.Component { static defaultProps = { flexable: true, keyboardType: 'default', @@ -54,6 +55,7 @@ class NewInput extends React.Component { render() { const textStyle = getTextStyle(this.props.textType) + const {containerStyle, decoration, error, forwardedRef, hideBorder, icon, ...plainInputProps} = this.props return ( { /> )} - + {this.props.decoration} ) } } +// $FlowIssue doesn't know about forwardRef (https://github.com/facebook/flow/issues/6103) +const NewInput = React.forwardRef((props, ref) => ) const styles = styleSheetCreate({ container: platformStyles({ diff --git a/shared/common-adapters/plain-input.desktop.js b/shared/common-adapters/plain-input.desktop.js index aaae6d37134d..625c0f8188bd 100644 --- a/shared/common-adapters/plain-input.desktop.js +++ b/shared/common-adapters/plain-input.desktop.js @@ -2,9 +2,11 @@ import * as React from 'react' import {getStyle as getTextStyle} from './text.desktop' import {collapseStyles, globalColors, styleSheetCreate, platformStyles} from '../styles' +import {pick} from 'lodash-es' +import logger from '../logger' import type {_StylesDesktop} from '../styles/css' -import type {InternalProps, TextInfo} from './plain-input' +import type {InternalProps, TextInfo, Selection} from './plain-input' import {checkTextInfo} from './input.shared' // A plain text input component. Handles callbacks, text styling, and auto resizing but @@ -21,6 +23,9 @@ class PlainInput extends React.PureComponent { this._input = ref } + // This is controlled if a value prop is passed + _controlled = () => typeof this.props.value === 'string' + _onChange = ({target: {value = ''}}) => { this.props.onChangeText && this.props.onChangeText(value) this._autoResize() @@ -73,6 +78,12 @@ class PlainInput extends React.PureComponent { } transformText = (fn: TextInfo => TextInfo, reflectChange?: boolean) => { + if (this._controlled()) { + const errMsg = + 'Attempted to use transformText on controlled input component. Use props.value and setSelection instead.' + logger.error(errMsg) + throw new Error(errMsg) + } const n = this._input if (n) { const textInfo: TextInfo = { @@ -96,6 +107,28 @@ class PlainInput extends React.PureComponent { } } + getSelection = () => { + const n = this._input + if (n) { + return {start: n.selectionStart, end: n.selectionEnd} + } + return null + } + + setSelection = (s: Selection) => { + if (!this._controlled()) { + const errMsg = + 'Attempted to use setSelection on uncontrolled input component. Use transformText instead' + logger.error(errMsg) + throw new Error(errMsg) + } + const n = this._input + if (n) { + n.selectionStart = s.start + n.selectionEnd = s.end + } + } + _onCompositionStart = () => { this._isComposingIME = true } @@ -129,6 +162,7 @@ class PlainInput extends React.PureComponent { _getCommonProps = () => { let commonProps: any = { + ...pick(this.props, ['maxLength', 'value']), // Props we should only passthrough if supplied autoFocus: this.props.autoFocus, className: this.props.className, onBlur: this._onBlur, @@ -145,9 +179,6 @@ class PlainInput extends React.PureComponent { if (this.props.disabled) { commonProps.readOnly = 'readonly' } - if (this.props.maxLength) { - commonProps.maxlength = this.props.maxLength - } return commonProps } diff --git a/shared/common-adapters/plain-input.js.flow b/shared/common-adapters/plain-input.js.flow index 2345cb612702..3002c45c3f79 100644 --- a/shared/common-adapters/plain-input.js.flow +++ b/shared/common-adapters/plain-input.js.flow @@ -20,7 +20,7 @@ export type KeyboardType = // Android Only | 'visible-password' -export type Props = { +export type Props = {| autoFocus?: boolean, className?: string, disabled?: boolean, @@ -38,6 +38,7 @@ export type Props = { style?: StylesCrossPlatform, textType?: TextType, type?: 'password' | 'text' | 'number', + value?: string, // Makes this a controlled input when passed. Also disables mutating value via `transformText`, see note at component API /* Platform discrepancies */ // Maps to onSubmitEditing on native @@ -55,7 +56,7 @@ export type Props = { returnKeyType?: 'done' | 'go' | 'next' | 'search' | 'send', selectTextOnFocus?: boolean, onEndEditing?: () => void, -} +|} // Use this to mix your props with input props like type Props = PropsWithInput<{foo: number}> export type PropsWithInput

= {| @@ -73,10 +74,10 @@ export type PropsWithInput

= {| * use `InternalProps`. * See more discussion here: https://github.com/facebook/flow/issues/1660 */ -export type DefaultProps = { +export type DefaultProps = {| keyboardType: KeyboardType, textType: TextType, -} +|} export type Selection = {start: number, end: number} @@ -85,11 +86,25 @@ export type TextInfo = { selection: Selection, } -export type InternalProps = DefaultProps & Props +export type InternalProps = {...DefaultProps, ...Props} declare export default class PlainInput extends React.Component { static defaultProps: DefaultProps; blur: () => void; focus: () => void; - // Supported only on desktop right now + getSelection: () => ?Selection; + /** + * This can only be used when the input is controlled. Use `transformText` if + * you want to do this on an uncontrolled input. Make sure the Selection is + * valid against the `value` prop. Avoid changing `value` and calling this at + * the same time if you don't want bad things to happen. Note that a + * selection will only appear when the input is focused. Call `focus()` + * before this if you want to be sure the user will see the selection. + **/ + setSelection: Selection => void; + /** + * This can only be used when the input is uncontrolled. Like `setSelection`, + * if you want to be sure the user will see a selection use `focus()` before + * calling this. + **/ transformText: (fn: (TextInfo) => TextInfo, reflectChange?: boolean) => void; } diff --git a/shared/common-adapters/plain-input.native.js b/shared/common-adapters/plain-input.native.js index d773b3281666..f8bb27e8cbea 100644 --- a/shared/common-adapters/plain-input.native.js +++ b/shared/common-adapters/plain-input.native.js @@ -4,8 +4,11 @@ import {getStyle as getTextStyle} from './text' import {NativeTextInput} from './native-wrappers.native' import {collapseStyles, globalColors, styleSheetCreate} from '../styles' import {isIOS} from '../constants/platform' +import {checkTextInfo} from './input.shared' +import {pick} from 'lodash-es' +import logger from '../logger' -import type {InternalProps} from './plain-input' +import type {InternalProps, TextInfo, Selection} from './plain-input' type ContentSizeChangeEvent = {nativeEvent: {contentSize: {width: number, height: number}}} @@ -26,18 +29,93 @@ class PlainInput extends Component { focused: false, height: null, } - _input: ?NativeTextInput + _lastNativeText: ?string + _lastNativeSelection: ?Selection + + // TODO remove this when we can use forwardRef with react-redux. That'd let us + // use HOCTimers with this component. + // https://github.com/reduxjs/react-redux/pull/1000 + _timeoutIDs = [] + _setInputRef = (ref: ?NativeTextInput) => { this._input = ref } + _setTimeout = (fn: () => void, timeoutMS: number) => { + this._timeoutIDs.push(setTimeout(fn, timeoutMS)) + } + + // This is controlled if a value prop is passed + _controlled = () => typeof this.props.value === 'string' + + componentWillUnmount() { + this._timeoutIDs.forEach(clearTimeout) + } + // Needed to support wrapping with e.g. a ClickableBox. See // https://facebook.github.io/react-native/docs/direct-manipulation.html . setNativeProps = (nativeProps: Object) => { this._input && this._input.setNativeProps(nativeProps) } + transformText = (fn: TextInfo => TextInfo) => { + if (this._controlled()) { + const errMsg = + 'Attempted to use transformText on controlled input component. Use props.value and setSelection instead.' + logger.error(errMsg) + throw new Error(errMsg) + } + const currentTextInfo = { + text: this._lastNativeText || '', + selection: this._lastNativeSelection || {start: 0, end: 0}, + } + const newTextInfo = fn(currentTextInfo) + checkTextInfo(newTextInfo) + this.setNativeProps({text: newTextInfo.text}) + this._lastNativeText = newTextInfo.text + this._setSelection(newTextInfo.selection) + } + + getSelection = () => this._lastNativeSelection || {start: 0, end: 0} + + setSelection = (s: Selection) => { + if (!this._controlled()) { + const errMsg = + 'Attempted to use setSelection on uncontrolled input component. Use transformText instead' + logger.error(errMsg) + throw new Error(errMsg) + } + this._setSelection(s) + } + + _setSelection = (selection: Selection) => { + this._setTimeout(() => { + // Validate that this selection makes sense with current value + let {start, end} = selection + const text = this._lastNativeText || '' // TODO write a good internal getValue fcn for this + end = Math.max(0, Math.min(end, text.length)) + start = Math.min(start, end) + const newSelection = {start, end} + this.setNativeProps({selection: newSelection}) + this._lastNativeSelection = selection + }, 0) + } + + _onChangeText = (t: string) => { + this._lastNativeText = t + this.props.onChangeText && this.props.onChangeText(t) + } + + _onSelectionChange = (event: {nativeEvent: {selection: Selection}}) => { + const {start: _start, end: _end} = event.nativeEvent.selection + // Work around Android bug which sometimes puts end before start: + // https://github.com/facebook/react-native/issues/18579 . + const start = Math.min(_start, _end) + const end = Math.max(_start, _end) + this._lastNativeSelection = {start, end} + } + _onContentSizeChange = (event: ContentSizeChangeEvent) => { if (this.props.multiline) { let height = event.nativeEvent.contentSize.height @@ -115,7 +193,8 @@ class PlainInput extends Component { } _getProps = () => { - const common: any = { + const common = { + ...pick(this.props, ['maxLength', 'value']), // Props we should only passthrough if supplied autoCapitalize: this.props.autoCapitalize || 'none', autoCorrect: !!this.props.autoCorrect, autoFocus: this.props.autoFocus, @@ -123,9 +202,10 @@ class PlainInput extends Component { keyboardType: this.props.keyboardType, multiline: false, onBlur: this._onBlur, - onChangeText: this.props.onChangeText, + onChangeText: this._onChangeText, onEndEditing: this.props.onEndEditing, onFocus: this._onFocus, + onSelectionChange: this._onSelectionChange, onSubmitEditing: this.props.onEnterKeyDown, placeholder: this.props.placeholder, placeholderTextColor: this.props.placeholderColor || globalColors.black_40, @@ -135,9 +215,6 @@ class PlainInput extends Component { style: this._getStyle(), underlineColorAndroid: 'transparent', } - if (this.props.maxLength) { - common.maxLength = this.props.maxLength - } if (this.props.multiline) { return { ...common, @@ -151,6 +228,9 @@ class PlainInput extends Component { render = () => { const props = this._getProps() + if (props.value) { + this._lastNativeText = props.value + } return } } diff --git a/shared/common-adapters/plain-input.stories.js b/shared/common-adapters/plain-input.stories.js index e0185a6392bf..547a4205e516 100644 --- a/shared/common-adapters/plain-input.stories.js +++ b/shared/common-adapters/plain-input.stories.js @@ -2,9 +2,11 @@ import * as React from 'react' import PlainInput from './plain-input' import Box, {Box2} from './box' +import Button from './button' +import ButtonBar from './button-bar' import Text from './text' -import {action, storiesOf} from '../stories/storybook' -import {globalColors} from '../styles' +import {action, scrollViewDecorator, storiesOf} from '../stories/storybook' +import {globalColors, globalMargins} from '../styles' const commonProps = { onBlur: action('onBlur'), @@ -17,9 +19,141 @@ const commonProps = { style: {borderWidth: 1, borderStyle: 'solid', borderColor: globalColors.black_10}, } +class TestInput extends React.Component<{multiline: boolean}, {value: string}> { + state = {value: ''} + // prettier-ignore + _input = React.createRef() + + _insertText = (t: string) => { + const input = this._input.current + if (input) { + const selection = input.getSelection() + if (selection) { + this.setState( + s => { + const value = s.value.substring(0, selection.start) + t + s.value.substring(selection.end) + return {value} + }, + () => { + const input = this._input.current + if (input) { + const newCursorPos = selection.start + t.length + input.setSelection({start: newCursorPos, end: newCursorPos}) + input.focus() + } + } + ) + } + } + } + + render() { + return ( + + this._insertText('foo')} + onChangeText={v => this.setState(s => (s.value === v ? null : {value: v}))} + /> + +