diff --git a/packages/docs/src/components/TextInput/examples/Multiline.js b/packages/docs/src/components/TextInput/examples/Multiline.js index 72c8c00c..5fd03474 100644 --- a/packages/docs/src/components/TextInput/examples/Multiline.js +++ b/packages/docs/src/components/TextInput/examples/Multiline.js @@ -1,12 +1,40 @@ -import React from 'react'; +import React, { useState } from 'react'; import { styles } from '../helpers'; import { TextInput, View } from 'react-native'; +const MIN_HEIGHT = 24; + +function Autogrow() { + const [height, setHeight] = useState(MIN_HEIGHT); + const [value, setValue] = useState(''); + + function onContentSizeChange(e) { + const { height } = e.nativeEvent.contentSize; + setHeight(Math.max(MIN_HEIGHT, height)); + } + + function onChangeText(text) { + setValue(text); + } + + return ( + + + + ); +} + export default function Multiline() { return ( - + ); } diff --git a/packages/docs/src/components/TextInput/helpers.js b/packages/docs/src/components/TextInput/helpers.js index 76e0e279..d94ee12d 100644 --- a/packages/docs/src/components/TextInput/helpers.js +++ b/packages/docs/src/components/TextInput/helpers.js @@ -6,7 +6,6 @@ export const styles = StyleSheet.create({ height: 26, borderWidth: 0.5, borderColor: '#0f0f0f', - flex: 1, padding: 4 }, eventLabel: { @@ -16,7 +15,6 @@ export const styles = StyleSheet.create({ multiline: { borderWidth: 0.5, borderColor: '#0f0f0f', - flex: 1, padding: 4, marginBottom: 4 } diff --git a/packages/react-native-web/src/exports/TextInput/__tests__/index-test.js b/packages/react-native-web/src/exports/TextInput/__tests__/index-test.js index eba211a1..f8d2c3f6 100644 --- a/packages/react-native-web/src/exports/TextInput/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/TextInput/__tests__/index-test.js @@ -1,12 +1,16 @@ /* eslint-env jasmine, jest */ import React from 'react'; -import ReactDOM from 'react-dom'; import TextInput from '..'; -import { mount, shallow } from 'enzyme'; +import { render } from '@testing-library/react'; -const findNativeInput = wrapper => wrapper.find('input'); -const findNativeTextarea = wrapper => wrapper.find('textarea'); +function findInput(container) { + return container.querySelector('input'); +} + +function findTextArea(container) { + return container.querySelector('textarea'); +} const testIfDocumentIsFocused = (message, fn) => { if (document.hasFocus && document.hasFocus()) { @@ -16,33 +20,80 @@ const testIfDocumentIsFocused = (message, fn) => { } }; +function createEvent(type, data = {}) { + const event = document.createEvent('CustomEvent'); + event.initCustomEvent(type, true, true); + if (data != null) { + Object.keys(data).forEach(key => { + const value = data[key]; + if (key === 'timeStamp' && !value) { + return; + } + Object.defineProperty(event, key, { value }); + }); + } + return event; +} + +function createKeyboardEvent( + type, + { + altKey = false, + ctrlKey = false, + isComposing = false, + key = '', + metaKey = false, + preventDefault = () => {}, + shiftKey = false + } = {} +) { + return createEvent(type, { + altKey, + ctrlKey, + isComposing, + key, + metaKey, + preventDefault, + shiftKey + }); +} + +function keydown(payload) { + return createKeyboardEvent('keydown', payload); +} + describe('components/TextInput', () => { describe('prop "autoComplete"', () => { test('value "on"', () => { - const input = findNativeInput(shallow()); - expect(input.prop('autoComplete')).toEqual('on'); + const { container } = render(); + const input = findInput(container); + expect(input.getAttribute('autoComplete')).toEqual('on'); }); test('value "off"', () => { - const input = findNativeInput(shallow()); - expect(input.prop('autoComplete')).toEqual('off'); + const { container } = render(); + const input = findInput(container); + expect(input.getAttribute('autoComplete')).toEqual('off'); }); test('autoCompleteType fallback', () => { - const input = findNativeInput(shallow()); - expect(input.prop('autoComplete')).toEqual('off'); + const { container } = render(); + const input = findInput(container); + expect(input.getAttribute('autoComplete')).toEqual('off'); }); }); describe('prop "autoFocus"', () => { test('value "false"', () => { - const input = findNativeInput(shallow()); - expect(input.prop('autoFocus')).toEqual(undefined); + const { container } = render(); + const input = findInput(container); + expect(document.activeElement).not.toBe(input); }); test('value "true"', () => { - const input = findNativeInput(shallow()); - expect(input.prop('autoFocus')).toEqual(true); + const { container } = render(); + const input = findInput(container); + expect(document.activeElement).toBe(input); }); }); @@ -50,166 +101,182 @@ describe('components/TextInput', () => { const defaultValue = 'defaultValue'; testIfDocumentIsFocused('value "false"', () => { - const input = findNativeInput(shallow()); - input.simulate('focus'); + const { container } = render(); + const input = findInput(container); + input.focus(); expect(input.node.value).toEqual(defaultValue); }); testIfDocumentIsFocused('value "true"', () => { - const input = findNativeInput( - shallow() - ); - input.simulate('focus'); + const { container } = render(); + const input = findInput(container); + input.focus(); expect(input.node.value).toEqual(''); }); }); test('prop "defaultValue"', () => { const defaultValue = 'defaultValue'; - const input = findNativeInput(shallow()); - expect(input.prop('defaultValue')).toEqual(defaultValue); + const { container } = render(); + const input = findInput(container); + expect(input.value).toEqual(defaultValue); }); describe('prop "disabled"', () => { test('value "false"', () => { - const input = findNativeInput(shallow()); - expect(input.prop('disabled')).toEqual(undefined); + const { container } = render(); + const input = findInput(container); + expect(input.disabled).toEqual(false); }); test('value "true"', () => { - const input = findNativeInput(shallow()); - expect(input.prop('disabled')).toEqual(true); + const { container } = render(); + const input = findInput(container); + expect(input.disabled).toEqual(true); }); }); describe('prop "editable"', () => { test('value "true"', () => { - const input = findNativeInput(shallow()); - expect(input.prop('readOnly')).toEqual(false); + const { container } = render(); + const input = findInput(container); + expect(input.readOnly).toEqual(false); }); test('value "false"', () => { - const input = findNativeInput(shallow()); - expect(input.prop('readOnly')).toEqual(true); + const { container } = render(); + const input = findInput(container); + expect(input.readOnly).toEqual(true); }); }); describe('prop "keyboardType"', () => { test('default value', () => { - let input = findNativeInput(shallow()); - expect(input.prop('type')).toEqual('text'); - input = findNativeInput(shallow()); - expect(input.prop('type')).toEqual('text'); + const { container } = render(); + const input = findInput(container); + expect(input.type).toEqual('text'); }); test('value "email-address"', () => { - const input = findNativeInput(shallow()); - expect(input.prop('type')).toEqual('email'); + const { container } = render(); + const input = findInput(container); + expect(input.type).toEqual('email'); }); test('value "numeric"', () => { - const input = findNativeInput(shallow()); - expect(input.prop('type')).toEqual('number'); + const { container } = render(); + const input = findInput(container); + expect(input.type).toEqual('number'); }); test('value "phone-pad"', () => { - const input = findNativeInput(shallow()); - expect(input.prop('type')).toEqual('tel'); + const { container } = render(); + const input = findInput(container); + expect(input.type).toEqual('tel'); }); test('value "url"', () => { - const input = findNativeInput(shallow()); - expect(input.prop('type')).toEqual('url'); + const { container } = render(); + const input = findInput(container); + expect(input.type).toEqual('url'); }); }); test('prop "maxLength"', () => { - let input = findNativeInput(shallow()); - expect(input.prop('maxLength')).toEqual(undefined); - input = findNativeInput(shallow()); - expect(input.prop('maxLength')).toEqual(10); + let { container } = render(); + let input = findInput(container); + expect(input.getAttribute('maxLength')).toEqual(null); + + ({ container } = render()); + input = findInput(container); + expect(input.getAttribute('maxLength')).toEqual('10'); }); describe('prop "multiline"', () => { test('value "false"', () => { - const input = findNativeInput(shallow()); - expect(input.length).toEqual(1); + const { container } = render(); + const input = findInput(container); + expect(input).toBeDefined(); }); test('value "true"', () => { - const input = findNativeTextarea(shallow()); - expect(input.length).toEqual(1); + const { container } = render(); + const textarea = findTextArea(container); + expect(textarea).toBeDefined(); }); }); describe('prop "numberOfLines"', () => { test('without "multiline"', () => { - const input = findNativeInput(shallow()); - expect(input.length).toEqual(1); + const { container } = render(); + const input = findInput(container); + const textarea = findTextArea(container); + expect(input).toBeDefined(); + expect(textarea).toBeNull(); }); test('with "multiline"', () => { - const input = findNativeTextarea(shallow()); - expect(input.prop('rows')).toEqual(3); + const { container } = render(); + const textarea = findTextArea(container); + expect(textarea.getAttribute('rows')).toEqual('3'); }); }); test('prop "onBlur"', () => { const onBlur = jest.fn(); - const input = findNativeInput(mount()); - const node = ReactDOM.findDOMNode(input.instance()); - - // more accurate blur simulation - input.simulate('blur'); - node.blur(); - + const { container } = render(); + const input = findInput(container); + input.dispatchEvent(new window.FocusEvent('blur', {})); expect(onBlur).toHaveBeenCalledTimes(1); expect(TextInput.State.currentlyFocusedField()).toBe(null); }); - test('prop "onChange"', () => { + test.skip('prop "onChange"', () => { const onChange = jest.fn(); - const input = findNativeInput(mount()); - input.simulate('change'); + const { container } = render(); + const input = findInput(container); + // This doesn't cause ReactDOM to trigger 'change' event... ¯\_(ツ)_/¯ + input.dispatchEvent(new window.Event('change', { bubbles: true })); expect(onChange).toHaveBeenCalledTimes(1); }); - test('prop "onChangeText"', () => { + test.skip('prop "onChangeText"', () => { const onChangeText = jest.fn(); - const newText = 'newText'; - const input = findNativeInput(mount()); - input.simulate('change', { target: { value: newText } }); + const { container } = render(); + const input = findInput(container); + // This doesn't cause ReactDOM to trigger 'change' event... ¯\_(ツ)_/¯ + input.dispatchEvent(keydown({ key: 'a' })); + input.dispatchEvent(new window.Event('change', { bubbles: true })); expect(onChangeText).toHaveBeenCalledTimes(1); - expect(onChangeText).toBeCalledWith(newText); + expect(onChangeText).toBeCalledWith('a'); }); - test.skip('prop "onFocus"', () => { + test('prop "onFocus"', () => { const onFocus = jest.fn(); - const input = findNativeInput(mount()); - const node = ReactDOM.findDOMNode(input.instance()); - - // more accurate focus simulation - input.simulate('focus'); - node.focus(); - + const { container } = render(); + const input = findInput(container); + input.focus(); expect(onFocus).toHaveBeenCalledTimes(1); - expect(TextInput.State.currentlyFocusedField()).toBe(node); + expect(TextInput.State.currentlyFocusedField()).toBe(input); }); describe('prop "onKeyPress"', () => { test('arrow key', () => { - const onKeyPress = jest.fn(); - const input = findNativeInput(mount()); - input.simulate('keyPress', { key: 'ArrowLeft' }); + const onKeyPress = jest.fn(e => { + e.persist(); + }); + const { container } = render(); + const input = findInput(container); + input.dispatchEvent(keydown({ key: 'ArrowLeft' })); expect(onKeyPress).toHaveBeenCalledTimes(1); expect(onKeyPress).toBeCalledWith( expect.objectContaining({ nativeEvent: { - altKey: undefined, - ctrlKey: undefined, + altKey: false, + ctrlKey: false, key: 'ArrowLeft', - metaKey: undefined, - shiftKey: undefined, + metaKey: false, + shiftKey: false, target: expect.anything() } }) @@ -217,18 +284,21 @@ describe('components/TextInput', () => { }); test('backspace key', () => { - const onKeyPress = jest.fn(); - const input = findNativeInput(mount()); - input.simulate('keyDown', { key: 'Backspace' }); + const onKeyPress = jest.fn(e => { + e.persist(); + }); + const { container } = render(); + const input = findInput(container); + input.dispatchEvent(keydown({ key: 'Backspace' })); expect(onKeyPress).toHaveBeenCalledTimes(1); expect(onKeyPress).toBeCalledWith( expect.objectContaining({ nativeEvent: { - altKey: undefined, - ctrlKey: undefined, + altKey: false, + ctrlKey: false, key: 'Backspace', - metaKey: undefined, - shiftKey: undefined, + metaKey: false, + shiftKey: false, target: expect.anything() } }) @@ -236,18 +306,21 @@ describe('components/TextInput', () => { }); test('enter key', () => { - const onKeyPress = jest.fn(); - const input = findNativeInput(mount()); - input.simulate('keyPress', { key: 'Enter' }); + const onKeyPress = jest.fn(e => { + e.persist(); + }); + const { container } = render(); + const input = findInput(container); + input.dispatchEvent(keydown({ key: 'Enter' })); expect(onKeyPress).toHaveBeenCalledTimes(1); expect(onKeyPress).toBeCalledWith( expect.objectContaining({ nativeEvent: { - altKey: undefined, - ctrlKey: undefined, + altKey: false, + ctrlKey: false, key: 'Enter', - metaKey: undefined, - shiftKey: undefined, + metaKey: false, + shiftKey: false, target: expect.anything() } }) @@ -255,18 +328,21 @@ describe('components/TextInput', () => { }); test('escape key', () => { - const onKeyPress = jest.fn(); - const input = findNativeInput(mount()); - input.simulate('keyPress', { key: 'Escape' }); + const onKeyPress = jest.fn(e => { + e.persist(); + }); + const { container } = render(); + const input = findInput(container); + input.dispatchEvent(keydown({ key: 'Escape' })); expect(onKeyPress).toHaveBeenCalledTimes(1); expect(onKeyPress).toBeCalledWith( expect.objectContaining({ nativeEvent: { - altKey: undefined, - ctrlKey: undefined, + altKey: false, + ctrlKey: false, key: 'Escape', - metaKey: undefined, - shiftKey: undefined, + metaKey: false, + shiftKey: false, target: expect.anything() } }) @@ -274,18 +350,21 @@ describe('components/TextInput', () => { }); test('space key', () => { - const onKeyPress = jest.fn(); - const input = findNativeInput(mount()); - input.simulate('keyPress', { key: ' ' }); + const onKeyPress = jest.fn(e => { + e.persist(); + }); + const { container } = render(); + const input = findInput(container); + input.dispatchEvent(keydown({ key: ' ' })); expect(onKeyPress).toHaveBeenCalledTimes(1); expect(onKeyPress).toBeCalledWith( expect.objectContaining({ nativeEvent: { - altKey: undefined, - ctrlKey: undefined, + altKey: false, + ctrlKey: false, key: ' ', - metaKey: undefined, - shiftKey: undefined, + metaKey: false, + shiftKey: false, target: expect.anything() } }) @@ -293,18 +372,21 @@ describe('components/TextInput', () => { }); test('tab key', () => { - const onKeyPress = jest.fn(); - const input = findNativeInput(mount()); - input.simulate('keyDown', { key: 'Tab' }); + const onKeyPress = jest.fn(e => { + e.persist(); + }); + const { container } = render(); + const input = findInput(container); + input.dispatchEvent(keydown({ key: 'Tab' })); expect(onKeyPress).toHaveBeenCalledTimes(1); expect(onKeyPress).toBeCalledWith( expect.objectContaining({ nativeEvent: { - altKey: undefined, - ctrlKey: undefined, + altKey: false, + ctrlKey: false, key: 'Tab', - metaKey: undefined, - shiftKey: undefined, + metaKey: false, + shiftKey: false, target: expect.anything() } }) @@ -312,18 +394,21 @@ describe('components/TextInput', () => { }); test('text key', () => { - const onKeyPress = jest.fn(); - const input = findNativeInput(mount()); - input.simulate('keyPress', { key: 'a' }); + const onKeyPress = jest.fn(e => { + e.persist(); + }); + const { container } = render(); + const input = findInput(container); + input.dispatchEvent(keydown({ key: 'a' })); expect(onKeyPress).toHaveBeenCalledTimes(1); expect(onKeyPress).toBeCalledWith( expect.objectContaining({ nativeEvent: { - altKey: undefined, - ctrlKey: undefined, + altKey: false, + ctrlKey: false, key: 'a', - metaKey: undefined, - shiftKey: undefined, + metaKey: false, + shiftKey: false, target: expect.anything() } }) @@ -331,15 +416,20 @@ describe('components/TextInput', () => { }); test('modifier keys are included', () => { - const onKeyPress = jest.fn(); - const input = findNativeInput(mount()); - input.simulate('keyPress', { - altKey: true, - ctrlKey: true, - metaKey: true, - shiftKey: true, - key: ' ' + const onKeyPress = jest.fn(e => { + e.persist(); }); + const { container } = render(); + const input = findInput(container); + input.dispatchEvent( + keydown({ + altKey: true, + ctrlKey: true, + metaKey: true, + shiftKey: true, + key: ' ' + }) + ); expect(onKeyPress).toHaveBeenCalledTimes(1); expect(onKeyPress).toBeCalledWith( expect.objectContaining({ @@ -356,45 +446,53 @@ describe('components/TextInput', () => { }); test('meta key + Enter calls "onKeyPress"', () => { - const onKeyPress = jest.fn(); - const input = findNativeInput(mount()); - input.simulate('keyDown', { - metaKey: true, - key: 'Enter' + const onKeyPress = jest.fn(e => { + e.persist(); }); + const { container } = render(); + const input = findInput(container); + input.dispatchEvent( + keydown({ + metaKey: true, + key: 'Enter' + }) + ); expect(onKeyPress).toHaveBeenCalledTimes(1); }); }); describe('prop "onSelectionChange"', () => { - test('is called on select', done => { - const input = findNativeInput( - mount() + test('is called on select', () => { + const { container } = render( + ); - input.simulate('select', { - target: { selectionStart: 0, selectionEnd: 3 } - }); + const input = findInput(container); + input.selectionStart = 0; + input.selectionEnd = 3; + input.dispatchEvent(new window.Event('select', {})); function onSelectionChange(e) { expect(e.nativeEvent.selection.end).toEqual(3); expect(e.nativeEvent.selection.start).toEqual(0); - done(); } }); - test('is called on change', () => { + test.skip('is called on change', () => { const onSelectionChange = jest.fn(); - const input = findNativeInput(mount()); - input.simulate('change'); + const { container } = render(); + const input = findInput(container); + // This doesn't cause ReactDOM to trigger 'change' event... ¯\_(ツ)_/¯ + input.dispatchEvent(new window.Event('change', { bubbles: true })); expect(onSelectionChange).toHaveBeenCalledTimes(1); }); }); describe('prop "onSubmitEditing"', () => { test('single-line input', done => { - const input = findNativeInput( - mount() + const { container } = render( + ); - input.simulate('keyPress', { key: 'Enter' }); + const input = findInput(container); + input.dispatchEvent(keydown({ key: 'Enter' })); function onSubmitEditing(e) { expect(e.nativeEvent.target).toBeDefined(); expect(e.nativeEvent.text).toBe('12345'); @@ -404,10 +502,11 @@ describe('components/TextInput', () => { test('multi-line input', () => { const onSubmitEditing = jest.fn(); - const input = findNativeTextarea( - mount() + const { container } = render( + ); - input.simulate('keyPress', { key: 'Enter' }); + const textarea = findTextArea(container); + textarea.dispatchEvent(keydown({ key: 'Enter' })); expect(onSubmitEditing).not.toHaveBeenCalled(); }); @@ -415,23 +514,16 @@ describe('components/TextInput', () => { const onSubmitEditing = jest.fn(); const preventDefault = jest.fn(); - const input = findNativeTextarea( - mount( - - ) + const { container } = render( + ); - + const textarea = findTextArea(container); + textarea.dispatchEvent(keydown({ key: 'Enter', preventDefault, shiftKey: true })); // shift+enter should enter newline, not submit - input.simulate('keyPress', { key: 'Enter', preventDefault, shiftKey: true }); expect(onSubmitEditing).not.toHaveBeenCalledWith(expect.objectContaining({ shiftKey: true })); expect(preventDefault).not.toHaveBeenCalled(); - input.simulate('keyPress', { key: 'Enter', preventDefault }); + textarea.dispatchEvent(keydown({ key: 'Enter', preventDefault })); expect(onSubmitEditing).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1); }); @@ -439,24 +531,28 @@ describe('components/TextInput', () => { test('prop "returnKeyType"', () => { const returnKeyType = 'previous'; - const input = findNativeInput(shallow()); - expect(input.prop('enterkeyhint')).toEqual(returnKeyType); + const { container } = render(); + const input = findInput(container); + expect(input.getAttribute('enterkeyhint')).toEqual(returnKeyType); }); test('prop "secureTextEntry"', () => { - let input = findNativeInput(shallow()); - expect(input.prop('type')).toEqual('password'); + let { container } = render(); + const input = findInput(container); + expect(input.getAttribute('type')).toEqual('password'); // ignored for multiline - input = findNativeTextarea(shallow()); - expect(input.prop('type')).toEqual(undefined); + ({ container } = render()); + const textarea = findTextArea(container); + expect(textarea.getAttribute('type')).toEqual(null); }); describe('prop "selectTextOnFocus"', () => { testIfDocumentIsFocused('value "false"', () => { - const input = findNativeInput(mount()); - input.node.focus(); - expect(input.node.selectionEnd).toEqual(4); - expect(input.node.selectionStart).toEqual(4); + const { container } = render(); + const input = findInput(container); + input.focus(); + expect(input.selectionEnd).toEqual(4); + expect(input.selectionStart).toEqual(4); }); // testIfDocumentIsFocused('value "true"', () => { @@ -470,42 +566,48 @@ describe('components/TextInput', () => { describe('prop "selection"', () => { test('set cursor location', () => { const cursorLocation = { start: 3, end: 3 }; - - const inputDefaultSelection = findNativeInput(mount()); - const inputCustomSelection = findNativeInput( - mount() + const { container: defaultContainer } = render(); + const { container: customContainer } = render( + ); + const inputDefaultSelection = findInput(defaultContainer); + const inputCustomSelection = findInput(customContainer); + // default selection is 0 - expect(inputDefaultSelection.instance().selectionStart).toEqual(0); - expect(inputDefaultSelection.instance().selectionEnd).toEqual(0); + expect(inputDefaultSelection.selectionStart).toEqual(0); + expect(inputDefaultSelection.selectionEnd).toEqual(0); // custom selection sets cursor at custom position - expect(inputCustomSelection.instance().selectionStart).toEqual(cursorLocation.start); - expect(inputCustomSelection.instance().selectionEnd).toEqual(cursorLocation.end); + expect(inputCustomSelection.selectionStart).toEqual(cursorLocation.start); + expect(inputCustomSelection.selectionEnd).toEqual(cursorLocation.end); }); }); describe('prop "spellCheck"', () => { test('default value', () => { - const input = findNativeInput(shallow()); - expect(input.prop('spellCheck')).toEqual(true); + const { container } = render(); + const input = findInput(container); + expect(input.getAttribute('spellCheck')).toEqual('true'); }); test('inherit from "autoCorrect"', () => { - const input = findNativeInput(shallow()); - expect(input.prop('spellCheck')).toEqual(false); + const { container } = render(); + const input = findInput(container); + expect(input.getAttribute('spellCheck')).toEqual('false'); }); test('value "false"', () => { - const input = findNativeInput(shallow()); - expect(input.prop('spellCheck')).toEqual(false); + const { container } = render(); + const input = findInput(container); + expect(input.getAttribute('spellCheck')).toEqual('false'); }); }); test('prop "value"', () => { const value = 'value'; - const input = findNativeInput(shallow()); - expect(input.prop('value')).toEqual(value); + const { container } = render(); + const input = findInput(container); + expect(input.value).toEqual(value); }); }); diff --git a/packages/react-native-web/src/exports/TextInput/index.js b/packages/react-native-web/src/exports/TextInput/index.js index 78d2cec0..22dd6c76 100644 --- a/packages/react-native-web/src/exports/TextInput/index.js +++ b/packages/react-native-web/src/exports/TextInput/index.js @@ -10,36 +10,24 @@ import type { TextInputProps } from './types'; -import applyLayout from '../../modules/applyLayout'; -import applyNativeMethods from '../../modules/applyNativeMethods'; -import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment'; import createElement from '../createElement'; import css from '../StyleSheet/css'; import filterSupportedProps from '../View/filterSupportedProps'; -import findNodeHandle from '../findNodeHandle'; -import React from 'react'; +import setAndForwardRef from '../../modules/setAndForwardRef'; +import useElementLayout from '../../hooks/useElementLayout'; +import usePlatformMethods from '../../hooks/usePlatformMethods'; +import { forwardRef, useEffect, useRef } from 'react'; import StyleSheet from '../StyleSheet'; import TextInputState from '../../modules/TextInputState'; -const isAndroid = canUseDOM && /Android/i.test(navigator && navigator.userAgent); const emptyObject = {}; -/** - * React Native events differ from W3C events. - */ -const normalizeEventHandler = handler => e => { - if (handler) { - e.nativeEvent.text = e.target.value; - return handler(e); - } -}; - /** * Determines whether a 'selection' prop differs from a node's existing * selection state. */ const isSelectionStale = (node, selection) => { - if (node != null && selection != null) { + if (node != null && selection != null && selection.start != null) { const { selectionEnd, selectionStart } = node; const { start, end } = selection; return start !== selectionStart || end !== selectionEnd; @@ -52,216 +40,159 @@ const isSelectionStale = (node, selection) => { * error. */ const setSelection = (node, selection) => { - try { - if (node != null && selection != null && isSelectionStale(node, selection)) { - const { start, end } = selection; - // workaround for Blink on Android: see https://github.com/text-mask/text-mask/issues/300 - if (isAndroid) { - setTimeout(() => node.setSelectionRange(start, end || start), 10); - } else { - node.setSelectionRange(start, end || start); - } - } - } catch (e) {} + if (node != null && selection != null && isSelectionStale(node, selection)) { + const { start, end } = selection; + try { + node.setSelectionRange(start, end || start); + } catch (e) {} + } }; -class TextInput extends React.Component { - _node: HTMLInputElement; - _nodeHeight: number; - _nodeWidth: number; +const TextInput = forwardRef((props, ref) => { + const { + autoCapitalize = 'sentences', + autoComplete, + autoCompleteType, + autoCorrect = true, + autoFocus, + blurOnSubmit, + clearTextOnFocus, + defaultValue, + disabled, + editable = true, + keyboardType = 'default', + maxLength, + multiline = false, + numberOfLines = 1, + onBlur, + onChange, + onChangeText, + onContentSizeChange, + onFocus, + onKeyPress, + onLayout, + onSelectionChange, + onSubmitEditing, + placeholder, + placeholderTextColor, + returnKeyType, + secureTextEntry = false, + selection = emptyObject, + selectTextOnFocus, + spellCheck, + value + } = props; - static displayName = 'TextInput'; + let type; - static State = TextInputState; - - clear() { - this._node.value = ''; + switch (keyboardType) { + case 'email-address': + type = 'email'; + break; + case 'number-pad': + case 'numeric': + type = 'number'; + break; + case 'phone-pad': + type = 'tel'; + break; + case 'search': + case 'web-search': + type = 'search'; + break; + case 'url': + type = 'url'; + break; + default: + type = 'text'; } - isFocused() { - return TextInputState.currentlyFocusedField() === this._node; + if (secureTextEntry) { + type = 'password'; } - componentDidMount() { - setSelection(this._node, this.props.selection); - if (document.activeElement === this._node) { - TextInputState._currentlyFocusedNode = this._node; + const hostRef = useRef(null); + const dimensions = useRef({ height: null, width: null }); + const setRef = setAndForwardRef({ + getForwardedRef: () => ref, + setLocalRef: c => { + hostRef.current = c; + if (hostRef.current != null) { + handleContentSizeChange(); + } } - } + }); - componentDidUpdate() { - setSelection(this._node, this.props.selection); - } + const component = multiline ? 'textarea' : 'input'; + const supportedProps = filterSupportedProps(props); + const classList = [classes.textinput]; + const style = StyleSheet.compose( + props.style, + placeholderTextColor && { placeholderTextColor } + ); - render() { - const { - autoCapitalize = 'sentences', - autoComplete, - autoCompleteType, - autoCorrect = true, - autoFocus, - defaultValue, - disabled, - editable = true, - keyboardType = 'default', - maxLength, - multiline = false, - numberOfLines = 1, - placeholder, - placeholderTextColor, - returnKeyType, - secureTextEntry = false, - spellCheck, - style, - value - } = this.props; - - let type; - - switch (keyboardType) { - case 'email-address': - type = 'email'; - break; - case 'number-pad': - case 'numeric': - type = 'number'; - break; - case 'phone-pad': - type = 'tel'; - break; - case 'search': - case 'web-search': - type = 'search'; - break; - case 'url': - type = 'url'; - break; - default: - type = 'text'; - } - - if (secureTextEntry) { - type = 'password'; - } - - const component = multiline ? 'textarea' : 'input'; - const supportedProps = filterSupportedProps(this.props); - - Object.assign(supportedProps, { - autoCapitalize, - autoComplete: autoComplete || autoCompleteType || 'on', - autoCorrect: autoCorrect ? 'on' : 'off', - autoFocus, - classList: [classes.textinput], - defaultValue, - dir: 'auto', - disabled, - enterkeyhint: returnKeyType, - maxLength, - onBlur: normalizeEventHandler(this._handleBlur), - onChange: normalizeEventHandler(this._handleChange), - onFocus: normalizeEventHandler(this._handleFocus), - onKeyDown: this._handleKeyDown, - onKeyPress: this._handleKeyPress, - onSelect: normalizeEventHandler(this._handleSelectionChange), - placeholder, - readOnly: !editable, - ref: this._setNode, - spellCheck: spellCheck != null ? spellCheck : autoCorrect, - style: StyleSheet.compose( - style, - placeholderTextColor && { placeholderTextColor } - ), - value - }); - - if (multiline) { - supportedProps.rows = numberOfLines; - } else { - supportedProps.type = type; - } - - return createElement(component, supportedProps); - } - - _handleBlur = e => { - const { onBlur } = this.props; + function handleBlur(e) { TextInputState._currentlyFocusedNode = null; if (onBlur) { + e.nativeEvent.text = e.target.value; onBlur(e); } - }; + } - _handleContentSizeChange = () => { - const { onContentSizeChange, multiline } = this.props; - if (multiline && onContentSizeChange) { - const newHeight = this._node.scrollHeight; - const newWidth = this._node.scrollWidth; - if (newHeight !== this._nodeHeight || newWidth !== this._nodeWidth) { - this._nodeHeight = newHeight; - this._nodeWidth = newWidth; + function handleContentSizeChange() { + const node = hostRef.current; + if (multiline && onContentSizeChange && node != null) { + const newHeight = node.scrollHeight; + const newWidth = node.scrollWidth; + if (newHeight !== dimensions.current.height || newWidth !== dimensions.current.width) { + dimensions.current.height = newHeight; + dimensions.current.width = newWidth; onContentSizeChange({ nativeEvent: { contentSize: { - height: this._nodeHeight, - width: this._nodeWidth + height: dimensions.current.height, + width: dimensions.current.width } } }); } } - }; + } - _handleChange = e => { - const { onChange, onChangeText } = this.props; + function handleChange(e) { const { text } = e.nativeEvent; - this._handleContentSizeChange(); + e.nativeEvent.text = text; + handleContentSizeChange(); if (onChange) { onChange(e); } if (onChangeText) { onChangeText(text); } - this._handleSelectionChange(e); - }; + handleSelectionChange(e); + } - _handleFocus = e => { - const { clearTextOnFocus, onFocus, selectTextOnFocus } = this.props; - const node = this._node; - TextInputState._currentlyFocusedNode = this._node; - if (onFocus) { - onFocus(e); + function handleFocus(e) { + const node = hostRef.current; + if (node != null) { + TextInputState._currentlyFocusedNode = node; + if (onFocus) { + e.nativeEvent.text = e.target.value; + onFocus(e); + } + if (clearTextOnFocus) { + node.value = ''; + } + if (selectTextOnFocus) { + node.select(); + } } - if (clearTextOnFocus) { - this.clear(); - } - if (selectTextOnFocus) { - node && node.select(); - } - }; + } - _handleKeyDown = e => { + function handleKeyDown(e) { // Prevent key events bubbling (see #612) e.stopPropagation(); - // Backspace, Escape, Tab, Cmd+Enter, and Arrow keys only fire 'keydown' - // DOM events - if ( - e.key === 'ArrowLeft' || - e.key === 'ArrowUp' || - e.key === 'ArrowRight' || - e.key === 'ArrowDown' || - e.key === 'Backspace' || - e.key === 'Escape' || - (e.key === 'Enter' && e.metaKey) || - e.key === 'Tab' - ) { - this._handleKeyPress(e); - } - }; - - _handleKeyPress = e => { - const { blurOnSubmit, multiline, onKeyPress, onSubmitEditing } = this.props; const blurOnSubmitDefault = !multiline; const shouldBlurOnSubmit = blurOnSubmit == null ? blurOnSubmitDefault : blurOnSubmit; @@ -290,13 +221,12 @@ class TextInput extends React.Component { } if (shouldBlurOnSubmit) { // $FlowFixMe - this.blur(); + hostRef.current.blur(); } } - }; + } - _handleSelectionChange = e => { - const { onSelectionChange, selection = emptyObject } = this.props; + function handleSelectionChange(e) { if (onSelectionChange) { try { const node = e.target; @@ -306,19 +236,68 @@ class TextInput extends React.Component { start: selectionStart, end: selectionEnd }; + e.nativeEvent.text = e.target.value; onSelectionChange(e); } } catch (e) {} } - }; + } - _setNode = component => { - this._node = findNodeHandle(component); - if (this._node) { - this._handleContentSizeChange(); + useEffect(() => { + setSelection(hostRef.current, selection); + if (document.activeElement === hostRef.current) { + TextInputState._currentlyFocusedNode = hostRef.current; } - }; -} + }, [hostRef, selection]); + + useElementLayout(hostRef, onLayout); + usePlatformMethods(hostRef, ref, classList, style, { + clear() { + if (hostRef.current != null) { + hostRef.current.value = ''; + } + }, + isFocused() { + return hostRef.current != null && TextInputState.currentlyFocusedField() === hostRef.current; + } + }); + + Object.assign(supportedProps, { + autoCapitalize, + autoComplete: autoComplete || autoCompleteType || 'on', + autoCorrect: autoCorrect ? 'on' : 'off', + autoFocus, + classList, + defaultValue, + dir: 'auto', + disabled, + enterkeyhint: returnKeyType, + maxLength, + onBlur: handleBlur, + onChange: handleChange, + onFocus: handleFocus, + onKeyDown: handleKeyDown, + onSelect: handleSelectionChange, + placeholder, + readOnly: !editable, + ref: setRef, + spellCheck: spellCheck != null ? spellCheck : autoCorrect, + style, + value + }); + + if (multiline) { + supportedProps.rows = numberOfLines; + } else { + supportedProps.type = type; + } + + return createElement(component, supportedProps); +}); + +TextInput.displayName = 'TextInput'; +// $FlowFixMe +TextInput.State = TextInputState; const classes = css.create({ textinput: { @@ -335,4 +314,4 @@ const classes = css.create({ } }); -export default applyLayout(applyNativeMethods(TextInput)); +export default TextInput; diff --git a/packages/react-native-web/src/hooks/usePlatformMethods.js b/packages/react-native-web/src/hooks/usePlatformMethods.js index 02e2457d..13c85eda 100644 --- a/packages/react-native-web/src/hooks/usePlatformMethods.js +++ b/packages/react-native-web/src/hooks/usePlatformMethods.js @@ -18,7 +18,8 @@ export default function usePlatformMethods( hostRef: ElementRef, ref: ElementRef, classList: Array, - style: GenericStyleProp + style: GenericStyleProp, + extras: any ) { const previousStyle = useRef(null); @@ -67,9 +68,10 @@ export default function usePlatformMethods( UIManager.updateView(node, domProps); } - } + }, + ...extras }; }, - [classList, hostRef, style] + [classList, hostRef, style, extras] ); }