From e72719380976dda36b0aed05ed538c30c7585385 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Sun, 20 Sep 2015 15:43:52 -0700 Subject: [PATCH] TextInput: props and tests --- README.md | 2 +- config/karma.config.js | 2 +- docs/components/TextInput.md | 148 +++++++++++++---- package.json | 3 +- src/components/TextInput/index.js | 135 +++++++++++---- src/components/TextInput/index.spec.js | 219 ++++++++++++++++++++++++- src/example.js | 13 +- src/modules/specHelpers/index.js | 31 +++- 8 files changed, 483 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index fe0c76d9..3427d0c7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![npm version][npm-image]][npm-url] The core [React Native][react-native-url] components adapted and expanded upon -for the web, backed by a precomputed CSS library. ~19KB minified and gzipped. +for the web, backed by a precomputed CSS library. ~21KB minified and gzipped. * [Slack: reactiflux channel #react-native-web][slack-url] * [Gitter: react-native-web][gitter-url] diff --git a/config/karma.config.js b/config/karma.config.js index 1886c470..f52eb8f7 100644 --- a/config/karma.config.js +++ b/config/karma.config.js @@ -5,7 +5,7 @@ var webpackConfig = require('./webpack.config.base') module.exports = function (config) { config.set({ basePath: constants.ROOT_DIRECTORY, - browsers: [ process.env.TRAVIS ? 'Firefox' : 'Chrome' ], + browsers: process.env.TRAVIS ? [ 'Firefox' ] : [ 'Chrome' ], browserNoActivityTimeout: 60000, client: { captureConsole: true, diff --git a/docs/components/TextInput.md b/docs/components/TextInput.md index 5d53f8f9..50da55ea 100644 --- a/docs/components/TextInput.md +++ b/docs/components/TextInput.md @@ -5,67 +5,130 @@ such as auto-complete, auto-focus, placeholder text, and event callbacks. Note: some props are exclusive to or excluded from `multiline`. +Unsupported React Native props: +`autoCapitalize`, +`autoCorrect`, +`onEndEditing`, +`onSubmitEditing`, +`clearButtonMode` (ios), +`enablesReturnKeyAutomatically` (ios), +`returnKeyType` (ios), +`selectionState` (ios), +`textAlign` (android), +`textAlignVertical` (android), +`underlineColorAndroid` (android) + ## Props -**autoComplete** bool +(web) **accessibilityLabel**: string + +Defines the text label available to assistive technologies upon interaction +with the element. (This is implemented using `aria-label`.) + +(web) **autoComplete**: bool = false Indicates whether the value of the control can be automatically completed by the browser. -**autoFocus** bool +**autoFocus**: bool = false If true, focuses the input on `componentDidMount`. Only the first form element -in a document with `autofocus` is focused. Default: `false`. +in a document with `autofocus` is focused. -**defaultValue** string +**clearTextOnFocus**: bool = false + +If `true`, clears the text field automatically when focused. + +**defaultValue**: string Provides an initial value that will change when the user starts typing. Useful for simple use-cases where you don't want to deal with listening to events and updating the `value` prop to keep the controlled state in sync. -**editable** bool +**editable**: bool = true -If false, text is not editable. Default: `true`. +If `false`, text is not editable (i.e., read-only). -**keyboardType** oneOf('default', 'email', 'numeric', 'search', 'tel', 'url') +**keyboardType**: oneOf('default', 'email-address', 'numeric', 'phone-pad', 'url') = 'default' -Determines which keyboard to open, e.g. `email`. Default: `default`. (Not -available when `multiline` is `true`.) +Determines which keyboard to open. -**multiline** bool +(Not available when `multiline` is `true`.) -If true, the text input can be multiple lines. Default: `false`. +**maxLength**: number -**onBlur** function +Limits the maximum number of characters that can be entered. + +(web) **maxNumberOfLines**: number = numberOfLines + +Limits the maximum number of lines for a multiline `TextInput`. + +(Requires `multiline` to be `true`.) + +**multiline**: bool = false + +If true, the text input can be multiple lines. + +**numberOfLines**: number = 2 + +Sets the initial number of lines for a multiline `TextInput`. + +(Requires `multiline` to be `true`.) + +**onBlur**: function Callback that is called when the text input is blurred. -**onChange** function +**onChange**: function Callback that is called when the text input's text changes. -**onChangeText** function +**onChangeText**: function -Callback that is called when the text input's text changes. Changed text is -passed as an argument to the callback handler. +Callback that is called when the text input's text changes. The text is passed +as an argument to the callback handler. -**onFocus** function +**onFocus**: function Callback that is called when the text input is focused. -**placeholder** string +**onLayout**: function + +TODO + +(web) **onSelectionChange**: function + +Callback that is called when the text input's selection changes. The following +object is passed as an argument to the callback handler. + +```js +{ + selectionDirection, + selectionEnd, + selectionStart, + nativeEvent +} +``` + +**placeholder**: string The string that will be rendered before text input has been entered. -**placeholderTextColor** string +**placeholderTextColor**: string -The text color of the placeholder string. +TODO. The text color of the placeholder string. -**secureTextEntry** bool +**secureTextEntry**: bool = false If true, the text input obscures the text entered so that sensitive text like -passwords stay secure. Default: `false`. (Not available when `multiline` is `true`.) +passwords stay secure. -**style** style +(Not available when `multiline` is `true`.) + +**selectTextOnFocus**: bool = false + +If `true`, all text will automatically be selected on focus. + +**style**: style [View](View.md) style @@ -81,31 +144,60 @@ passwords stay secure. Default: `false`. (Not available when `multiline` is `tru + `textDecoration` + `textTransform` -**testID** string +**testID**: string Used to locate this view in end-to-end tests. +**value**: string + +The value to show for the text input. `TextInput` is a controlled component, +which means the native `value` will be forced to match this prop if provided. +Read about how [React form +components](https://facebook.github.io/react/docs/forms.html) work. To prevent +user edits to the value set `editable={false}`. + ## Examples ```js import React, { TextInput } from 'react-native-web' -const { Component, PropTypes } = React +const { Component } = React class AppTextInput extends Component { - static propTypes = { + constructor(props, context) { + super(props, context) + this.state = { isFocused: false } } - static defaultProps = { + _onFocus(e) { + this.setState({ isFocused: true }) } render() { return ( - + ); } } const styles = { + default: { + borderColor: 'gray', + borderWidth: '0 0 2px 0' + }, + focused: { + borderColor: 'blue' + } } ``` diff --git a/package.json b/package.json index 215d66f5..22fb2227 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "dependencies": { "react": ">=0.13.3", "react-swipeable": "^3.0.2", - "react-tappable": "^0.6.0" + "react-tappable": "^0.6.0", + "react-textarea-autosize": "^2.5.2" }, "devDependencies": { "autoprefixer-loader": "^3.1.0", diff --git a/src/components/TextInput/index.js b/src/components/TextInput/index.js index af36be12..8774ccab 100644 --- a/src/components/TextInput/index.js +++ b/src/components/TextInput/index.js @@ -1,6 +1,7 @@ import { pickProps } from '../../modules/filterObjectProps' import CoreComponent from '../CoreComponent' import React, { PropTypes } from 'react' +import TextareaAutosize from 'react-textarea-autosize' import TextInputStylePropTypes from './TextInputStylePropTypes' const textInputStyleKeys = Object.keys(TextInputStylePropTypes) @@ -9,90 +10,166 @@ const styles = { initial: { appearance: 'none', backgroundColor: 'transparent', + borderColor: 'black', borderWidth: '1px', + boxSizing: 'border-box', color: 'inherit', - font: 'inherit' + font: 'inherit', + padding: 0 } } class TextInput extends React.Component { static propTypes = { + accessibilityLabel: PropTypes.string, autoComplete: PropTypes.bool, autoFocus: PropTypes.bool, + clearTextOnFocus: PropTypes.bool, defaultValue: PropTypes.string, editable: PropTypes.bool, - keyboardType: PropTypes.oneOf(['default', 'email', 'numeric', 'search', 'tel', 'url']), + keyboardType: PropTypes.oneOf(['default', 'email-address', 'numeric', 'phone-pad', 'url']), + maxLength: PropTypes.number, + maxNumberOfLines: PropTypes.number, multiline: PropTypes.bool, + numberOfLines: PropTypes.number, onBlur: PropTypes.func, onChange: PropTypes.func, onChangeText: PropTypes.func, onFocus: PropTypes.func, + onSelectionChange: PropTypes.func, placeholder: PropTypes.string, + placeholderTextColor: PropTypes.string, secureTextEntry: PropTypes.bool, + selectTextOnFocus: PropTypes.bool, style: PropTypes.shape(TextInputStylePropTypes), - testID: CoreComponent.propTypes.testID + testID: CoreComponent.propTypes.testID, + value: PropTypes.string } static stylePropTypes = TextInputStylePropTypes static defaultProps = { - autoComplete: false, - autoFocus: false, editable: true, keyboardType: 'default', multiline: false, + numberOfLines: 2, secureTextEntry: false, style: styles.initial } _onBlur(e) { - if (this.props.onBlur) this.props.onBlur(e) + const { onBlur } = this.props + if (onBlur) onBlur(e) } _onChange(e) { - if (this.props.onChangeText) this.props.onChangeText(e.target.value) - if (this.props.onChange) this.props.onChange(e) + const { onChange, onChangeText } = this.props + if (onChangeText) onChangeText(e.target.value) + if (onChange) onChange(e) } _onFocus(e) { - if (this.props.onFocus) this.props.onFocus(e) + const { clearTextOnFocus, onFocus, selectTextOnFocus } = this.props + const node = React.findDOMNode(this) + if (clearTextOnFocus) node.value = '' + if (selectTextOnFocus) node.select() + if (onFocus) onFocus(e) + } + + _onSelectionChange(e) { + const { onSelectionChange } = this.props + const { selectionDirection, selectionEnd, selectionStart } = e.target + const event = { + selectionDirection, + selectionEnd, + selectionStart, + nativeEvent: e.nativeEvent + } + if (onSelectionChange) onSelectionChange(event) } render() { const { + accessibilityLabel, autoComplete, autoFocus, defaultValue, editable, keyboardType, + maxLength, + maxNumberOfLines, multiline, + numberOfLines, + onBlur, + onChange, + onChangeText, + onSelectionChange, placeholder, secureTextEntry, style, - testID + testID, + value } = this.props const resolvedStyle = pickProps(style, textInputStyleKeys) - const type = secureTextEntry && 'password' || (keyboardType === 'default' ? '' : keyboardType) + let type - return ( - + switch (keyboardType) { + case 'email-address': + type = 'email' + break + case 'numeric': + type = 'number' + break + case 'phone-pad': + type = 'tel' + break + case 'url': + type = 'url' + break + } + + if (secureTextEntry) { + type = 'password' + } + + const propsCommon = { + 'aria-label': accessibilityLabel, + autoComplete: autoComplete && 'on', + autoFocus, + className: 'TextInput', + defaultValue, + maxLength, + onBlur: onBlur && this._onBlur.bind(this), + onChange: (onChange || onChangeText) && this._onChange.bind(this), + onFocus: this._onFocus.bind(this), + onSelect: onSelectionChange && this._onSelectionChange.bind(this), + placeholder, + readOnly: !editable, + style: { + ...styles.initial, + ...resolvedStyle + }, + testID, + value + } + + const propsMultiline = { + ...propsCommon, + component: TextareaAutosize, + maxRows: maxNumberOfLines || numberOfLines, + minRows: numberOfLines + } + + const propsSingleline = { + ...propsCommon, + component: 'input', + type + } + + return (multiline + ? + : ) } } diff --git a/src/components/TextInput/index.spec.js b/src/components/TextInput/index.spec.js index cd4d7ba6..1325adf0 100644 --- a/src/components/TextInput/index.spec.js +++ b/src/components/TextInput/index.spec.js @@ -1,5 +1,4 @@ -/* -import { assertProps, renderToDOM, shallowRender } from '../../modules/specHelpers' +import * as utils from '../../modules/specHelpers' import assert from 'assert' import React from 'react/addons' @@ -7,7 +6,217 @@ import TextInput from '.' const ReactTestUtils = React.addons.TestUtils -suite.skip('TextInput', () => { - test('prop "children"', () => {}) +suite('TextInput', () => { + test('prop "accessibilityLabel"', () => { + utils.assertProps.accessibilityLabel(TextInput) + }) + + test('prop "autoComplete"', () => { + // off + let dom = utils.renderToDOM() + assert.equal(dom.getAttribute('autocomplete'), undefined) + // on + dom = utils.renderToDOM() + assert.equal(dom.getAttribute('autocomplete'), 'on') + }) + + test('prop "autoFocus"', () => { + // false + let dom = utils.renderToDOM() + assert.deepEqual(document.activeElement, document.body) + // true + dom = utils.renderToDOM() + assert.deepEqual(document.activeElement, dom) + }) + + test('prop "clearTextOnFocus"', () => { + const defaultValue = 'defaultValue' + utils.requiresFocus(() => { + // false + let dom = utils.renderAndInject() + dom.focus() + assert.equal(dom.value, defaultValue) + // true + dom = utils.renderAndInject() + dom.focus() + assert.equal(dom.value, '') + }) + }) + + test('prop "defaultValue"', () => { + const defaultValue = 'defaultValue' + const result = utils.shallowRender() + assert.equal(result.props.defaultValue, defaultValue) + }) + + test('prop "editable"', () => { + // true + let dom = utils.renderToDOM() + assert.equal(dom.getAttribute('readonly'), undefined) + // false + dom = utils.renderToDOM() + assert.equal(dom.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) + // email-address + dom = utils.renderToDOM() + assert.equal(dom.getAttribute('type'), 'email') + // numeric + dom = utils.renderToDOM() + assert.equal(dom.getAttribute('type'), 'number') + // phone-pad + dom = utils.renderToDOM() + assert.equal(dom.getAttribute('type'), 'tel') + // url + dom = utils.renderToDOM() + assert.equal(dom.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') + }) + + test('prop "maxNumberOfLines"', () => { + const style = { borderWidth: 0, fontSize: 20, lineHeight: 1 } + const value = (() => { + let str = '' + while (str.length < 100) str += 'x' + return str + }()) + let dom = utils.renderAndInject( + + ) + const height = dom.getBoundingClientRect().height + // need a range because of cross-browser differences + assert.ok(height >= 60) + assert.ok(height <= 65) + }) + + test('prop "multiline"', () => { + // false + let dom = utils.renderToDOM() + assert.equal(dom.tagName, 'INPUT') + // true + dom = utils.renderToDOM() + assert.equal(dom.tagName, 'TEXTAREA') + }) + + test('prop "numberOfLines"', () => { + const style = { borderWidth: 0, fontSize: 20, lineHeight: 1 } + // missing multiline + let dom = utils.renderToDOM() + assert.equal(dom.tagName, 'INPUT') + // with multiline + dom = utils.renderAndInject() + assert.equal(dom.tagName, 'TEXTAREA') + const height = dom.getBoundingClientRect().height + // need a range because of cross-browser differences + assert.ok(height >= 40) + assert.ok(height <= 45) + }) + + test('prop "onBlur"', (done) => { + const input = utils.renderToDOM() + ReactTestUtils.Simulate.blur(input) + function onBlur(e) { + assert.ok(e) + done() + } + }) + + test('prop "onChange"', (done) => { + const input = utils.renderToDOM() + ReactTestUtils.Simulate.change(input) + function onChange(e) { + assert.ok(e) + done() + } + }) + + test('prop "onChangeText"', (done) => { + const newText = 'newText' + const input = utils.renderToDOM() + ReactTestUtils.Simulate.change(input, { target: { value: newText } }) + function onChangeText(text) { + assert.equal(text, newText) + done() + } + }) + + test('prop "onFocus"', (done) => { + const input = utils.renderToDOM() + ReactTestUtils.Simulate.focus(input) + function onFocus(e) { + assert.ok(e) + done() + } + }) + + test.skip('prop "onLayout"', () => {}) + + test('prop "onSelectionChange"', (done) => { + const input = utils.renderAndInject() + ReactTestUtils.Simulate.select(input, { target: { selectionStart: 0, selectionEnd: 3 } }) + function onSelectionChange(e) { + assert.equal(e.selectionEnd, 3) + assert.equal(e.selectionStart, 0) + done() + } + }) + + test.skip('prop "placeholder"', () => {}) + + test.skip('prop "placeholderTextColor"', () => {}) + + test('prop "secureTextEntry"', () => { + let dom = utils.renderToDOM() + assert.equal(dom.getAttribute('type'), 'password') + // ignored for multiline + dom = utils.renderToDOM() + assert.equal(dom.getAttribute('type'), undefined) + }) + + test('prop "selectTextOnFocus"', () => { + const text = 'Text' + utils.requiresFocus(() => { + // false + let dom = utils.renderAndInject() + dom.focus() + assert.equal(dom.selectionEnd, 0) + assert.equal(dom.selectionStart, 0) + // true + dom = utils.renderAndInject() + dom.focus() + assert.equal(dom.selectionEnd, 4) + assert.equal(dom.selectionStart, 0) + }) + }) + + test('prop "style"', () => { + utils.assertProps.style(TextInput) + }) + + test('prop "testID"', () => { + utils.assertProps.testID(TextInput) + }) + + test('prop "value"', () => { + const value = 'value' + const result = utils.shallowRender() + assert.equal(result.props.value, value) + }) }) -*/ diff --git a/src/example.js b/src/example.js index d60c13bc..1058459d 100644 --- a/src/example.js +++ b/src/example.js @@ -132,13 +132,20 @@ class Example extends Component { onChange={(e) => { console.log('TextInput.onChange', e) }} onChangeText={(e) => { console.log('TextInput.onChangeText', e) }} onFocus={(e) => { console.log('TextInput.onFocus', e) }} + onSelectionChange={(e) => { console.log('TextInput.onSelectionChange', e) }} /> + + - + - - + Touchable ) assert.deepEqual( - shallow.props.style.margin, - styleToMerge.margin, + shallow.props.style, + { ...Component.defaultProps.style, ...styleToMerge } ) }, @@ -86,6 +86,33 @@ export function renderToDOM(element, container) { return React.findDOMNode(result) } +export function renderAndInject(element) { + const id = '_renderAndInject' + let div = document.getElementById(id) + + if (!div) { + div = document.createElement('div') + div.id = id + document.body.appendChild(div) + } else { + div.innerHTML = '' + } + + const result = render(element, div) + return React.findDOMNode(result) +} + +export function requiresFocus(test, fallback) { + if (document.hasFocus && document.hasFocus()) { + test() + } else { + console.warn('Test was skipped as the document is not focused') + if (fallback) { + fallback() + } + } +} + export function shallowRender(component, context = {}) { const shallowRenderer = React.addons.TestUtils.createRenderer() shallowRenderer.render(component, context)