277 lines
7.4 KiB
TypeScript
277 lines
7.4 KiB
TypeScript
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;
|
|
};
|
|
|
|
/**
|
|
* # 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,
|
|
...props
|
|
}: InputProps<KeyType>) {
|
|
const [focus, setFocus] = React.useState(false);
|
|
const [value, setValue] = React.useState(
|
|
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]);
|
|
|
|
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",
|
|
"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-transparent",
|
|
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",
|
|
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>
|
|
);
|
|
}
|