new-personal-site/components/lib/form/Input/index.tsx
Benjamin Toby a0a0ab8ee4 Updates
2025-07-20 10:35:54 +01:00

518 lines
18 KiB
TypeScript

import React, {
ComponentProps,
DetailedHTMLProps,
InputHTMLAttributes,
LabelHTMLAttributes,
ReactNode,
RefObject,
TextareaHTMLAttributes,
} from "react";
import { twMerge } from "tailwind-merge";
import Span from "../../layout/Span";
import Button from "../../layout/Button";
import { Eye, EyeOff, Info, InfoIcon, X } from "lucide-react";
import { AutocompleteOptions } from "../../types";
import twuiNumberfy from "../../utils/numberfy";
import Dropdown from "../../elements/Dropdown";
import Card from "../../elements/Card";
import Stack from "../../layout/Stack";
import NumberInputButtons from "./NumberInputButtons";
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
import twuiUseReady from "../../hooks/useReady";
import Row from "../../layout/Row";
import Paper from "../../elements/Paper";
import { TWUISelectValidityObject } from "../Select";
let timeout: any;
let validationFnTimeout: any;
let externalValueChangeTimeout: any;
export type InputProps<KeyType extends string> = DetailedHTMLProps<
InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> &
DetailedHTMLProps<
TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement
> & {
label?: string;
variant?: "normal" | "warning" | "error" | "inactive";
prefix?: string | ReactNode;
suffix?: string | ReactNode;
suffixProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
showLabel?: boolean;
istextarea?: boolean;
wrapperProps?: DetailedHTMLProps<
InputHTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
wrapperWrapperProps?: ComponentProps<typeof Stack>;
labelProps?: DetailedHTMLProps<
LabelHTMLAttributes<HTMLLabelElement>,
HTMLLabelElement
>;
componentRef?: RefObject<any>;
validationRegex?: RegExp;
debounce?: number;
invalidMessage?: string;
validationFunction?: (
value: string,
element?: HTMLInputElement | HTMLTextAreaElement
) => Promise<TWUISelectValidityObject>;
changeHandler?: (
value: string,
element?: HTMLInputElement | HTMLTextAreaElement
) => void;
autoComplete?: (typeof AutocompleteOptions)[number];
name?: KeyType;
valueUpdate?: string;
numberText?: boolean;
setReady?: React.Dispatch<React.SetStateAction<boolean>>;
decimal?: number;
info?: string | ReactNode;
ready?: boolean;
validity?: TWUISelectValidityObject;
};
let refreshes = 0;
/**
* # Input Element
* @className twui-input
* @className twui-input-wrapper
* @className twui-input-invalid
* @className twui-clear-input-field-button
*/
export default function Input<KeyType extends string>(
inputProps: InputProps<KeyType>
) {
const {
label,
variant,
prefix,
suffix,
componentRef,
labelProps,
wrapperProps,
wrapperWrapperProps,
showLabel,
istextarea,
debounce,
invalidMessage: initialInvalidMessage,
autoComplete,
validationFunction,
validationRegex,
valueUpdate,
numberText,
decimal,
suffixProps,
ready: existingReady,
setReady: externalSetReady,
info,
changeHandler,
validity: existingValidity,
...props
} = inputProps;
function getFinalValue(v: any) {
if (numberText) {
return (
twuiNumberfy(v, decimal).toLocaleString() +
(String(v).match(/\.$/) ? "." : "")
);
}
return v;
}
const defaultInitialValue =
props.defaultValue || props.value
? getFinalValue(props.defaultValue || props.value)
: undefined;
const [validity, setValidity] = React.useState<TWUISelectValidityObject>(
existingValidity || {
isValid: true,
}
);
const inputRef = componentRef || React.useRef<HTMLInputElement>(null);
const textAreaRef = componentRef || React.useRef<HTMLTextAreaElement>(null);
const buttonDownRef = React.useRef(false);
const [focus, setFocus] = React.useState(false);
const [inputType, setInputType] = React.useState(
numberText ? "text" : props.type
);
const DEFAULT_DEBOUNCE = 500;
const finalDebounce = debounce || DEFAULT_DEBOUNCE;
const finalLabel =
label ||
props.title ||
props.placeholder ||
(props.name ? twuiSlugToNormalText(props.name) : undefined);
function getNormalizedValue(value: string) {
if (numberText) {
if (props.max && twuiNumberfy(value) > twuiNumberfy(props.max))
return getFinalValue(props.max);
if (props.min && twuiNumberfy(value) < twuiNumberfy(props.min))
return getFinalValue(props.min);
return getFinalValue(value);
} else {
return value;
}
}
React.useEffect(() => {
if (!existingValidity) return;
setValidity(existingValidity);
}, [existingValidity]);
const updateValueFn = (
val: string,
el?: HTMLInputElement | HTMLTextAreaElement
) => {
if (buttonDownRef.current) return;
if (changeHandler) {
window.clearTimeout(externalValueChangeTimeout);
externalValueChangeTimeout = setTimeout(() => {
changeHandler(val, el);
}, finalDebounce);
}
if (typeof val == "string") {
if (!val.match(/./)) {
setValidity({ isValid: true });
props.value = "";
if (istextarea && textAreaRef.current) {
textAreaRef.current.value = "";
} else if (inputRef?.current) {
inputRef.current.value = "";
}
return;
}
window.clearTimeout(timeout);
if (validationRegex && !validationFunction) {
timeout = setTimeout(() => {
setValidity({
isValid: validationRegex.test(val),
msg: "Value mismatch",
});
}, finalDebounce);
} else if (validationFunction) {
window.clearTimeout(validationFnTimeout);
validationFnTimeout = setTimeout(() => {
if (validationRegex && !validationRegex.test(val)) {
return;
}
validationFunction(val, el).then((res) => {
setValidity(res);
});
}, finalDebounce);
}
}
};
React.useEffect(() => {
if (typeof props.value !== "string" || !props.value.match(/./)) return;
updateValueFn(String(props.value));
}, [props.value]);
function handleValueChange(
e: React.ChangeEvent<HTMLInputElement> &
React.ChangeEvent<HTMLTextAreaElement>
) {
const newValue = e.target.value;
updateValue(newValue, e.target);
props.onChange?.(e);
}
function updateValue(
v: string,
el?: HTMLInputElement | HTMLTextAreaElement
) {
if (istextarea && textAreaRef.current) {
} else if (inputRef?.current) {
inputRef.current.value = getFinalValue(v);
}
updateValueFn(v, el);
}
const targetComponent = istextarea ? (
<textarea
placeholder={
props.name ? twuiSlugToNormalText(props.name) : undefined
}
{...props}
className={twMerge(
"w-full outline-none bg-transparent",
"twui-textarea",
props.className
)}
ref={textAreaRef}
onFocus={(e) => {
setFocus(true);
props?.onFocus?.(e);
}}
onBlur={(e) => {
setFocus(false);
props?.onBlur?.(e);
}}
onChange={handleValueChange}
autoComplete={autoComplete}
rows={props.height ? Number(props.height) : props.rows || 2}
defaultValue={defaultInitialValue}
value={props.value ? getFinalValue(props.value) : undefined}
/>
) : (
<input
placeholder={
props.name ? twuiSlugToNormalText(props.name) : undefined
}
{...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={inputRef}
onFocus={(e) => {
setFocus(true);
props?.onFocus?.(e);
}}
onBlur={(e) => {
setFocus(false);
props?.onBlur?.(e);
}}
onChange={handleValueChange}
type={inputType}
defaultValue={defaultInitialValue}
value={props.value ? getFinalValue(props.value) : undefined}
/>
);
return (
<Stack
title={`${finalLabel}${props.required ? " (Required)" : ""}`}
{...wrapperWrapperProps}
className={twMerge(
"w-full gap-1.5 relative z-0 hover:z-10",
wrapperWrapperProps?.className
)}
>
<div
{...wrapperProps}
className={twMerge(
"relative flex items-center gap-2 rounded-default px-3 py-2 outline-1",
"hover:[&_.twui-clear-input-field-button]:opacity-100",
"w-full border-none",
focus && validity.isValid
? "outline-slate-700 dark:outline-white/50"
: "outline-slate-300 dark:outline-white/20",
focus && validity.isValid
? "outline-slate-700 dark:outline-white/50"
: "outline-slate-300 dark:outline-white/20",
variant == "warning" &&
validity.isValid &&
"outline-yellow-500 dark:outline-yellow-300",
variant == "error" &&
validity.isValid &&
"border-red-500 dark:border-red-300 outline-red-500 dark:outline-red-300",
variant == "inactive" &&
validity.isValid &&
"opacity-40 pointer-events-none",
"bg-white dark:bg-background-dark",
validity.isValid
? ""
: "border-orange-500 outline-orange-500 dark:border-orange-500 dark:outline-orange-500 twui-input-invalid",
props.readOnly
? props.type == "password"
? "opacity-50"
: "opacity-50 pointer-events-none"
: undefined,
"twui-input-wrapper",
wrapperProps?.className
)}
>
{showLabel && (
<label
htmlFor={props.name}
{...labelProps}
className={twMerge(
"text-xs absolute -top-2.5 left-2 text-foreground-light/80 bg-background-light",
"dark:text-foreground-dark/80 dark:bg-background-dark whitespace-nowrap",
"overflow-hidden overflow-ellipsis z-20 px-1.5 rounded-t-default",
"twui-input-label",
labelProps?.className
)}
>
{finalLabel}
{props.required ? (
<span className="text-secondary ml-1">*</span>
) : (
""
)}
</label>
)}
{prefix && (
<div className="opacity-60 pointer-events-none whitespace-nowrap">
{prefix}
</div>
)}
{targetComponent}
{props.type == "search" || props.readOnly ? null : (
<div
title="Clear Input Field"
className={twMerge(
"p-1 -my-2 -mx-1 opacity-0 cursor-pointer",
"bg-background-light dark:bg-background-dark",
"twui-clear-input-field-button"
)}
onClick={(e) => {
e.preventDefault();
if (inputRef.current) {
inputRef.current.value = "";
}
if (textAreaRef.current) {
textAreaRef.current.value = "";
}
updateValue("");
}}
>
<X size={15} />
</div>
)}
{props.type == "password" ? (
<div
title={
inputType == "password"
? "View Psasword"
: "Hide Password"
}
className={twMerge("p-1 -my-2 -mx-1")}
onClick={(e) => {
e.preventDefault();
if (inputType == "password") {
setInputType("text");
} else {
setInputType("password");
}
}}
>
{inputType == "password" ? (
<Eye size={15} />
) : (
<EyeOff size={15} />
)}
</div>
) : null}
{suffix ? (
<div
{...suffixProps}
className={twMerge(
"opacity-60 pointer-events-none whitespace-nowrap",
suffixProps?.className
)}
>
{suffix}
</div>
) : null}
{numberText ? (
<NumberInputButtons
updateValue={updateValue}
inputRef={inputRef}
getNormalizedValue={getNormalizedValue}
max={props.max}
min={props.min}
step={props.step}
buttonDownRef={buttonDownRef}
/>
) : null}
{/* {info && (
<Dropdown
target={
<Button
variant="ghost"
color="gray"
title="Input Info Button"
>
<Info
size={15}
className="opacity-50 hover:opacity-100"
/>
</Button>
}
hoverOpen
>
<Card className="min-w-[250px] text-sm p-6">
{typeof info == "string" ? (
<Span className="text-sm">{info}</Span>
) : (
info
)}
</Card>
</Dropdown>
)} */}
</div>
{info && (
<Dropdown
target={
<Row className="gap-1">
<Info size={12} className="opacity-40" />
<Span size="smaller" className="opacity-70">
{info}
</Span>
</Row>
}
openDebounce={700}
hoverOpen
>
<Paper
className={twMerge(
"min-w-[250px] shadow-lg shadow-slate-200 dark:shadow-white/10",
"max-w-[300px] w-full"
)}
>
<Stack className="gap-2 items-center">
<Row className="gap-1">
<InfoIcon size={15} opacity={0.4} />
<Span className="text-xs opacity-50">Info</Span>
</Row>
<Span className="text-center">{info}</Span>
</Stack>
</Paper>
</Dropdown>
)}
{!validity.isValid && validity.msg ? (
<Span className="text-warning whitespace-nowrap" size="smaller">
{validity.msg || "Invalid"}
</Span>
) : undefined}
</Stack>
);
}