@shopify/polaris
Version:
Shopify’s product component library
217 lines (216 loc) • 9.24 kB
JavaScript
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;
}
}