first commit

This commit is contained in:
2026-01-26 10:14:10 +03:30
commit 9a995d5109
160 changed files with 34879 additions and 0 deletions

View File

@@ -0,0 +1,653 @@
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;