import { cloneElement, forwardRef, ReactElement, ReactNode, useImperativeHandle, useRef, useState, } from 'react'; import classnames from 'classnames'; import { TOKEN_SPACE_100 } from '@swipebox/morphe-design-tokens'; import styles from './InternalTextField.css'; import Box from '../Box'; import focusStyles from '../Focus.css'; import layout from '../Layout.css'; import formElement from '../sharedSubcomponents/FormElement.css'; import FormErrorMessage from '../sharedSubcomponents/FormErrorMessage'; import FormHelperText from '../sharedSubcomponents/FormHelperText'; import FormLabel from '../sharedSubcomponents/FormLabel'; import { MaxLength } from '../TextField'; import typographyStyle from '../Typography.css'; type SizeType = 'sm' | 'md' | 'lg'; export type autoCompleteType = | 'additional-name' | 'address-level1' | 'address-level2' | 'address-level3' | 'address-level4' | 'address-line1' | 'address-line2' | 'address-line3' | 'bday-day' | 'bday-month' | 'bday-year' | 'bday' | 'billing' | 'cc-additional-name' | 'cc-csc' | 'cc-exp-month' | 'cc-exp-year' | 'cc-exp' | 'cc-family-name' | 'cc-given-name' | 'cc-name' | 'cc-number' | 'cc-type' | 'country-name' | 'country' | 'current-password' | 'email' | 'family-name' | 'given-name' | 'honoric-prefix' | 'honoric-suffix' | 'impp' | 'language' | 'name' | 'new-password' | 'nickname' | 'off' | 'on' | 'one-time-code' | 'organization-title' | 'organization' | 'photo' | 'postal-code' | 'sex' | 'shipping' | 'street-address' | 'tel-area-code' | 'tel-country-code' | 'tel-extension' | 'tel-local-prefix' | 'tel-local-suffix' | 'tel-local' | 'tel-national' | 'tel' | 'transaction-amount' | 'transaction-currency' | 'url' | 'username' | 'webauthn'; type Props = { // REQUIRED id: string; onChange: (arg1: { event: React.ChangeEvent; value: string }) => void; // OPTIONAL accessibilityControls?: string; accessibilityActiveDescendant?: string; autoComplete?: autoCompleteType; dataTestId?: string; disabled?: boolean; errorMessage?: ReactNode; hasError?: boolean; helperText?: string; iconButton?: ReactElement; label?: string; labelDisplay?: 'visible' | 'hidden'; max?: number; maxLength?: MaxLength | null | undefined; min?: number; mobileEnterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send'; mobileInputMode?: 'none' | 'text' | 'decimal' | 'numeric'; name?: string; onBlur?: (arg1: { event: React.FocusEvent; value: string }) => void; onClick?: (arg1: { event: React.MouseEvent; value: string }) => void; onFocus?: (arg1: { event: React.FocusEvent; value: string }) => void; onKeyDown?: (arg1: { event: React.KeyboardEvent; value: string }) => void; placeholder?: string; readOnly?: boolean; size?: SizeType; step?: number; tags?: ReadonlyArray; type?: 'date' | 'email' | 'number' | 'password' | 'tel' | 'text' | 'url'; value?: string; }; const applyDensityStyle = (size: SizeType) => styles[`${size}`]; const InternalTextFieldWithForwardRef = forwardRef(function TextField( { accessibilityControls, accessibilityActiveDescendant, autoComplete, dataTestId, disabled = false, errorMessage, hasError = false, helperText, id, iconButton, label, labelDisplay, max, maxLength, mobileEnterKeyHint, mobileInputMode, min, name, onBlur, onChange, onClick, onFocus, onKeyDown, placeholder, readOnly, size = 'md', step, tags, type = 'text', value, }: Props, ref, ) { // ==== REFS ==== const innerRef = useRef(null); const innerTagsRef = useRef(null); // When using both forwardRef and innerRefs, useimperativehandle() allows to externally set focus via the ref prop: textfieldRef.current.focus() // @ts-expect-error - TS2322 - Type 'HTMLDivElement | HTMLInputElement | null' is not assignable to type 'HTMLInputElement'. useImperativeHandle(ref, () => (tags ? innerTagsRef.current : innerRef.current)); // ==== STATE ==== const [focused, setFocused] = useState(false); const [currentLength, setCurrentLength] = useState(value?.length ?? 0); // ==== HANDLERS ==== const handleBlur = (event: React.FocusEvent) => { setFocused(false); onBlur?.({ event, value: event.currentTarget.value }); }; const handleClick = (event: React.MouseEvent) => onClick?.({ event, value: event.currentTarget.value }); const handleChange = (event: React.ChangeEvent) => { setCurrentLength(event.currentTarget.value?.length ?? 0); onChange({ event, value: event.currentTarget.value }); }; const handleFocus = (event: React.FocusEvent) => { setFocused(true); onFocus?.({ event, value: event.currentTarget.value }); }; const handleKeyDown = (event: React.KeyboardEvent) => onKeyDown?.({ event, value: event.currentTarget.value }); // ==== STYLING ==== const hasErrorMessage = Boolean(errorMessage); const styledClasses = classnames( styles.textField, formElement.base, disabled ? formElement.disabled : formElement.enabled, (hasError || hasErrorMessage) && !focused ? formElement.errored : formElement.normal, { [formElement.sm]: size === 'sm', [formElement.md]: size === 'md', [formElement.lg]: size === 'lg', // note: layout CSS controls min-height of element [layout.small]: size === 'sm', [layout.medium]: size === 'md', [layout.large]: size === 'lg', [styles.actionButton]: iconButton, }, applyDensityStyle(size), tags ? { [focusStyles.accessibilityOutlineFocus]: focused, [styles.textFieldWrapper]: true, } : { [typographyStyle.truncate]: true }, ); const unstyledClasses = classnames(styles.unstyledTextField); if (maxLength && maxLength.characterCount < 0) { throw new Error('`maxLength` must be an integer value 0 or higher.'); } let ariaDescribedby; if (hasErrorMessage) { ariaDescribedby = `${id}-error`; } if (helperText || maxLength) { ariaDescribedby = `${id}-helperText`; } const inputElement = ( ); // Explicit margin for the small size, we don't have a token for 2px const tagMarginY = size === 'sm' || size === 'md' ? '2px' : TOKEN_SPACE_100; return ( {label && } {tags ? (
{tags.map((tag, tagIndex) => ( {cloneElement(tag, { size: size === 'lg' ? 'md' : 'sm' })} ))} {/* This is an invisible spacer div which mirrors the input's * content. We use it to implement the flex wrapping behavior * which is not supported by inputs, by having the actual input * track it with absolute positioning. */}
{value}
{inputElement}
) : ( inputElement )} {!disabled && iconButton}
{(helperText || maxLength) && !errorMessage ? ( ) : null} {hasErrorMessage ? ( ) : null}
); }); InternalTextFieldWithForwardRef.displayName = 'InternalTextField'; export default InternalTextFieldWithForwardRef;