new-personal-site/components/lib/form/Select.tsx
Benjamin Toby a0a0ab8ee4 Updates
2025-07-20 10:35:54 +01:00

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>
);
}