new-personal-site/components/lib/form/SearchSelect.tsx
Benjamin Toby aceddf5146 Updates
2025-07-22 11:58:03 +01:00

295 lines
10 KiB
TypeScript

import { ChevronDown, Info, LucideProps, Search } from "lucide-react";
import React from "react";
import { twMerge } from "tailwind-merge";
import Dropdown from "../elements/Dropdown";
import Stack from "../layout/Stack";
import {
TWUISelectOptionObject,
TWUISelectProps,
TWUISelectValidityObject,
} from "./Select";
import Border from "../elements/Border";
import Input from "./Input";
import Paper from "../elements/Paper";
import Button from "../layout/Button";
import Divider from "../layout/Divider";
/**
* # Search Select Element
* @className twui-search-select-wrapper
* @className twui-search-select
* @className twui-search-select-dropdown-icon
*/
export default function SearchSelect<
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 [currentOptions, setCurrentOptions] =
React.useState<TWUISelectOptionObject<KeyType, T>[]>(options);
const defaultOption = options.find((opt) => opt.default) || options[0];
const [value, setValue] = React.useState<
TWUISelectOptionObject<KeyType, T>
>({
value: defaultOption.value,
data: defaultOption.data,
});
const [inputValue, setInputValue] = React.useState<string>(
defaultOption.value
);
const [selectIndex, setSelectIndex] = React.useState<number | undefined>();
const [open, setOpen] = React.useState<boolean>(false);
const isFocusedRef = React.useRef<boolean>(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const contentWrapperRef = React.useRef<HTMLDivElement>(null);
let focusTimeout: any;
let keyDownInterval: any;
const FOCUS_TIMEOUT = 200;
React.useEffect(() => {
setTimeout(() => {
requestAnimationFrame(() => {
const currentSelectValue = selectRef.current?.value;
if (currentSelectValue && validateValueFn) {
validateValueFn(currentSelectValue).then((res) => {
setValidity(res);
});
}
});
}, 200);
}, []);
React.useEffect(() => {
if (!open) {
setCurrentOptions(options);
}
}, [open]);
React.useEffect(() => {
dispatchState?.(value.data);
setInputValue(value.value);
clearTimeout(focusTimeout);
setOpen(false);
changeHandler?.(value.value);
setSelectIndex(undefined);
}, [value]);
const handleArrowUpScrollAdjust = React.useCallback(() => {
if (contentWrapperRef.current) {
const targetOption = contentWrapperRef.current.querySelector(
".twui-select-target-option"
) as HTMLButtonElement;
if (targetOption) {
contentWrapperRef.current.scrollTop =
targetOption.offsetTop - 100;
}
}
}, []);
const handleArrowDownScrollAdjust = React.useCallback(() => {
if (contentWrapperRef.current) {
const targetOption = contentWrapperRef.current.querySelector(
".twui-select-target-option"
) as HTMLButtonElement;
if (targetOption) {
contentWrapperRef.current.scrollTop =
targetOption.offsetTop -
(contentWrapperRef.current.offsetHeight - 100);
}
}
}, []);
React.useEffect(() => {
if (!selectIndex) return;
}, [selectIndex]);
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
if (selectIndex !== undefined) {
setValue(currentOptions[selectIndex]);
if (inputRef.current) {
inputRef.current.blur();
}
}
} else if (e.key === "ArrowUp") {
if (selectIndex == undefined) {
setSelectIndex(currentOptions.length - 1);
} else if (selectIndex === 0) {
setSelectIndex(0);
} else {
setSelectIndex(selectIndex - 1);
}
handleArrowUpScrollAdjust();
} else if (e.key === "ArrowDown") {
if (selectIndex == undefined) {
setSelectIndex(0);
} else if (selectIndex === currentOptions.length - 1) {
setSelectIndex(currentOptions.length - 1);
} else {
setSelectIndex(selectIndex + 1);
}
handleArrowDownScrollAdjust();
}
};
const handleKeyUp = (e: React.KeyboardEvent) => {
e.preventDefault();
clearInterval(keyDownInterval);
handleKey(e);
};
// const handleKeyDown = (e: React.KeyboardEvent) => {
// e.preventDefault();
// keyDownInterval = setInterval(() => {
// handleKey(e);
// }, 100);
// };
return (
<Stack
{...wrapperWrapperProps}
className={twMerge(
"gap-1 w-full",
"twui-search-select-wrapper",
wrapperWrapperProps?.className
)}
onKeyUp={handleKeyUp}
// onKeyDown={handleKeyDown}
>
<Dropdown
disableClickActions
target={
<Input
type="text"
placeholder={props.title || "Search Options"}
value={inputValue}
prefix={(<Search size={18} />) as any}
suffix={(<ChevronDown size={20} />) as any}
suffixProps={{
onClick: (e) => {
e.preventDefault();
clearTimeout(focusTimeout);
setOpen(!open);
},
className: "pointer-events-auto opacity-100",
}}
changeHandler={(value) => {
if (!isFocusedRef.current) return;
if (!open) setOpen(true);
}}
onFocus={() => {
clearTimeout(focusTimeout);
isFocusedRef.current = true;
setOpen(true);
}}
onBlur={() => {
focusTimeout = setTimeout(() => {
isFocusedRef.current = false;
setOpen(false);
}, FOCUS_TIMEOUT);
}}
onChange={(e) => {
if (!open) setOpen(true);
setInputValue(e.target.value);
const updatedOptions = options.filter((option) =>
option.value
.toLowerCase()
.match(
new RegExp(
`${e.target.value.toLowerCase()}`
)
)
);
if (updatedOptions?.[0]) {
setCurrentOptions(updatedOptions);
} else {
setCurrentOptions(options);
}
setSelectIndex(undefined);
}}
componentRef={inputRef}
showLabel={showLabel}
/>
}
targetWrapperProps={{ className: "w-full" }}
contentWrapperProps={{ className: "w-full" }}
className="w-full"
externalOpen={open}
>
<Paper
className={twMerge(
"gap-0 p-0 w-full max-h-[40vh] overflow-y-auto"
)}
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"
);
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>
);
})}
</Stack>
</Paper>
</Dropdown>
</Stack>
);
}