import React, { DetailedHTMLProps, InputHTMLAttributes, LabelHTMLAttributes, RefObject, TextareaHTMLAttributes, } from "react"; import { twMerge } from "tailwind-merge"; import Span from "../layout/Span"; let timeout: any; const autocompleteOptions = [ // Personal Information "name", "honorific-prefix", "given-name", "additional-name", "family-name", "honorific-suffix", "nickname", // Contact Information "email", "username", "new-password", "current-password", "one-time-code", "organization-title", "organization", // Address Fields "street-address", "address-line1", "address-line2", "address-line3", "address-level4", "address-level3", "address-level2", "address-level1", "country", "country-name", "postal-code", // Phone Numbers "tel", "tel-country-code", "tel-national", "tel-area-code", "tel-local", "tel-extension", // Dates "bday", "bday-day", "bday-month", "bday-year", // Payment Information "cc-name", "cc-given-name", "cc-additional-name", "cc-family-name", "cc-number", "cc-exp", "cc-exp-month", "cc-exp-year", "cc-csc", "cc-type", // Additional Options "sex", "url", "photo", // Special Values "on", // Enables autofill (default) "off", // Disables autofill ] as const; export type InputProps<KeyType extends string> = DetailedHTMLProps< InputHTMLAttributes<HTMLInputElement>, HTMLInputElement > & DetailedHTMLProps< TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement > & { label?: string; variant?: "normal" | "warning" | "error" | "inactive"; prefix?: string | React.ReactNode; suffix?: string | React.ReactNode; showLabel?: boolean; istextarea?: boolean; wrapperProps?: DetailedHTMLProps< InputHTMLAttributes<HTMLDivElement>, HTMLDivElement >; labelProps?: DetailedHTMLProps< LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement >; componentRef?: RefObject<any>; validationRegex?: RegExp; debounce?: number; invalidMessage?: string; validationFunction?: (value: string) => Promise<boolean>; autoComplete?: (typeof autocompleteOptions)[number]; name?: KeyType; valueUpdate?: string; }; /** * # Input Element * @className twui-input * @className twui-input-wrapper * @className twui-input-invalid */ export default function Input<KeyType extends string>({ label, variant, prefix, suffix, componentRef, labelProps, wrapperProps, showLabel, istextarea, debounce, invalidMessage, autoComplete, validationFunction, validationRegex, valueUpdate, ...props }: InputProps<KeyType>) { const [focus, setFocus] = React.useState(false); const [value, setValue] = React.useState( props.value ? String(props.value) : props.defaultValue ? String(props.defaultValue) : "" ); delete props.defaultValue; const [isValid, setIsValid] = React.useState(true); const DEFAULT_DEBOUNCE = 500; const finalDebounce = debounce || DEFAULT_DEBOUNCE; React.useEffect(() => { if (!value.match(/./)) return setIsValid(true); window.clearTimeout(timeout); if (validationRegex) { timeout = setTimeout(() => { setIsValid(validationRegex.test(value)); }, finalDebounce); } if (validationFunction) { timeout = setTimeout(() => { validationFunction(value).then((res) => { setIsValid(res); }); }, finalDebounce); } }, [value]); React.useEffect(() => { if (!props.value) return; setValue(String(props.value)); }, [props.value]); const targetComponent = istextarea ? ( <textarea {...props} className={twMerge( "w-full outline-none bg-transparent", "twui-textarea", props.className )} ref={componentRef} onFocus={(e) => { setFocus(true); props?.onFocus?.(e); }} onBlur={(e) => { setFocus(false); props?.onBlur?.(e); }} value={value} onChange={(e) => setValue(e.target.value)} autoComplete={autoComplete} rows={props.height ? Number(props.height) : 4} /> ) : ( <input {...props} className={twMerge( "w-full outline-none bg-transparent border-none", "hover:border-none hover:outline-none focus:border-none focus:outline-none", "dark:bg-transparent dark:outline-none dark:border-none", "p-0", "twui-input", props.className )} ref={componentRef} onFocus={(e) => { setFocus(true); props?.onFocus?.(e); }} onBlur={(e) => { setFocus(false); props?.onBlur?.(e); }} value={value} onChange={(e) => { setValue(e.target.value); props?.onChange?.(e); }} /> ); return ( <div {...wrapperProps} className={twMerge( "relative flex items-center gap-2 border rounded-md px-3 py-2 outline outline-1", focus && isValid ? "border-slate-700 dark:border-white/50" : "border-slate-300 dark:border-white/20", focus && isValid ? "outline-slate-700 dark:outline-white/50" : "outline-slate-300 dark:outline-white/20", variant == "warning" && isValid && "border-yellow-500 dark:border-yellow-300 outline-yellow-500 dark:outline-yellow-300", variant == "error" && isValid && "border-red-500 dark:border-red-300 outline-red-500 dark:outline-red-300", variant == "inactive" && isValid && "opacity-40 pointer-events-none", "bg-white dark:bg-black", isValid ? "" : "border-orange-500 outline-orange-500 twui-input-invalid", props.readOnly && "opacity-50 pointer-events-none", "twui-input-wrapper", wrapperProps?.className )} > {showLabel && ( <label htmlFor={props.name} {...labelProps} className={twMerge( "text-xs absolute -top-2.5 left-2 text-slate-500 bg-white px-1.5 rounded-t", "dark:text-white/60 dark:bg-black", "twui-input-label", labelProps?.className )} > {label || props.placeholder || props.name} </label> )} {prefix && ( <div className="opacity-60 pointer-events-none whitespace-nowrap"> {prefix} </div> )} {targetComponent} {suffix && ( <div className="opacity-60 pointer-events-none whitespace-nowrap"> {suffix} </div> )} {!isValid && ( <Span className="opacity-30 pointer-events-none whitespace-nowrap"> {invalidMessage || "Invalid"} </Span> )} </div> ); }