This commit is contained in:
Benjamin Toby
2025-01-05 07:25:38 +01:00
parent 998158369a
commit 5587024789
30 changed files with 4756 additions and 75 deletions
+98
View File
@@ -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>
);
}
+15 -3
View File
@@ -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>
+34 -22
View File
@@ -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>
+148 -8
View File
@@ -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 -1
View File
@@ -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} />;
}