This commit is contained in:
Benjamin Toby
2025-07-20 10:35:54 +01:00
parent dd1d05251d
commit a0a0ab8ee4
99 changed files with 5678 additions and 909 deletions
+87 -48
View File
@@ -1,29 +1,38 @@
import React, {
ComponentProps,
DetailedHTMLProps,
HTMLAttributes,
InputHTMLAttributes,
ReactNode,
} from "react";
import { twMerge } from "tailwind-merge";
import CheckMarkSVG from "../svgs/CheckMarkSVG";
import Stack from "../layout/Stack";
import Row from "../layout/Row";
import { Info } from "lucide-react";
import Span from "../layout/Span";
export type CheckboxProps = DetailedHTMLProps<
InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
export type CheckboxProps = React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
name: string;
wrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
label?: string | ReactNode;
labelProps?: DetailedHTMLProps<
HTMLAttributes<HTMLLabelElement>,
HTMLLabelElement
labelProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
defaultChecked?: boolean;
wrapperClassName?: string;
setChecked?: React.Dispatch<React.SetStateAction<boolean>>;
checked?: boolean;
readOnly?: boolean;
size?: number;
changeHandler?: (value: boolean) => void;
info?: string | ReactNode;
wrapperWrapperProps?: ComponentProps<typeof Stack>;
};
/**
@@ -37,62 +46,92 @@ export default function Checkbox({
label,
labelProps,
size,
name,
wrapperClassName,
defaultChecked,
setChecked,
setChecked: externalSetChecked,
readOnly,
checked: externalChecked,
changeHandler,
info,
wrapperWrapperProps,
...props
}: CheckboxProps) {
const finalSize = size || 20;
const [internalChecked, setInternalChecked] = React.useState(
defaultChecked || false
const [checked, setChecked] = React.useState(
defaultChecked || externalChecked || false
);
const checkMarkRef = React.useRef<HTMLInputElement>();
const finalTitle = props.title
? props.title
: `Checkbox-${Math.round(Math.random() * 100000)}`;
React.useEffect(() => {
if (typeof externalChecked == "undefined") return;
setChecked(externalChecked);
}, [externalChecked]);
React.useEffect(() => {
changeHandler?.(checked);
}, [checked]);
return (
<div
{...wrapperProps}
onClick={(e) => {
checkMarkRef.current?.click();
wrapperProps?.onClick?.(e);
}}
className={twMerge(
"flex items-center gap-2",
wrapperClassName,
wrapperProps?.className
)}
<Stack
{...wrapperWrapperProps}
className={twMerge("gap-1.5", wrapperWrapperProps?.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
{...wrapperProps}
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"
"flex items-start md:items-center gap-2 flex-wrap md:flex-nowrap",
readOnly ? "opacity-70 pointer-events-none" : "",
wrapperClassName,
wrapperProps?.className
)}
style={{
width: finalSize + "px",
height: finalSize + "px",
onClick={() => {
setChecked(!checked);
externalSetChecked?.(!checked);
}}
>
{internalChecked && <CheckMarkSVG />}
<div
{...props}
className={twMerge(
"flex items-center justify-center p-[3px] rounded-default",
checked
? "bg-primary twui-checkbox-checked text-white outline-slate-400"
: "dark:outline-white/50 outline-2 -outline-offset-2 twui-checkbox-unchecked",
"twui-checkbox",
props.className
)}
style={{
minWidth: finalSize + "px",
width: finalSize + "px",
height: finalSize + "px",
...props.style,
}}
>
{checked && <CheckMarkSVG />}
</div>
<Stack className="gap-0.5">
<div
{...labelProps}
className={twMerge(
"select-none whitespace-normal md:whitespace-nowrap",
labelProps?.className
)}
>
{label || finalTitle}
</div>
</Stack>
</div>
{label && <label>{label}</label>}
</div>
{info && (
<Row className="gap-1" title={info.toString()}>
<Info size={12} className="opacity-40" />
<Span size="smaller" className="opacity-70">
{info}
</Span>
</Row>
)}
</Stack>
);
}
+204 -40
View File
@@ -1,31 +1,25 @@
import Button from "../layout/Button";
import Stack from "../layout/Stack";
import {
File,
FileArchive,
FilePlus,
FilePlus2,
ImagePlus,
X,
} from "lucide-react";
import React, { DetailedHTMLProps } from "react";
import { FileArchive, FilePlus2, X } from "lucide-react";
import React, { ComponentProps, DetailedHTMLProps, ReactNode } from "react";
import Card from "../elements/Card";
import Span from "../layout/Span";
import Center from "../layout/Center";
import imageInputToBase64, {
FileInputToBase64FunctionReturn,
} from "../utils/form/fileInputToBase64";
import { FileInputToBase64FunctionReturn } from "../utils/form/fileInputToBase64";
import { twMerge } from "tailwind-merge";
import fileInputToBase64 from "../utils/form/fileInputToBase64";
import Row from "../layout/Row";
import Input from "./Input";
import Loading from "../elements/Loading";
type ImageUploadProps = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
onChangeHandler?: (
imgData: FileInputToBase64FunctionReturn | undefined
fileData: FileInputToBase64FunctionReturn | undefined
) => any;
onClear?: () => void;
fileInputProps?: DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
@@ -42,12 +36,21 @@ type ImageUploadProps = DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>;
label?: string;
label?: string | ReactNode;
disablePreview?: boolean;
allowedRegex?: RegExp;
externalSetFile?: React.Dispatch<
React.SetStateAction<FileInputToBase64FunctionReturn | undefined>
>;
externalSetFiles?: React.Dispatch<
React.SetStateAction<FileInputToBase64FunctionReturn[] | undefined>
>;
existingFile?: FileInputToBase64FunctionReturn;
existingFileUrl?: string;
icon?: ReactNode;
labelSpanProps?: ComponentProps<typeof Span>;
loading?: boolean;
multiple?: boolean;
};
/**
@@ -63,12 +66,37 @@ export default function FileUpload({
disablePreview,
allowedRegex,
externalSetFile,
externalSetFiles,
existingFile,
existingFileUrl,
icon,
labelSpanProps,
loading,
multiple,
onClear,
...props
}: ImageUploadProps) {
const [file, setFile] = React.useState<
FileInputToBase64FunctionReturn | undefined
>(undefined);
const inputRef = React.useRef<HTMLInputElement>();
>(existingFile);
const [fileUrl, setFileUrl] = React.useState<string | undefined>(
existingFileUrl
);
const [fileDraggedOver, setFileDraggedOver] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (existingFileUrl) {
setFileUrl(existingFileUrl);
}
}, [existingFileUrl]);
React.useEffect(() => {
if (existingFile) {
setFile(existingFile);
}
}, [existingFile]);
return (
<Stack
@@ -77,26 +105,117 @@ export default function FileUpload({
>
<input
type="file"
multiple={multiple}
className={twMerge("hidden", fileInputProps?.className)}
{...fileInputProps}
onChange={(e) => {
const inputFile = e.target.files?.[0];
if (multiple) {
(async () => {
const files = e.target.files;
if (!files?.[0]) return;
if (!inputFile) return;
let filesArr: FileInputToBase64FunctionReturn[] =
[];
fileInputToBase64({ inputFile, allowedRegex }).then(
(res) => {
setFile(res);
externalSetFile?.(res);
onChangeHandler?.(res);
fileInputProps?.onChange?.(e);
}
);
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileObj = await fileInputToBase64({
inputFile: file,
});
filesArr.push(fileObj);
}
externalSetFiles?.(filesArr);
})();
} else {
const inputFile = e.target.files?.[0];
if (!inputFile) return;
fileInputToBase64({ inputFile, allowedRegex }).then(
(res) => {
setFile(res);
externalSetFile?.(res);
onChangeHandler?.(res);
fileInputProps?.onChange?.(e);
}
);
}
}}
ref={inputRef as any}
/>
{file ? (
{loading ? (
<Card className={twMerge("w-full h-full ")}>
<Center>
<Loading />
</Center>
</Card>
) : file ? (
<Card
{...previewImageWrapperProps}
className={twMerge(
"w-full relative h-full items-center justify-center overflow-hidden",
"pb-10",
previewImageWrapperProps?.className
)}
>
<Stack>
{disablePreview ? (
<Span className="opacity-50" size="small">
Image Uploaded!
</Span>
) : file.fileType?.match(/image/i) ? (
<img
src={file.fileBase64Full}
className="w-full object-contain overflow-hidden"
{...previewImageProps}
/>
) : (
<Stack>
<FileArchive size={36} strokeWidth={1} />
<Stack className="gap-0">
<Span>
{file.file?.name || file.fileName}
</Span>
<Span size="smaller" className="opacity-70">
{file.fileType}
</Span>
</Stack>
</Stack>
)}
<Button
variant="ghost"
className={twMerge(
"absolute p-2 top-2 right-2 z-20 bg-background-light dark:bg-background-dark",
"hover:bg-white dark:hover:bg-black"
)}
onClick={(e) => {
setFile(undefined);
externalSetFile?.(undefined);
onChangeHandler?.(undefined);
if (inputRef.current) {
inputRef.current.value = "";
}
onClear?.();
}}
title="Cancel File Upload Button"
>
<X className="text-slate-950 dark:text-white" />
</Button>
<Input
value={file.fileName}
onChange={(e) => {
setFile({ ...file, fileName: e.target.value });
externalSetFile?.({
...file,
fileName: e.target.value,
});
}}
/>
</Stack>
</Card>
) : fileUrl ? (
<Card
className="w-full relative h-full items-center justify-center overflow-hidden"
{...previewImageWrapperProps}
@@ -105,22 +224,21 @@ export default function FileUpload({
<Span className="opacity-50" size="small">
Image Uploaded!
</Span>
) : file.fileType?.match(/image/i) ? (
<img
src={file.fileBase64Full}
className="w-full object-contain overflow-hidden"
{...previewImageProps}
/>
) : (
) : fileUrl.match(/\.pdf$|\.txt$/) ? (
<Row>
<FileArchive size={36} strokeWidth={1} />
<Stack className="gap-0">
<Span>{file.file?.name || file.fileName}</Span>
<Span size="smaller" className="opacity-70">
{file.fileType}
{fileUrl}
</Span>
</Stack>
</Row>
) : (
<img
src={fileUrl}
className="w-full object-contain overflow-hidden"
{...previewImageProps}
/>
)}
<Button
variant="ghost"
@@ -132,7 +250,9 @@ export default function FileUpload({
setFile(undefined);
externalSetFile?.(undefined);
onChangeHandler?.(undefined);
setFileUrl(undefined);
}}
title="Cancel File Button"
>
<X className="text-slate-950 dark:text-white" />
</Button>
@@ -140,19 +260,63 @@ export default function FileUpload({
) : (
<Card
className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20",
"w-full h-full cursor-pointer hover:bg-slate-100/50 dark:hover:bg-white/5",
"border-dashed border-2",
fileDraggedOver ? "bg-slate-100 dark:bg-white/10" : "",
placeHolderWrapper?.className
)}
onClick={(e) => {
inputRef.current?.click();
placeHolderWrapper?.onClick?.(e);
}}
onDragOver={(e) => {
e.preventDefault();
setFileDraggedOver(true);
}}
onDragLeave={(e) => {
setFileDraggedOver(false);
}}
onDrop={(e) => {
e.preventDefault();
setFileDraggedOver(false);
let inputFile: File | null = null;
if (e.dataTransfer.items) {
[...e.dataTransfer.items].forEach((item, i) => {
if (inputFile) return;
if (item.kind === "file") {
const file = item.getAsFile();
inputFile = file;
}
});
} else {
inputFile = e.dataTransfer.files?.[0];
}
if (!inputFile) return;
fileInputToBase64({ inputFile, allowedRegex }).then(
(res) => {
setFile(res);
externalSetFile?.(res);
onChangeHandler?.(res);
}
);
}}
{...placeHolderWrapper}
>
<Center>
<Center
className={twMerge(
fileDraggedOver ? "pointer-events-none" : ""
)}
>
<Stack className="items-center gap-2">
<FilePlus2 className="text-slate-400" />
<Span size="smaller" variant="faded">
{icon || <FilePlus2 className="text-slate-400" />}
<Span
size="smaller"
variant="faded"
{...labelSpanProps}
>
{label || "Click to Upload File"}
</Span>
</Stack>
+19 -6
View File
@@ -2,16 +2,20 @@ import _ from "lodash";
import { DetailedHTMLProps, FormHTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type Props<T extends { [key: string]: any } = { [key: string]: any }> =
DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
submitHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
changeHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
};
/**
* # Form Element
* @className twui-form
*/
export default function Form<T extends object = { [key: string]: any }>({
...props
}: DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
submitHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
}) {
const finalProps = _.omit(props, "submitHandler");
export default function Form<
T extends { [key: string]: any } = { [key: string]: any }
>({ ...props }: Props<T>) {
const finalProps = _.omit(props, ["submitHandler", "changeHandler"]);
return (
<form
@@ -29,6 +33,15 @@ export default function Form<T extends object = { [key: string]: any }>({
props.submitHandler?.(e, data);
props.onSubmit?.(e);
}}
onChange={(e) => {
e.preventDefault();
const taregtEl = e.target as HTMLElement;
const formEl = taregtEl.closest("form") as HTMLFormElement;
const formData = new FormData(formEl);
const data = Object.fromEntries(formData.entries()) as T;
props.changeHandler?.(e, data);
props.onChange?.(e);
}}
>
{props.children}
</form>
+108 -12
View File
@@ -9,6 +9,7 @@ import imageInputToBase64, {
ImageInputToBase64FunctionReturn,
} from "../utils/form/imageInputToBase64";
import { twMerge } from "tailwind-merge";
import Tag from "../elements/Tag";
type ImageUploadProps = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
@@ -35,6 +36,17 @@ type ImageUploadProps = DetailedHTMLProps<
>;
label?: string;
disablePreview?: boolean;
multiple?: boolean;
existingImageUrl?: string;
externalSetImage?: React.Dispatch<
React.SetStateAction<ImageInputToBase64FunctionReturn | undefined>
>;
externalSetImages?: React.Dispatch<
React.SetStateAction<ImageInputToBase64FunctionReturn[] | undefined>
>;
setLoading?: React.Dispatch<React.SetStateAction<boolean>>;
externalImage?: ImageInputToBase64FunctionReturn;
restoreImageFn?: () => void;
};
/**
@@ -48,53 +60,118 @@ export default function ImageUpload({
previewImageProps,
label,
disablePreview,
existingImageUrl,
externalSetImage,
externalSetImages,
externalImage,
multiple,
restoreImageFn,
setLoading,
...props
}: ImageUploadProps) {
const [src, setSrc] = React.useState<string | undefined>(undefined);
const inputRef = React.useRef<HTMLInputElement>();
const [imageObject, setImageObject] = React.useState<
ImageInputToBase64FunctionReturn | undefined
>(externalImage);
const [src, setSrc] = React.useState<string | undefined>(existingImageUrl);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (existingImageUrl) setSrc(existingImageUrl);
}, [existingImageUrl]);
return (
<Stack
{...props}
className={twMerge("w-full h-[300px]", props?.className)}
className={twMerge(
"w-full h-[300px] overflow-hidden",
props?.className
)}
>
<input
type="file"
className={twMerge("hidden", fileInputProps?.className)}
multiple={multiple}
accept="image/*"
{...fileInputProps}
onChange={(e) => {
imageInputToBase64({ imageInput: e.target }).then((res) => {
setSrc(res.imageBase64Full);
onChangeHandler?.(res);
fileInputProps?.onChange?.(e);
});
setLoading?.(true);
if (multiple) {
(async () => {
const files = e.target.files;
if (!files?.[0]) return;
let imgArr: ImageInputToBase64FunctionReturn[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileObj = await imageInputToBase64({
file,
});
imgArr.push(fileObj);
}
externalSetImages?.(imgArr);
setLoading?.(false);
})();
} else {
imageInputToBase64({ imageInput: e.target }).then(
(res) => {
setSrc(res.imageBase64Full);
onChangeHandler?.(res);
setImageObject?.(res);
externalSetImage?.(res);
fileInputProps?.onChange?.(e);
setLoading?.(false);
}
);
}
}}
ref={inputRef as any}
/>
{src ? (
{src || imageObject?.imageBase64Full ? (
<Card
className="w-full relative h-full items-center justify-center"
{...previewImageWrapperProps}
>
{label && (
<label
className={twMerge(
"absolute top-0 left-0 text-xs z-50"
)}
>
<Tag color="gray">
<span className="opacity-70">{label}</span>
</Tag>
</label>
)}
{disablePreview ? (
<Span className="opacity-50" size="small">
Image Uploaded!
</Span>
) : (
<img
src={src}
className="w-full object-contain"
src={imageObject?.imageBase64Full || src}
className="w-full h-full object-contain"
{...previewImageProps}
/>
)}
<Button
variant="ghost"
className="absolute p-2 top-2 right-2 z-20"
className={twMerge(
"absolute p-1 top-2 right-2 z-20 bg-background-light dark:bg-background-dark"
)}
onClick={(e) => {
setSrc(undefined);
onChangeHandler?.(undefined);
setImageObject?.(undefined);
externalSetImage?.(undefined);
if (inputRef.current) {
inputRef.current.value == "";
}
}}
title="Cancel Image Upload Button"
>
<X className="text-slate-950 dark:text-white" />
</Button>
@@ -106,6 +183,11 @@ export default function ImageUpload({
placeHolderWrapper?.className
)}
onClick={(e) => {
const targetEl = e.target as HTMLElement | undefined;
if (targetEl?.closest(".cancel-upload")) {
e.preventDefault();
return;
}
inputRef.current?.click();
placeHolderWrapper?.onClick?.(e);
}}
@@ -117,6 +199,20 @@ export default function ImageUpload({
<Span size="smaller" variant="faded">
{label || "Click to Upload Image"}
</Span>
{existingImageUrl && (
<Button
title="Restore Image Button"
size="smaller"
variant="ghost"
onClick={() => {
restoreImageFn?.() ||
setSrc(existingImageUrl);
}}
className="cancel-upload"
>
Restore Original Image
</Button>
)}
</Stack>
</Center>
</Card>
-287
View File
@@ -1,287 +0,0 @@
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;
valueUpdate?: string;
};
/**
* # 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,
valueUpdate,
...props
}: InputProps<KeyType>) {
const [focus, setFocus] = React.useState(false);
const [value, setValue] = React.useState(props.defaultValue || props.value);
const [isValid, setIsValid] = React.useState(true);
const DEFAULT_DEBOUNCE = 500;
const finalDebounce = debounce || DEFAULT_DEBOUNCE;
React.useEffect(() => {
if (typeof value == "string") {
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]);
React.useEffect(() => {
setValue(props.value || "");
}, [props.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);
props?.onChange?.(e);
}}
autoComplete={autoComplete}
rows={props.height ? Number(props.height) : 4}
/>
) : (
<input
{...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={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-slate-300 dark:outline-white/20",
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",
"twui-input-label",
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>
);
}
@@ -0,0 +1,121 @@
import React from "react";
import Row from "../../layout/Row";
import { Info, Minus, Plus } from "lucide-react";
import twuiNumberfy from "../../utils/numberfy";
import { InputProps } from ".";
let pressInterval: any;
let pressTimeout: any;
type Props = Pick<InputProps<any>, "min" | "max" | "step"> & {
updateValue: (v: string) => void;
getNormalizedValue: (v: string) => void;
buttonDownRef: React.MutableRefObject<boolean>;
inputRef: React.RefObject<HTMLInputElement | null>;
};
/**
* # Input Number Text Buttons
*/
export default function NumberInputButtons({
getNormalizedValue,
updateValue,
min,
max,
step,
buttonDownRef,
inputRef,
}: Props) {
const PRESS_TRIGGER_TIMEOUT = 200;
const DEFAULT_STEP = 1;
function incrementDownPress() {
window.clearTimeout(pressTimeout);
pressTimeout = setTimeout(() => {
buttonDownRef.current = true;
pressInterval = setInterval(() => {
increment();
}, 50);
}, PRESS_TRIGGER_TIMEOUT);
}
function incrementDownCancel() {
buttonDownRef.current = false;
window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
}
function decrementDownPress() {
pressTimeout = setTimeout(() => {
buttonDownRef.current = true;
pressInterval = setInterval(() => {
decrement();
}, 50);
}, PRESS_TRIGGER_TIMEOUT);
}
function decrementDownCancel() {
buttonDownRef.current = false;
window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
}
function increment() {
const existingValue = inputRef.current?.value;
const existingNumberValue = twuiNumberfy(existingValue);
if (max && existingNumberValue >= twuiNumberfy(max)) {
return updateValue(String(max));
} else if (min && existingNumberValue < twuiNumberfy(min)) {
return updateValue(String(min));
} else {
updateValue(
String(existingNumberValue + twuiNumberfy(step || DEFAULT_STEP))
);
}
}
function decrement() {
const existingValue = inputRef.current?.value;
const existingNumberValue = twuiNumberfy(existingValue);
if (min && existingNumberValue <= twuiNumberfy(min)) {
updateValue(String(min));
} else {
updateValue(
String(existingNumberValue - twuiNumberfy(step || DEFAULT_STEP))
);
}
}
return (
<Row className="flex-nowrap gap-1 -my-2 ml-auto -mr-2">
<Row
className="rounded-full w-8 h-8 cursor-pointer touch-none select-none justify-center"
onClick={(e) => {
e.preventDefault();
decrement();
}}
onMouseDown={decrementDownPress}
onTouchStart={decrementDownPress}
onMouseUp={decrementDownCancel}
onTouchEnd={decrementDownCancel}
>
<Minus size={20} />
</Row>
<Row
className="rounded-full w-8 h-8 cursor-pointer touch-none select-none justify-center"
onClick={(e) => {
e.preventDefault();
increment();
}}
onMouseDown={incrementDownPress}
onTouchStart={incrementDownPress}
onMouseUp={incrementDownCancel}
onTouchEnd={incrementDownCancel}
>
<Plus size={20} />
</Row>
</Row>
);
}
File diff suppressed because it is too large Load Diff
+293
View File
@@ -0,0 +1,293 @@
import { ChevronDown, Info, LucideProps, Search } from "lucide-react";
import React from "react";
import { twMerge } from "tailwind-merge";
import Dropdown from "../elements/Dropdown";
import Stack from "../layout/Stack";
import {
TWUISelectOptionObject,
TWUISelectProps,
TWUISelectValidityObject,
} from "./Select";
import Border from "../elements/Border";
import Input from "./Input";
import Paper from "../elements/Paper";
import Button from "../layout/Button";
import Divider from "../layout/Divider";
/**
* # Search Select Element
* @className twui-search-select-wrapper
* @className twui-search-select
* @className twui-search-select-dropdown-icon
*/
export default function SearchSelect<
KeyType extends string,
T extends { [k: string]: any } = { [k: string]: any }
>({
label,
options,
componentRef,
labelProps,
wrapperProps,
showLabel,
iconProps,
changeHandler,
info,
validateValueFn,
wrapperWrapperProps,
dispatchState,
...props
}: TWUISelectProps<KeyType, T>) {
const [validity, setValidity] = React.useState<TWUISelectValidityObject>({
isValid: true,
});
const selectRef = componentRef || React.useRef<HTMLSelectElement>(null);
const [currentOptions, setCurrentOptions] =
React.useState<TWUISelectOptionObject<KeyType, T>[]>(options);
const defaultOption = options.find((opt) => opt.default) || options[0];
const [value, setValue] = React.useState<
TWUISelectOptionObject<KeyType, T>
>({
value: defaultOption.value,
data: defaultOption.data,
});
const [inputValue, setInputValue] = React.useState<string>(
defaultOption.value
);
const [selectIndex, setSelectIndex] = React.useState<number | undefined>();
const [open, setOpen] = React.useState<boolean>(false);
const isFocusedRef = React.useRef<boolean>(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const contentWrapperRef = React.useRef<HTMLDivElement>(null);
let focusTimeout: any;
let keyDownInterval: any;
const FOCUS_TIMEOUT = 200;
React.useEffect(() => {
setTimeout(() => {
requestAnimationFrame(() => {
const currentSelectValue = selectRef.current?.value;
if (currentSelectValue && validateValueFn) {
validateValueFn(currentSelectValue).then((res) => {
setValidity(res);
});
}
});
}, 200);
}, []);
React.useEffect(() => {
if (!open) {
setCurrentOptions(options);
}
}, [open]);
React.useEffect(() => {
dispatchState?.(value.data);
setInputValue(value.value);
clearTimeout(focusTimeout);
setOpen(false);
changeHandler?.(value.value);
setSelectIndex(undefined);
}, [value]);
const handleArrowUpScrollAdjust = React.useCallback(() => {
if (contentWrapperRef.current) {
const targetOption = contentWrapperRef.current.querySelector(
".twui-select-target-option"
) as HTMLButtonElement;
if (targetOption) {
contentWrapperRef.current.scrollTop =
targetOption.offsetTop - 100;
}
}
}, []);
const handleArrowDownScrollAdjust = React.useCallback(() => {
if (contentWrapperRef.current) {
const targetOption = contentWrapperRef.current.querySelector(
".twui-select-target-option"
) as HTMLButtonElement;
if (targetOption) {
contentWrapperRef.current.scrollTop =
targetOption.offsetTop -
(contentWrapperRef.current.offsetHeight - 100);
}
}
}, []);
React.useEffect(() => {
if (!selectIndex) return;
}, [selectIndex]);
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
if (selectIndex !== undefined) {
setValue(currentOptions[selectIndex]);
if (inputRef.current) {
inputRef.current.blur();
}
}
} else if (e.key === "ArrowUp") {
if (selectIndex == undefined) {
setSelectIndex(currentOptions.length - 1);
} else if (selectIndex === 0) {
setSelectIndex(0);
} else {
setSelectIndex(selectIndex - 1);
}
handleArrowUpScrollAdjust();
} else if (e.key === "ArrowDown") {
if (selectIndex == undefined) {
setSelectIndex(0);
} else if (selectIndex === currentOptions.length - 1) {
setSelectIndex(currentOptions.length - 1);
} else {
setSelectIndex(selectIndex + 1);
}
handleArrowDownScrollAdjust();
}
};
const handleKeyUp = (e: React.KeyboardEvent) => {
e.preventDefault();
clearInterval(keyDownInterval);
handleKey(e);
};
// const handleKeyDown = (e: React.KeyboardEvent) => {
// e.preventDefault();
// keyDownInterval = setInterval(() => {
// handleKey(e);
// }, 100);
// };
return (
<Stack
{...wrapperWrapperProps}
className={twMerge(
"gap-1 w-full",
"twui-search-select-wrapper",
wrapperWrapperProps?.className
)}
onKeyUp={handleKeyUp}
// onKeyDown={handleKeyDown}
>
<Dropdown
disableClickActions
target={
<Input
type="text"
placeholder="Search"
value={inputValue}
prefix={(<Search size={18} />) as any}
suffix={(<ChevronDown size={20} />) as any}
suffixProps={{
onClick: (e) => {
e.preventDefault();
clearTimeout(focusTimeout);
setOpen(!open);
},
className: "pointer-events-auto opacity-100",
}}
changeHandler={(value) => {
if (!isFocusedRef.current) return;
if (!open) setOpen(true);
}}
onFocus={() => {
clearTimeout(focusTimeout);
isFocusedRef.current = true;
setOpen(true);
}}
onBlur={() => {
focusTimeout = setTimeout(() => {
isFocusedRef.current = false;
setOpen(false);
}, FOCUS_TIMEOUT);
}}
onChange={(e) => {
if (!open) setOpen(true);
setInputValue(e.target.value);
const updatedOptions = options.filter((option) =>
option.value
.toLowerCase()
.match(
new RegExp(
`${e.target.value.toLowerCase()}`
)
)
);
if (updatedOptions?.[0]) {
setCurrentOptions(updatedOptions);
} else {
setCurrentOptions(options);
}
setSelectIndex(undefined);
}}
componentRef={inputRef}
/>
}
targetWrapperProps={{ className: "w-full" }}
contentWrapperProps={{ className: "w-full" }}
className="w-full"
externalOpen={open}
>
<Paper
className={twMerge(
"gap-0 p-0 w-full max-h-[40vh] overflow-y-auto"
)}
componentRef={contentWrapperRef}
>
<Stack className="w-full items-start gap-0">
{currentOptions.map((_o, index) => {
const isTargetOption = index === selectIndex;
const targetOptionClasses = twMerge(
"bg-background-dark dark:bg-background-light text-foreground-dark dark:text-foreground-light",
"twui-select-target-option"
);
return (
<React.Fragment key={index}>
<Button
title={_o.title || "Option"}
variant="ghost"
onClick={(e) => {
e.preventDefault();
setValue(_o);
setOpen(false);
}}
className={twMerge(
"w-full text-foreground-light dark:text-foreground-dark",
"hover:bg-gray/20 dark:hover:bg-gray-dark/20",
isTargetOption
? targetOptionClasses
: ""
)}
>
{_o.value}
</Button>
<Divider />
</React.Fragment>
);
})}
</Stack>
</Paper>
</Dropdown>
</Stack>
);
}
+199 -74
View File
@@ -1,39 +1,73 @@
import { ChevronDown, LucideProps } from "lucide-react";
import {
import { ChevronDown, Info, LucideProps } from "lucide-react";
import React, {
ComponentProps,
DetailedHTMLProps,
ForwardRefExoticComponent,
Dispatch,
InputHTMLAttributes,
LabelHTMLAttributes,
RefAttributes,
ReactNode,
RefObject,
SelectHTMLAttributes,
SetStateAction,
} from "react";
import { twMerge } from "tailwind-merge";
import Row from "../layout/Row";
import Dropdown from "../elements/Dropdown";
import Card from "../elements/Card";
import Span from "../layout/Span";
import Stack from "../layout/Stack";
import twuiSlugify from "../utils/slugify";
import twuiSlugToNormalText from "../utils/slug-to-normal-text";
type SelectOptionObject = {
title: string;
value: string;
default?: boolean;
export type TWUISelectValidityObject = {
isValid?: boolean;
msg?: string;
};
type SelectProps = DetailedHTMLProps<
export type TWUISelectOptionObject<
KeyType extends string,
T extends { [k: string]: any } = any
> = {
title?: string;
value: KeyType;
default?: boolean;
data?: T;
};
export type TWUISelectProps<
KeyType extends string,
T extends { [k: string]: any } = any
> = DetailedHTMLProps<
SelectHTMLAttributes<HTMLSelectElement>,
HTMLSelectElement
> & {
options: SelectOptionObject[];
options: TWUISelectOptionObject<KeyType, T>[];
label?: string;
showLabel?: boolean;
wrapperProps?: DetailedHTMLProps<
InputHTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
wrapperWrapperProps?: ComponentProps<typeof Stack>;
labelProps?: DetailedHTMLProps<
LabelHTMLAttributes<HTMLLabelElement>,
HTMLLabelElement
>;
componentRef?: RefObject<HTMLSelectElement>;
iconProps?: LucideProps;
changeHandler?: (value: SelectProps["options"][number]["value"]) => void;
changeHandler?: (value: KeyType, data?: T) => void;
info?: string | ReactNode;
validateValueFn?: (value: string) => Promise<TWUISelectValidityObject>;
dispatchState?: Dispatch<SetStateAction<T | undefined>>;
name?: KeyType;
};
export type TWUISelectValueObject<
KeyType extends string,
T extends { [k: string]: any } = { [k: string]: any }
> = {
value: KeyType;
data?: T;
};
/**
@@ -42,7 +76,10 @@ type SelectProps = DetailedHTMLProps<
* @className twui-select
* @className twui-select-dropdown-icon
*/
export default function Select({
export default function Select<
KeyType extends string,
T extends { [k: string]: any } = { [k: string]: any }
>({
label,
options,
componentRef,
@@ -51,76 +88,164 @@ export default function Select({
showLabel,
iconProps,
changeHandler,
info,
validateValueFn,
wrapperWrapperProps,
dispatchState,
...props
}: SelectProps) {
return (
<div
{...wrapperProps}
className={twMerge(
"relative w-full flex items-center",
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",
"twui-input-label",
labelProps?.className
)}
>
{label || props.name}
</label>
)}
}: TWUISelectProps<KeyType, T>) {
const [validity, setValidity] = React.useState<TWUISelectValidityObject>({
isValid: true,
});
<select
{...props}
const selectRef = componentRef || React.useRef<HTMLSelectElement>(null);
const [value, setValue] = React.useState<TWUISelectValueObject<KeyType, T>>(
{
value: options[0]?.value,
data: options[0]?.data,
}
);
React.useEffect(() => {
setTimeout(() => {
requestAnimationFrame(() => {
const currentSelectValue = selectRef.current?.value;
if (currentSelectValue && validateValueFn) {
validateValueFn(currentSelectValue).then((res) => {
setValidity(res);
});
}
});
}, 200);
}, []);
React.useEffect(() => {
dispatchState?.(value.data);
}, [value]);
const selectID = label
? twuiSlugify(label)
: props.name
? twuiSlugify(props.name)
: props.title
? twuiSlugify(props.title)
: `select-${Math.round(Math.random() * 1000000)}`;
return (
<Stack
{...wrapperWrapperProps}
className={twMerge("gap-1", wrapperWrapperProps?.className)}
>
<div
{...wrapperProps}
className={twMerge(
"w-full pl-3 py-2 border rounded-md appearance-none pr-8",
"border-slate-300 dark:border-white/20",
"relative w-full flex items-center border rounded-default",
"border-slate-300 dark:border-white/20 pr-2",
"focus:border-slate-700 dark:focus:border-white/50",
"outline-slate-300 dark:outline-white/20",
"focus:outline-slate-700 dark:focus:outline-white/50",
"bg-white dark:bg-black",
"twui-select",
props.className
"bg-white dark:bg-background-dark",
validity.isValid ? "" : "outline-warning border-warning",
wrapperProps?.className
)}
ref={componentRef}
value={
options.flat().find((opt) => opt.default)?.value ||
undefined
}
onChange={(e) => {
changeHandler?.(
e.target.value as (typeof options)[number]["value"]
);
props.onChange?.(e);
}}
>
{options.flat().map((option, index) => {
return (
<option
key={index}
value={option.value}
// selected={option.default || undefined}
>
{option.title}
</option>
);
})}
</select>
<ChevronDown
size={20}
{...iconProps}
className={twMerge(
"absolute right-2 pointer-events-none",
iconProps?.className
{showLabel && (
<label
htmlFor={selectID}
{...labelProps}
className={twMerge(
"text-xs absolute -top-2.5 left-2 text-foreground-light/80 bg-background-light",
"dark:text-foreground-dark/70 dark:bg-background-dark px-1.5 rounded-t",
"twui-input-label",
labelProps?.className
)}
>
{label || props.title || props.name}
</label>
)}
/>
</div>
<select
id={selectID}
{...props}
className={twMerge(
"w-full pl-3 py-2 rounded-default appearance-none pr-8",
"grow !border-none !outline-none",
"twui-select",
props.className
)}
ref={selectRef}
value={
options.flat().find((opt) => opt.default)?.value ||
undefined
}
onChange={(e) => {
const targetValue = options.find(
(opt) => opt.value == e.target.value
);
if (targetValue) {
setValue(targetValue);
}
changeHandler?.(
e.target.value as (typeof options)[number]["value"],
targetValue?.data
);
props.onChange?.(e);
validateValueFn?.(e.target.value).then((res) => {
setValidity(res);
});
}}
>
{options.flat().map((option, index) => {
const optionTitle =
option.title || twuiSlugToNormalText(option.value);
return (
<option key={index} value={option.value}>
{optionTitle}
</option>
);
})}
</select>
<ChevronDown
size={20}
{...iconProps}
className={twMerge(
"pointer-events-none -ml-6",
iconProps?.className
)}
/>
{info && (
<Dropdown
target={
<div title="Select Info Button">
<Info size={20} />
</div>
}
hoverOpen
>
<Card className="min-w-[250px] text-sm p-6">
{typeof info == "string" ? (
<Span className="text-sm">{info}</Span>
) : (
info
)}
</Card>
</Dropdown>
)}
</div>
{!validity.isValid && validity.msg ? (
<Span size="smaller" className="text-warning">
{validity.msg}
</Span>
) : undefined}
</Stack>
);
}