Updates
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import React, {
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import CheckMarkSVG from "../svgs/CheckMarkSVG";
|
||||
|
||||
export type CheckboxProps = DetailedHTMLProps<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
> & {
|
||||
name: string;
|
||||
wrapperProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
label?: string | ReactNode;
|
||||
labelProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLLabelElement>,
|
||||
HTMLLabelElement
|
||||
>;
|
||||
defaultChecked?: boolean;
|
||||
wrapperClassName?: string;
|
||||
setChecked?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Checkbox Component
|
||||
* @className twui-checkbox
|
||||
* @className twui-checkbox-checked
|
||||
* @className twui-checkbox-unchecked
|
||||
*/
|
||||
export default function Checkbox({
|
||||
wrapperProps,
|
||||
label,
|
||||
labelProps,
|
||||
size,
|
||||
name,
|
||||
wrapperClassName,
|
||||
defaultChecked,
|
||||
setChecked,
|
||||
...props
|
||||
}: CheckboxProps) {
|
||||
const finalSize = size || 20;
|
||||
|
||||
const [internalChecked, setInternalChecked] = React.useState(
|
||||
defaultChecked || false
|
||||
);
|
||||
|
||||
const checkMarkRef = React.useRef<HTMLInputElement>();
|
||||
|
||||
return (
|
||||
<div
|
||||
{...wrapperProps}
|
||||
onClick={(e) => {
|
||||
checkMarkRef.current?.click();
|
||||
wrapperProps?.onClick?.(e);
|
||||
}}
|
||||
className={twMerge(
|
||||
"flex items-center gap-2",
|
||||
wrapperClassName,
|
||||
wrapperProps?.className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
{...props}
|
||||
width={finalSize}
|
||||
height={finalSize}
|
||||
className={twMerge("hidden")}
|
||||
name={name}
|
||||
onChange={(e) => {
|
||||
setInternalChecked(e.target.checked);
|
||||
setChecked?.(e.target.checked);
|
||||
}}
|
||||
ref={checkMarkRef as any}
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center justify-center p-[3px] rounded",
|
||||
internalChecked
|
||||
? "bg-emerald-700 twui-checkbox-checked"
|
||||
: "outline-slate-600 dark:outline-white/50 outline-2 outline -outline-offset-2 twui-checkbox-unchecked",
|
||||
"twui-checkbox"
|
||||
)}
|
||||
style={{
|
||||
width: finalSize + "px",
|
||||
height: finalSize + "px",
|
||||
}}
|
||||
>
|
||||
{internalChecked && <CheckMarkSVG />}
|
||||
</div>
|
||||
{label && <label>{label}</label>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import _ from "lodash";
|
||||
import { DetailedHTMLProps, FormHTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -5,17 +6,28 @@ import { twMerge } from "tailwind-merge";
|
||||
* # Form Element
|
||||
* @className twui-form
|
||||
*/
|
||||
export default function Form({
|
||||
export default function Form<T extends object = { [key: string]: any }>({
|
||||
...props
|
||||
}: DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>) {
|
||||
}: DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
|
||||
submitHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
|
||||
}) {
|
||||
const finalProps = _.omit(props, "submitHandler");
|
||||
|
||||
return (
|
||||
<form
|
||||
{...props}
|
||||
{...finalProps}
|
||||
className={twMerge(
|
||||
"flex flex-col items-stretch gap-2 w-full bg-transparent",
|
||||
"twui-form",
|
||||
props.className
|
||||
)}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formEl = e.target as HTMLFormElement;
|
||||
const formData = new FormData(formEl);
|
||||
const data = Object.fromEntries(formData.entries()) as T;
|
||||
props.submitHandler?.(e, data);
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</form>
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import Button from "@/components/lib/layout/Button";
|
||||
import Stack from "@/components/lib/layout/Stack";
|
||||
import Button from "../layout/Button";
|
||||
import Stack from "../layout/Stack";
|
||||
import { ImagePlus, X } from "lucide-react";
|
||||
import React, { DetailedHTMLProps } from "react";
|
||||
import Card from "@/components/lib/elements/Card";
|
||||
import Span from "@/components/lib/layout/Span";
|
||||
import Center from "@/components/lib/layout/Center";
|
||||
import Card from "../elements/Card";
|
||||
import Span from "../layout/Span";
|
||||
import Center from "../layout/Center";
|
||||
import imageInputToBase64, {
|
||||
ImageInputToBase64FunctionReturn,
|
||||
} from "../utils/form/imageInputToBase64";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type ImageUploadProps = {
|
||||
type ImageUploadProps = DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
onChange?: (imgData: ImageInputToBase64FunctionReturn | undefined) => any;
|
||||
fileInputProps?: DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
>;
|
||||
wrapperProps?: DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
placeHolderWrapper?: DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
@@ -32,23 +31,27 @@ type ImageUploadProps = {
|
||||
React.ImgHTMLAttributes<HTMLImageElement>,
|
||||
HTMLImageElement
|
||||
>;
|
||||
label?: string;
|
||||
disablePreview?: boolean;
|
||||
};
|
||||
|
||||
export default function ImageUpload({
|
||||
onChange,
|
||||
fileInputProps,
|
||||
wrapperProps,
|
||||
placeHolderWrapper,
|
||||
previewImageWrapperProps,
|
||||
previewImageProps,
|
||||
label,
|
||||
disablePreview,
|
||||
...props
|
||||
}: ImageUploadProps) {
|
||||
const [src, setSrc] = React.useState<string | undefined>(undefined);
|
||||
const inputRef = React.useRef<HTMLInputElement>();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className={twMerge("w-full", wrapperProps?.className)}
|
||||
{...wrapperProps}
|
||||
{...props}
|
||||
className={twMerge("w-full h-[300px]", props?.className)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
@@ -65,12 +68,21 @@ export default function ImageUpload({
|
||||
/>
|
||||
|
||||
{src ? (
|
||||
<Card className="w-full relative" {...previewImageWrapperProps}>
|
||||
<img
|
||||
src={src}
|
||||
className="w-full h-[300px] object-contain"
|
||||
{...previewImageProps}
|
||||
/>
|
||||
<Card
|
||||
className="w-full relative h-full items-center justify-center"
|
||||
{...previewImageWrapperProps}
|
||||
>
|
||||
{disablePreview ? (
|
||||
<Span className="opacity-50" size="small">
|
||||
Image Uploaded!
|
||||
</Span>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
className="w-full object-contain"
|
||||
{...previewImageProps}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute p-2 top-2 right-2 z-20"
|
||||
@@ -79,13 +91,13 @@ export default function ImageUpload({
|
||||
onChange?.(undefined);
|
||||
}}
|
||||
>
|
||||
<X className="text-slate-950" />
|
||||
<X className="text-slate-950 dark:text-white" />
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
className={twMerge(
|
||||
"w-full h-[300px] cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20",
|
||||
"w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20",
|
||||
placeHolderWrapper?.className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
@@ -98,7 +110,7 @@ export default function ImageUpload({
|
||||
<Stack className="items-center gap-2">
|
||||
<ImagePlus className="text-slate-400" />
|
||||
<Span size="smaller" variant="faded">
|
||||
Click to Upload Image
|
||||
{label || "Click to Upload Image"}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Center>
|
||||
|
||||
@@ -6,8 +6,79 @@ import React, {
|
||||
TextareaHTMLAttributes,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Span from "../layout/Span";
|
||||
|
||||
export type InputProps = DetailedHTMLProps<
|
||||
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
|
||||
> &
|
||||
@@ -30,13 +101,21 @@ export type InputProps = DetailedHTMLProps<
|
||||
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({
|
||||
export default function Input<KeyType extends string>({
|
||||
label,
|
||||
variant,
|
||||
prefix,
|
||||
@@ -46,9 +125,43 @@ export default function Input({
|
||||
wrapperProps,
|
||||
showLabel,
|
||||
istextarea,
|
||||
debounce,
|
||||
invalidMessage,
|
||||
autoComplete,
|
||||
validationFunction,
|
||||
validationRegex,
|
||||
...props
|
||||
}: InputProps) {
|
||||
}: 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
|
||||
@@ -67,6 +180,10 @@ export default function Input({
|
||||
setFocus(false);
|
||||
props?.onBlur?.(e);
|
||||
}}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
autoComplete={autoComplete}
|
||||
rows={props.height ? Number(props.height) : 4}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@@ -85,6 +202,11 @@ export default function Input({
|
||||
setFocus(false);
|
||||
props?.onBlur?.(e);
|
||||
}}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
props?.onChange?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -93,18 +215,27 @@ export default function Input({
|
||||
{...wrapperProps}
|
||||
className={twMerge(
|
||||
"relative flex items-center gap-2 border rounded-md px-3 py-2 outline outline-1",
|
||||
focus
|
||||
focus && isValid
|
||||
? "border-slate-700 dark:border-white/50"
|
||||
: "border-slate-300 dark:border-white/20",
|
||||
focus
|
||||
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" && "opacity-40 pointer-events-none",
|
||||
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
|
||||
)}
|
||||
>
|
||||
@@ -123,13 +254,22 @@ export default function Input({
|
||||
)}
|
||||
|
||||
{prefix && (
|
||||
<div className="opacity-60 pointer-events-none">{prefix}</div>
|
||||
<div className="opacity-60 pointer-events-none whitespace-nowrap">
|
||||
{prefix}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetComponent}
|
||||
|
||||
{suffix && (
|
||||
<div className="opacity-60 pointer-events-none">{suffix}</div>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,9 @@ import Input, { InputProps } from "./Input";
|
||||
* # Textarea Component
|
||||
* @className twui-textarea
|
||||
*/
|
||||
export default function Textarea({ componentRef, ...props }: InputProps) {
|
||||
export default function Textarea<KeyType extends string>({
|
||||
componentRef,
|
||||
...props
|
||||
}: InputProps<KeyType>) {
|
||||
return <Input istextarea {...props} componentRef={componentRef} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user