From 881e844c7906267ed72c63c6a441a5c0be22aad4 Mon Sep 17 00:00:00 2001 From: Daniel Leroux Date: Tue, 20 Aug 2019 15:46:51 -0400 Subject: [PATCH] converting Textfield to function --- UNRELEASED.md | 1 + src/components/TextField/TextField.tsx | 684 +++++++++--------- .../TextField/tests/TextField.test.tsx | 13 +- 3 files changed, 332 insertions(+), 366 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index 67a01b7ff80..4c8fecdeb7f 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -36,6 +36,7 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f - Moved `ResourceItem` to its own component ([#1774](https://github.com/Shopify/polaris-react/pull/1774)) - Updated `ResourceList` sort to show an inline label ([#1774](https://github.com/Shopify/polaris-react/pull/1774)) - Removed the `tap-highlight-color` for `Buttons` ([#1545](https://github.com/Shopify/polaris-react/pull/1545)) +- Converted `TextField` into a functional component ([#1997](https://github.com/Shopify/polaris-react/pull/1997)); ### Bug fixes diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 8162a9aacc3..cbfd700eb58 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -1,18 +1,15 @@ -import React from 'react'; +import React, {useState, useEffect, useRef, useCallback} from 'react'; import {addEventListener} from '@shopify/javascript-utilities/events'; import {createUniqueIDFactory} from '@shopify/javascript-utilities/other'; import {CircleCancelMinor} from '@shopify/polaris-icons'; import VisuallyHidden from '../VisuallyHidden'; import {classNames, variationName} from '../../utilities/css'; +import {useI18n} from '../../utilities/i18n'; import Labelled, {Action, helpTextID, labelID} from '../Labelled'; import Connected from '../Connected'; import {Error, Key} from '../../types'; -import { - withAppProvider, - WithAppProviderProps, -} from '../../utilities/with-app-provider'; import Icon from '../Icon'; import {Resizer, Spinner} from './components'; import styles from './TextField.scss'; @@ -34,7 +31,7 @@ export type Type = export type Alignment = 'left' | 'center' | 'right'; -interface State { +export interface State { height?: number | null; focus: boolean; id: string; @@ -129,391 +126,362 @@ export type Props = NonMutuallyExclusiveProps & | {disabled: true} | {onChange(value: string, id: string): void}); -type CombinedProps = Props & WithAppProviderProps; - const getUniqueID = createUniqueIDFactory('TextField'); -class TextField extends React.PureComponent { - static getDerivedStateFromProps(nextProps: CombinedProps, prevState: State) { - return {id: nextProps.id || prevState.id}; - } - - private input: HTMLElement; - private buttonPressTimer: number; - private prefix = React.createRef(); - private suffix = React.createRef(); - - constructor(props: CombinedProps) { - super(props); - - // eslint-disable-next-line react/state-in-constructor - this.state = { - height: null, - focus: props.focused || false, - id: props.id || getUniqueID(), - }; - } - - componentDidMount() { - if (!this.props.focused) { - return; - } - - this.input.focus(); - } - - componentDidUpdate({focused: wasFocused}: CombinedProps) { - const {focused} = this.props; - - if (!wasFocused && focused) { - this.input.focus(); - } else if (wasFocused && !focused) { - this.input.blur(); - } - } - - render() { - const { - align, - ariaActiveDescendant, - ariaAutocomplete, - ariaControls, - ariaOwns, - autoComplete, - autoFocus, - connectedLeft, - clearButton, - connectedRight, - disabled, - error, - helpText, - id = this.state.id, - label, - labelAction, - labelHidden, - max, - maxLength, - min, - minLength, - multiline, - name, - onBlur, - onFocus, - pattern, - placeholder, - polaris: {intl}, - prefix, - readOnly, - role, - showCharacterCount, - spellCheck, - step, - suffix, - type, - value, - } = this.props; - - const normalizedValue = value != null ? value : ''; - - const {height, focus} = this.state; - - const className = classNames( - styles.TextField, - Boolean(normalizedValue) && styles.hasValue, - disabled && styles.disabled, - readOnly && styles.readOnly, - error && styles.error, - multiline && styles.multiline, - this.state.focus && styles.focus, - ); - - const inputType = type === 'currency' ? 'text' : type; - - const prefixMarkup = prefix ? ( -
- {prefix} -
- ) : null; - - const suffixMarkup = suffix ? ( -
- {suffix} -
- ) : null; - - const characterCount = normalizedValue.length; - const characterCountLabel = intl.translate( - maxLength - ? 'Polaris.TextField.characterCountWithMaxLength' - : 'Polaris.TextField.characterCount', - {count: characterCount, limit: maxLength}, - ); - - const characterCountClassName = classNames( - styles.CharacterCount, - multiline && styles.AlignFieldBottom, - ); - - const characterCountText = !maxLength - ? characterCount - : `${characterCount}/${maxLength}`; - - const characterCountMarkup = showCharacterCount ? ( -
(null); + const [focus, setFocus] = useState(focused || false); + + const generatedId = useRef(getUniqueID()); + const id = idProp || generatedId.current; + + const inputRef = useRef(null); + const prefixRef = useRef(null); + const suffixRef = useRef(null); + const buttonPressTimer = useRef(); + + useEffect( + () => { + if (!inputRef.current) return; + if (focused) { + inputRef.current.focus(); + } else if (focused === false) { + inputRef.current.blur(); + } + }, + [focused], + ); + + const normalizedValue = value != null ? value : ''; + + const className = classNames( + styles.TextField, + Boolean(normalizedValue) && styles.hasValue, + disabled && styles.disabled, + readOnly && styles.readOnly, + error && styles.error, + multiline && styles.multiline, + focus && styles.focus, + ); + + const inputType = type === 'currency' ? 'text' : type; + + const prefixMarkup = prefix ? ( +
+ {prefix} +
+ ) : null; + + const suffixMarkup = suffix ? ( +
+ {suffix} +
+ ) : null; + + const characterCount = normalizedValue.length; + const characterCountLabel = intl.translate( + maxLength + ? 'Polaris.TextField.characterCountWithMaxLength' + : 'Polaris.TextField.characterCount', + {count: characterCount, limit: maxLength}, + ); + + const characterCountClassName = classNames( + styles.CharacterCount, + multiline && styles.AlignFieldBottom, + ); + + const characterCountText = !maxLength + ? characterCount + : `${characterCount}/${maxLength}`; + + const characterCountMarkup = showCharacterCount ? ( +
+ {characterCountText} +
+ ) : null; + + const clearButtonMarkup = + clearButton && normalizedValue !== '' ? ( +
+ + {intl.translate('Polaris.Common.clear')} + + + ) : null; - const clearButtonMarkup = - clearButton && normalizedValue !== '' ? ( - - ) : null; - - const spinnerMarkup = - type === 'number' && !disabled && !readOnly ? ( - - ) : null; - - const style = multiline && height ? {height} : null; - - const resizer = multiline ? ( - { + if (onChange == null) { + return; + } + // Returns the length of decimal places in a number + const dpl = (num: number) => (num.toString().split('.')[1] || []).length; + + const numericValue = value ? parseFloat(value) : 0; + if (isNaN(numericValue)) { + return; + } + + // Making sure the new value has the same length of decimal places as the + // step / value has. + const decimalPlaces = Math.max(dpl(numericValue), dpl(step)); + + const newValue = Math.min( + max, + Math.max(numericValue + steps * step, min), + ); + onChange(String(newValue.toFixed(decimalPlaces)), id); + }, + [id, max, min, onChange, step, value], + ); + + const handleButtonRelease = useCallback( + () => { + clearTimeout(buttonPressTimer.current); + }, + [buttonPressTimer], + ); + + const handleButtonPress = useCallback( + (onChange: Function) => { + const minInterval = 50; + const decrementBy = 10; + let interval = 200; + + const onChangeInterval = () => { + if (interval > minInterval) interval -= decrementBy; + onChange(); + buttonPressTimer.current = window.setTimeout( + onChangeInterval, + interval, + ); + }; + + buttonPressTimer.current = window.setTimeout(onChangeInterval, interval); + + addEventListener(document, 'mouseup', handleButtonRelease, { + once: true, + }); + }, + [handleButtonRelease], + ); + + const spinnerMarkup = + type === 'number' && !disabled && !readOnly ? ( + ) : null; - const describedBy: string[] = []; - if (error) { - describedBy.push(`${id}Error`); - } - if (helpText) { - describedBy.push(helpTextID(id)); - } - if (showCharacterCount) { - describedBy.push(`${id}CharacterCounter`); - } + const style = multiline && height ? {height} : null; - const labelledBy: string[] = []; + const handleExpandingResize = useCallback((height: number) => { + setHeight(height); + }, []); - if (prefix) { - labelledBy.push(`${id}Prefix`); - } - - if (suffix) { - labelledBy.push(`${id}Suffix`); - } - - if (labelledBy.length) { - labelledBy.unshift(labelID(id)); - } - - const inputClassName = classNames( - styles.Input, - align && styles[variationName('Input-align', align)], - suffix && styles['Input-suffixed'], - clearButton && styles['Input-hasClearButton'], - ); + const resizer = multiline ? ( + + ) : null; - const input = React.createElement(multiline ? 'textarea' : 'input', { - name, - id, - disabled, - readOnly, - role, - autoFocus, - value: normalizedValue, - placeholder, - onFocus, - onBlur, - onKeyPress: this.handleKeyPress, - style, - autoComplete: normalizeAutoComplete(autoComplete), - className: inputClassName, - onChange: this.handleChange, - ref: this.setInput, - min, - max, - step, - minLength, - maxLength, - spellCheck, - pattern, - type: inputType, - 'aria-describedby': describedBy.length - ? describedBy.join(' ') - : undefined, - 'aria-labelledby': labelledBy.length ? labelledBy.join(' ') : undefined, - 'aria-invalid': Boolean(error), - 'aria-owns': ariaOwns, - 'aria-activedescendant': ariaActiveDescendant, - 'aria-autocomplete': ariaAutocomplete, - 'aria-controls': ariaControls, - 'aria-multiline': multiline, - }); - - return ( - - -
- {prefixMarkup} - {input} - {suffixMarkup} - {characterCountMarkup} - {clearButtonMarkup} - {spinnerMarkup} -
- {resizer} -
- - - ); + const describedBy: string[] = []; + if (error) { + describedBy.push(`${id}Error`); + } + if (helpText) { + describedBy.push(helpTextID(id)); + } + if (showCharacterCount) { + describedBy.push(`${id}CharacterCounter`); } - private setInput = (input: HTMLElement) => { - this.input = input; - }; - - private handleNumberChange = (steps: number) => { - const { - onChange, - value, - step = 1, - min = -Infinity, - max = Infinity, - } = this.props; - if (onChange == null) { - return; - } - - // Returns the length of decimal places in a number - const dpl = (num: number) => (num.toString().split('.')[1] || []).length; + const labelledBy: string[] = []; - const numericValue = value ? parseFloat(value) : 0; - if (isNaN(numericValue)) { - return; - } - - // Making sure the new value has the same length of decimal places as the - // step / value has. - const decimalPlaces = Math.max(dpl(numericValue), dpl(step)); + if (prefix) { + labelledBy.push(`${id}Prefix`); + } - const newValue = Math.min(max, Math.max(numericValue + steps * step, min)); - onChange(String(newValue.toFixed(decimalPlaces)), this.state.id); - }; + if (suffix) { + labelledBy.push(`${id}Suffix`); + } - private handleClearButtonPress = () => { - const { - state: {id}, - props: {onClearButtonClick}, - } = this; + if (labelledBy.length) { + labelledBy.unshift(labelID(id)); + } + const inputClassName = classNames( + styles.Input, + align && styles[variationName('Input-align', align)], + suffix && styles['Input-suffixed'], + clearButton && styles['Input-hasClearButton'], + ); + + const input = React.createElement(multiline ? 'textarea' : 'input', { + name, + id, + disabled, + readOnly, + role, + autoFocus, + value: normalizedValue, + placeholder, + onFocus, + onBlur, + onKeyPress: handleKeyPress, + style, + autoComplete: normalizeAutoComplete(autoComplete), + className: inputClassName, + onChange: handleChange, + ref: inputRef, + min, + max, + step, + minLength, + maxLength, + spellCheck, + pattern, + type: inputType, + 'aria-describedby': describedBy.length ? describedBy.join(' ') : undefined, + 'aria-labelledby': labelledBy.length ? labelledBy.join(' ') : undefined, + 'aria-invalid': Boolean(error), + 'aria-owns': ariaOwns, + 'aria-activedescendant': ariaActiveDescendant, + 'aria-autocomplete': ariaAutocomplete, + 'aria-controls': ariaControls, + 'aria-multiline': multiline, + }); + + return ( + + +
+ {prefixMarkup} + {input} + {suffixMarkup} + {characterCountMarkup} + {clearButtonMarkup} + {spinnerMarkup} +
+ {resizer} +
+ + + ); + + function handleClearButtonPress() { onClearButtonClick && onClearButtonClick(id); - }; - - private handleExpandingResize = (height: number) => { - this.setState({height}); - }; + } - private handleKeyPress = (event: React.KeyboardEvent) => { + function handleKeyPress(event: React.KeyboardEvent) { const {key, which} = event; - const {type} = this.props; const numbersSpec = /[\d.eE+-]$/; - if (type !== 'number' || which === Key.Enter || key.match(numbersSpec)) { return; } - event.preventDefault(); - }; + } - private handleChange = (event: React.ChangeEvent) => { - const {onChange} = this.props; - onChange && onChange(event.currentTarget.value, this.state.id); - }; + function containsAffix(target: HTMLElement | EventTarget) { + return ( + target instanceof HTMLElement && + ((prefixRef.current && prefixRef.current.contains(target)) || + (suffixRef.current && suffixRef.current.contains(target))) + ); + } + + function handleChange(event: React.ChangeEvent) { + onChange && onChange(event.currentTarget.value, id); + } - private handleFocus = ({target}: React.FocusEvent) => { - if (this.containsAffix(target)) { + function handleFocus({target}: React.FocusEvent) { + if (containsAffix(target)) { return; } + setFocus(true); + } - this.setState({focus: true}); - }; - - private handleBlur = () => { - this.setState({focus: false}); - }; + function handleBlur() { + setFocus(false); + } - private handleClick = ({target}: React.MouseEvent) => { - if (this.containsAffix(target)) { + function handleClick({target}: React.MouseEvent) { + if (containsAffix(target)) { return; } - - this.input.focus(); - }; - - private containsAffix(target: HTMLElement | EventTarget) { - return ( - target instanceof HTMLElement && - ((this.prefix.current && this.prefix.current.contains(target)) || - (this.suffix.current && this.suffix.current.contains(target))) - ); + inputRef.current && inputRef.current.focus(); } - - private handleButtonPress = (onChange: Function) => { - const minInterval = 50; - const decrementBy = 10; - let interval = 200; - - const onChangeInterval = () => { - if (interval > minInterval) interval -= decrementBy; - onChange(); - this.buttonPressTimer = window.setTimeout(onChangeInterval, interval); - }; - this.buttonPressTimer = window.setTimeout(onChangeInterval, interval); - - addEventListener(document, 'mouseup', this.handleButtonRelease, { - once: true, - }); - }; - - private handleButtonRelease = () => { - clearTimeout(this.buttonPressTimer); - }; } function normalizeAutoComplete(autoComplete?: boolean | string) { @@ -527,5 +495,3 @@ function normalizeAutoComplete(autoComplete?: boolean | string) { return autoComplete; } } - -export default withAppProvider()(TextField); diff --git a/src/components/TextField/tests/TextField.test.tsx b/src/components/TextField/tests/TextField.test.tsx index 059f2c6da0d..2f43c285e3c 100644 --- a/src/components/TextField/tests/TextField.test.tsx +++ b/src/components/TextField/tests/TextField.test.tsx @@ -473,15 +473,14 @@ describe('', () => { />, ); - expect( - textField.find('#MyFieldCharacterCounter').prop('aria-live'), - ).toBe('off'); + expect(textField.find('#MyFieldCharacterCounter').prop('aria-live')).toBe( + 'off', + ); textField.find('input').simulate('focus'); - - expect( - textField.find('#MyFieldCharacterCounter').prop('aria-live'), - ).toBe('polite'); + expect(textField.find('#MyFieldCharacterCounter').prop('aria-live')).toBe( + 'polite', + ); }); });