295 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|