This commit is contained in:
2025-10-02 08:16:11 +01:00
parent 6d833c7d3b
commit 979728e6c8
30 changed files with 695 additions and 100 deletions
+25 -16
View File
@@ -11,10 +11,14 @@ import Row from "../layout/Row";
import { Info } from "lucide-react";
import Span from "../layout/Span";
export type CheckboxProps = React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
export type CheckboxProps = Omit<
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>,
"title"
> & {
title?: string | ReactNode;
wrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
@@ -29,6 +33,7 @@ export type CheckboxProps = React.DetailedHTMLProps<
setChecked?: React.Dispatch<React.SetStateAction<boolean>>;
checked?: boolean;
readOnly?: boolean;
noLabel?: boolean;
size?: number;
changeHandler?: (value: boolean) => void;
info?: string | ReactNode;
@@ -54,6 +59,8 @@ export default function Checkbox({
changeHandler,
info,
wrapperWrapperProps,
noLabel,
title,
...props
}: CheckboxProps) {
const finalSize = size || 20;
@@ -62,8 +69,8 @@ export default function Checkbox({
defaultChecked || externalChecked || false
);
const finalTitle = props.title
? props.title
const finalTitle = title
? title
: `Checkbox-${Math.round(Math.random() * 100000)}`;
React.useEffect(() => {
@@ -112,17 +119,19 @@ export default function Checkbox({
>
{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>
{!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" title={info.toString()}>
+35 -9
View File
@@ -12,12 +12,23 @@ import Row from "../layout/Row";
import Input from "./Input";
import Loading from "../elements/Loading";
type FileInputUtils = {
clearFileInput?: () => void;
};
type ImageUploadProps = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
onChangeHandler?: (
fileData: FileInputToBase64FunctionReturn | undefined
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<
@@ -74,6 +85,7 @@ export default function FileUpload({
loading,
multiple,
onClear,
changeHandler,
...props
}: ImageUploadProps) {
const [file, setFile] = React.useState<
@@ -98,6 +110,19 @@ export default function FileUpload({
}
}, [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}
@@ -136,7 +161,12 @@ export default function FileUpload({
(res) => {
setFile(res);
externalSetFile?.(res);
onChangeHandler?.(res);
onChangeHandler?.(
res,
inputRef,
fileInputUtils
);
changeHandler?.(res, inputRef, fileInputUtils);
fileInputProps?.onChange?.(e);
}
);
@@ -191,13 +221,7 @@ export default function FileUpload({
"hover:bg-white dark:hover:bg-black"
)}
onClick={(e) => {
setFile(undefined);
externalSetFile?.(undefined);
onChangeHandler?.(undefined);
if (inputRef.current) {
inputRef.current.value = "";
}
onClear?.();
clearFileInput();
}}
title="Cancel File Upload Button"
>
@@ -250,6 +274,7 @@ export default function FileUpload({
setFile(undefined);
externalSetFile?.(undefined);
onChangeHandler?.(undefined);
changeHandler?.(undefined);
setFileUrl(undefined);
}}
title="Cancel File Button"
@@ -300,6 +325,7 @@ export default function FileUpload({
setFile(res);
externalSetFile?.(res);
onChangeHandler?.(res);
changeHandler?.(res);
}
);
}}
+14 -5
View File
@@ -75,6 +75,10 @@ export type InputProps<KeyType extends string> = DetailedHTMLProps<
info?: string | ReactNode;
ready?: boolean;
validity?: TWUISelectValidityObject;
clearInputProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
};
let refreshes = 0;
@@ -114,6 +118,7 @@ export default function Input<KeyType extends string>(
info,
changeHandler,
validity: existingValidity,
clearInputProps,
...props
} = inputProps;
@@ -256,7 +261,7 @@ export default function Input<KeyType extends string>(
}
{...props}
className={twMerge(
"w-full outline-none bg-transparent",
"w-full outline-none bg-transparent grow",
"twui-textarea",
props.className
)}
@@ -285,7 +290,7 @@ export default function Input<KeyType extends string>(
"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",
"p-0 grow",
"twui-input",
props.className
)}
@@ -301,6 +306,7 @@ export default function Input<KeyType extends string>(
onChange={handleValueChange}
type={inputType}
defaultValue={defaultInitialValue}
autoComplete={autoComplete}
value={props.value ? getFinalValue(props.value) : undefined}
/>
);
@@ -381,10 +387,12 @@ export default function Input<KeyType extends string>(
{props.type == "search" || props.readOnly ? null : (
<div
title="Clear Input Field"
{...clearInputProps}
className={twMerge(
"p-1 -my-2 -mx-1 opacity-0 cursor-pointer",
"p-1 -my-2 -mx-1 opacity-0 cursor-pointer w-7 h-7",
"bg-background-light dark:bg-background-dark",
"twui-clear-input-field-button"
"twui-clear-input-field-button",
clearInputProps?.className
)}
onClick={(e) => {
e.preventDefault();
@@ -397,9 +405,10 @@ export default function Input<KeyType extends string>(
}
updateValue("");
clearInputProps?.onClick?.(e);
}}
>
<X size={15} />
<X className="w-full h-full" />
</div>
)}
+79
View File
@@ -0,0 +1,79 @@
import {
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>
);
}
+52 -41
View File
@@ -47,17 +47,23 @@ export default function SearchSelect<
const [currentOptions, setCurrentOptions] =
React.useState<TWUISelectOptionObject<KeyType, T>[]>(options);
const defaultOption = options.find((opt) => opt.default) || options[0];
const defaultOption = (options.find((opt) => opt.default) || options[0]) as
| TWUISelectOptionObject<KeyType, T>
| undefined;
const [value, setValue] = React.useState<
TWUISelectOptionObject<KeyType, T>
>({
value: defaultOption.value,
data: defaultOption.data,
});
TWUISelectOptionObject<KeyType, T> | undefined
>(
defaultOption
? {
value: defaultOption?.value,
data: defaultOption?.data,
}
: undefined
);
const [inputValue, setInputValue] = React.useState<string>(
defaultOption.value
defaultOption?.value || ""
);
const [selectIndex, setSelectIndex] = React.useState<number | undefined>();
@@ -91,11 +97,14 @@ export default function SearchSelect<
}, [open]);
React.useEffect(() => {
dispatchState?.(value.data);
setInputValue(value.value);
if (value) {
dispatchState?.(value.data);
setInputValue(value.value);
changeHandler?.(value.value);
}
clearTimeout(focusTimeout);
setOpen(false);
changeHandler?.(value.value);
setSelectIndex(undefined);
}, [value]);
@@ -246,7 +255,7 @@ export default function SearchSelect<
targetWrapperProps={{ className: "w-full" }}
contentWrapperProps={{ className: "w-full" }}
className="w-full"
externalOpen={open}
externalOpen={currentOptions?.[0] && open}
>
<Paper
className={twMerge(
@@ -255,37 +264,39 @@ export default function SearchSelect<
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"
);
{currentOptions?.[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>
);
})}
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>
);
})
: null}
</Stack>
</Paper>
</Dropdown>
+4 -3
View File
@@ -25,8 +25,8 @@ export type TWUISelectValidityObject = {
};
export type TWUISelectOptionObject<
KeyType extends string,
T extends { [k: string]: any } = any
KeyType extends string = string,
T extends { [k: string]: any } = { [k: string]: any }
> = {
title?: string;
value: KeyType;
@@ -36,7 +36,7 @@ export type TWUISelectOptionObject<
export type TWUISelectProps<
KeyType extends string,
T extends { [k: string]: any } = any
T extends { [k: string]: any } = { [k: string]: any }
> = DetailedHTMLProps<
SelectHTMLAttributes<HTMLSelectElement>,
HTMLSelectElement
@@ -168,6 +168,7 @@ export default function Select<
<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",