Updates
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user