Updates
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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]) as
|
||||
| TWUISelectOptionObject<KeyType, T>
|
||||
| undefined;
|
||||
|
||||
const [value, setValue] = React.useState<
|
||||
TWUISelectOptionObject<KeyType, T> | undefined
|
||||
>(
|
||||
defaultOption
|
||||
? {
|
||||
value: defaultOption?.value,
|
||||
data: defaultOption?.data,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
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(() => {
|
||||
if (value) {
|
||||
dispatchState?.(value.data);
|
||||
setInputValue(value.value);
|
||||
changeHandler?.(value.value);
|
||||
}
|
||||
|
||||
clearTimeout(focusTimeout);
|
||||
setOpen(false);
|
||||
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={currentOptions?.[0] && 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?.[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>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Dropdown>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user