From 0ab984f5074df4481baabd0caad402668dab2523 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Sun, 20 Dec 2015 02:52:20 -0800 Subject: [PATCH] [change] TextInput: implement `placeholder` and `placeholderTextColor` Without access to the Shadow DOM pseudo-elements, the placeholder behaviour needs to be reimplemented. Update to match React Native's modification to `TextInput` to include all `View` props and use the `Text` style props. Fix #12 Fix #48 --- docs/components/TextInput.md | 29 ++-- examples/components/App.js | 4 +- examples/components/MediaQueryWidget.js | 2 +- .../TextInput/TextInputStylePropTypes.js | 21 +-- .../TextInput/__tests__/index-test.js | 163 ++++++++++-------- src/components/TextInput/index.js | 85 ++++++--- src/modules/StylePropTypes/index.js | 1 + 7 files changed, 174 insertions(+), 131 deletions(-) diff --git a/docs/components/TextInput.md b/docs/components/TextInput.md index efacc854..df214304 100644 --- a/docs/components/TextInput.md +++ b/docs/components/TextInput.md @@ -48,9 +48,10 @@ updating the `value` prop to keep the controlled state in sync. If `false`, text is not editable (i.e., read-only). -**keyboardType**: oneOf('default', 'email-address', 'numeric', 'phone-pad', 'url') = 'default' +**keyboardType**: oneOf('default', 'email-address', 'numeric', 'phone-pad', 'search', 'url', 'web-search') = 'default' -Determines which keyboard to open. +Determines which keyboard to open. (NOTE: Safari iOS requires an ancestral +`
` element to display the `search` keyboard). (Not available when `multiline` is `true`.) @@ -111,11 +112,12 @@ object is passed as an argument to the callback handler. **placeholder**: string -The string that will be rendered before text input has been entered. +The string that will be rendered in an empty `TextInput` before text has been +entered. **placeholderTextColor**: string -TODO. The text color of the placeholder string. +The text color of the placeholder string. **secureTextEntry**: bool = false @@ -130,18 +132,8 @@ If `true`, all text will automatically be selected on focus. **style**: style -+ ...[View#style](View.md) -+ `color` -+ `direction` -+ `fontFamily` -+ `fontSize` -+ `fontStyle` -+ `fontWeight` -+ `letterSpacing` -+ `lineHeight` -+ `textAlign` -+ `textDecoration` -+ `textTransform` ++ ...[Text#style](Text.md) ++ `outline` **testID**: string @@ -166,6 +158,10 @@ export default class TextInputExample extends Component { this.state = { isFocused: false } } + _onBlur(e) { + this.setState({ isFocused: false }) + } + _onFocus(e) { this.setState({ isFocused: true }) } @@ -177,6 +173,7 @@ export default class TextInputExample extends Component { maxNumberOfLines={5} multiline numberOfLines={2} + onBlur={this._onBlur.bind(this)} onFocus={this._onFocus.bind(this)} placeholder={`What's happening?`} style={{ diff --git a/examples/components/App.js b/examples/components/App.js index 09aa7988..87f71019 100644 --- a/examples/components/App.js +++ b/examples/components/App.js @@ -95,10 +95,10 @@ export default class App extends React.Component { /> - + - + { return ( Active Media Query - {`"${active.alias}"`} {active.mql.media} + {`"${active.alias}"`} {active.mql && active.mql.media} ) } diff --git a/src/components/TextInput/TextInputStylePropTypes.js b/src/components/TextInput/TextInputStylePropTypes.js index 5ac21792..e36cd937 100644 --- a/src/components/TextInput/TextInputStylePropTypes.js +++ b/src/components/TextInput/TextInputStylePropTypes.js @@ -1,20 +1,7 @@ -import { pickProps } from '../../modules/filterObjectProps' -import View from '../View' -import CoreComponent from '../CoreComponent' +import React from 'react' +import Text from '../Text' export default { - ...(View.stylePropTypes), - ...pickProps(CoreComponent.stylePropTypes, [ - 'color', - 'direction', - 'fontFamily', - 'fontSize', - 'fontStyle', - 'fontWeight', - 'letterSpacing', - 'lineHeight', - 'textAlign', - 'textDecoration', - 'textTransform' - ]) + ...Text.stylePropTypes, + outline: React.PropTypes.string } diff --git a/src/components/TextInput/__tests__/index-test.js b/src/components/TextInput/__tests__/index-test.js index d0e2312c..2f7eb809 100644 --- a/src/components/TextInput/__tests__/index-test.js +++ b/src/components/TextInput/__tests__/index-test.js @@ -7,6 +7,10 @@ import ReactTestUtils from 'react-addons-test-utils' import TextInput from '../' +const findInput = (dom) => dom.querySelector('input, textarea') +const findShallowInput = (vdom) => vdom.props.children.props.children[0] +const findShallowPlaceholder = (vdom) => vdom.props.children.props.children[1] + suite('components/TextInput', () => { test('prop "accessibilityLabel"', () => { const accessibilityLabel = 'accessibilityLabel' @@ -16,123 +20,120 @@ suite('components/TextInput', () => { test('prop "autoComplete"', () => { // off - let dom = utils.renderToDOM() - assert.equal(dom.getAttribute('autocomplete'), undefined) + let input = findInput(utils.renderToDOM()) + assert.equal(input.getAttribute('autocomplete'), undefined) // on - dom = utils.renderToDOM() - assert.equal(dom.getAttribute('autocomplete'), 'on') + input = findInput(utils.renderToDOM()) + assert.equal(input.getAttribute('autocomplete'), 'on') }) test('prop "autoFocus"', () => { // false - let dom = utils.renderToDOM() + let input = findInput(utils.renderToDOM()) assert.deepEqual(document.activeElement, document.body) // true - dom = utils.renderToDOM() - assert.deepEqual(document.activeElement, dom) + input = findInput(utils.renderToDOM()) + assert.deepEqual(document.activeElement, input) }) utils.testIfDocumentFocused('prop "clearTextOnFocus"', () => { const defaultValue = 'defaultValue' // false - let dom = utils.renderAndInject() - dom.focus() - assert.equal(dom.value, defaultValue) + let input = findInput(utils.renderAndInject()) + input.focus() + assert.equal(input.value, defaultValue) // true - dom = utils.renderAndInject() - dom.focus() - assert.equal(dom.value, '') + input = findInput(utils.renderAndInject()) + input.focus() + assert.equal(input.value, '') }) test('prop "defaultValue"', () => { const defaultValue = 'defaultValue' - const result = utils.shallowRender() - assert.equal(result.props.defaultValue, defaultValue) + const input = findShallowInput(utils.shallowRender()) + assert.equal(input.props.defaultValue, defaultValue) }) test('prop "editable"', () => { // true - let dom = utils.renderToDOM() - assert.equal(dom.getAttribute('readonly'), undefined) + let input = findInput(utils.renderToDOM()) + assert.equal(input.getAttribute('readonly'), undefined) // false - dom = utils.renderToDOM() - assert.equal(dom.getAttribute('readonly'), '') + input = findInput(utils.renderToDOM()) + assert.equal(input.getAttribute('readonly'), '') }) test('prop "keyboardType"', () => { // default - let dom = utils.renderToDOM() - assert.equal(dom.getAttribute('type'), undefined) - dom = utils.renderToDOM() - assert.equal(dom.getAttribute('type'), undefined) + let input = findInput(utils.renderToDOM()) + assert.equal(input.getAttribute('type'), undefined) + input = findInput(utils.renderToDOM()) + assert.equal(input.getAttribute('type'), undefined) // email-address - dom = utils.renderToDOM() - assert.equal(dom.getAttribute('type'), 'email') + input = findInput(utils.renderToDOM()) + assert.equal(input.getAttribute('type'), 'email') // numeric - dom = utils.renderToDOM() - assert.equal(dom.getAttribute('type'), 'number') + input = findInput(utils.renderToDOM()) + assert.equal(input.getAttribute('type'), 'number') // phone-pad - dom = utils.renderToDOM() - assert.equal(dom.getAttribute('type'), 'tel') + input = findInput(utils.renderToDOM()) + assert.equal(input.getAttribute('type'), 'tel') // url - dom = utils.renderToDOM() - assert.equal(dom.getAttribute('type'), 'url') + input = findInput(utils.renderToDOM()) + assert.equal(input.getAttribute('type'), 'url') }) test('prop "maxLength"', () => { - let dom = utils.renderToDOM() - assert.equal(dom.getAttribute('maxlength'), undefined) - dom = utils.renderToDOM() - assert.equal(dom.getAttribute('maxlength'), '10') + let input = findInput(utils.renderToDOM()) + assert.equal(input.getAttribute('maxlength'), undefined) + input = findInput(utils.renderToDOM()) + assert.equal(input.getAttribute('maxlength'), '10') }) test('prop "maxNumberOfLines"', () => { - const style = { borderWidth: 0, fontSize: 20, lineHeight: 1 } const generateValue = () => { let str = '' while (str.length < 100) str += 'x' return str } - let dom = utils.renderAndInject( + let input = findInput(utils.renderAndInject( - ) - const height = dom.getBoundingClientRect().height + )) + const height = input.getBoundingClientRect().height // need a range because of cross-browser differences - assert.ok(height >= 60, height) - assert.ok(height <= 66, height) + assert.ok(height >= 42, height) + assert.ok(height <= 48, height) }) test('prop "multiline"', () => { // false - let dom = utils.renderToDOM() - assert.equal(dom.tagName, 'INPUT') + let input = findInput(utils.renderToDOM()) + assert.equal(input.tagName, 'INPUT') // true - dom = utils.renderToDOM() - assert.equal(dom.tagName, 'TEXTAREA') + input = findInput(utils.renderToDOM()) + assert.equal(input.tagName, 'TEXTAREA') }) test('prop "numberOfLines"', () => { - const style = { borderWidth: 0, fontSize: 20, lineHeight: 1 } // missing multiline - let dom = utils.renderToDOM() - assert.equal(dom.tagName, 'INPUT') + let input = findInput(utils.renderToDOM()) + assert.equal(input.tagName, 'INPUT') // with multiline - dom = utils.renderAndInject() - assert.equal(dom.tagName, 'TEXTAREA') - const height = dom.getBoundingClientRect().height + input = findInput(utils.renderAndInject()) + assert.equal(input.tagName, 'TEXTAREA') + const height = input.getBoundingClientRect().height // need a range because of cross-browser differences - assert.ok(height >= 40) - assert.ok(height <= 46) + assert.ok(height >= 30, height) + assert.ok(height <= 36, height) }) test('prop "onBlur"', (done) => { - const input = utils.renderToDOM() + const input = findInput(utils.renderToDOM()) ReactTestUtils.Simulate.blur(input) function onBlur(e) { assert.ok(e) @@ -141,7 +142,7 @@ suite('components/TextInput', () => { }) test('prop "onChange"', (done) => { - const input = utils.renderToDOM() + const input = findInput(utils.renderToDOM()) ReactTestUtils.Simulate.change(input) function onChange(e) { assert.ok(e) @@ -151,7 +152,7 @@ suite('components/TextInput', () => { test('prop "onChangeText"', (done) => { const newText = 'newText' - const input = utils.renderToDOM() + const input = findInput(utils.renderToDOM()) ReactTestUtils.Simulate.change(input, { target: { value: newText } }) function onChangeText(text) { assert.equal(text, newText) @@ -160,7 +161,7 @@ suite('components/TextInput', () => { }) test('prop "onFocus"', (done) => { - const input = utils.renderToDOM() + const input = findInput(utils.renderToDOM()) ReactTestUtils.Simulate.focus(input) function onFocus(e) { assert.ok(e) @@ -171,7 +172,7 @@ suite('components/TextInput', () => { test('prop "onLayout"') test('prop "onSelectionChange"', (done) => { - const input = utils.renderAndInject() + const input = findInput(utils.renderAndInject()) ReactTestUtils.Simulate.select(input, { target: { selectionStart: 0, selectionEnd: 3 } }) function onSelectionChange(e) { assert.equal(e.selectionEnd, 3) @@ -180,30 +181,42 @@ suite('components/TextInput', () => { } }) - test('prop "placeholder"') + test('prop "placeholder"', () => { + const placeholder = 'placeholder' + const result = findShallowPlaceholder(utils.shallowRender()) + assert.equal(result.props.children, placeholder) + }) - test('prop "placeholderTextColor"') + test('prop "placeholderTextColor"', () => { + const placeholder = 'placeholder' + + let result = findShallowPlaceholder(utils.shallowRender()) + assert.equal(result.props.style.color, 'darkgray') + + result = findShallowPlaceholder(utils.shallowRender()) + assert.equal(result.props.style.color, 'red') + }) test('prop "secureTextEntry"', () => { - let dom = utils.renderToDOM() - assert.equal(dom.getAttribute('type'), 'password') + let input = findInput(utils.renderToDOM()) + assert.equal(input.getAttribute('type'), 'password') // ignored for multiline - dom = utils.renderToDOM() - assert.equal(dom.getAttribute('type'), undefined) + input = findInput(utils.renderToDOM()) + assert.equal(input.getAttribute('type'), undefined) }) utils.testIfDocumentFocused('prop "selectTextOnFocus"', () => { const text = 'Text' // false - let dom = utils.renderAndInject() - dom.focus() - assert.equal(dom.selectionEnd, 0) - assert.equal(dom.selectionStart, 0) + let input = findInput(utils.renderAndInject()) + input.focus() + assert.equal(input.selectionEnd, 0) + assert.equal(input.selectionStart, 0) // true - dom = utils.renderAndInject() - dom.focus() - assert.equal(dom.selectionEnd, 4) - assert.equal(dom.selectionStart, 0) + input = findInput(utils.renderAndInject()) + input.focus() + assert.equal(input.selectionEnd, 4) + assert.equal(input.selectionStart, 0) }) test('prop "style"', () => { @@ -218,7 +231,7 @@ suite('components/TextInput', () => { test('prop "value"', () => { const value = 'value' - const result = utils.shallowRender() - assert.equal(result.props.value, value) + const input = findShallowInput(utils.shallowRender()) + assert.equal(input.props.value, value) }) }) diff --git a/src/components/TextInput/index.js b/src/components/TextInput/index.js index f1479c2c..a2a9b8ce 100644 --- a/src/components/TextInput/index.js +++ b/src/components/TextInput/index.js @@ -3,27 +3,50 @@ import CoreComponent from '../CoreComponent' import React, { PropTypes } from 'react' import ReactDOM from 'react-dom' import StyleSheet from '../../modules/StyleSheet' +import Text from '../Text' import TextareaAutosize from 'react-textarea-autosize' import TextInputStylePropTypes from './TextInputStylePropTypes' +import View from '../View' const textInputStyleKeys = Object.keys(TextInputStylePropTypes) const styles = StyleSheet.create({ initial: { + ...View.defaultProps.style, + borderColor: 'black', + borderWidth: 1 + }, + input: { appearance: 'none', backgroundColor: 'transparent', - borderColor: 'black', - borderWidth: '1px', + borderWidth: 0, boxSizing: 'border-box', color: 'inherit', + flexGrow: 1, font: 'inherit', - padding: 0 + padding: 0, + zIndex: 1 + }, + placeholder: { + bottom: 0, + color: 'darkgray', + left: 0, + overflow: 'hidden', + position: 'absolute', + right: 0, + top: 0, + whiteSpace: 'pre' } }) class TextInput extends React.Component { + constructor(props, context) { + super(props, context) + this.state = { showPlaceholder: !props.value && !props.defaultValue } + } + static propTypes = { - accessibilityLabel: CoreComponent.propTypes.accessibilityLabel, + ...View.propTypes, autoComplete: PropTypes.bool, autoFocus: PropTypes.bool, clearTextOnFocus: PropTypes.bool, @@ -61,20 +84,26 @@ class TextInput extends React.Component { _onBlur(e) { const { onBlur } = this.props + const value = e.target.value + this.setState({ showPlaceholder: value === '' }) if (onBlur) onBlur(e) } _onChange(e) { const { onChange, onChangeText } = this.props - if (onChangeText) onChangeText(e.target.value) + const value = e.target.value + this.setState({ showPlaceholder: value === '' }) + if (onChangeText) onChangeText(value) if (onChange) onChange(e) } _onFocus(e) { const { clearTextOnFocus, onFocus, selectTextOnFocus } = this.props - const node = ReactDOM.findDOMNode(this) + const node = ReactDOM.findDOMNode(this.refs.input) + const value = e.target.value if (clearTextOnFocus) node.value = '' if (selectTextOnFocus) node.select() + this.setState({ showPlaceholder: value === '' }) if (onFocus) onFocus(e) } @@ -92,7 +121,9 @@ class TextInput extends React.Component { render() { const { + /* eslint-disable react/prop-types */ accessibilityLabel, + /* eslint-enable react/prop-types */ autoComplete, autoFocus, defaultValue, @@ -102,11 +133,9 @@ class TextInput extends React.Component { maxNumberOfLines, multiline, numberOfLines, - onBlur, - onChange, - onChangeText, onSelectionChange, placeholder, + placeholderTextColor, secureTextEntry, style, testID, @@ -126,6 +155,10 @@ class TextInput extends React.Component { case 'phone-pad': type = 'tel' break + case 'search': + case 'web-search': + type = 'search' + break case 'url': type = 'url' break @@ -136,23 +169,16 @@ class TextInput extends React.Component { } const propsCommon = { - accessibilityLabel, autoComplete: autoComplete && 'on', autoFocus, - className: 'TextInput', defaultValue, maxLength, - onBlur: onBlur && this._onBlur.bind(this), - onChange: (onChange || onChangeText) && this._onChange.bind(this), + onBlur: this._onBlur.bind(this), + onChange: this._onChange.bind(this), onFocus: this._onFocus.bind(this), onSelect: onSelectionChange && this._onSelectionChange.bind(this), - placeholder, readOnly: !editable, - style: { - ...styles.initial, - ...resolvedStyle - }, - testID, + style: { ...styles.input, outline: style.outline }, value } @@ -172,7 +198,26 @@ class TextInput extends React.Component { const props = multiline ? propsMultiline : propsSingleline return ( - + + + + {placeholder && this.state.showPlaceholder && {placeholder}} + + ) } } diff --git a/src/modules/StylePropTypes/index.js b/src/modules/StylePropTypes/index.js index 3203d80e..dd328aa9 100644 --- a/src/modules/StylePropTypes/index.js +++ b/src/modules/StylePropTypes/index.js @@ -80,6 +80,7 @@ export default { minWidth: numberOrString, opacity: numberOrString, order: numberOrString, + outline: string, overflow: string, overflowX: string, overflowY: string,