UNPKG

@shopify/polaris

Version:

Shopify’s admin product component library

477 lines (470 loc) • 17.8 kB
'use strict'; var React = require('react'); var polarisIcons = require('@shopify/polaris-icons'); var css = require('../../utilities/css.js'); var useIsAfterInitialMount = require('../../utilities/use-is-after-initial-mount.js'); var types = require('../../types.js'); var useEventListener = require('../../utilities/use-event-listener.js'); var TextField_module = require('./TextField.css.js'); var Labelled = require('../Labelled/Labelled.js'); var Connected = require('../Connected/Connected.js'); var Spinner$1 = require('./components/Spinner/Spinner.js'); var Resizer = require('./components/Resizer/Resizer.js'); var Label = require('../Label/Label.js'); var hooks = require('../../utilities/i18n/hooks.js'); var Icon = require('../Icon/Icon.js'); var Text = require('../Text/Text.js'); var Spinner = require('../Spinner/Spinner.js'); function TextField({ prefix, suffix, verticalContent, placeholder, value = '', helpText, label, labelAction, labelHidden, disabled, clearButton, readOnly, autoFocus, focused, multiline, error, connectedRight, connectedLeft, type = 'text', name, id: idProp, role, step, largeStep, autoComplete, max, maxLength, maxHeight, min, minLength, pattern, inputMode, spellCheck, ariaOwns, ariaControls, ariaExpanded, ariaActiveDescendant, ariaAutocomplete, showCharacterCount, align, requiredIndicator, monospaced, selectTextOnFocus, suggestion, variant = 'inherit', size = 'medium', onClearButtonClick, onChange, onSpinnerChange, onFocus, onBlur, tone, autoSize, loading }) { const i18n = hooks.useI18n(); const [height, setHeight] = React.useState(null); const [focus, setFocus] = React.useState(Boolean(focused)); const isAfterInitial = useIsAfterInitialMount.useIsAfterInitialMount(); const uniqId = React.useId(); const id = idProp ?? uniqId; const textFieldRef = React.useRef(null); const inputRef = React.useRef(null); const textAreaRef = React.useRef(null); const prefixRef = React.useRef(null); const suffixRef = React.useRef(null); const loadingRef = React.useRef(null); const verticalContentRef = React.useRef(null); const buttonPressTimer = React.useRef(); const spinnerRef = React.useRef(null); const getInputRef = React.useCallback(() => { return multiline ? textAreaRef.current : inputRef.current; }, [multiline]); React.useEffect(() => { const input = getInputRef(); if (!input || focused === undefined) return; focused ? input.focus() : input.blur(); }, [focused, verticalContent, getInputRef]); React.useEffect(() => { const input = inputRef.current; const isSupportedInputType = type === 'text' || type === 'tel' || type === 'search' || type === 'url' || type === 'password'; if (!input || !isSupportedInputType || !suggestion) { return; } input.setSelectionRange(value.length, suggestion.length); }, [focus, value, type, suggestion]); const normalizedValue = suggestion ? suggestion : value; const normalizedStep = step != null ? step : 1; const normalizedMax = max != null ? max : Infinity; const normalizedMin = min != null ? min : -Infinity; const className = css.classNames(TextField_module.default.TextField, Boolean(normalizedValue) && TextField_module.default.hasValue, disabled && TextField_module.default.disabled, readOnly && TextField_module.default.readOnly, error && TextField_module.default.error, tone && TextField_module.default[css.variationName('tone', tone)], multiline && TextField_module.default.multiline, focus && !disabled && TextField_module.default.focus, variant !== 'inherit' && TextField_module.default[variant], size === 'slim' && TextField_module.default.slim); const inputType = type === 'currency' ? 'text' : type; const isNumericType = type === 'number' || type === 'integer'; const iconPrefix = /*#__PURE__*/React.isValidElement(prefix) && prefix.type === Icon.Icon; const prefixMarkup = prefix ? /*#__PURE__*/React.createElement("div", { className: css.classNames(TextField_module.default.Prefix, iconPrefix && TextField_module.default.PrefixIcon), id: `${id}-Prefix`, ref: prefixRef }, /*#__PURE__*/React.createElement(Text.Text, { as: "span", variant: "bodyMd" }, prefix)) : null; const suffixMarkup = suffix ? /*#__PURE__*/React.createElement("div", { className: TextField_module.default.Suffix, id: `${id}-Suffix`, ref: suffixRef }, /*#__PURE__*/React.createElement(Text.Text, { as: "span", variant: "bodyMd" }, suffix)) : null; const loadingMarkup = loading ? /*#__PURE__*/React.createElement("div", { className: TextField_module.default.Loading, id: `${id}-Loading`, ref: loadingRef }, /*#__PURE__*/React.createElement(Spinner.Spinner, { size: "small" })) : null; let characterCountMarkup = null; if (showCharacterCount) { const characterCount = normalizedValue.length; const characterCountLabel = maxLength ? i18n.translate('Polaris.TextField.characterCountWithMaxLength', { count: characterCount, limit: maxLength }) : i18n.translate('Polaris.TextField.characterCount', { count: characterCount }); const characterCountClassName = css.classNames(TextField_module.default.CharacterCount, multiline && TextField_module.default.AlignFieldBottom); const characterCountText = !maxLength ? characterCount : `${characterCount}/${maxLength}`; characterCountMarkup = /*#__PURE__*/React.createElement("div", { id: `${id}-CharacterCounter`, className: characterCountClassName, "aria-label": characterCountLabel, "aria-live": focus ? 'polite' : 'off', "aria-atomic": "true", onClick: handleClickChild }, /*#__PURE__*/React.createElement(Text.Text, { as: "span", variant: "bodyMd" }, characterCountText)); } const clearButtonVisible = normalizedValue !== ''; const clearButtonMarkup = clearButton && clearButtonVisible ? /*#__PURE__*/React.createElement("button", { type: "button", className: TextField_module.default.ClearButton, onClick: handleClearButtonPress, disabled: disabled }, /*#__PURE__*/React.createElement(Text.Text, { as: "span", visuallyHidden: true }, i18n.translate('Polaris.Common.clear')), /*#__PURE__*/React.createElement(Icon.Icon, { source: polarisIcons.XCircleIcon, tone: "base" })) : null; const handleNumberChange = React.useCallback((steps, stepAmount = normalizedStep) => { if (onChange == null && onSpinnerChange == 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 = type === 'integer' ? 0 : Math.max(dpl(numericValue), dpl(stepAmount)); const newValue = Math.min(Number(normalizedMax), Math.max(numericValue + steps * stepAmount, Number(normalizedMin))); if (onSpinnerChange != null) { onSpinnerChange(String(newValue.toFixed(decimalPlaces)), id); } else if (onChange != null) { onChange(String(newValue.toFixed(decimalPlaces)), id); } }, [id, normalizedMax, normalizedMin, onChange, onSpinnerChange, normalizedStep, type, value]); const handleSpinnerButtonRelease = React.useCallback(() => { clearTimeout(buttonPressTimer.current); }, []); const handleSpinnerButtonPress = React.useCallback(onChange => { const minInterval = 50; const decrementBy = 10; let interval = 200; const onChangeInterval = () => { if (interval > minInterval) interval -= decrementBy; onChange(0); buttonPressTimer.current = window.setTimeout(onChangeInterval, interval); }; buttonPressTimer.current = window.setTimeout(onChangeInterval, interval); document.addEventListener('mouseup', handleSpinnerButtonRelease, { once: true }); }, [handleSpinnerButtonRelease]); const spinnerMarkup = isNumericType && step !== 0 && !disabled && !readOnly ? /*#__PURE__*/React.createElement(Spinner$1.Spinner, { onClick: handleClickChild, onChange: handleNumberChange, onMouseDown: handleSpinnerButtonPress, onMouseUp: handleSpinnerButtonRelease, ref: spinnerRef, onBlur: handleOnBlur }) : null; const style = multiline && height ? { height, maxHeight } : null; const handleExpandingResize = React.useCallback(height => { setHeight(height); }, []); const resizer = multiline && isAfterInitial ? /*#__PURE__*/React.createElement(Resizer.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(Labelled.helpTextID(id)); } if (showCharacterCount) { describedBy.push(`${id}-CharacterCounter`); } const labelledBy = []; if (prefix) { labelledBy.push(`${id}-Prefix`); } if (suffix) { labelledBy.push(`${id}-Suffix`); } if (verticalContent) { labelledBy.push(`${id}-VerticalContent`); } labelledBy.unshift(Label.labelID(id)); const inputClassName = css.classNames(TextField_module.default.Input, align && TextField_module.default[css.variationName('Input-align', align)], suffix && TextField_module.default['Input-suffixed'], clearButton && TextField_module.default['Input-hasClearButton'], monospaced && TextField_module.default.monospaced, suggestion && TextField_module.default.suggestion, autoSize && TextField_module.default['Input-autoSize']); const handleOnFocus = event => { setFocus(true); if (selectTextOnFocus && !suggestion) { const input = getInputRef(); input?.select(); } if (onFocus) { onFocus(event); } }; useEventListener.useEventListener('wheel', handleOnWheel, inputRef); function handleOnWheel(event) { if (document.activeElement === event.target && isNumericType) { event.stopPropagation(); } } const input = /*#__PURE__*/React.createElement(multiline ? 'textarea' : 'input', { name, id, disabled, readOnly, role, autoFocus, value: normalizedValue, placeholder, style, autoComplete, className: inputClassName, ref: multiline ? textAreaRef : inputRef, min, max, step, minLength, maxLength, spellCheck, pattern, inputMode, type: inputType, rows: getRows(multiline), size: autoSize ? 1 : undefined, 'aria-describedby': describedBy.length ? describedBy.join(' ') : undefined, 'aria-labelledby': labelledBy.join(' '), 'aria-invalid': Boolean(error), 'aria-owns': ariaOwns, 'aria-activedescendant': ariaActiveDescendant, 'aria-autocomplete': ariaAutocomplete, 'aria-controls': ariaControls, 'aria-expanded': ariaExpanded, 'aria-required': requiredIndicator, ...normalizeAriaMultiline(multiline), onFocus: handleOnFocus, onBlur: handleOnBlur, onClick: handleClickChild, onKeyPress: handleKeyPress, onKeyDown: handleKeyDown, onChange: !suggestion ? handleChange : undefined, onInput: suggestion ? handleChange : undefined, // 1Password disable data attribute 'data-1p-ignore': autoComplete === 'off' || undefined, // LastPass disable data attribute 'data-lpignore': autoComplete === 'off' || undefined, // Dashlane disable data attribute 'data-form-type': autoComplete === 'off' ? 'other' : undefined }); const inputWithVerticalContentMarkup = verticalContent ? /*#__PURE__*/React.createElement("div", { className: TextField_module.default.VerticalContent, id: `${id}-VerticalContent`, ref: verticalContentRef, onClick: handleClickChild }, verticalContent, input) : null; const inputMarkup = verticalContent ? inputWithVerticalContentMarkup : input; const backdropMarkup = /*#__PURE__*/React.createElement("div", { className: css.classNames(TextField_module.default.Backdrop, connectedLeft && TextField_module.default['Backdrop-connectedLeft'], connectedRight && TextField_module.default['Backdrop-connectedRight']) }); const inputAndSuffixMarkup = autoSize ? /*#__PURE__*/React.createElement("div", { className: TextField_module.default.InputAndSuffixWrapper }, /*#__PURE__*/React.createElement("div", { className: css.classNames(TextField_module.default.AutoSizeWrapper, suffix && TextField_module.default.AutoSizeWrapperWithSuffix), "data-auto-size-value": value || placeholder }, inputMarkup), suffixMarkup) : /*#__PURE__*/React.createElement(React.Fragment, null, inputMarkup, suffixMarkup); return /*#__PURE__*/React.createElement(Labelled.Labelled, { label: label, id: id, error: error, action: labelAction, labelHidden: labelHidden, helpText: helpText, requiredIndicator: requiredIndicator, disabled: disabled, readOnly: readOnly }, /*#__PURE__*/React.createElement(Connected.Connected, { left: connectedLeft, right: connectedRight }, /*#__PURE__*/React.createElement("div", { className: className, onClick: handleClick, ref: textFieldRef }, prefixMarkup, inputAndSuffixMarkup, characterCountMarkup, loadingMarkup, clearButtonMarkup, spinnerMarkup, backdropMarkup, resizer))); function handleChange(event) { onChange && onChange(event.currentTarget.value, id); } function handleClick(event) { const { target } = event; // For TextFields used with Combobox, focus needs to be set again even // if the TextField is already focused to trigger the logic to open the // Combobox activator const inputRefRole = inputRef?.current?.getAttribute('role'); if (target === inputRef.current && inputRefRole === 'combobox') { inputRef.current?.focus(); handleOnFocus(event); return; } if (isPrefixOrSuffix(target) || isVerticalContent(target) || isInput(target) || isSpinner(target) || isLoadingSpinner(target) || focus) { return; } getInputRef()?.focus(); } function handleClickChild(event) { if (!isSpinner(event.target) && !isInput(event.target)) { event.stopPropagation(); } if (isPrefixOrSuffix(event.target) || isVerticalContent(event.target) || isInput(event.target) || isLoadingSpinner(event.target) || focus) { return; } setFocus(true); getInputRef()?.focus(); } function handleClearButtonPress() { onClearButtonClick && onClearButtonClick(id); } function handleKeyPress(event) { const { key, which } = event; const numbersSpec = /[\d.,eE+-]$/; const integerSpec = /[\deE+-]$/; if (!isNumericType || which === types.Key.Enter || type === 'number' && numbersSpec.test(key) || type === 'integer' && integerSpec.test(key)) { return; } event.preventDefault(); } function handleKeyDown(event) { if (!isNumericType) { return; } const { key, which } = event; if (type === 'integer' && (key === 'ArrowUp' || which === types.Key.UpArrow)) { handleNumberChange(1); event.preventDefault(); } if (type === 'integer' && (key === 'ArrowDown' || which === types.Key.DownArrow)) { handleNumberChange(-1); event.preventDefault(); } if ((which === types.Key.Home || key === 'Home') && min !== undefined) { if (onSpinnerChange != null) { onSpinnerChange(String(min), id); } else if (onChange != null) { onChange(String(min), id); } } if ((which === types.Key.End || key === 'End') && max !== undefined) { if (onSpinnerChange != null) { onSpinnerChange(String(max), id); } else if (onChange != null) { onChange(String(max), id); } } if ((which === types.Key.PageUp || key === 'PageUp') && largeStep !== undefined) { handleNumberChange(1, largeStep); } if ((which === types.Key.PageDown || key === 'PageDown') && largeStep !== undefined) { handleNumberChange(-1, largeStep); } } function handleOnBlur(event) { setFocus(false); // Return early if new focus target is inside the TextField component if (textFieldRef.current?.contains(event?.relatedTarget)) { return; } if (onBlur) { onBlur(event); } } function isInput(target) { const input = getInputRef(); return target instanceof HTMLElement && input && (input.contains(target) || input.contains(document.activeElement)); } function isPrefixOrSuffix(target) { return target instanceof Element && (prefixRef.current && prefixRef.current.contains(target) || suffixRef.current && suffixRef.current.contains(target)); } function isSpinner(target) { return target instanceof Element && spinnerRef.current && spinnerRef.current.contains(target); } function isLoadingSpinner(target) { return target instanceof Element && loadingRef.current && loadingRef.current.contains(target); } function isVerticalContent(target) { return target instanceof Element && verticalContentRef.current && (verticalContentRef.current.contains(target) || verticalContentRef.current.contains(document.activeElement)); } } function getRows(multiline) { if (!multiline) return undefined; return typeof multiline === 'number' ? multiline : 1; } function normalizeAriaMultiline(multiline) { if (!multiline) return undefined; return Boolean(multiline) || typeof multiline === 'number' && multiline > 0 ? { 'aria-multiline': true } : undefined; } exports.TextField = TextField;