Files
Rasadyar_Inspection_System/src/components/AutoComplete/AutoComplete.tsx
2026-01-26 10:14:10 +03:30

654 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;