first commit
This commit is contained in:
758
src/components/PopOver/PopOver.tsx
Normal file
758
src/components/PopOver/PopOver.tsx
Normal file
@@ -0,0 +1,758 @@
|
||||
import { useState, useRef, useEffect, createContext, useContext } from "react";
|
||||
import {
|
||||
Cog6ToothIcon,
|
||||
InformationCircleIcon,
|
||||
MinusIcon,
|
||||
DocumentTextIcon,
|
||||
KeyIcon,
|
||||
CheckIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Grid } from "../Grid/Grid";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useUserProfileStore } from "../../context/zustand-store/userStore";
|
||||
import { Tooltip } from "../Tooltip/Tooltip";
|
||||
import Button from "../Button/Button";
|
||||
import { useModalStore } from "../../context/zustand-store/appStore";
|
||||
import Typography from "../Typography/Typography";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { ItemWithSubItems } from "../../types/userPermissions";
|
||||
import { getUserPermissions } from "../../utils/getUserAvalableItems";
|
||||
import { useApiRequest } from "../../utils/useApiRequest";
|
||||
import Checkbox from "../CheckBox/CheckBox";
|
||||
import { useFetchProfile } from "../../hooks/useFetchProfile";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import api from "../../utils/axios";
|
||||
import { getFaPermissions } from "../../utils/getFaPermissions";
|
||||
|
||||
export const PopOverContext = createContext<boolean>(false);
|
||||
export const usePopOverContext = () => useContext(PopOverContext);
|
||||
|
||||
const PermissionsContent = ({
|
||||
filterProps,
|
||||
showToast,
|
||||
matchedSubItem,
|
||||
}: {
|
||||
filterProps: Array<{ page: string; access: string; title: string }>;
|
||||
showToast: (text: string, type?: "success" | "error" | "default") => void;
|
||||
matchedSubItem: any;
|
||||
}) => {
|
||||
const [copiedItem, setCopiedItem] = useState<{
|
||||
type: "page" | "access";
|
||||
index: number;
|
||||
} | null>(null);
|
||||
const [expandedPermissions, setExpandedPermissions] = useState<{
|
||||
[key: string]: boolean;
|
||||
}>({});
|
||||
const queryClient = useQueryClient();
|
||||
const { getProfile } = useFetchProfile();
|
||||
const [selectedRoleIdsByKey, setSelectedRoleIdsByKey] = useState<{
|
||||
[key: string]: Set<number>;
|
||||
}>({});
|
||||
const [submittingKey, setSubmittingKey] = useState<string | null>(null);
|
||||
|
||||
const { data: rolesData, refetch: refetchRoles } = useApiRequest({
|
||||
api: "/auth/api/v1/role/",
|
||||
method: "get",
|
||||
params: { page: 1, page_size: 1000 },
|
||||
queryKey: ["roles-permissions"],
|
||||
});
|
||||
|
||||
const { data: permissionsData } = useApiRequest({
|
||||
api: "/auth/api/v1/permission/",
|
||||
method: "get",
|
||||
params: { page: 1, page_size: 1000 },
|
||||
queryKey: ["all-permissions"],
|
||||
});
|
||||
|
||||
const findPermissionId = (page: string, access: string): number | null => {
|
||||
if (!permissionsData?.results) return null;
|
||||
const permission = permissionsData.results.find(
|
||||
(p: any) => p.page === page && p.name === access
|
||||
);
|
||||
return permission?.id || null;
|
||||
};
|
||||
|
||||
const checkRoleHasPermission = (
|
||||
role: any,
|
||||
page: string,
|
||||
access: string
|
||||
): boolean => {
|
||||
if (!role?.permissions || !Array.isArray(role.permissions)) {
|
||||
return false;
|
||||
}
|
||||
if (!permissionsData?.results) return false;
|
||||
|
||||
const permissionId = findPermissionId(page, access);
|
||||
if (!permissionId) return false;
|
||||
|
||||
const rolePermissionIds = role.permissions.map((p: any) => p.id || p);
|
||||
return rolePermissionIds.includes(permissionId);
|
||||
};
|
||||
|
||||
const ensureInitialized = (key: string, page: string, access: string) => {
|
||||
if (selectedRoleIdsByKey[key]) return;
|
||||
if (!rolesData?.results) return;
|
||||
const next = new Set<number>();
|
||||
rolesData.results.forEach((role: any) => {
|
||||
if (checkRoleHasPermission(role, page, access)) {
|
||||
next.add(role.id);
|
||||
}
|
||||
});
|
||||
setSelectedRoleIdsByKey((prev) => ({ ...prev, [key]: next }));
|
||||
};
|
||||
|
||||
const toggleLocal = (key: string, roleId: number) => {
|
||||
setSelectedRoleIdsByKey((prev) => {
|
||||
const current = new Set(prev[key] || []);
|
||||
if (current.has(roleId)) current.delete(roleId);
|
||||
else current.add(roleId);
|
||||
return { ...prev, [key]: current };
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = (key: string, page: string, access: string) => {
|
||||
if (!rolesData?.results) return;
|
||||
const next = new Set<number>();
|
||||
rolesData.results.forEach((role: any) => {
|
||||
if (checkRoleHasPermission(role, page, access)) {
|
||||
next.add(role.id);
|
||||
}
|
||||
});
|
||||
setSelectedRoleIdsByKey((prev) => ({ ...prev, [key]: next }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (key: string, page: string, access: string) => {
|
||||
try {
|
||||
const permissionId = findPermissionId(page, access);
|
||||
if (!permissionId) {
|
||||
showToast("دسترسی یافت نشد", "error");
|
||||
return;
|
||||
}
|
||||
if (!rolesData?.results) return;
|
||||
let selected = selectedRoleIdsByKey[key];
|
||||
if (!selected) {
|
||||
const init = new Set<number>();
|
||||
rolesData.results.forEach((role: any) => {
|
||||
if (checkRoleHasPermission(role, page, access)) {
|
||||
init.add(role.id);
|
||||
}
|
||||
});
|
||||
setSelectedRoleIdsByKey((prev) => ({ ...prev, [key]: init }));
|
||||
selected = init;
|
||||
}
|
||||
const updates = rolesData.results.flatMap((role: any) => {
|
||||
const currentPermissionIds = (role.permissions || []).map(
|
||||
(p: any) => p.id || p
|
||||
);
|
||||
const currentlyHas = currentPermissionIds.includes(permissionId);
|
||||
const shouldHave = selected.has(role.id);
|
||||
if (currentlyHas === shouldHave) return [];
|
||||
const updatedPermissionIds = shouldHave
|
||||
? currentPermissionIds.includes(permissionId)
|
||||
? currentPermissionIds
|
||||
: [...currentPermissionIds, permissionId]
|
||||
: currentPermissionIds.filter((id: number) => id !== permissionId);
|
||||
return [
|
||||
api.patch(`/auth/api/v1/role/${role.id}/`, {
|
||||
permissions: updatedPermissionIds,
|
||||
}),
|
||||
];
|
||||
});
|
||||
if (updates.length === 0) {
|
||||
showToast("تغییری برای ارسال وجود ندارد", "default");
|
||||
return;
|
||||
}
|
||||
setSubmittingKey(key);
|
||||
await Promise.all(updates);
|
||||
showToast("بهروزرسانی نقشها با موفقیت انجام شد", "success");
|
||||
queryClient.invalidateQueries({ queryKey: ["roles"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["roles-permissions"] });
|
||||
refetchRoles();
|
||||
getProfile();
|
||||
} catch (error: any) {
|
||||
showToast(
|
||||
error?.response?.data?.message || "خطا در بهروزرسانی نقشها",
|
||||
"error"
|
||||
);
|
||||
} finally {
|
||||
setSubmittingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (
|
||||
text: string,
|
||||
type: "page" | "access",
|
||||
index: number
|
||||
) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedItem({ type, index });
|
||||
showToast(
|
||||
type === "page" ? `صفحه "${text}" کپی شد` : `دسترسی "${text}" کپی شد`,
|
||||
"success"
|
||||
);
|
||||
setTimeout(() => {
|
||||
setCopiedItem(null);
|
||||
}, 2000);
|
||||
} catch {
|
||||
showToast("خطا در کپی کردن", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<Grid
|
||||
container
|
||||
className="mb-2 gap-1 border-gray-200/60 dark:border-gray-700/40 justify-center items-center"
|
||||
>
|
||||
{matchedSubItem?.name && (
|
||||
<div className="px-3 py-1.5 rounded-lg bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800/50 dark:to-gray-800/30 border border-gray-200/50 dark:border-gray-700/30 shadow-sm">
|
||||
<Typography
|
||||
variant="caption"
|
||||
className="text-gray-700 dark:text-gray-300 font-medium text-sm line-clamp-1"
|
||||
>
|
||||
صفحه فعلی: {matchedSubItem?.name} (
|
||||
{getFaPermissions(matchedSubItem?.name)})
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</Grid>
|
||||
<div className="space-y-3">
|
||||
{filterProps && filterProps.length > 0 ? (
|
||||
filterProps.map(
|
||||
(
|
||||
item: {
|
||||
page: string;
|
||||
access: string;
|
||||
title: string;
|
||||
},
|
||||
index: number
|
||||
) => (
|
||||
<motion.div
|
||||
key={`${item.page}-${index}`}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
className="relative bg-gradient-to-br from-gray-50 to-gray-100/50 dark:from-dark-700 dark:to-dark-800 rounded-lg p-2.5 border border-gray-200/60 dark:border-gray-600/40 shadow-sm hover:shadow transition-all duration-200 group"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-md bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center group-hover:bg-primary-200 dark:group-hover:bg-primary-900/50 transition-colors">
|
||||
<span className="text-xs text-primary-600 dark:text-primary-400 font-bold">
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{item.title && (
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className="text-gray-700 dark:text-gray-300 font-medium text-sm mb-1.5 line-clamp-1"
|
||||
>
|
||||
{item.title}
|
||||
</Typography>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.page && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopy(item.page, "page", index);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-white/60 dark:bg-dark-600/60 border border-gray-200/50 dark:border-gray-700/50 cursor-pointer hover:bg-white/80 dark:hover:bg-dark-600/80 transition-colors active:scale-95"
|
||||
>
|
||||
{copiedItem?.type === "page" &&
|
||||
copiedItem?.index === index ? (
|
||||
<CheckIcon className="w-3.5 h-3.5 text-green-500 dark:text-green-400 flex-shrink-0" />
|
||||
) : (
|
||||
<DocumentTextIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<Typography
|
||||
variant="caption"
|
||||
className="text-gray-700 dark:text-gray-300 text-xs font-medium truncate max-w-[120px]"
|
||||
>
|
||||
{item.page}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
{item.access && (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopy(item.access, "access", index);
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300 border border-primary-200 dark:border-primary-800 cursor-pointer hover:bg-primary-200 dark:hover:bg-primary-900/60 transition-colors active:scale-95"
|
||||
>
|
||||
{copiedItem?.type === "access" &&
|
||||
copiedItem?.index === index ? (
|
||||
<CheckIcon className="w-3.5 h-3.5 text-green-500 dark:text-green-400" />
|
||||
) : (
|
||||
<KeyIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{item.access}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{rolesData?.results && rolesData.results.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200/60 dark:border-gray-700/40">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedPermissions((prev) => ({
|
||||
...prev,
|
||||
[`${item.page}-${item.access}-${index}`]:
|
||||
!prev[`${item.page}-${item.access}-${index}`],
|
||||
}));
|
||||
const k = `${item.page}-${item.access}-${index}`;
|
||||
ensureInitialized(k, item.page, item.access);
|
||||
}}
|
||||
className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors mb-2"
|
||||
>
|
||||
<span>
|
||||
{expandedPermissions[
|
||||
`${item.page}-${item.access}-${index}`
|
||||
]
|
||||
? "▲"
|
||||
: "▼"}{" "}
|
||||
نقشها (
|
||||
{
|
||||
rolesData.results.filter((role: any) =>
|
||||
checkRoleHasPermission(
|
||||
role,
|
||||
item.page,
|
||||
item.access
|
||||
)
|
||||
).length
|
||||
}
|
||||
/{rolesData.results.length})
|
||||
</span>
|
||||
</button>
|
||||
{expandedPermissions[
|
||||
`${item.page}-${item.access}-${index}`
|
||||
] && (
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{rolesData.results.map((role: any) => {
|
||||
const k = `${item.page}-${item.access}-${index}`;
|
||||
const selectedSet = selectedRoleIdsByKey[k];
|
||||
const hasPermission =
|
||||
selectedSet?.has(role.id) ??
|
||||
checkRoleHasPermission(
|
||||
role,
|
||||
item.page,
|
||||
item.access
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={role.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md bg-white/40 dark:bg-dark-600/40 border border-gray-200/50 dark:border-gray-700/50 hover:bg-white/60 dark:hover:bg-dark-600/60 transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
checked={hasPermission}
|
||||
disabled={submittingKey === k}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
ensureInitialized(
|
||||
k,
|
||||
item.page,
|
||||
item.access
|
||||
);
|
||||
toggleLocal(k, role.id);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
className="text-xs text-gray-700 dark:text-gray-300 flex-1"
|
||||
>
|
||||
{role.role_name}
|
||||
</Typography>
|
||||
{role.type?.name && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
className="text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
({role.type.name})
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{expandedPermissions[
|
||||
`${item.page}-${item.access}-${index}`
|
||||
] && (
|
||||
<div className="flex items-center justify-between gap-2 mt-2">
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-gray-100 dark:bg-dark-600 text-gray-700 dark:text-gray-200 hover:bg-gray-200/80 dark:hover:bg-dark-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const k = `${item.page}-${item.access}-${index}`;
|
||||
handleCancel(k, item.page, item.access);
|
||||
}}
|
||||
disabled={
|
||||
submittingKey ===
|
||||
`${item.page}-${item.access}-${index}`
|
||||
}
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-60"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const k = `${item.page}-${item.access}-${index}`;
|
||||
handleSubmit(k, item.page, item.access);
|
||||
}}
|
||||
disabled={
|
||||
submittingKey ===
|
||||
`${item.page}-${item.access}-${index}`
|
||||
}
|
||||
>
|
||||
{submittingKey ===
|
||||
`${item.page}-${item.access}-${index}`
|
||||
? "در حال ارسال..."
|
||||
: "ثبت تغییرات"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||
<Typography variant="body2" className="text-sm">
|
||||
دسترسیای یافت نشد
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Popover = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode | any;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { openModal } = useModalStore();
|
||||
const { profile } = useUserProfileStore();
|
||||
|
||||
const menuItems: ItemWithSubItems[] = getUserPermissions(
|
||||
profile?.permissions
|
||||
);
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
const matchedSubItem = menuItems
|
||||
.flatMap((item) => item.subItems)
|
||||
.find(
|
||||
(sub) =>
|
||||
sub.path === currentPath ||
|
||||
sub.path.includes(currentPath.split("/")[1] + "/")
|
||||
);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 640);
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
const buttonRect = buttonRef?.current?.getBoundingClientRect();
|
||||
|
||||
const showToast = useToast();
|
||||
|
||||
const filterProps: Array<{ page: string; access: string; title: string }> =
|
||||
!Array.isArray(children)
|
||||
? [
|
||||
{
|
||||
page:
|
||||
children?.props?.page ||
|
||||
children?.props?.children?.props?.page ||
|
||||
"",
|
||||
access:
|
||||
children?.props?.access ||
|
||||
children?.props?.children?.props?.access ||
|
||||
"",
|
||||
title: children?.props?.tooltipText || children?.props?.title || "",
|
||||
},
|
||||
]
|
||||
: children
|
||||
?.filter(
|
||||
(item: any) =>
|
||||
(item?.props?.page && item?.props?.access) ||
|
||||
(item?.props?.children?.props?.access &&
|
||||
item?.props?.children?.props?.page)
|
||||
)
|
||||
?.map((option: any) => {
|
||||
return {
|
||||
page:
|
||||
option?.props?.page ||
|
||||
option?.props?.children?.props?.page ||
|
||||
"",
|
||||
access:
|
||||
option?.props?.access ||
|
||||
option?.props?.children?.props?.access ||
|
||||
"",
|
||||
title: option?.props?.tooltipText || option?.props?.title || "",
|
||||
};
|
||||
}) || [];
|
||||
|
||||
const ableToSeeAnyButton = (
|
||||
filterProps: Array<{ page: string; access: string }>
|
||||
) => {
|
||||
if (!filterProps || filterProps.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return filterProps?.some(({ page, access }) => {
|
||||
if (!access || !page) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const foundPermission = profile?.permissions?.find(
|
||||
(item: any) => item.page_name === page
|
||||
);
|
||||
|
||||
return foundPermission && foundPermission.page_access.includes(access);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent | TouchEvent) => {
|
||||
if (
|
||||
!buttonRef.current?.contains(e.target as Node) &&
|
||||
!popoverRef.current?.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
if (isMobile) document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
if (isMobile) document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen, isMobile]);
|
||||
|
||||
const togglePopover = () => setIsOpen(!isOpen);
|
||||
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
useEffect(() => {
|
||||
if (!isOpen || isMobile) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
const buttonRect = buttonRef.current?.getBoundingClientRect();
|
||||
const popoverElement = popoverRef.current;
|
||||
|
||||
if (!buttonRect) return;
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const scrollY = window.scrollY;
|
||||
const scrollX = window.scrollX;
|
||||
|
||||
const padding = 16;
|
||||
const maxPopoverHeight = viewportHeight - padding * 2;
|
||||
|
||||
let top = buttonRect.top + scrollY;
|
||||
let left = buttonRect.right + scrollX + 8;
|
||||
|
||||
if (popoverElement) {
|
||||
const popoverRect = popoverElement.getBoundingClientRect();
|
||||
|
||||
const popoverHeight = Math.min(popoverRect.height, maxPopoverHeight);
|
||||
const popoverWidth = popoverRect.width;
|
||||
|
||||
const popoverBottomInViewport = buttonRect.top + popoverHeight;
|
||||
|
||||
if (popoverBottomInViewport > viewportHeight - padding) {
|
||||
const overflow = popoverBottomInViewport - (viewportHeight - padding);
|
||||
|
||||
top = buttonRect.top + scrollY - overflow;
|
||||
|
||||
if (top < scrollY + padding) {
|
||||
top = scrollY + padding;
|
||||
}
|
||||
}
|
||||
|
||||
const popoverRightInViewport = buttonRect.right + popoverWidth + 8;
|
||||
if (popoverRightInViewport > viewportWidth - padding) {
|
||||
left = buttonRect.left + scrollX - popoverWidth - 8;
|
||||
|
||||
if (left < scrollX + padding) {
|
||||
left = scrollX + padding;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const estimatedHeight = Math.min(
|
||||
(Array.isArray(children) ? children.length : 1) * 50 + 100,
|
||||
maxPopoverHeight
|
||||
);
|
||||
|
||||
if (buttonRect.top + estimatedHeight > viewportHeight - padding) {
|
||||
const overflow =
|
||||
buttonRect.top + estimatedHeight - (viewportHeight - padding);
|
||||
|
||||
top = buttonRect.top + scrollY - overflow;
|
||||
if (top < scrollY + padding) {
|
||||
top = scrollY + padding;
|
||||
}
|
||||
}
|
||||
|
||||
const estimatedWidth = 300;
|
||||
if (buttonRect.right + estimatedWidth + 8 > viewportWidth - padding) {
|
||||
left = buttonRect.left + scrollX - estimatedWidth - 8;
|
||||
if (left < scrollX + padding) {
|
||||
left = scrollX + padding;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setPosition({ top, left });
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
const timeoutId = setTimeout(updatePosition, 0);
|
||||
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
window.addEventListener("resize", updatePosition);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
};
|
||||
}, [isOpen, isMobile, children]);
|
||||
|
||||
return (
|
||||
<PopOverContext.Provider value={true}>
|
||||
<div className={`relative ${className}`}>
|
||||
<motion.button
|
||||
disabled={!ableToSeeAnyButton(filterProps)}
|
||||
ref={buttonRef}
|
||||
onClick={togglePopover}
|
||||
className={`p-2 rounded-full hover:bg-gray-100/50 dark:hover:bg-gray-800 transition-colors`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<motion.div animate={{ rotate: isOpen ? 90 : 0 }}>
|
||||
{!ableToSeeAnyButton(filterProps) ? (
|
||||
<MinusIcon className="h-5 w-5 text-red-600 cursor-not-allowed" />
|
||||
) : (
|
||||
<Cog6ToothIcon
|
||||
className={`h-5 w-5 ${
|
||||
isOpen
|
||||
? "text-primary-600"
|
||||
: "text-gray-600 dark:text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<AnimatePresence>
|
||||
<>
|
||||
{isMobile && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.4 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
ref={popoverRef}
|
||||
id="popover-content"
|
||||
initial={isMobile ? { y: "100%" } : { opacity: 0, y: -10 }}
|
||||
animate={isMobile ? { y: 0 } : { opacity: 1, y: 0 }}
|
||||
exit={isMobile ? { y: "100%" } : { opacity: 0, y: -10 }}
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
className={`z-[9999] bg-white dark:bg-dark-700 rounded-lg shadow-lg ring-1 ring-black/10 dark:ring-white/10 ${
|
||||
isMobile
|
||||
? "fixed bottom-0 inset-x-0 rounded-t-3xl pb-[env(safe-area-inset-bottom)]"
|
||||
: "fixed max-h-[calc(100vh-32px)]"
|
||||
}`}
|
||||
style={
|
||||
!isMobile
|
||||
? {
|
||||
top: `${
|
||||
buttonRect?.bottom && buttonRect?.bottom > 700
|
||||
? position.top - 100
|
||||
: position.top
|
||||
}px`,
|
||||
left: `${position.left}px`,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{isMobile && (
|
||||
<div className="flex justify-center py-2">
|
||||
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PopOverContext.Provider value={true}>
|
||||
<Grid
|
||||
container
|
||||
column
|
||||
className="gap-2 p-2 max-h-[calc(100vh-32px)] overflow-y-auto"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{children}
|
||||
{profile?.role?.type?.key === "ADM" && (
|
||||
<Tooltip
|
||||
title="نمایش دسترسی های این قسمت"
|
||||
position="right"
|
||||
>
|
||||
<Button
|
||||
icon={
|
||||
<InformationCircleIcon className="w-5 h-5 text-gray-600 dark:text-red-400" />
|
||||
}
|
||||
variant="detail"
|
||||
onClick={() => {
|
||||
openModal({
|
||||
title: "دسترسی های این قسمت",
|
||||
content: (
|
||||
<PermissionsContent
|
||||
filterProps={filterProps}
|
||||
showToast={showToast}
|
||||
matchedSubItem={matchedSubItem}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Grid>
|
||||
</PopOverContext.Provider>
|
||||
</motion.div>
|
||||
</>
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</PopOverContext.Provider>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user