Package library as @moduletrace/twui npm package
- Move components/ to src/ and compile to dist/ with tsc - Add root barrel export plus deep-import subpath exports and base.css export - Post-compile step: copy base.css, add .js import extensions, mark client modules - Add publish.sh and .npmrc for the Gitea npm registry - Fix type errors, guard TinyMCE env access, drop ace-builds type dependency - Rename clashing props type names, remove Next.js scaffolding and unused deps Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import React, {
|
||||
type ComponentProps,
|
||||
type DetailedHTMLProps,
|
||||
type HTMLAttributes,
|
||||
type 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 = Omit<
|
||||
React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>,
|
||||
"title"
|
||||
> & {
|
||||
title?: string | ReactNode;
|
||||
wrapperProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
label?: string | ReactNode;
|
||||
labelProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
defaultChecked?: boolean;
|
||||
wrapperClassName?: string;
|
||||
setChecked?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
checked?: boolean;
|
||||
readOnly?: boolean;
|
||||
noLabel?: boolean;
|
||||
size?: number;
|
||||
changeHandler?: (value: boolean) => void;
|
||||
clickDispatchHandler?: (value: boolean) => void;
|
||||
info?: string | ReactNode;
|
||||
wrapperWrapperProps?: ComponentProps<typeof Stack>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Checkbox Component
|
||||
* @className twui-checkbox
|
||||
* @className twui-checkbox-checked
|
||||
* @className twui-checkbox-unchecked
|
||||
*/
|
||||
export default function Checkbox({
|
||||
wrapperProps,
|
||||
label,
|
||||
labelProps,
|
||||
size,
|
||||
wrapperClassName,
|
||||
defaultChecked,
|
||||
setChecked: externalSetChecked,
|
||||
readOnly,
|
||||
checked: externalChecked,
|
||||
changeHandler,
|
||||
info,
|
||||
wrapperWrapperProps,
|
||||
noLabel,
|
||||
title,
|
||||
clickDispatchHandler,
|
||||
...props
|
||||
}: CheckboxProps) {
|
||||
const finalSize = size || 20;
|
||||
|
||||
const [checked, setChecked] = React.useState(
|
||||
defaultChecked || externalChecked || false,
|
||||
);
|
||||
|
||||
const finalTitle = title
|
||||
? title
|
||||
: `Checkbox-${Math.round(Math.random() * 100000)}`;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof externalChecked == "undefined") return;
|
||||
setChecked(externalChecked);
|
||||
}, [externalChecked]);
|
||||
|
||||
React.useEffect(() => {
|
||||
changeHandler?.(checked);
|
||||
}, [checked]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...wrapperWrapperProps}
|
||||
className={twMerge("gap-1.5", wrapperWrapperProps?.className)}
|
||||
>
|
||||
<div
|
||||
{...wrapperProps}
|
||||
className={twMerge(
|
||||
"flex items-start md:items-center gap-2 flex-wrap md:flex-nowrap",
|
||||
readOnly ? "opacity-70 pointer-events-none" : "",
|
||||
wrapperClassName,
|
||||
wrapperProps?.className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
setChecked(!checked);
|
||||
externalSetChecked?.(!checked);
|
||||
clickDispatchHandler?.(!checked);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{!noLabel && (
|
||||
<Stack className="gap-0.5">
|
||||
<div
|
||||
{...labelProps}
|
||||
className={twMerge(
|
||||
"select-none whitespace-normal md:whitespace-nowrap",
|
||||
labelProps?.className,
|
||||
)}
|
||||
>
|
||||
{label || finalTitle}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
{info && (
|
||||
<Row className="gap-1 flex-nowrap" title={info.toString()}>
|
||||
<Info size={13} className="opacity-40 min-w-[20px]" />
|
||||
<Span size="smaller" className="opacity-70">
|
||||
{info}
|
||||
</Span>
|
||||
</Row>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import Button from "../layout/Button";
|
||||
import Stack from "../layout/Stack";
|
||||
import { FileArchive, FilePlus2, X } from "lucide-react";
|
||||
import React, {
|
||||
type ComponentProps,
|
||||
type DetailedHTMLProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import Card from "../elements/Card";
|
||||
import Span from "../layout/Span";
|
||||
import Center from "../layout/Center";
|
||||
import type { 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";
|
||||
import Tag from "../elements/Tag";
|
||||
|
||||
type FileInputUtils = {
|
||||
clearFileInput?: () => void;
|
||||
};
|
||||
|
||||
type ImageUploadProps = DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
onChangeHandler?: (
|
||||
fileData: FileInputToBase64FunctionReturn | undefined,
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>,
|
||||
utils?: FileInputUtils,
|
||||
) => any;
|
||||
changeHandler?: (
|
||||
fileData: FileInputToBase64FunctionReturn | undefined,
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>,
|
||||
utils?: FileInputUtils,
|
||||
) => any;
|
||||
onClear?: () => void;
|
||||
fileInputProps?: DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
>;
|
||||
placeHolderWrapper?: DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
previewImageWrapperProps?: DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
previewImageProps?: DetailedHTMLProps<
|
||||
React.ImgHTMLAttributes<HTMLImageElement>,
|
||||
HTMLImageElement
|
||||
>;
|
||||
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;
|
||||
externalSetFileURL?: React.Dispatch<string | undefined>;
|
||||
labelSpanProps?: ComponentProps<typeof Span>;
|
||||
loading?: boolean;
|
||||
multiple?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @note use the `onChangeHandler` prop to grab the parsed base64 image object
|
||||
*/
|
||||
export default function FileUpload({
|
||||
onChangeHandler,
|
||||
fileInputProps,
|
||||
placeHolderWrapper,
|
||||
previewImageWrapperProps,
|
||||
previewImageProps,
|
||||
label,
|
||||
disablePreview,
|
||||
allowedRegex,
|
||||
externalSetFile,
|
||||
externalSetFiles,
|
||||
existingFile,
|
||||
existingFileUrl,
|
||||
icon,
|
||||
labelSpanProps,
|
||||
loading,
|
||||
multiple,
|
||||
onClear,
|
||||
changeHandler,
|
||||
externalSetFileURL,
|
||||
...props
|
||||
}: ImageUploadProps) {
|
||||
const [file, setFile] = React.useState<
|
||||
FileInputToBase64FunctionReturn | undefined
|
||||
>(existingFile);
|
||||
const [fileUrl, setFileUrl] = React.useState<string | undefined>(
|
||||
existingFileUrl,
|
||||
);
|
||||
|
||||
const [fileDraggedOver, setFileDraggedOver] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const tempFileURLRef = React.useRef<string>("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (existingFileUrl) {
|
||||
setFileUrl(existingFileUrl);
|
||||
}
|
||||
}, [existingFileUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (existingFile) {
|
||||
setFile(existingFile);
|
||||
}
|
||||
}, [existingFile]);
|
||||
|
||||
function clearFileInput() {
|
||||
setFile(undefined);
|
||||
externalSetFile?.(undefined);
|
||||
onChangeHandler?.(undefined);
|
||||
changeHandler?.(undefined);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
onClear?.();
|
||||
}
|
||||
|
||||
const fileInputUtils: FileInputUtils = { clearFileInput };
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...props}
|
||||
className={twMerge("w-full h-[300px]", props?.className)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple={multiple}
|
||||
className={twMerge("hidden", fileInputProps?.className)}
|
||||
{...fileInputProps}
|
||||
onChange={(e) => {
|
||||
if (multiple) {
|
||||
(async () => {
|
||||
const files = e.target.files;
|
||||
if (!files?.[0]) return;
|
||||
|
||||
let filesArr: FileInputToBase64FunctionReturn[] =
|
||||
[];
|
||||
|
||||
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,
|
||||
inputRef,
|
||||
fileInputUtils,
|
||||
);
|
||||
changeHandler?.(res, inputRef, fileInputUtils);
|
||||
fileInputProps?.onChange?.(e);
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
ref={inputRef as any}
|
||||
/>
|
||||
|
||||
{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) => {
|
||||
clearFileInput();
|
||||
}}
|
||||
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}
|
||||
>
|
||||
<Stack className="w-full">
|
||||
{disablePreview ? (
|
||||
<Span className="opacity-50" size="small">
|
||||
Image Uploaded!
|
||||
</Span>
|
||||
) : fileUrl.match(/\.pdf$|\.txt$/) ? (
|
||||
<Row>
|
||||
<FileArchive size={36} strokeWidth={1} />
|
||||
<Stack className="gap-0">
|
||||
<Span size="smaller" className="opacity-70">
|
||||
{fileUrl}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Row>
|
||||
) : (
|
||||
<img
|
||||
src={fileUrl}
|
||||
className="w-full object-contain overflow-hidden"
|
||||
{...previewImageProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tag
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
className="w-full py-2 text-sm"
|
||||
>
|
||||
{fileUrl}
|
||||
</Tag>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={twMerge(
|
||||
"absolute p-2 top-2 right-2 z-20 bg-white dark:bg-black",
|
||||
"hover:bg-white dark:hover:bg-black",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
setFile(undefined);
|
||||
externalSetFile?.(undefined);
|
||||
onChangeHandler?.(undefined);
|
||||
changeHandler?.(undefined);
|
||||
setFileUrl(undefined);
|
||||
}}
|
||||
title="Cancel File Button"
|
||||
>
|
||||
<X className="text-slate-950 dark:text-white" />
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
className={twMerge(
|
||||
"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);
|
||||
changeHandler?.(res);
|
||||
},
|
||||
);
|
||||
}}
|
||||
{...placeHolderWrapper}
|
||||
>
|
||||
<Center
|
||||
className={twMerge(
|
||||
fileDraggedOver ? "pointer-events-none" : "",
|
||||
)}
|
||||
>
|
||||
<Stack className="items-center gap-2">
|
||||
{icon || <FilePlus2 className="text-slate-400" />}
|
||||
<Span
|
||||
size="smaller"
|
||||
variant="faded"
|
||||
{...labelSpanProps}
|
||||
>
|
||||
{label || "Click to Upload File"}
|
||||
</Span>
|
||||
{externalSetFileURL ? (
|
||||
<Row
|
||||
className="flex-nowrap gap-0 items-stretch"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Add Media URL"
|
||||
className="text-sm"
|
||||
wrapperProps={{ className: "h-full" }}
|
||||
wrapperWrapperProps={{
|
||||
className: "h-full",
|
||||
}}
|
||||
changeHandler={(value) => {
|
||||
tempFileURLRef.current = value;
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
if (tempFileURLRef.current) {
|
||||
setFileUrl(
|
||||
tempFileURLRef.current,
|
||||
);
|
||||
externalSetFileURL(
|
||||
tempFileURLRef.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
title="Add Media URL"
|
||||
variant="outlined"
|
||||
className="py-0 px-3"
|
||||
color="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (tempFileURLRef.current) {
|
||||
setFileUrl(
|
||||
tempFileURLRef.current,
|
||||
);
|
||||
externalSetFileURL(
|
||||
tempFileURLRef.current,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-2xl">+</span>
|
||||
</Button>
|
||||
</Row>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import _ from "lodash";
|
||||
import type { DetailedHTMLProps, FormHTMLAttributes, RefObject } 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;
|
||||
formRef?: RefObject<HTMLFormElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Form Element
|
||||
* @className twui-form
|
||||
*/
|
||||
export default function Form<
|
||||
T extends { [key: string]: any } = { [key: string]: any },
|
||||
>({ formRef, ...props }: Props<T>) {
|
||||
const finalProps = _.omit(props, ["submitHandler", "changeHandler"]);
|
||||
|
||||
return (
|
||||
<form
|
||||
{...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.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);
|
||||
}}
|
||||
ref={formRef}
|
||||
>
|
||||
{props.children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import Button from "../layout/Button";
|
||||
import Stack from "../layout/Stack";
|
||||
import { ImagePlus, X } from "lucide-react";
|
||||
import React, { type DetailedHTMLProps } from "react";
|
||||
import Card from "../elements/Card";
|
||||
import Span from "../layout/Span";
|
||||
import Center from "../layout/Center";
|
||||
import imageInputToBase64, {
|
||||
type ImageInputToBase64FunctionReturn,
|
||||
} from "../utils/form/imageInputToBase64";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Tag from "../elements/Tag";
|
||||
import Input from "./Input";
|
||||
import Row from "../layout/Row";
|
||||
|
||||
type ImageUploadProps = DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
onChangeHandler?: (
|
||||
imgData: ImageInputToBase64FunctionReturn | undefined,
|
||||
) => any;
|
||||
multipleOnChangeHandler?: (
|
||||
imgData: ImageInputToBase64FunctionReturn[] | undefined,
|
||||
) => any;
|
||||
fileInputProps?: DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
>;
|
||||
placeHolderWrapper?: DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
previewImageWrapperProps?: DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
previewImageProps?: DetailedHTMLProps<
|
||||
React.ImgHTMLAttributes<HTMLImageElement>,
|
||||
HTMLImageElement
|
||||
>;
|
||||
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>>;
|
||||
setImgURL?: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
externalImage?: ImageInputToBase64FunctionReturn;
|
||||
restoreImageFn?: () => void;
|
||||
upload_only?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @note use the `onChangeHandler` prop to grab the parsed base64 image object
|
||||
*/
|
||||
export default function ImageUpload({
|
||||
onChangeHandler,
|
||||
fileInputProps,
|
||||
placeHolderWrapper,
|
||||
previewImageWrapperProps,
|
||||
previewImageProps,
|
||||
label,
|
||||
disablePreview,
|
||||
existingImageUrl,
|
||||
externalSetImage,
|
||||
externalSetImages,
|
||||
externalImage,
|
||||
multiple,
|
||||
restoreImageFn,
|
||||
setLoading,
|
||||
setImgURL,
|
||||
multipleOnChangeHandler,
|
||||
upload_only,
|
||||
...props
|
||||
}: ImageUploadProps) {
|
||||
const [imageObject, setImageObject] = React.useState<
|
||||
ImageInputToBase64FunctionReturn | undefined
|
||||
>(externalImage);
|
||||
const [src, setSrc] = React.useState<string | undefined>(existingImageUrl);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const imageUrlRef = React.useRef("");
|
||||
|
||||
React.useEffect(() => {
|
||||
setImgURL?.(src);
|
||||
}, [src]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (existingImageUrl) setSrc(existingImageUrl);
|
||||
}, [existingImageUrl]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...props}
|
||||
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) => {
|
||||
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);
|
||||
}
|
||||
multipleOnChangeHandler?.(imgArr);
|
||||
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 || 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={imageObject?.imageBase64Full || src}
|
||||
className="w-full h-full object-contain"
|
||||
{...previewImageProps}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute p-1 top-2 right-2 z-20 bg-background-light dark:bg-background-dark",
|
||||
"cursor-pointer",
|
||||
)}
|
||||
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" />
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
className={twMerge(
|
||||
"w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20",
|
||||
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);
|
||||
}}
|
||||
{...placeHolderWrapper}
|
||||
>
|
||||
<Center>
|
||||
<Stack className="items-center gap-2">
|
||||
<ImagePlus className="text-slate-400" />
|
||||
<Span size="smaller" variant="faded">
|
||||
{label || "Click to Upload Image"}
|
||||
</Span>
|
||||
{upload_only ? null : (
|
||||
<>
|
||||
<Stack className="cancel-upload w-full items-stretch gap-1">
|
||||
<Input
|
||||
placeholder="URL Eg. https://example.com/img.png"
|
||||
className="text-sm twui-image-url-input"
|
||||
title="Enter Image URL"
|
||||
wrapperWrapperProps={{
|
||||
className: "mt-2",
|
||||
}}
|
||||
changeHandler={(value) => {
|
||||
imageUrlRef.current = value;
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
title="Restore Image Button"
|
||||
size="smaller"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
if (!imageUrlRef.current)
|
||||
return;
|
||||
setSrc(imageUrlRef.current);
|
||||
}}
|
||||
className="opacity-70"
|
||||
>
|
||||
Set Image URL
|
||||
</Button>
|
||||
</Stack>
|
||||
{existingImageUrl && (
|
||||
<Button
|
||||
title="Restore Image Button"
|
||||
size="smaller"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
restoreImageFn?.() ||
|
||||
setSrc(existingImageUrl);
|
||||
}}
|
||||
className="cancel-upload"
|
||||
>
|
||||
Restore Original Image
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import Row from "../../layout/Row";
|
||||
import { Info, Minus, Plus } from "lucide-react";
|
||||
import twuiNumberfy from "../../utils/numberfy";
|
||||
import type { InputProps } from ".";
|
||||
|
||||
let pressInterval: any;
|
||||
let pressTimeout: any;
|
||||
|
||||
type Props = Pick<InputProps<any>, "min" | "max" | "step" | "decimal"> & {
|
||||
value: string;
|
||||
setValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
buttonDownRef: React.MutableRefObject<boolean>;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Input Number Text Buttons
|
||||
*/
|
||||
export default function NumberInputButtons({
|
||||
value,
|
||||
setValue,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
buttonDownRef,
|
||||
inputRef,
|
||||
decimal,
|
||||
}: Props) {
|
||||
const PRESS_TRIGGER_TIMEOUT = 200;
|
||||
const DEFAULT_STEP = 1;
|
||||
|
||||
const [buttonDown, setButtonDown] = useState(false);
|
||||
|
||||
// function getNormalizedValue(value: string) {
|
||||
// if (numberText) {
|
||||
// if (props.max && twuiNumberfy(value) > twuiNumberfy(props.max))
|
||||
// return getFinalValue(props.max);
|
||||
|
||||
// if (props.min && twuiNumberfy(value) < twuiNumberfy(props.min))
|
||||
// return getFinalValue(props.min);
|
||||
|
||||
// return getFinalValue(value);
|
||||
// } else {
|
||||
// return value;
|
||||
// }
|
||||
// }
|
||||
|
||||
useEffect(() => {
|
||||
buttonDownRef.current = buttonDown;
|
||||
if (buttonDown) {
|
||||
setValue(inputRef.current?.value || "");
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
setValue(inputRef.current?.value || "");
|
||||
}, 50);
|
||||
}
|
||||
}, [buttonDown]);
|
||||
|
||||
function incrementDownPress() {
|
||||
window.clearTimeout(pressTimeout);
|
||||
setButtonDown(true);
|
||||
|
||||
pressTimeout = setTimeout(() => {
|
||||
pressInterval = setInterval(() => {
|
||||
increment();
|
||||
}, 50);
|
||||
}, PRESS_TRIGGER_TIMEOUT);
|
||||
}
|
||||
|
||||
function incrementDownCancel() {
|
||||
setButtonDown(false);
|
||||
window.clearTimeout(pressTimeout);
|
||||
window.clearInterval(pressInterval);
|
||||
}
|
||||
|
||||
function decrementDownPress() {
|
||||
setButtonDown(true);
|
||||
|
||||
pressTimeout = setTimeout(() => {
|
||||
pressInterval = setInterval(() => {
|
||||
decrement();
|
||||
}, 50);
|
||||
}, PRESS_TRIGGER_TIMEOUT);
|
||||
}
|
||||
|
||||
function decrementDownCancel() {
|
||||
setButtonDown(false);
|
||||
window.clearTimeout(pressTimeout);
|
||||
window.clearInterval(pressInterval);
|
||||
}
|
||||
|
||||
function increment() {
|
||||
if (!inputRef.current) return;
|
||||
|
||||
const existingValue = inputRef.current.value;
|
||||
const existingNumberValue = twuiNumberfy(existingValue, decimal);
|
||||
|
||||
let new_value = "";
|
||||
|
||||
if (max && existingNumberValue >= twuiNumberfy(max, decimal)) {
|
||||
new_value = twuiNumberfy(max, decimal).toLocaleString();
|
||||
} else if (min && existingNumberValue < twuiNumberfy(min, decimal)) {
|
||||
new_value = twuiNumberfy(min, decimal).toLocaleString();
|
||||
} else {
|
||||
new_value = (
|
||||
existingNumberValue +
|
||||
twuiNumberfy(step || DEFAULT_STEP, decimal)
|
||||
).toLocaleString();
|
||||
}
|
||||
|
||||
inputRef.current.value = new_value;
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
if (!inputRef.current) return;
|
||||
|
||||
const existingValue = inputRef.current?.value;
|
||||
const existingNumberValue = twuiNumberfy(existingValue, decimal);
|
||||
|
||||
let new_value = "";
|
||||
|
||||
if (min && existingNumberValue <= twuiNumberfy(min, decimal)) {
|
||||
new_value = twuiNumberfy(min, decimal).toLocaleString();
|
||||
} else {
|
||||
new_value = (
|
||||
existingNumberValue -
|
||||
twuiNumberfy(step || DEFAULT_STEP, decimal)
|
||||
).toLocaleString();
|
||||
}
|
||||
|
||||
inputRef.current.value = new_value;
|
||||
}
|
||||
|
||||
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,79 @@
|
||||
import type {
|
||||
ComponentProps,
|
||||
DetailedHTMLProps,
|
||||
InputHTMLAttributes,
|
||||
LabelHTMLAttributes,
|
||||
} from "react";
|
||||
import Row from "../layout/Row";
|
||||
import twuiSlugify from "../utils/slugify";
|
||||
import twuiSlugToNormalText from "../utils/slug-to-normal-text";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Value = {
|
||||
value: string;
|
||||
title?: string;
|
||||
default?: boolean;
|
||||
};
|
||||
|
||||
export type TWUI_FORM_RADIO_PROPS = {
|
||||
values: Value[];
|
||||
name: string;
|
||||
inputProps?: DetailedHTMLProps<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
>;
|
||||
labelProps?: DetailedHTMLProps<
|
||||
LabelHTMLAttributes<HTMLLabelElement>,
|
||||
HTMLLabelElement
|
||||
>;
|
||||
wrapperProps?: ComponentProps<typeof Row>;
|
||||
changeHandler?: (value: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Form Radios Component
|
||||
* @className twui-textarea
|
||||
*/
|
||||
export default function Radios({
|
||||
values,
|
||||
name,
|
||||
inputProps,
|
||||
labelProps,
|
||||
wrapperProps,
|
||||
changeHandler,
|
||||
}: TWUI_FORM_RADIO_PROPS) {
|
||||
const finalName = twuiSlugify(name);
|
||||
const finalTitle = twuiSlugToNormalText(finalName);
|
||||
|
||||
return (
|
||||
<Row
|
||||
title={finalTitle}
|
||||
{...wrapperProps}
|
||||
className={twMerge("gap-4", wrapperProps?.className)}
|
||||
>
|
||||
{values.map((v, i) => {
|
||||
const valueName = twuiSlugify(`${finalName}-${v.value}`);
|
||||
const valueTitle = v.title || twuiSlugToNormalText(v.value);
|
||||
|
||||
return (
|
||||
<Row key={i} className="gap-1.5">
|
||||
<input
|
||||
id={valueName}
|
||||
type="radio"
|
||||
defaultChecked={v.default}
|
||||
name={finalName}
|
||||
onChange={(e) => {
|
||||
const targetValue = v.value;
|
||||
changeHandler?.(targetValue);
|
||||
}}
|
||||
{...inputProps}
|
||||
/>
|
||||
<label htmlFor={valueName} {...labelProps}>
|
||||
{valueTitle}
|
||||
</label>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
import { ChevronDown, Info, type LucideProps } from "lucide-react";
|
||||
import React, {
|
||||
type ComponentProps,
|
||||
type DetailedHTMLProps,
|
||||
type Dispatch,
|
||||
type InputHTMLAttributes,
|
||||
type LabelHTMLAttributes,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
type SelectHTMLAttributes,
|
||||
type 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";
|
||||
import Input from "./Input";
|
||||
import Button from "../layout/Button";
|
||||
|
||||
export type TWUISelectValidityObject = {
|
||||
isValid?: boolean;
|
||||
msg?: string;
|
||||
};
|
||||
|
||||
export type TWUISelectOptionObject<
|
||||
KeyType extends string = string,
|
||||
T extends { [k: string]: any } = { [k: string]: any },
|
||||
> = {
|
||||
title?: string;
|
||||
value: KeyType;
|
||||
default?: boolean;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
export type TWUISelectProps<
|
||||
KeyType extends string,
|
||||
T extends { [k: string]: any } = { [k: string]: any },
|
||||
> = DetailedHTMLProps<
|
||||
SelectHTMLAttributes<HTMLSelectElement>,
|
||||
HTMLSelectElement
|
||||
> & {
|
||||
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: KeyType, data?: T) => void;
|
||||
info?: string | ReactNode;
|
||||
validateValueFn?: (value: string) => Promise<TWUISelectValidityObject>;
|
||||
dispatchState?: Dispatch<SetStateAction<T | undefined>>;
|
||||
name?: KeyType;
|
||||
input_props?: ComponentProps<typeof Input>;
|
||||
option_button_props?: Omit<ComponentProps<typeof Button>, "title">;
|
||||
};
|
||||
|
||||
export type TWUISelectValueObject<
|
||||
KeyType extends string,
|
||||
T extends { [k: string]: any } = { [k: string]: any },
|
||||
> = {
|
||||
value: KeyType;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Select Element
|
||||
* @className twui-select-wrapper
|
||||
* @className twui-select
|
||||
* @className twui-select-dropdown-icon
|
||||
*/
|
||||
export default function Select<
|
||||
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 [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(
|
||||
"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-background-dark",
|
||||
validity.isValid ? "" : "outline-warning border-warning",
|
||||
wrapperProps?.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>
|
||||
)}
|
||||
|
||||
<select
|
||||
id={selectID}
|
||||
aria-label={props["aria-label"] || props.title}
|
||||
{...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] p-6">
|
||||
{typeof info == "string" ? (
|
||||
<Span>{info}</Span>
|
||||
) : (
|
||||
info
|
||||
)}
|
||||
</Card>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
{!validity.isValid && validity.msg ? (
|
||||
<Span size="smaller" className="text-warning">
|
||||
{validity.msg}
|
||||
</Span>
|
||||
) : undefined}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Input, { type InputProps } from "./Input";
|
||||
|
||||
/**
|
||||
* # Textarea Component
|
||||
* @className twui-textarea
|
||||
*/
|
||||
export default function Textarea<KeyType extends string>({
|
||||
componentRef,
|
||||
...props
|
||||
}: InputProps<KeyType>) {
|
||||
return <Input istextarea {...props} componentRef={componentRef} />;
|
||||
}
|
||||
Reference in New Issue
Block a user