new-personal-site/components/lib/form/Input.tsx

277 lines
7.4 KiB
TypeScript
Raw Normal View History

2024-12-09 15:36:17 +00:00
import React, {
DetailedHTMLProps,
InputHTMLAttributes,
LabelHTMLAttributes,
RefObject,
TextareaHTMLAttributes,
} from "react";
import { twMerge } from "tailwind-merge";
2025-01-05 06:25:38 +00:00
import Span from "../layout/Span";
2024-12-09 15:36:17 +00:00
2025-01-05 06:25:38 +00:00
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<
2024-12-09 15:36:17 +00:00
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>;
2025-01-05 06:25:38 +00:00
validationRegex?: RegExp;
debounce?: number;
invalidMessage?: string;
validationFunction?: (value: string) => Promise<boolean>;
autoComplete?: (typeof autocompleteOptions)[number];
name?: KeyType;
2024-12-09 15:36:17 +00:00
};
/**
* # Input Element
* @className twui-input
2025-01-05 06:25:38 +00:00
* @className twui-input-wrapper
* @className twui-input-invalid
2024-12-09 15:36:17 +00:00
*/
2025-01-05 06:25:38 +00:00
export default function Input<KeyType extends string>({
2024-12-09 15:36:17 +00:00
label,
variant,
prefix,
suffix,
componentRef,
labelProps,
wrapperProps,
showLabel,
istextarea,
2025-01-05 06:25:38 +00:00
debounce,
invalidMessage,
autoComplete,
validationFunction,
validationRegex,
2024-12-09 15:36:17 +00:00
...props
2025-01-05 06:25:38 +00:00
}: InputProps<KeyType>) {
2024-12-09 15:36:17 +00:00
const [focus, setFocus] = React.useState(false);
2025-01-05 06:25:38 +00:00
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]);
2024-12-09 15:36:17 +00:00
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);
}}
2025-01-05 06:25:38 +00:00
value={value}
onChange={(e) => setValue(e.target.value)}
autoComplete={autoComplete}
rows={props.height ? Number(props.height) : 4}
2024-12-09 15:36:17 +00:00
/>
) : (
<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);
}}
2025-01-05 06:25:38 +00:00
value={value}
onChange={(e) => {
setValue(e.target.value);
props?.onChange?.(e);
}}
2024-12-09 15:36:17 +00:00
/>
);
return (
<div
{...wrapperProps}
className={twMerge(
"relative flex items-center gap-2 border rounded-md px-3 py-2 outline outline-1",
2025-01-05 06:25:38 +00:00
focus && isValid
2024-12-09 15:36:17 +00:00
? "border-slate-700 dark:border-white/50"
: "border-slate-300 dark:border-white/20",
2025-01-05 06:25:38 +00:00
focus && isValid
2024-12-09 15:36:17 +00:00
? "outline-slate-700 dark:outline-white/50"
: "outline-transparent",
variant == "warning" &&
2025-01-05 06:25:38 +00:00
isValid &&
2024-12-09 15:36:17 +00:00
"border-yellow-500 dark:border-yellow-300 outline-yellow-500 dark:outline-yellow-300",
variant == "error" &&
2025-01-05 06:25:38 +00:00
isValid &&
2024-12-09 15:36:17 +00:00
"border-red-500 dark:border-red-300 outline-red-500 dark:outline-red-300",
2025-01-05 06:25:38 +00:00
variant == "inactive" &&
isValid &&
"opacity-40 pointer-events-none",
2024-12-09 15:36:17 +00:00
"bg-white dark:bg-black",
2025-01-05 06:25:38 +00:00
isValid
? ""
: "border-orange-500 outline-orange-500 twui-input-invalid",
props.readOnly && "opacity-50 pointer-events-none",
"twui-input-wrapper",
2024-12-09 15:36:17 +00:00
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 && (
2025-01-05 06:25:38 +00:00
<div className="opacity-60 pointer-events-none whitespace-nowrap">
{prefix}
</div>
2024-12-09 15:36:17 +00:00
)}
{targetComponent}
{suffix && (
2025-01-05 06:25:38 +00:00
<div className="opacity-60 pointer-events-none whitespace-nowrap">
{suffix}
</div>
)}
{!isValid && (
<Span className="opacity-30 pointer-events-none whitespace-nowrap">
{invalidMessage || "Invalid"}
</Span>
2024-12-09 15:36:17 +00:00
)}
</div>
);
}