/* eslint-disable max-statements */ /* eslint-disable max-lines-per-function */ import { useCallback, useEffect, useId, useRef, useState } from "react"; import { useForwardedRef } from "@bedrock-layout/use-forwarded-ref"; import { pxToRem, useIsFocused, useForceUpdate } from "../../utils"; import { TEXTAREA_DEFAULT_HEIGHT, TEXTAREA_MAX_HEIGHT } from "./consts"; import type { ForwardedRef, ClipboardEventHandler, KeyboardEventHandler, FocusEventHandler, } from "react"; import type { InputProps, TextAreaProps, CommonProps } from "."; export const isTextArea = (props: InputProps): props is TextAreaProps & CommonProps => { return Boolean(props.multiLine); }; export const useInput = ( props: InputProps, ref: ForwardedRef ) => { const { id, label, rightElement, info, error, onChange, onKeyDown: _onKeyDown, onPaste: _onPaste, onBlur: _onBlur, ...restProps } = props; const [isMaxHeight, setIsMaxHeight] = useState(false); const fieldsetRef = useRef(null); const randomId = useId(); const rightElementContainer = useRef(null); const forceUpdate = useForceUpdate(); const inputRef = useForwardedRef(ref); const inputId = id ? id : randomId; const _clearScientificNotation = "clearScientificNotation" in restProps && restProps.clearScientificNotation; const type = "type" in restProps ? restProps.type : "text"; const isTextAreaInstance = isTextArea(props); const isTextareaFixHeight = Boolean(props.multiLine && props.height); const textAreaDefaultHeight = isTextAreaInstance && props.height ? props.height : TEXTAREA_DEFAULT_HEIGHT; const textAreaMaxHeight = isTextAreaInstance && props.height ? props.height : TEXTAREA_MAX_HEIGHT; delete restProps.type; delete restProps.multiLine; // @ts-expect-error We don't need runtime check, if it doesn't exist it's a noop delete restProps.clearScientificNotation; const [hasValue, setHasValue] = useState(Boolean(inputRef.current?.value)); useEffect(() => { if (!inputRef.current) { return; } const input = inputRef.current; Object.defineProperty(input, "value", { set(newValue) { if (input.value === newValue) { // Ignore change to the same value to prevent infinite loops return; } if (input instanceof HTMLInputElement) { // Call the original setter to update the value in the DOM Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call( this, newValue ); } else if (input instanceof HTMLTextAreaElement) { Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set?.call( this, newValue ); } // Just force update of the hook, the useEffect below will catch the input value change forceUpdate(); }, }); }, [inputRef.current, forceUpdate]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const isControlled = "value" in restProps; if (isControlled) { if (typeof restProps.value === "string") { setHasValue(restProps.value.length !== 0); } else { setHasValue(typeof restProps.value !== "undefined"); } } else { if (typeof inputRef.current?.value === "string") { setHasValue(inputRef.current.value.length !== 0); } else { setHasValue(typeof inputRef.current?.value !== "undefined"); } } }, [restProps, inputRef.current, inputRef.current?.value]); // eslint-disable-line react-hooks/exhaustive-deps const { isFocused } = useIsFocused(inputRef); const calcNewHeight = useCallback( (currentTarget: HTMLInputElement | HTMLTextAreaElement | null) => { if (!currentTarget) { return; } const style = getComputedStyle(currentTarget); currentTarget.style.height = pxToRem(textAreaDefaultHeight); currentTarget.style.overflow = "hidden"; const maxHeight = Math.max( currentTarget.scrollHeight + parseInt(style.borderTopWidth.replace("px", "")) + parseInt(style.borderBottomWidth.replace("px", "")), textAreaDefaultHeight ); if (isMaxHeight !== maxHeight > textAreaMaxHeight) { setIsMaxHeight(maxHeight > textAreaMaxHeight); } const newHeight = maxHeight > textAreaDefaultHeight ? `calc(${pxToRem(maxHeight)})` : pxToRem(textAreaDefaultHeight); currentTarget.style.height = newHeight; currentTarget.style.removeProperty("overflow"); if (fieldsetRef.current) { fieldsetRef.current.style.height = newHeight; } }, [isMaxHeight, textAreaDefaultHeight, textAreaMaxHeight] ); useEffect(() => { if (inputRef.current instanceof HTMLTextAreaElement) { calcNewHeight(inputRef.current); } }, [calcNewHeight, inputRef.current, restProps.value, hasValue]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (inputRef.current instanceof HTMLInputElement) { fieldsetRef.current?.style.removeProperty("height"); } }, [inputRef.current]); // eslint-disable-line react-hooks/exhaustive-deps const handleOnChange: React.ChangeEventHandler = (e) => { (onChange as TextAreaProps["onChange"])?.(e); calcNewHeight(e.currentTarget); }; const clearScientificNotation = props.type === "number" && Boolean(_clearScientificNotation); const onKeyDown: KeyboardEventHandler = useCallback( (e) => { if (clearScientificNotation) { if (e.key.toLowerCase() === "e" || e.key === "+") { e.preventDefault(); } } (_onKeyDown as KeyboardEventHandler | undefined)?.(e); }, [_onKeyDown, clearScientificNotation] ); const onBlur: FocusEventHandler = useCallback( (e) => { if (clearScientificNotation) { if (e.currentTarget.value === "") { e.currentTarget.value = "1"; e.currentTarget.value = ""; } } (_onBlur as FocusEventHandler | undefined)?.(e); }, [_onBlur, clearScientificNotation] ); const onPaste: ClipboardEventHandler = useCallback( (e) => { if (clearScientificNotation) { const text = e.clipboardData.getData("text"); if (text.toLowerCase().includes("e") || text.includes("+")) { e.preventDefault(); } } (_onPaste as ClipboardEventHandler | undefined)?.(e); }, [_onPaste, clearScientificNotation] ); return { rightElement, rightElementContainer, label, inputId, type, restProps, inputRef, isMaxHeight, isTextArea, isTextareaFixHeight, isFocused, isFocusedOrHasValue: isFocused || hasValue, info, error, fieldsetRef, onChange, onKeyDown, onPaste, onBlur, handleOnChange, }; };