@primer/react
Version:
An implementation of GitHub's Primer Design System using React
201 lines (193 loc) • 8.28 kB
JavaScript
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 };