'use client' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, } from 'react' import * as React from 'react' import { CancelCircleFilledIcon } from '@channel.io/bezier-icons' import classNames from 'classnames' import { v4 as uuid } from 'uuid' import { COMMON_IME_CONTROL_KEYS, useKeyboardActionLockerWhileComposing, } from '~/src/hooks/useKeyboardActionLockerWhileComposing' import { type FormFieldSize } from '~/src/types/props' import { getFormFieldSizeClassName } from '~/src/types/props-helpers' import { toString } from '~/src/utils/string' import { isArray, isEmpty, isNil } from '~/src/utils/type' import { BaseButton } from '~/src/components/BaseButton' import { useFormFieldProps } from '~/src/components/FormControl' import { Icon } from '~/src/components/Icon' import { useWindow } from '~/src/components/WindowProvider' import { type SelectionRangeDirections, type TextFieldItemProps, type TextFieldProps, type TextFieldRef, } from './TextField.types' import styles from './TextField.module.scss' /** * @deprecated */ const TEXT_INPUT_TEST_ID = 'bezier-text-input' /** * @deprecated */ const TEXT_INPUT_CLEAR_ICON_TEST_ID = 'bezier-text-input-clear-icon' /** * FIXME: This mapping constant was defined for UI consistency, * and it should be removed with size attribute */ const INPUT_LENGTH_BY_SIZE: Record = { xs: 28, m: 36, l: 44, xl: 54, } function TextFieldLeftContent({ children, wrapperStyle, wrapperClassName, withoutWrapper, }: { children: TextFieldProps['leftContent'] wrapperStyle: TextFieldProps['leftWrapperStyle'] wrapperClassName: TextFieldProps['leftWrapperClassName'] withoutWrapper: TextFieldProps['withoutLeftContentWrapper'] }) { if (isNil(children)) { return null } const Content = (() => { if ('icon' in children) { return ( ) } return children })() if (withoutWrapper) { return Content } return (
{Content}
) } function TextFieldRightContent({ children, wrapperStyle, wrapperClassName, withoutWrapper, }: { children: TextFieldProps['rightContent'] wrapperStyle: TextFieldProps['rightWrapperStyle'] wrapperClassName: TextFieldProps['rightWrapperClassName'] withoutWrapper: TextFieldProps['withoutRightContentWrapper'] }) { const renderRightItem = useCallback( (item: TextFieldItemProps, key?: string) => { if ('icon' in item) { const Comp = !isNil(item.onClick) ? BaseButton : 'div' return ( ) } return React.cloneElement(item, { key }) }, [] ) if (isNil(children) || isEmpty(children)) { return null } const contents = isArray(children) ? children.map((item) => renderRightItem(item, uuid())) : renderRightItem(children) if (withoutWrapper) { return <>{contents} } return (
{contents}
) } export const TextField = forwardRef( function TextField( { type, size: sizeProps, autoFocus, autoComplete = 'off', variant = 'primary', allowClear = false, selectAllOnInit = false, selectAllOnFocus = false, leftContent, rightContent, withoutLeftContentWrapper = false, withoutRightContentWrapper = false, style, className, wrapperStyle, wrapperClassName, leftWrapperStyle, leftWrapperClassName, rightWrapperStyle, rightWrapperClassName, value, onFocus, onChange, onKeyDown, onKeyUp, ...rest }, forwardedRef ) { const { window } = useWindow() const { disabled, readOnly, hasError, size: formFieldSize, ...ownProps } = useFormFieldProps(rest) const focusTimeout = useRef>(undefined) const blurTimeout = useRef>(undefined) const normalizedValue = isNil(value) ? undefined : toString(value) const activeInput = !disabled && !readOnly const activeClear = activeInput && allowClear && !isEmpty(normalizedValue) const size = sizeProps ?? formFieldSize ?? 'm' const inputRef = useRef(null) const focus = useCallback(() => { clearTimeout(focusTimeout.current) focusTimeout.current = window.setTimeout(() => { inputRef.current?.focus() }, 0) }, [window]) const blur = useCallback(() => { clearTimeout(blurTimeout.current) blurTimeout.current = window.setTimeout(() => { inputRef.current?.blur() }, 0) }, [window]) const setSelectionRange = useCallback( (start?: number, end?: number, direction?: SelectionRangeDirections) => { if (type && ['number', 'email', 'hidden'].includes(type)) { return } inputRef.current?.setSelectionRange( start || 0, end || 0, direction || 'none' ) }, [type] ) const getSelectionRange = useCallback( (): [number, number] => [ inputRef.current?.selectionStart || 0, inputRef.current?.selectionEnd || 0, ], [] ) const selectAll = useCallback(() => { focus() if (inputRef.current) { setSelectionRange(0, inputRef.current.value.length, 'backward') } }, [focus, setSelectionRange]) const unselect = useCallback(() => { focus() if (inputRef.current) { const valueLen = inputRef.current.value.length setSelectionRange(valueLen, valueLen) } }, [focus, setSelectionRange]) const getBoundingClientRect = useCallback((): DOMRect => { if (inputRef.current) { return inputRef.current.getBoundingClientRect() } return new DOMRect(undefined, undefined, 0, 0) }, []) const getDOMNode = useCallback(() => inputRef.current, []) const handle = useMemo( (): TextFieldRef => ({ focus, blur, setSelectionRange, getSelectionRange, selectAll, unselect, getBoundingClientRect, getDOMNode, }), [ focus, blur, setSelectionRange, getSelectionRange, selectAll, unselect, getBoundingClientRect, getDOMNode, ] ) useImperativeHandle(forwardedRef, () => handle) useEffect(() => { if (autoFocus) { focus() } if (selectAllOnInit) { focus() selectAll() } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const handleFocus = useCallback( (event: React.FocusEvent) => { if (!activeInput) { return } if (selectAllOnFocus) { selectAll() } if (onFocus) { onFocus(event) } }, [selectAllOnFocus, selectAll, activeInput, onFocus] ) const handleChange = useCallback( (event: React.ChangeEvent) => { if (activeInput && onChange) { onChange(event) } }, [activeInput, onChange] ) const { handleKeyDown: handleKeyDownWrappedWithComposingLocker, handleKeyUp: handleKeyUpWrappedWithComposingLocker, } = useKeyboardActionLockerWhileComposing({ keysToLock: COMMON_IME_CONTROL_KEYS, onKeyDown, onKeyUp, }) const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { if (activeInput && handleKeyDownWrappedWithComposingLocker) { handleKeyDownWrappedWithComposingLocker(event) } }, [activeInput, handleKeyDownWrappedWithComposingLocker] ) const handleKeyUp = useCallback( (event: React.KeyboardEvent) => { if (activeInput && handleKeyUpWrappedWithComposingLocker) { handleKeyUpWrappedWithComposingLocker(event) } }, [activeInput, handleKeyUpWrappedWithComposingLocker] ) const handleClear = useCallback(() => { const input = inputRef.current if (activeInput && input) { const setValue = Object?.getOwnPropertyDescriptor( HTMLInputElement.prototype, 'value' )?.set const event = new Event('input', { bubbles: true }) setValue?.call(input, '') input.dispatchEvent(event) } }, [activeInput]) return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions
{leftContent} {activeClear && ( )} {rightContent}
) } )