This commit is contained in:
2026-02-13 19:04:07 +01:00
parent 9505331165
commit 69d432b6af
42 changed files with 650 additions and 360 deletions
+6 -6
View File
@@ -66,7 +66,7 @@ export default function Checkbox({
const finalSize = size || 20;
const [checked, setChecked] = React.useState(
defaultChecked || externalChecked || false
defaultChecked || externalChecked || false,
);
const finalTitle = title
@@ -93,7 +93,7 @@ export default function Checkbox({
"flex items-start md:items-center gap-2 flex-wrap md:flex-nowrap",
readOnly ? "opacity-70 pointer-events-none" : "",
wrapperClassName,
wrapperProps?.className
wrapperProps?.className,
)}
onClick={() => {
setChecked(!checked);
@@ -108,7 +108,7 @@ export default function Checkbox({
? "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
props.className,
)}
style={{
minWidth: finalSize + "px",
@@ -125,7 +125,7 @@ export default function Checkbox({
{...labelProps}
className={twMerge(
"select-none whitespace-normal md:whitespace-nowrap",
labelProps?.className
labelProps?.className,
)}
>
{label || finalTitle}
@@ -134,8 +134,8 @@ export default function Checkbox({
)}
</div>
{info && (
<Row className="gap-1" title={info.toString()}>
<Info size={12} className="opacity-40" />
<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>
+6 -4
View File
@@ -1,11 +1,12 @@
import _ from "lodash";
import { DetailedHTMLProps, FormHTMLAttributes } from "react";
import { 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>;
};
/**
@@ -13,8 +14,8 @@ type Props<T extends { [key: string]: any } = { [key: string]: any }> =
* @className twui-form
*/
export default function Form<
T extends { [key: string]: any } = { [key: string]: any }
>({ ...props }: Props<T>) {
T extends { [key: string]: any } = { [key: string]: any },
>({ formRef, ...props }: Props<T>) {
const finalProps = _.omit(props, ["submitHandler", "changeHandler"]);
return (
@@ -23,7 +24,7 @@ export default function Form<
className={twMerge(
"flex flex-col items-stretch gap-2 w-full bg-transparent",
"twui-form",
props.className
props.className,
)}
onSubmit={(e) => {
e.preventDefault();
@@ -42,6 +43,7 @@ export default function Form<
props.changeHandler?.(e, data);
props.onChange?.(e);
}}
ref={formRef}
>
{props.children}
</form>
+42 -9
View File
@@ -10,13 +10,15 @@ import imageInputToBase64, {
} 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
imgData: ImageInputToBase64FunctionReturn | undefined,
) => any;
fileInputProps?: DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
@@ -45,6 +47,7 @@ type ImageUploadProps = DetailedHTMLProps<
React.SetStateAction<ImageInputToBase64FunctionReturn[] | undefined>
>;
setLoading?: React.Dispatch<React.SetStateAction<boolean>>;
setImgURL?: React.Dispatch<React.SetStateAction<string | undefined>>;
externalImage?: ImageInputToBase64FunctionReturn;
restoreImageFn?: () => void;
};
@@ -67,6 +70,7 @@ export default function ImageUpload({
multiple,
restoreImageFn,
setLoading,
setImgURL,
...props
}: ImageUploadProps) {
const [imageObject, setImageObject] = React.useState<
@@ -74,6 +78,11 @@ export default function ImageUpload({
>(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);
@@ -84,7 +93,7 @@ export default function ImageUpload({
{...props}
className={twMerge(
"w-full h-[300px] overflow-hidden",
props?.className
props?.className,
)}
>
<input
@@ -123,7 +132,7 @@ export default function ImageUpload({
externalSetImage?.(res);
fileInputProps?.onChange?.(e);
setLoading?.(false);
}
},
);
}
}}
@@ -138,7 +147,7 @@ export default function ImageUpload({
{label && (
<label
className={twMerge(
"absolute top-0 left-0 text-xs z-50"
"absolute top-0 left-0 text-xs z-50",
)}
>
<Tag color="gray">
@@ -157,10 +166,10 @@ export default function ImageUpload({
{...previewImageProps}
/>
)}
<Button
variant="ghost"
<div
className={twMerge(
"absolute p-1 top-2 right-2 z-20 bg-background-light dark:bg-background-dark"
"absolute p-1 top-2 right-2 z-20 bg-background-light dark:bg-background-dark",
"cursor-pointer",
)}
onClick={(e) => {
setSrc(undefined);
@@ -174,13 +183,13 @@ export default function ImageUpload({
title="Cancel Image Upload Button"
>
<X className="text-slate-950 dark:text-white" />
</Button>
</div>
</Card>
) : (
<Card
className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20",
placeHolderWrapper?.className
placeHolderWrapper?.className,
)}
onClick={(e) => {
const targetEl = e.target as HTMLElement | undefined;
@@ -199,6 +208,30 @@ export default function ImageUpload({
<Span size="smaller" variant="faded">
{label || "Click to Upload Image"}
</Span>
<Stack className="cancel-upload w-full items-stretch gap-1">
<Input
placeholder="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;
}}
showLabel
/>
<Button
title="Restore Image Button"
size="smaller"
variant="outlined"
color="gray"
onClick={() => {
if (!imageUrlRef.current) return;
setSrc(imageUrlRef.current);
}}
>
Set Image URL
</Button>
</Stack>
{existingImageUrl && (
<Button
title="Restore Image Button"
@@ -8,7 +8,7 @@ let pressInterval: any;
let pressTimeout: any;
type Props = Pick<InputProps<any>, "min" | "max" | "step"> & {
updateValue: (v: string) => void;
setValue: React.Dispatch<React.SetStateAction<string>>;
getNormalizedValue: (v: string) => void;
buttonDownRef: React.MutableRefObject<boolean>;
inputRef: React.RefObject<HTMLInputElement | null>;
@@ -19,7 +19,7 @@ type Props = Pick<InputProps<any>, "min" | "max" | "step"> & {
*/
export default function NumberInputButtons({
getNormalizedValue,
updateValue,
setValue,
min,
max,
step,
@@ -65,12 +65,14 @@ export default function NumberInputButtons({
const existingNumberValue = twuiNumberfy(existingValue);
if (max && existingNumberValue >= twuiNumberfy(max)) {
return updateValue(String(max));
return setValue(String(max));
} else if (min && existingNumberValue < twuiNumberfy(min)) {
return updateValue(String(min));
return setValue(String(min));
} else {
updateValue(
String(existingNumberValue + twuiNumberfy(step || DEFAULT_STEP))
setValue(
String(
existingNumberValue + twuiNumberfy(step || DEFAULT_STEP),
),
);
}
}
@@ -80,10 +82,12 @@ export default function NumberInputButtons({
const existingNumberValue = twuiNumberfy(existingValue);
if (min && existingNumberValue <= twuiNumberfy(min)) {
updateValue(String(min));
setValue(String(min));
} else {
updateValue(
String(existingNumberValue - twuiNumberfy(step || DEFAULT_STEP))
setValue(
String(
existingNumberValue - twuiNumberfy(step || DEFAULT_STEP),
),
);
}
}
+62 -61
View File
@@ -27,13 +27,16 @@ let timeout: any;
let validationFnTimeout: any;
let externalValueChangeTimeout: any;
export type InputProps<KeyType extends string> = DetailedHTMLProps<
InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
export type InputProps<KeyType extends string> = Omit<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
"prefix" | "suffix"
> &
DetailedHTMLProps<
TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement
Omit<
DetailedHTMLProps<
TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement
>,
"prefix" | "suffix"
> & {
label?: string;
variant?: "normal" | "warning" | "error" | "inactive";
@@ -60,16 +63,14 @@ export type InputProps<KeyType extends string> = DetailedHTMLProps<
invalidMessage?: string;
validationFunction?: (
value: string,
element?: HTMLInputElement | HTMLTextAreaElement
element?: HTMLInputElement | HTMLTextAreaElement,
) => Promise<TWUISelectValidityObject>;
changeHandler?: (
value: string,
element?: HTMLInputElement | HTMLTextAreaElement
) => void;
changeHandler?: (value: string) => void;
autoComplete?: (typeof AutocompleteOptions)[number];
name?: KeyType;
valueUpdate?: string;
numberText?: boolean;
rawNumber?: boolean;
setReady?: React.Dispatch<React.SetStateAction<boolean>>;
decimal?: number;
info?: string | ReactNode;
@@ -91,7 +92,7 @@ let refreshes = 0;
* @className twui-clear-input-field-button
*/
export default function Input<KeyType extends string>(
inputProps: InputProps<KeyType>
inputProps: InputProps<KeyType>,
) {
const {
label,
@@ -119,10 +120,12 @@ export default function Input<KeyType extends string>(
changeHandler,
validity: existingValidity,
clearInputProps,
rawNumber,
...props
} = inputProps;
function getFinalValue(v: any) {
if (rawNumber) return twuiNumberfy(v);
if (numberText) {
return (
twuiNumberfy(v, decimal).toLocaleString() +
@@ -141,16 +144,19 @@ export default function Input<KeyType extends string>(
const [validity, setValidity] = React.useState<TWUISelectValidityObject>(
existingValidity || {
isValid: true,
}
},
);
const inputRef = componentRef || React.useRef<HTMLInputElement>(null);
const textAreaRef = componentRef || React.useRef<HTMLTextAreaElement>(null);
const buttonDownRef = React.useRef(false);
const [value, setValue] = React.useState(
props.defaultValue ? String(props.defaultValue) : "",
);
const [focus, setFocus] = React.useState(false);
const [inputType, setInputType] = React.useState(
numberText ? "text" : props.type
numberText ? "text" : props.type,
);
const DEFAULT_DEBOUNCE = 500;
@@ -180,23 +186,20 @@ export default function Input<KeyType extends string>(
setValidity(existingValidity);
}, [existingValidity]);
const updateValueFn = (
val: string,
el?: HTMLInputElement | HTMLTextAreaElement
) => {
const updateValueFn = (val: string) => {
if (buttonDownRef.current) return;
if (changeHandler) {
window.clearTimeout(externalValueChangeTimeout);
externalValueChangeTimeout = setTimeout(() => {
changeHandler(val, el);
changeHandler(val);
}, finalDebounce);
}
if (typeof val == "string") {
if (!val.match(/./)) {
setValidity({ isValid: true });
props.value = "";
setValue("");
if (istextarea && textAreaRef.current) {
textAreaRef.current.value = "";
} else if (inputRef?.current) {
@@ -223,7 +226,7 @@ export default function Input<KeyType extends string>(
if (validationRegex && !validationRegex.test(val)) {
return;
}
validationFunction(val, el).then((res) => {
validationFunction(val).then((res) => {
setValidity(res);
});
}, finalDebounce);
@@ -233,28 +236,36 @@ export default function Input<KeyType extends string>(
React.useEffect(() => {
if (typeof props.value !== "string" || !props.value.match(/./)) return;
updateValueFn(String(props.value));
setValue(String(props.value));
}, [props.value]);
React.useEffect(() => {
if (istextarea && textAreaRef.current) {
} else if (inputRef?.current) {
inputRef.current.value = getFinalValue(value);
}
updateValueFn(value);
}, [value]);
function handleValueChange(
e: React.ChangeEvent<HTMLInputElement> &
React.ChangeEvent<HTMLTextAreaElement>
React.ChangeEvent<HTMLTextAreaElement>,
) {
const newValue = e.target.value;
updateValue(newValue, e.target);
setValue(newValue);
props.onChange?.(e);
}
function updateValue(
v: string,
el?: HTMLInputElement | HTMLTextAreaElement
) {
if (istextarea && textAreaRef.current) {
} else if (inputRef?.current) {
inputRef.current.value = getFinalValue(v);
}
updateValueFn(v, el);
}
// function updateValue(
// v: string,
// el?: HTMLInputElement | HTMLTextAreaElement,
// ) {
// if (istextarea && textAreaRef.current) {
// } else if (inputRef?.current) {
// inputRef.current.value = getFinalValue(v);
// }
// updateValueFn(v);
// }
const targetComponent = istextarea ? (
<textarea
@@ -265,7 +276,7 @@ export default function Input<KeyType extends string>(
className={twMerge(
"w-full outline-none bg-transparent grow",
"twui-textarea",
props.className
props.className,
)}
ref={textAreaRef}
onFocus={(e) => {
@@ -294,7 +305,7 @@ export default function Input<KeyType extends string>(
"dark:bg-transparent dark:outline-none dark:border-none",
"p-0 grow",
"twui-input",
props.className
props.className,
)}
ref={inputRef}
onFocus={(e) => {
@@ -318,8 +329,8 @@ export default function Input<KeyType extends string>(
title={`${finalLabel}${props.required ? " (Required)" : ""}`}
{...wrapperWrapperProps}
className={twMerge(
"w-full gap-1.5 relative z-0 hover:z-10",
wrapperWrapperProps?.className
"w-full gap-1.5 relative z-0 hover:z-100",
wrapperWrapperProps?.className,
)}
>
<div
@@ -353,7 +364,7 @@ export default function Input<KeyType extends string>(
: "opacity-50 pointer-events-none"
: undefined,
"twui-input-wrapper",
wrapperProps?.className
wrapperProps?.className,
)}
>
{showLabel && (
@@ -365,7 +376,7 @@ export default function Input<KeyType extends string>(
"dark:text-foreground-dark/80 dark:bg-background-dark whitespace-nowrap",
"overflow-hidden overflow-ellipsis z-20 px-1.5 rounded-t-default",
"twui-input-label",
labelProps?.className
labelProps?.className,
)}
>
{finalLabel}
@@ -378,11 +389,7 @@ export default function Input<KeyType extends string>(
</label>
)}
{prefix && (
<div className="opacity-60 pointer-events-none whitespace-nowrap">
{prefix}
</div>
)}
{prefix && prefix}
{targetComponent}
@@ -394,7 +401,7 @@ export default function Input<KeyType extends string>(
"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",
clearInputProps?.className
clearInputProps?.className,
)}
onClick={(e) => {
e.preventDefault();
@@ -406,7 +413,7 @@ export default function Input<KeyType extends string>(
textAreaRef.current.value = "";
}
updateValue("");
setValue("");
clearInputProps?.onClick?.(e);
}}
>
@@ -439,21 +446,11 @@ export default function Input<KeyType extends string>(
</div>
) : null}
{suffix ? (
<div
{...suffixProps}
className={twMerge(
"opacity-60 pointer-events-none whitespace-nowrap",
suffixProps?.className
)}
>
{suffix}
</div>
) : null}
{suffix ? suffix : null}
{numberText ? (
<NumberInputButtons
updateValue={updateValue}
setValue={setValue}
inputRef={inputRef}
getNormalizedValue={getNormalizedValue}
max={props.max}
@@ -468,18 +465,22 @@ export default function Input<KeyType extends string>(
target={
<Row className="gap-1">
<Info size={12} className="opacity-40" />
<Span size="smaller" className="opacity-70">
<Span
size="smaller"
className="opacity-70 hover:opacity-100"
>
{info}
</Span>
</Row>
}
openDebounce={700}
className="z-1000"
hoverOpen
>
<Paper
className={twMerge(
"min-w-[250px] shadow-lg shadow-slate-200 dark:shadow-white/10",
"max-w-[300px] w-full"
"max-w-[300px] w-full bg-background-light! dark:bg-background-dark! z-1000",
)}
>
<Stack className="gap-2 items-center">