UNPKG

@shopify/polaris

Version:

Shopify’s product component library

217 lines (216 loc) • 9.24 kB
import React, { useState, useEffect, useRef, useCallback } from 'react'; import { addEventListener } from '@shopify/javascript-utilities/events'; 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, helpTextID, labelID } from '../Labelled'; import { Connected } from '../Connected'; import { Key } from '../../types'; import { Icon } from '../Icon'; import { Resizer, Spinner } from './components'; import styles from './TextField.scss'; 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, }) { const i18n = useI18n(); const [height, setHeight] = useState(null); const [focus, setFocus] = useState(Boolean(focused)); const [isMounted, setIsMounted] = useState(false); const id = useUniqueId('TextField', idProp); const inputRef = useRef(null); const prefixRef = useRef(null); const suffixRef = useRef(null); const buttonPressTimer = useRef(); useEffect(() => { setIsMounted(true); }, []); useEffect(() => { const input = inputRef.current; if (!input || focused === undefined) return; focused ? input.focus() : input.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 ? (<div className={styles.Prefix} id={`${id}Prefix`} ref={prefixRef}> {prefix} </div>) : null; const suffixMarkup = suffix ? (<div className={styles.Suffix} id={`${id}Suffix`} ref={suffixRef}> {suffix} </div>) : null; const characterCount = normalizedValue.length; const characterCountLabel = i18n.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={focus ? 'polite' : 'off'} aria-atomic="true"> {characterCountText} </div>) : null; const clearButtonMarkup = clearButton && normalizedValue !== '' ? (<button type="button" testID="clearButton" className={styles.ClearButton} onClick={handleClearButtonPress} disabled={disabled}> <VisuallyHidden> {i18n.translate('Polaris.Common.clear')} </VisuallyHidden> <Icon source={CircleCancelMinor} color="inkLightest"/> </button>) : null; const handleNumberChange = useCallback((steps) => { 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(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); }, []); const handleButtonPress = useCallback((onChange) => { 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 ? (<Spinner onChange={handleNumberChange} onMouseDown={handleButtonPress} onMouseUp={handleButtonRelease}/>) : null; const style = multiline && height ? { height } : null; const handleExpandingResize = useCallback((height) => { setHeight(height); }, []); const resizer = multiline && isMounted ? (<Resizer contents={normalizedValue || placeholder} currentHeight={height} minimumLines={typeof multiline === 'number' ? multiline : 1} onHeightChange={handleExpandingResize}/>) : null; const describedBy = []; if (error) { describedBy.push(`${id}Error`); } if (helpText) { describedBy.push(helpTextID(id)); } if (showCharacterCount) { describedBy.push(`${id}CharacterCounter`); } const labelledBy = []; 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 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 (<Labelled label={label} id={id} error={error} action={labelAction} labelHidden={labelHidden} helpText={helpText}> <Connected left={connectedLeft} right={connectedRight}> <div className={className} onFocus={handleFocus} onBlur={handleBlur} onClick={handleClick}> {prefixMarkup} {input} {suffixMarkup} {characterCountMarkup} {clearButtonMarkup} {spinnerMarkup} <div className={styles.Backdrop}/> {resizer} </div> </Connected> </Labelled>); function handleClearButtonPress() { onClearButtonClick && onClearButtonClick(id); } function handleKeyPress(event) { const { key, which } = event; const numbersSpec = /[\d.eE+-]$/; if (type !== 'number' || which === Key.Enter || key.match(numbersSpec)) { return; } event.preventDefault(); } function containsAffix(target) { return (target instanceof HTMLElement && ((prefixRef.current && prefixRef.current.contains(target)) || (suffixRef.current && suffixRef.current.contains(target)))); } function handleChange(event) { onChange && onChange(event.currentTarget.value, id); } function handleFocus({ target }) { if (containsAffix(target)) { return; } setFocus(true); } function handleBlur() { setFocus(false); } function handleClick({ target }) { if (containsAffix(target)) { return; } inputRef.current && inputRef.current.focus(); } } function normalizeAutoComplete(autoComplete) { if (autoComplete == null) { return autoComplete; } else if (autoComplete === true) { return 'on'; } else if (autoComplete === false) { return 'off'; } else { return autoComplete; } }