654 lines
20 KiB
TypeScript
654 lines
20 KiB
TypeScript
import React, {
|
||
useState,
|
||
useEffect,
|
||
useRef,
|
||
useId,
|
||
useMemo,
|
||
useCallback,
|
||
} from "react";
|
||
import { ChevronDownIcon, CheckIcon } from "@heroicons/react/24/outline";
|
||
import { getSizeStyles } from "../../data/getInputSizes";
|
||
import Textfield from "../Textfeild/Textfeild";
|
||
import { motion } from "framer-motion";
|
||
import { Tooltip } from "../Tooltip/Tooltip";
|
||
import { createPortal } from "react-dom";
|
||
import { checkIsMobile } from "../../utils/checkIsMobile";
|
||
|
||
interface DataItem {
|
||
key: number | string;
|
||
value: string;
|
||
disabled?: boolean;
|
||
isGroupHeader?: boolean;
|
||
originalGroupKey?: string | number;
|
||
}
|
||
|
||
interface AutoCompleteProps {
|
||
data: DataItem[];
|
||
multiselect?: boolean;
|
||
inPage?: boolean;
|
||
disabled?: boolean;
|
||
selectedKeys: (number | string)[];
|
||
onChange: (keys: (number | string)[]) => void | [];
|
||
width?: string;
|
||
buttonHeight?: number | string;
|
||
title?: string;
|
||
error?: boolean;
|
||
size?: "small" | "medium" | "large";
|
||
helperText?: string;
|
||
onChangeValue?: (data: { value: string; key: number | string }) => void;
|
||
onGroupHeaderClick?: (groupKey: string | number) => void;
|
||
selectField?: boolean;
|
||
}
|
||
|
||
const AutoComplete: React.FC<AutoCompleteProps> = ({
|
||
data,
|
||
multiselect = false,
|
||
selectedKeys,
|
||
onChange,
|
||
disabled = false,
|
||
inPage = false,
|
||
title = "",
|
||
error = false,
|
||
size = "medium",
|
||
helperText,
|
||
onChangeValue,
|
||
onGroupHeaderClick,
|
||
selectField = false,
|
||
}) => {
|
||
const [filteredData, setFilteredData] = useState<DataItem[]>(data);
|
||
const [showOptions, setShowOptions] = useState<boolean>(false);
|
||
const [dropdownWidth, setDropdownWidth] = useState<number>(0);
|
||
const [dropdownPosition, setDropdownPosition] = useState<{
|
||
top: number;
|
||
left: number;
|
||
}>({ top: 0, left: 0 });
|
||
const [maxHeight, setMaxHeight] = useState<number>(240);
|
||
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const dropdownRef = useRef<HTMLUListElement>(null);
|
||
const uniqueId = useId();
|
||
const selectedKeysRef = useRef<(number | string)[]>(selectedKeys);
|
||
const isInternalChangeRef = useRef<boolean>(false);
|
||
|
||
useEffect(() => {
|
||
const updateDropdownDimensions = () => {
|
||
if (inputRef.current) {
|
||
const rect = inputRef.current.getBoundingClientRect();
|
||
const defaultMaxHeight = 240;
|
||
const spaceBelow = window.innerHeight - rect.bottom;
|
||
const availableHeight = Math.max(100, spaceBelow - 10);
|
||
const calculatedMaxHeight =
|
||
spaceBelow < defaultMaxHeight ? availableHeight : defaultMaxHeight;
|
||
|
||
setDropdownWidth(rect.width);
|
||
setMaxHeight(calculatedMaxHeight);
|
||
setDropdownPosition({
|
||
top: rect.bottom + window.scrollY,
|
||
left: rect.left + window.scrollX,
|
||
});
|
||
}
|
||
};
|
||
|
||
updateDropdownDimensions();
|
||
|
||
const resizeObserver = new ResizeObserver(updateDropdownDimensions);
|
||
if (inputRef.current) {
|
||
resizeObserver.observe(inputRef.current);
|
||
}
|
||
|
||
window.addEventListener("resize", updateDropdownDimensions);
|
||
window.addEventListener("scroll", updateDropdownDimensions);
|
||
|
||
return () => {
|
||
resizeObserver.disconnect();
|
||
window.removeEventListener("resize", updateDropdownDimensions);
|
||
window.removeEventListener("scroll", updateDropdownDimensions);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!showOptions) return;
|
||
|
||
let animationFrameId: number;
|
||
let isActive = true;
|
||
let lastTop = 0;
|
||
let lastLeft = 0;
|
||
let lastWidth = 0;
|
||
let lastMaxHeight = 0;
|
||
|
||
const updatePosition = (force = false) => {
|
||
if (!isActive || !inputRef.current) return;
|
||
|
||
const rect = inputRef.current.getBoundingClientRect();
|
||
const defaultMaxHeight = 240;
|
||
const viewportHeight =
|
||
window.visualViewport?.height || window.innerHeight;
|
||
const spaceBelow = viewportHeight - rect.bottom;
|
||
const availableHeight = Math.max(100, spaceBelow - 10);
|
||
const calculatedMaxHeight =
|
||
spaceBelow < defaultMaxHeight ? availableHeight : defaultMaxHeight;
|
||
|
||
const newTop = rect.bottom + window.scrollY;
|
||
const newLeft = rect.left + window.scrollX;
|
||
const newWidth = rect.width;
|
||
|
||
if (
|
||
force ||
|
||
Math.abs(newTop - lastTop) > 0.5 ||
|
||
Math.abs(newLeft - lastLeft) > 0.5 ||
|
||
Math.abs(newWidth - lastWidth) > 0.5 ||
|
||
Math.abs(calculatedMaxHeight - lastMaxHeight) > 1
|
||
) {
|
||
setDropdownWidth(newWidth);
|
||
setMaxHeight(calculatedMaxHeight - 30);
|
||
setDropdownPosition({
|
||
top: newTop,
|
||
left: newLeft,
|
||
});
|
||
lastTop = newTop;
|
||
lastLeft = newLeft;
|
||
lastWidth = newWidth;
|
||
lastMaxHeight = calculatedMaxHeight;
|
||
}
|
||
|
||
if (isActive && showOptions) {
|
||
animationFrameId = requestAnimationFrame(() => updatePosition(false));
|
||
}
|
||
};
|
||
|
||
updatePosition();
|
||
|
||
const handleResize = () => updatePosition(true);
|
||
const handleScroll = () => updatePosition();
|
||
let lastViewportHeight =
|
||
window.visualViewport?.height || window.innerHeight;
|
||
const handleVisualViewportResize = () => {
|
||
const currentHeight = window.visualViewport?.height || window.innerHeight;
|
||
const heightDiff = Math.abs(currentHeight - lastViewportHeight);
|
||
lastViewportHeight = currentHeight;
|
||
|
||
if (heightDiff > 50) {
|
||
setTimeout(() => {
|
||
updatePosition(true);
|
||
setTimeout(() => updatePosition(true), 200);
|
||
setTimeout(() => updatePosition(true), 400);
|
||
}, 50);
|
||
} else {
|
||
setTimeout(() => updatePosition(true), 50);
|
||
}
|
||
};
|
||
const handleVisualViewportScroll = () => updatePosition();
|
||
const handleFocus = () => {
|
||
setTimeout(() => updatePosition(true), 300);
|
||
};
|
||
const handleBlur = () => {
|
||
setTimeout(() => {
|
||
updatePosition(true);
|
||
setTimeout(() => updatePosition(true), 200);
|
||
setTimeout(() => updatePosition(true), 400);
|
||
}, 100);
|
||
};
|
||
|
||
window.addEventListener("resize", handleResize);
|
||
window.addEventListener("scroll", handleScroll, true);
|
||
|
||
if (checkIsMobile()) {
|
||
if (window.visualViewport) {
|
||
window.visualViewport.addEventListener(
|
||
"resize",
|
||
handleVisualViewportResize
|
||
);
|
||
window.visualViewport.addEventListener(
|
||
"scroll",
|
||
handleVisualViewportScroll
|
||
);
|
||
}
|
||
const inputElement = inputRef.current;
|
||
if (inputElement) {
|
||
inputElement.addEventListener("focus", handleFocus);
|
||
inputElement.addEventListener("blur", handleBlur);
|
||
inputElement.addEventListener("touchstart", handleFocus);
|
||
}
|
||
}
|
||
|
||
return () => {
|
||
isActive = false;
|
||
if (animationFrameId) {
|
||
cancelAnimationFrame(animationFrameId);
|
||
}
|
||
window.removeEventListener("resize", handleResize);
|
||
window.removeEventListener("scroll", handleScroll, true);
|
||
if (checkIsMobile()) {
|
||
if (window.visualViewport) {
|
||
window.visualViewport.removeEventListener(
|
||
"resize",
|
||
handleVisualViewportResize
|
||
);
|
||
window.visualViewport.removeEventListener(
|
||
"scroll",
|
||
handleVisualViewportScroll
|
||
);
|
||
}
|
||
const inputElement = inputRef.current;
|
||
if (inputElement) {
|
||
inputElement.removeEventListener("focus", handleFocus);
|
||
inputElement.removeEventListener("blur", handleBlur);
|
||
inputElement.removeEventListener("touchstart", handleFocus);
|
||
}
|
||
}
|
||
};
|
||
}, [showOptions]);
|
||
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
const target = event.target as HTMLElement;
|
||
const clickedInsideCurrent = target.closest(`.select-group-${uniqueId}`);
|
||
const clickedOnAnotherAutocomplete =
|
||
target.closest(".select-group") && !clickedInsideCurrent;
|
||
|
||
const clickedOnPortalDropdown = target.closest(
|
||
`[data-autocomplete-portal="${uniqueId}"]`
|
||
);
|
||
|
||
if (clickedOnAnotherAutocomplete) {
|
||
setShowOptions(false);
|
||
return;
|
||
}
|
||
|
||
if (!clickedInsideCurrent && !clickedOnPortalDropdown) {
|
||
setTimeout(() => {
|
||
const isInputFocused = document.activeElement === inputRef.current;
|
||
if (!isInputFocused) {
|
||
setShowOptions(false);
|
||
}
|
||
}, 0);
|
||
}
|
||
};
|
||
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => {
|
||
document.removeEventListener("mousedown", handleClickOutside);
|
||
};
|
||
}, [uniqueId]);
|
||
|
||
useEffect(() => {
|
||
setFilteredData(data);
|
||
}, [data]);
|
||
|
||
useEffect(() => {
|
||
selectedKeysRef.current = selectedKeys;
|
||
}, [selectedKeys]);
|
||
|
||
useEffect(() => {
|
||
if (isInternalChangeRef.current) {
|
||
isInternalChangeRef.current = false;
|
||
return;
|
||
}
|
||
|
||
if (selectedKeys?.length > 0 && onChangeValue) {
|
||
const selectedItem = data.find((item) => item.key === selectedKeys[0]);
|
||
if (selectedItem) {
|
||
onChangeValue({
|
||
value: selectedItem.value.trim(),
|
||
key: selectedItem.key,
|
||
});
|
||
}
|
||
}
|
||
}, [selectedKeys, data]);
|
||
|
||
useEffect(() => {
|
||
if (!showOptions) {
|
||
setIsTyping(false);
|
||
}
|
||
}, [showOptions]);
|
||
|
||
useEffect(() => {
|
||
if (!showOptions || !checkIsMobile()) return;
|
||
|
||
const originalOverflow = window.getComputedStyle(document.body).overflow;
|
||
const originalPosition = window.getComputedStyle(document.body).position;
|
||
const originalTop = document.body.style.top;
|
||
const scrollY = window.scrollY;
|
||
|
||
document.body.style.overflow = "hidden";
|
||
document.body.style.position = "fixed";
|
||
document.body.style.top = `-${scrollY}px`;
|
||
document.body.style.width = "100%";
|
||
|
||
const preventTouchMove = (e: TouchEvent) => {
|
||
const target = e.target as HTMLElement;
|
||
const dropdown = document.querySelector(
|
||
`[data-autocomplete-portal="${uniqueId}"]`
|
||
);
|
||
|
||
if (dropdown) {
|
||
const touch = e.touches[0] || e.changedTouches[0];
|
||
if (touch) {
|
||
const elementAtPoint = document.elementFromPoint(
|
||
touch.clientX,
|
||
touch.clientY
|
||
);
|
||
if (
|
||
elementAtPoint &&
|
||
(dropdown.contains(elementAtPoint) || dropdown.contains(target))
|
||
) {
|
||
return;
|
||
}
|
||
} else if (dropdown.contains(target)) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
e.preventDefault();
|
||
};
|
||
|
||
document.addEventListener("touchmove", preventTouchMove, {
|
||
passive: false,
|
||
});
|
||
|
||
return () => {
|
||
document.body.style.overflow = originalOverflow;
|
||
document.body.style.position = originalPosition;
|
||
document.body.style.top = originalTop;
|
||
document.body.style.width = "";
|
||
window.scrollTo(0, scrollY);
|
||
document.removeEventListener("touchmove", preventTouchMove);
|
||
};
|
||
}, [showOptions, uniqueId]);
|
||
|
||
const inputValue = useMemo(() => {
|
||
if (selectedKeys?.length > 0) {
|
||
const selectedValues = data
|
||
.filter((item) => selectedKeys?.includes(item.key))
|
||
.map((item) => item.value);
|
||
return selectedValues?.join(", ");
|
||
}
|
||
return "";
|
||
}, [selectedKeys, data]);
|
||
|
||
const [localInputValue, setLocalInputValue] = useState<string>("");
|
||
const [isTyping, setIsTyping] = useState<boolean>(false);
|
||
|
||
const handleInputChange = useCallback(
|
||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const value = event.target.value;
|
||
setLocalInputValue(value);
|
||
setIsTyping(true);
|
||
const filtered = data.filter((item) =>
|
||
item.value.toLowerCase().includes(value.toLowerCase())
|
||
);
|
||
setFilteredData(filtered);
|
||
setShowOptions(true);
|
||
},
|
||
[data]
|
||
);
|
||
|
||
const handleChange = useCallback(
|
||
(newSelectedKeys: (number | string)[]) => {
|
||
isInternalChangeRef.current = true;
|
||
onChange(newSelectedKeys);
|
||
|
||
if (onChangeValue && newSelectedKeys.length > 0) {
|
||
const selectedItem = data.find(
|
||
(item) => item.key === newSelectedKeys[0]
|
||
);
|
||
if (selectedItem) {
|
||
onChangeValue({
|
||
value: selectedItem.value.trim(),
|
||
key: selectedItem.key,
|
||
});
|
||
}
|
||
}
|
||
},
|
||
[onChange, onChangeValue, data]
|
||
);
|
||
|
||
const handleOptionClick = useCallback(
|
||
(key: number | string) => {
|
||
const currentSelectedKeys = selectedKeysRef.current;
|
||
let newSelectedKeys: (number | string)[];
|
||
|
||
if (multiselect) {
|
||
if (currentSelectedKeys.includes(key)) {
|
||
newSelectedKeys = currentSelectedKeys.filter((item) => item !== key);
|
||
} else {
|
||
newSelectedKeys = [...currentSelectedKeys, key];
|
||
}
|
||
} else {
|
||
if (currentSelectedKeys.includes(key)) {
|
||
newSelectedKeys = currentSelectedKeys.filter((item) => item !== key);
|
||
} else {
|
||
newSelectedKeys = [key];
|
||
}
|
||
}
|
||
|
||
handleChange(newSelectedKeys);
|
||
|
||
setIsTyping(false);
|
||
if (!multiselect) {
|
||
setLocalInputValue("");
|
||
setShowOptions(false);
|
||
}
|
||
},
|
||
[multiselect, handleChange]
|
||
);
|
||
|
||
const handleInputClick = useCallback(() => {
|
||
document.querySelectorAll(".select-group").forEach((el) => {
|
||
if (!el.classList.contains(`select-group-${uniqueId}`)) {
|
||
const input = el.querySelector("input");
|
||
if (input) {
|
||
input.blur();
|
||
}
|
||
}
|
||
});
|
||
|
||
setShowOptions(true);
|
||
setFilteredData(data);
|
||
setLocalInputValue("");
|
||
setIsTyping(false);
|
||
}, [uniqueId, data]);
|
||
|
||
const handleCloseInput = useCallback(() => {
|
||
setShowOptions(false);
|
||
setIsTyping(false);
|
||
}, []);
|
||
|
||
const selectedKeysSet = useMemo(() => new Set(selectedKeys), [selectedKeys]);
|
||
|
||
const handleSelectAll = useCallback(() => {
|
||
const enabledItems = filteredData.filter((item) => !item.disabled);
|
||
const allEnabledKeys = enabledItems.map((item) => item.key);
|
||
handleChange(allEnabledKeys);
|
||
}, [filteredData, handleChange]);
|
||
|
||
const handleDeselectAll = useCallback(() => {
|
||
handleChange([]);
|
||
}, [handleChange]);
|
||
|
||
const areAllSelected = useMemo(() => {
|
||
const enabledItems = filteredData.filter((item) => !item.disabled);
|
||
return (
|
||
enabledItems.length > 0 &&
|
||
enabledItems.every((item) => selectedKeysSet.has(item.key))
|
||
);
|
||
}, [filteredData, selectedKeysSet]);
|
||
|
||
const dropdownOptions = useMemo(() => {
|
||
if (filteredData.length === 0) {
|
||
return (
|
||
<li className="px-4 py-3 text-gray-500 dark:text-dark-400 text-center">
|
||
نتیجهای یافت نشد
|
||
</li>
|
||
);
|
||
}
|
||
|
||
const selectAllHeader = multiselect ? (
|
||
<li
|
||
key="select-all-header"
|
||
onClick={areAllSelected ? handleDeselectAll : handleSelectAll}
|
||
className="flex items-center my-1 justify-start gap-2 px-4 py-2 cursor-pointer transition-colors duration-150 rounded-md border border-gray-200 dark:border-dark-600 hover:bg-primary-100 text-dark-800 dark:text-dark-100 dark:hover:bg-dark-700 bg-gray-50 dark:bg-dark-700 font-semibold"
|
||
>
|
||
<span className="text-sm">
|
||
{areAllSelected ? "عدم انتخاب همه" : "انتخاب همه"}
|
||
</span>
|
||
{areAllSelected && (
|
||
<CheckIcon className="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0" />
|
||
)}
|
||
</li>
|
||
) : null;
|
||
|
||
const options = filteredData.map((item) => {
|
||
const isSelected = selectedKeysSet.has(item.key);
|
||
const isGroupHeader = item.isGroupHeader;
|
||
const handleClick = () => {
|
||
if (isGroupHeader && onGroupHeaderClick) {
|
||
const groupKey =
|
||
item.originalGroupKey !== undefined
|
||
? item.originalGroupKey
|
||
: String(item.key).startsWith("__group__")
|
||
? String(item.key).slice(11)
|
||
: item.key;
|
||
onGroupHeaderClick(groupKey);
|
||
} else if (!item.disabled) {
|
||
handleOptionClick(item.key);
|
||
}
|
||
};
|
||
return (
|
||
<li
|
||
key={`${item.key}`}
|
||
onClick={handleClick}
|
||
className={`flex items-center justify-between px-4 py-2 transition-colors duration-150 rounded-md
|
||
${
|
||
isGroupHeader && onGroupHeaderClick
|
||
? "cursor-pointer opacity-55 hover:bg-gray-100 text-dark-800 dark:text-dark-100 dark:hover:bg-primary-900/90 font-semibold bg-gray-200 dark:bg-primary-900"
|
||
: item.disabled
|
||
? "text-gray-400 dark:text-dark-500 cursor-not-allowed"
|
||
: "cursor-pointer hover:bg-primary-100 text-dark-800 dark:text-dark-100 dark:hover:bg-dark-700"
|
||
}
|
||
${
|
||
isSelected && !isGroupHeader
|
||
? "bg-primary-50 dark:bg-dark-700 font-semibold"
|
||
: ""
|
||
}
|
||
`}
|
||
aria-disabled={item?.disabled && !isGroupHeader}
|
||
>
|
||
{checkIsMobile() ? (
|
||
<span
|
||
className={`truncate ${
|
||
item?.value.length > 55 ? "text-xs" : "text-sm"
|
||
}`}
|
||
>
|
||
{item.value}
|
||
</span>
|
||
) : (
|
||
<Tooltip
|
||
title={item.value}
|
||
hidden={item?.value?.length < 55}
|
||
position="right"
|
||
>
|
||
<span
|
||
className={`truncate ${
|
||
item?.value.length > 55 ? "text-xs" : "text-sm"
|
||
}`}
|
||
>
|
||
{item.value}
|
||
</span>
|
||
</Tooltip>
|
||
)}
|
||
{isSelected && (
|
||
<CheckIcon className="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0" />
|
||
)}
|
||
</li>
|
||
);
|
||
});
|
||
|
||
return selectAllHeader ? [selectAllHeader, ...options] : options;
|
||
}, [
|
||
filteredData,
|
||
selectedKeysSet,
|
||
handleOptionClick,
|
||
multiselect,
|
||
areAllSelected,
|
||
handleSelectAll,
|
||
handleDeselectAll,
|
||
onGroupHeaderClick,
|
||
]);
|
||
|
||
const dropdownPortalContent = useMemo(() => {
|
||
if (!showOptions) return null;
|
||
|
||
return createPortal(
|
||
<motion.ul
|
||
ref={dropdownRef}
|
||
data-autocomplete-portal={`${uniqueId}`}
|
||
initial={{ opacity: 0, scaleY: 0.95, y: -5 }}
|
||
animate={{ opacity: 1, scaleY: 1, y: 0 }}
|
||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||
style={{
|
||
position: "fixed",
|
||
top: dropdownPosition.top,
|
||
left: dropdownPosition.left,
|
||
width: `${dropdownWidth}px`,
|
||
maxHeight: `${maxHeight}px`,
|
||
zIndex: 9999,
|
||
transformOrigin: "top center",
|
||
scrollbarWidth: "thin",
|
||
scrollbarColor: "#cbd5e1 transparent",
|
||
boxSizing: "border-box",
|
||
}}
|
||
className={`overflow-y-auto border border-gray-200 dark:border-dark-500 bg-white dark:bg-dark-800 divide-y divide-gray-100 dark:divide-dark-600 text-sm backdrop-blur-lg rounded-xl shadow-2xl modern-scrollbar`}
|
||
>
|
||
{dropdownOptions}
|
||
</motion.ul>,
|
||
document.body
|
||
);
|
||
}, [
|
||
showOptions,
|
||
dropdownPosition,
|
||
dropdownWidth,
|
||
uniqueId,
|
||
dropdownOptions,
|
||
maxHeight,
|
||
]);
|
||
|
||
return (
|
||
<div
|
||
className={`select-group select-group-${uniqueId} ${
|
||
inPage ? "w-auto" : "w-full"
|
||
}`}
|
||
>
|
||
<div className="relative w-full">
|
||
<div className="relative">
|
||
<Textfield
|
||
disabled={disabled}
|
||
readOnly={selectField}
|
||
inputMode={selectField ? "none" : undefined}
|
||
handleCloseInput={handleCloseInput}
|
||
error={error}
|
||
helperText={helperText}
|
||
ref={inputRef}
|
||
isAutoComplete
|
||
inputSize={size}
|
||
value={isTyping ? localInputValue : inputValue}
|
||
onChange={handleInputChange}
|
||
onClick={handleInputClick}
|
||
className="selected-value w-full p-3 pl-10 outline-0 rounded-lg border border-black-100 transition-all duration-200 text-right"
|
||
placeholder={title || "انتخاب کنید..."}
|
||
/>
|
||
<ChevronDownIcon
|
||
className={`absolute left-3 text-dark-400 dark:text-dark-100 transition-transform duration-200 ${
|
||
showOptions ? "transform rotate-180" : ""
|
||
} ${getSizeStyles(size).icon}`}
|
||
/>
|
||
</div>
|
||
|
||
{dropdownPortalContent}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AutoComplete;
|