diff --git a/UNRELEASED.md b/UNRELEASED.md index ba7b42cd821..e6ae9315020 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -40,5 +40,6 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f - Migrated `ContextualSaveBar` to use hooks instead of `withAppProvider` ([#2091](https://github.com/Shopify/polaris-react/pull/2091)) - Migrated `RangeSlider`, `ScrollLock` and `TopBar.SearchField` to use hooks instead of withAppProvider ([#2083](https://github.com/Shopify/polaris-react/pull/2083)) - Updated `ResourceItem` to no longer rely on withAppProvider ([#2094](https://github.com/Shopify/polaris-react/pull/2094)) +- Migrated `TextField` and `Resizer` to use hooks ([#1997](https://github.com/Shopify/polaris-react/pull/1997)); ### Deprecations diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index a300dddd06b..437c0a7ab32 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -1,18 +1,14 @@ -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 {useUniqueId} from '../../utilities/unique-id'; 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,12 +30,6 @@ export type Type = type Alignment = 'left' | 'center' | 'right'; -interface State { - height?: number | null; - focus: boolean; - id: string; -} - export interface BaseProps { /** Text to display before value */ prefix?: React.ReactNode; @@ -129,394 +119,354 @@ export type TextFieldProps = NonMutuallyExclusiveProps & | {disabled: true} | {onChange(value: string, id: string): void}); -type CombinedProps = TextFieldProps & 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(); +export function TextField({ + prefix, + suffix, + placeholder, + value, + helpText, + label, + labelAction, + labelHidden, + disabled, + clearButton, + readOnly, + autoFocus, + focused, + multiline, + error, + connectedRight, + connectedLeft, + type, + name, + id: idProp, + role, + step = 1, + autoComplete, + max = Infinity, + maxLength, + min = -Infinity, + minLength, + pattern, + spellCheck, + ariaOwns, + ariaControls, + ariaActiveDescendant, + ariaAutocomplete, + showCharacterCount, + align, + onClearButtonClick, + onChange, + onFocus, + onBlur, +}: TextFieldProps) { + const intl = useI18n(); + const [height, setHeight] = useState(null); + const [focus, setFocus] = useState(focused || false); + + const id = useUniqueId('TextField', idProp); + + 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(); } - } - - 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 ? ( -
+ {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 !== '' ? ( + ) : 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( + Number(max), + Math.max(numericValue + steps * step, Number(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 labelledBy: string[] = []; + const style = multiline && height ? {height} : null; - if (prefix) { - labelledBy.push(`${id}Prefix`); - } + const handleExpandingResize = useCallback((height: number) => { + setHeight(height); + }, []); - if (suffix) { - labelledBy.push(`${id}Suffix`); - } + const resizer = multiline ? ( + + ) : null; - 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: 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 numericValue = value ? parseFloat(value) : 0; - if (isNaN(numericValue)) { - return; - } + const labelledBy: string[] = []; - // 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( - Number(max), - Math.max(numericValue + steps * step, Number(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(); - }; + } + + function containsAffix(target: HTMLElement | EventTarget) { + return ( + target instanceof HTMLElement && + ((prefixRef.current && prefixRef.current.contains(target)) || + (suffixRef.current && suffixRef.current.contains(target))) + ); + } - private handleChange = (event: React.ChangeEvent) => { - const {onChange} = this.props; - onChange && onChange(event.currentTarget.value, this.state.id); - }; + 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) { @@ -530,7 +480,3 @@ function normalizeAutoComplete(autoComplete?: boolean | string) { return autoComplete; } } - -// Use named export once withAppProvider is refactored away -// eslint-disable-next-line import/no-default-export -export default withAppProvider()(TextField); diff --git a/src/components/TextField/components/Resizer/Resizer.tsx b/src/components/TextField/components/Resizer/Resizer.tsx index 7f443c15513..9f994d75a3c 100644 --- a/src/components/TextField/components/Resizer/Resizer.tsx +++ b/src/components/TextField/components/Resizer/Resizer.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useRef, useEffect, useCallback, useLayoutEffect} from 'react'; import {EventListener} from '../../../EventListener'; import styles from '../../TextField.scss'; @@ -9,77 +9,72 @@ export interface ResizerProps { onHeightChange(height: number): void; } -export class Resizer extends React.PureComponent { - private contentNode = React.createRef(); - private minimumLinesNode = React.createRef(); - private animationFrame: number | null = null; - - componentDidMount() { - this.handleHeightCheck(); - } - - componentDidUpdate() { - this.handleHeightCheck(); - } - - componentWillUnmount() { - if (this.animationFrame) { - cancelAnimationFrame(this.animationFrame); - } - } - - render() { - const {contents, minimumLines} = this.props; - - const minimumLinesMarkup = minimumLines ? ( -
- ) : null; - - return ( -
- -
- {minimumLinesMarkup} -
- ); - } - - private handleHeightCheck = () => { - if (this.animationFrame) { - cancelAnimationFrame(this.animationFrame); +export function Resizer({ + contents, + currentHeight, + minimumLines, + onHeightChange, +}: ResizerProps) { + const contentNode = useRef(null); + const minimumLinesNode = useRef(null); + const animationFrame = useRef(); + + useEffect(() => { + return () => { + if (animationFrame.current) { + cancelAnimationFrame(animationFrame.current); + } + }; + }, []); + + const minimumLinesMarkup = minimumLines ? ( +
+ ) : null; + + const handleHeightCheck = useCallback(() => { + if (animationFrame.current) { + cancelAnimationFrame(animationFrame.current); } - this.animationFrame = requestAnimationFrame(() => { - const contentNode = this.contentNode.current; - const minimumLinesNode = this.minimumLinesNode.current; - - if (!contentNode || !minimumLinesNode) { + animationFrame.current = requestAnimationFrame(() => { + if (!contentNode.current || !minimumLinesNode.current) { return; } const newHeight = Math.max( - contentNode.offsetHeight, - minimumLinesNode.offsetHeight, + contentNode.current.offsetHeight, + minimumLinesNode.current.offsetHeight, ); - const {currentHeight, onHeightChange} = this.props; if (newHeight !== currentHeight) { onHeightChange(newHeight); } }); - }; + }, [currentHeight, onHeightChange]); + + useLayoutEffect(() => { + handleHeightCheck(); + }); + + return ( +
+ +
+ {minimumLinesMarkup} +
+ ); } const ENTITIES_TO_REPLACE = { diff --git a/src/components/TextField/index.ts b/src/components/TextField/index.ts index c5abc026ced..6913a907556 100644 --- a/src/components/TextField/index.ts +++ b/src/components/TextField/index.ts @@ -1,3 +1 @@ -import TextField, {TextFieldProps, Type} from './TextField'; - -export {TextField, TextFieldProps, Type}; +export {TextField, TextFieldProps, Type} from './TextField'; diff --git a/src/components/TextField/tests/TextField.test.tsx b/src/components/TextField/tests/TextField.test.tsx index 059f2c6da0d..483d1d65be0 100644 --- a/src/components/TextField/tests/TextField.test.tsx +++ b/src/components/TextField/tests/TextField.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {mountWithAppProvider, findByTestID} from 'test-utilities/legacy'; import {InlineError, Labelled, Connected, Select} from 'components'; import {Resizer, Spinner} from '../components'; -import TextField from '../TextField'; +import {TextField} from '../TextField'; describe('', () => { it('allows specific props to pass through properties on the input', () => { @@ -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', + ); }); }); @@ -532,6 +531,24 @@ describe('', () => { expect(spy).toHaveBeenCalledWith('2', 'MyTextField'); }); + it('does not call the onChange if the value is not a number', () => { + const spy = jest.fn(); + const element = mountWithAppProvider( + , + ); + element + .find('[role="button"]') + .first() + .simulate('click'); + expect(spy).not.toHaveBeenCalled(); + }); + it('handles incrementing from no value', () => { const spy = jest.fn(); const element = mountWithAppProvider( @@ -840,7 +857,7 @@ describe('', () => { }); describe('multiline', () => { - it('does not render a resizer if multiline is false', () => { + it('does not render a resizer if `multiline` is false', () => { const textField = mountWithAppProvider( ', () => { ); expect(textField.find(Resizer).exists()).toBe(false); }); + + it('renders a resizer with `minimumLines` set to 1 if `multiline` is true', () => { + const textField = mountWithAppProvider( + , + ); + + expect( + textField + .findWhere( + (wrap) => wrap.is(Resizer) && wrap.prop('minimumLines') === 1, + ) + .exists(), + ).toBe(true); + }); + + it('renders a resizer with `minimumLines` set to the `multiline` numeric value', () => { + const textField = mountWithAppProvider( + , + ); + + expect( + textField + .findWhere( + (wrap) => wrap.is(Resizer) && wrap.prop('minimumLines') === 5, + ) + .exists(), + ).toBe(true); + }); + + it('passes the `placeholder` to the resizer `contents` prop', () => { + const placeholderText = 'placeholder text'; + const textField = mountWithAppProvider( + , + ); + + expect( + textField + .findWhere( + (wrap) => + wrap.is(Resizer) && wrap.prop('contents') === placeholderText, + ) + .exists(), + ).toBe(true); + }); }); describe('aria labels', () => {