252 lines
7.8 KiB
TypeScript
252 lines
7.8 KiB
TypeScript
import { ChevronDown, Info, LucideProps } from "lucide-react";
|
|
import React, {
|
|
ComponentProps,
|
|
DetailedHTMLProps,
|
|
Dispatch,
|
|
InputHTMLAttributes,
|
|
LabelHTMLAttributes,
|
|
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";
|
|
|
|
export type TWUISelectValidityObject = {
|
|
isValid?: boolean;
|
|
msg?: string;
|
|
};
|
|
|
|
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: 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;
|
|
};
|
|
|
|
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}
|
|
{...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>
|
|
);
|
|
}
|