import cx from "classnames"; import React, { ChangeEvent, ChangeEventHandler, forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"; import useDebounceEvent from "../../hooks/useDebounceEvent"; import Icon from "../Icon/Icon"; import { backwardCompatibilityForProperties } from "../../helpers/backwardCompatibilityForProperties"; import Loader from "../Loader/Loader"; import Text from "../Text/Text"; import FieldLabel from "../FieldLabel/FieldLabel"; import { FEEDBACK_CLASSES, getActualSize, SIZE_MAPPER, TextFieldAriaLabel, TextFieldFeedbackState, TextFieldSize, TextFieldTextType } from "./TextFieldConstants"; import { BASE_SIZES } from "../../constants/sizes"; import useMergeRef from "../../hooks/useMergeRef"; import Clickable from "../../components/Clickable/Clickable"; import { getTestId } from "../../tests/test-ids-utils"; import { NOOP } from "../../utils/function-utils"; import { ComponentDefaultTestId } from "../../tests/constants"; import { VibeComponentProps, VibeComponent, withStaticProps } from "../../types"; import styles from "./TextField.module.scss"; import { Tooltip } from "../Tooltip"; import { HiddenText } from "../HiddenText"; const EMPTY_OBJECT = { primary: "", secondary: "", layout: "" }; export interface TextFieldProps extends VibeComponentProps { placeholder?: string; /** See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete for all of the available options */ autoComplete?: string; value?: string; onChange?: ( value: string, event: React.ChangeEvent | Pick, "target"> ) => void; onBlur?: (event: React.FocusEvent) => void; onFocus?: (event: React.FocusEvent) => void; onKeyDown?: (event: React.KeyboardEvent) => void; onWheel?: (event: React.WheelEvent) => void; debounceRate?: number; autoFocus?: boolean; disabled?: boolean; readonly?: boolean; setRef?: (node: HTMLElement) => void; iconName?: string | React.FunctionComponent | null; secondaryIconName?: string | React.FunctionComponent | null; title?: string; /** SIZES is exposed on the component itself */ size?: TextFieldSize; /** Don't provide status for plain assistant text */ validation?: { status?: "error" | "success"; text?: string }; // TODO make common validation class? wrapperClassName?: string; onIconClick?: (icon: string | React.FunctionComponent | null) => void; clearOnIconClick?: boolean; labelIconName?: string | React.FunctionComponent | null; showCharCount?: boolean; inputAriaLabel?: string; searchResultsContainerId?: string; activeDescendant?: string; /** Icon names labels for a11y */ /// TODO Remove layout in next major iconsNames?: { layout: string; primary: string; secondary: string; }; /** TEXT_TYPES is exposed on the component itself */ type?: TextFieldTextType; maxLength?: number; allowExceedingMaxLength?: boolean; trim?: boolean; /** ARIA role for container landmark */ role?: string; /** adds required to the input element */ required?: boolean; requiredErrorText?: string; /** shows loading animation */ loading?: boolean; /** * @deprecated - use "data-testid" instead */ dataTestId?: string; requiredAsterisk?: boolean; // TODO: Deprecate in next major version. secondaryDataTestId?: string; tabIndex?: number; name?: string; underline?: boolean; /** * Apply new style for read only, use along with `readonly` prop */ withReadOnlyStyle?: boolean; /** * When true, component is controlled by an external state */ controlled?: boolean; iconTooltipContent?: string; secondaryTooltipContent?: string; } const TextField: VibeComponent & { sizes?: typeof BASE_SIZES; types?: typeof TextFieldTextType; feedbacks?: typeof TextFieldFeedbackState; } = forwardRef( ( { className = "", placeholder = "", autoComplete = "off", value, onChange = NOOP, onBlur = NOOP, onFocus = NOOP, onKeyDown = NOOP, onWheel = NOOP, debounceRate = 0, autoFocus = false, disabled = false, readonly = false, setRef = NOOP, iconName, secondaryIconName, id = "input", title = "", size = TextField.sizes.SMALL, validation = null, wrapperClassName = "", onIconClick = NOOP, clearOnIconClick = false, labelIconName, showCharCount = false, inputAriaLabel, searchResultsContainerId = "", activeDescendant = "", iconsNames = EMPTY_OBJECT, type = TextFieldTextType.TEXT, maxLength = null, allowExceedingMaxLength = false, trim = false, role = "", required = false, requiredErrorText = "", loading = false, requiredAsterisk = false, dataTestId: backwardCompatibilityDataTestId, "data-testid": dataTestId, secondaryDataTestId, tabIndex, underline = false, name, withReadOnlyStyle, controlled = false, iconTooltipContent, secondaryTooltipContent }, ref ) => { const [isRequiredAndEmpty, setIsRequiredAndEmpty] = useState(false); const overrideDataTestId = backwardCompatibilityForProperties( [dataTestId, backwardCompatibilityDataTestId], getTestId(ComponentDefaultTestId.TEXT_FIELD, id) ); const inputRef = useRef(null); const mergedRef = useMergeRef(ref, inputRef, setRef); const onBlurCallback = useCallback( (e: React.FocusEvent) => { if (required && !e.target.value) { setIsRequiredAndEmpty(true); } onBlur(e); }, [onBlur, required] ); const onChangeCallback = useCallback( (value: string, e?: React.ChangeEvent) => { if (isRequiredAndEmpty && value) { setIsRequiredAndEmpty(false); } const event = e || { target: inputRef.current }; onChange(value, event); }, [onChange, isRequiredAndEmpty] ); const { inputValue: uncontrolledInput, onEventChanged, clearValue } = useDebounceEvent({ delay: debounceRate, onChange: onChangeCallback, initialStateValue: value, trim }); const inputValue = useMemo(() => { return controlled ? value : uncontrolledInput; }, [controlled, value, uncontrolledInput]); const handleChange = useCallback>( event => { controlled ? onChangeCallback(event.target.value, event) : onEventChanged(event); }, [controlled, onChangeCallback, onEventChanged] ); const currentStateIconName = useMemo(() => { if (secondaryIconName) { return inputValue ? secondaryIconName : iconName; } return iconName; }, [iconName, secondaryIconName, inputValue]); const onIconClickCallback = useCallback(() => { if (disabled) { return; } if (clearOnIconClick) { if (inputRef.current) { inputRef.current.focus(); } // Do it cause otherwise the value is not cleared in target object inputRef.current.value = ""; controlled ? onChangeCallback("") : clearValue(); } onIconClick(currentStateIconName); }, [disabled, clearOnIconClick, onIconClick, currentStateIconName, controlled, onChangeCallback, clearValue]); const validationClass = useMemo(() => { if (typeof maxLength === "number" && inputValue && inputValue.length > maxLength) { return FEEDBACK_CLASSES.error; } if ((!validation || !validation.status) && !isRequiredAndEmpty) { return ""; } const status = isRequiredAndEmpty ? "error" : validation.status; return FEEDBACK_CLASSES[status]; }, [maxLength, validation, isRequiredAndEmpty, inputValue]); const hasIcon = iconName || secondaryIconName; const shouldShowExtraText = showCharCount || (validation && validation.text) || isRequiredAndEmpty; const isSecondary = secondaryIconName === currentStateIconName; const isPrimary = iconName === currentStateIconName; const shouldFocusOnPrimaryIcon = (onIconClick !== NOOP || iconsNames.primary || iconTooltipContent) && inputValue && iconName.length && isPrimary; const shouldFocusOnSecondaryIcon = (secondaryIconName || secondaryTooltipContent) && isSecondary && !!inputValue; const allowExceedingMaxLengthTextId = allowExceedingMaxLength ? `${id}-allow-exceeding-max-length-text` : undefined; useEffect(() => { if (!inputRef?.current || !autoFocus) { return; } const animationFrame = requestAnimationFrame(() => inputRef.current.focus()); return () => cancelAnimationFrame(animationFrame); }, [inputRef, autoFocus]); const isIconContainerClickable = onIconClick !== NOOP || clearOnIconClick; const primaryIconLabel = iconsNames.primary || iconTooltipContent; const secondaryIconLabel = iconsNames.secondary || secondaryTooltipContent; return (
{/*Programatical input (tabIndex={-1}) is working fine with aria-activedescendant attribute despite the rule*/} {/*eslint-disable-next-line jsx-a11y/aria-activedescendant-has-tabindex*/} {loading && (
)}
{shouldShowExtraText && ( {validation && validation.text && ( {isRequiredAndEmpty ? requiredErrorText : validation.text} )} {showCharCount && ( {(inputValue && inputValue.length) || 0} {typeof maxLength === "number" && `/${maxLength}`} )} )}
); } ); export default withStaticProps(TextField, { sizes: BASE_SIZES, feedbacks: TextFieldFeedbackState, types: TextFieldTextType });