UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

201 lines (193 loc) • 8.28 kB
import React, { useState, useRef, useId, useCallback, useEffect } from 'react'; import { isValidElementType } from 'react-is'; import { clsx } from 'clsx'; import { AlertFillIcon } from '@primer/octicons-react'; import classes from './TextInput.module.css.js'; import TextInputInnerVisualSlot from '../internal/components/TextInputInnerVisualSlot.js'; import { TextInputWrapper } from '../internal/components/TextInputWrapper.js'; import TextInputAction from '../internal/components/TextInputInnerAction.js'; import UnstyledTextInput from '../internal/components/UnstyledTextInput.js'; import VisuallyHidden from '../_VisuallyHidden.js'; import { CharacterCounter } from '../utils/character-counter.js'; import { jsxs, Fragment, jsx } from 'react/jsx-runtime'; import { useProvidedRefOrCreate } from '../hooks/useProvidedRefOrCreate.js'; import Text from '../Text/Text.js'; // using forwardRef is important so that other components can autofocus the input const TextInput = /*#__PURE__*/React.forwardRef(({ icon: IconComponent, leadingVisual: LeadingVisual, trailingVisual: TrailingVisual, trailingAction, block, className, contrast, disabled, loading, loaderPosition = 'auto', loaderText = 'Loading', monospace, validationStatus, size: sizeProp, onFocus, onBlur, // start deprecated props variant: variantProp, width: widthProp, minWidth: minWidthProp, maxWidth: maxWidthProp, // end deprecated props type = 'text', required, characterLimit, onChange, value, defaultValue, ...inputProps }, ref) => { const [isInputFocused, setIsInputFocused] = useState(false); const inputRef = useProvidedRefOrCreate(ref); const [characterCount, setCharacterCount] = useState(''); const [isOverLimit, setIsOverLimit] = useState(false); const [screenReaderMessage, setScreenReaderMessage] = useState(''); const characterCounterRef = useRef(null); // this class is necessary to style FilterSearch, plz no touchy! const wrapperClasses = clsx(className, 'TextInput-wrapper'); const showLeadingLoadingIndicator = loading && (loaderPosition === 'leading' || Boolean(LeadingVisual && loaderPosition !== 'trailing')); const showTrailingLoadingIndicator = loading && (loaderPosition === 'trailing' || Boolean(loaderPosition === 'auto' && !LeadingVisual)); // Date/time input types that have segment-based focus const isSegmentedInputType = type === 'date' || type === 'time' || type === 'datetime-local'; const focusInput = e => { // Don't call focus() if the input itself was clicked on date/time inputs. if (e.target !== inputRef.current || !isSegmentedInputType) { var _inputRef$current; (_inputRef$current = inputRef.current) === null || _inputRef$current === void 0 ? void 0 : _inputRef$current.focus(); } }; const leadingVisualId = useId(); const trailingVisualId = useId(); const loadingId = useId(); const inputDescribedBy = clsx(inputProps['aria-describedby'], LeadingVisual && leadingVisualId, TrailingVisual && trailingVisualId, loading && loadingId) || undefined; const handleInputFocus = useCallback(e_0 => { setIsInputFocused(true); onFocus && onFocus(e_0); }, [onFocus]); const handleInputBlur = useCallback(e_1 => { setIsInputFocused(false); onBlur && onBlur(e_1); }, [onBlur]); // Initialize character counter useEffect(() => { if (characterLimit) { characterCounterRef.current = new CharacterCounter({ onCountUpdate: (count, overLimit, message) => { setCharacterCount(message); setIsOverLimit(overLimit); }, onScreenReaderAnnounce: message_0 => { setScreenReaderMessage(message_0); } }); return () => { var _characterCounterRef$; (_characterCounterRef$ = characterCounterRef.current) === null || _characterCounterRef$ === void 0 ? void 0 : _characterCounterRef$.cleanup(); characterCounterRef.current = null; }; } }, [characterLimit]); // Update character count when value changes or on mount useEffect(() => { if (characterLimit && characterCounterRef.current) { const currentValue = value !== undefined ? String(value) : defaultValue !== undefined ? String(defaultValue) : ''; characterCounterRef.current.updateCharacterCount(currentValue.length, characterLimit); } }, [value, defaultValue, characterLimit]); // Handle input change with character counter const handleInputChange = useCallback(e_2 => { if (characterLimit && characterCounterRef.current) { characterCounterRef.current.updateCharacterCount(e_2.target.value.length, characterLimit); } onChange === null || onChange === void 0 ? void 0 : onChange(e_2); }, [onChange, characterLimit]); const characterCountId = useId(); const characterCountStaticMessageId = useId(); const isValid = isOverLimit ? 'error' : validationStatus; return /*#__PURE__*/jsxs(Fragment, { children: [/*#__PURE__*/jsxs(TextInputWrapper, { block: block, className: wrapperClasses, validationStatus: isValid, contrast: contrast, disabled: disabled, monospace: monospace, size: sizeProp, width: widthProp, minWidth: minWidthProp, maxWidth: maxWidthProp, variant: variantProp, hasLeadingVisual: Boolean(LeadingVisual || showLeadingLoadingIndicator), hasTrailingVisual: Boolean(TrailingVisual || showTrailingLoadingIndicator), hasTrailingAction: Boolean(trailingAction), isInputFocused: isInputFocused, onClick: focusInput, "aria-busy": Boolean(loading), children: [IconComponent && /*#__PURE__*/jsx(IconComponent, { className: "TextInput-icon" }), /*#__PURE__*/jsx(TextInputInnerVisualSlot, { visualPosition: "leading", showLoadingIndicator: showLeadingLoadingIndicator, hasLoadingIndicator: typeof loading === 'boolean', id: leadingVisualId, children: typeof LeadingVisual !== 'string' && isValidElementType(LeadingVisual) ? /*#__PURE__*/jsx(LeadingVisual, {}) : LeadingVisual }), /*#__PURE__*/jsx(UnstyledTextInput // @ts-expect-error it needs a non nullable ref , { ref: inputRef, disabled: disabled, onFocus: handleInputFocus, onBlur: handleInputBlur, onChange: handleInputChange, type: type, "aria-required": required, "aria-invalid": isValid === 'error' ? 'true' : undefined, value: value, defaultValue: defaultValue, ...inputProps, "aria-describedby": characterLimit ? [characterCountStaticMessageId, inputDescribedBy].filter(Boolean).join(' ') || undefined : inputDescribedBy, "data-component": "input" }), loading && /*#__PURE__*/jsx(VisuallyHidden, { id: loadingId, children: loaderText }), /*#__PURE__*/jsx(TextInputInnerVisualSlot, { visualPosition: "trailing", showLoadingIndicator: showTrailingLoadingIndicator, hasLoadingIndicator: typeof loading === 'boolean', id: trailingVisualId, "data-testid": "text-input-trailing-visual", children: typeof TrailingVisual !== 'string' && isValidElementType(TrailingVisual) ? /*#__PURE__*/jsx(TrailingVisual, {}) : TrailingVisual }), trailingAction] }), characterLimit && /*#__PURE__*/jsxs(Fragment, { children: [/*#__PURE__*/jsx(VisuallyHidden, { "aria-live": "polite", role: "status", children: screenReaderMessage }), /*#__PURE__*/jsxs(VisuallyHidden, { id: characterCountStaticMessageId, children: ["You can enter up to ", characterLimit, " ", characterLimit === 1 ? 'character' : 'characters'] }), /*#__PURE__*/jsxs(Text, { "aria-hidden": "true", id: characterCountId, size: "small", className: clsx(classes.CharacterCounter, isOverLimit && classes['CharacterCounter--error']), children: [isOverLimit && /*#__PURE__*/jsx(AlertFillIcon, { size: 16 }), characterCount] })] })] }); }); TextInput.displayName = 'TextInput'; var TextInput$1 = Object.assign(TextInput, { __SLOT__: Symbol('TextInput'), Action: TextInputAction }); export { TextInput$1 as default };