UNPKG

@hackplan/polaris

Version:

Shopify’s product component library

227 lines (226 loc) 9.81 kB
import React 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 Labelled, { helpTextID, labelID } from '../Labelled'; import Connected from '../Connected'; import { Key } from '../../types'; import { withAppProvider } from '../AppProvider'; import Icon from '../Icon'; import { Resizer, Spinner } from './components'; import styles from './TextField.scss'; const getUniqueID = createUniqueIDFactory('TextField'); class TextField extends React.PureComponent { constructor(props) { super(props); this.setInput = (input) => { this.input = input; }; this.handleNumberChange = (steps) => { 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) => (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)), this.state.id); }; this.handleClearButtonPress = () => { const { state: { id }, props: { onClearButtonClick }, } = this; onClearButtonClick && onClearButtonClick(id); }; this.handleExpandingResize = (height) => { this.setState({ height }); }; this.handleKeyPress = (event) => { const { key, which } = event; const { type } = this.props; const numbersSpec = /[\d.eE+-]$/; if (type !== 'number' || which === Key.Enter || key.match(numbersSpec)) { return; } event.preventDefault(); }; this.handleChange = (event) => { const { onChange } = this.props; onChange && onChange(event.currentTarget.value, this.state.id); }; this.handleFocus = () => { this.setState({ focus: true }); }; this.handleBlur = () => { this.setState({ focus: false }); }; this.handleClick = () => { this.input.focus(); }; this.handleButtonPress = (onChange) => { 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, }); }; this.handleButtonRelease = () => { clearTimeout(this.buttonPressTimer); }; this.state = { height: null, focus: props.focused || false, id: props.id || getUniqueID(), }; } static getDerivedStateFromProps(nextProps, prevState) { return { id: nextProps.id || prevState.id }; } componentDidMount() { if (!this.props.focused) { return; } this.input.focus(); } componentDidUpdate({ focused: wasFocused }) { 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 } = 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 ? (<div className={styles.Prefix} id={`${id}Prefix`}> {prefix} </div>) : null; const suffixMarkup = suffix ? (<div className={styles.Suffix} id={`${id}Suffix`}> {suffix} </div>) : 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 ? (<div id={`${id}CharacterCounter`} className={characterCountClassName} aria-label={characterCountLabel} aria-live="polite" aria-atomic="true"> {characterCountText} </div>) : null; const clearButtonMarkup = clearButton && normalizedValue !== '' ? (<button testID="clearButton" className={styles.ClearButton} onClick={this.handleClearButtonPress} disabled={disabled}> <VisuallyHidden> {intl.translate('Polaris.Common.clear')} </VisuallyHidden> <Icon source={CircleCancelMinor} color="inkLightest"/> </button>) : null; const spinnerMarkup = type === 'number' && !disabled && !readOnly ? (<Spinner onChange={this.handleNumberChange} onMouseDown={this.handleButtonPress} onMouseUp={this.handleButtonRelease}/>) : null; const style = multiline && height ? { height } : null; const resizer = multiline ? (<Resizer contents={normalizedValue || placeholder} currentHeight={height} minimumLines={typeof multiline === 'number' ? multiline : 1} onHeightChange={this.handleExpandingResize}/>) : null; const describedBy = []; if (error) { describedBy.push(`${id}Error`); } if (helpText) { describedBy.push(helpTextID(id)); } if (showCharacterCount) { describedBy.push(`${id}CharacterCounter`); } const labelledBy = [labelID(id)]; if (prefix) { labelledBy.push(`${id}Prefix`); } if (suffix) { labelledBy.push(`${id}Suffix`); } 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-label': label, 'aria-labelledby': labelledBy.join(' '), 'aria-invalid': Boolean(error), 'aria-owns': ariaOwns, 'aria-activedescendant': ariaActiveDescendant, 'aria-autocomplete': ariaAutocomplete, 'aria-controls': ariaControls, 'aria-multiline': multiline, }); return (<Labelled label={label} id={id} error={error} action={labelAction} labelHidden={labelHidden} helpText={helpText}> <Connected left={connectedLeft} right={connectedRight}> <div className={className} onFocus={this.handleFocus} onBlur={this.handleBlur} onClick={this.handleClick}> {prefixMarkup} {input} {suffixMarkup} {characterCountMarkup} {clearButtonMarkup} {spinnerMarkup} <div className={styles.Backdrop}/> {resizer} </div> </Connected> </Labelled>); } } function normalizeAutoComplete(autoComplete) { if (autoComplete == null) { return autoComplete; } else if (autoComplete === true) { return 'on'; } else if (autoComplete === false) { return 'off'; } else { return autoComplete; } } export default withAppProvider()(TextField);