492 lines
17 KiB
TypeScript
492 lines
17 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}
|
|
</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>
|
|
);
|
|
}
|