uikit/src/layout/Sidebar.jsx
Mohamadzadeh 6dc5472fa2
All checks were successful
Build and Deploy Next.js + Nginx Docker Image / build-and-deploy (push) Successful in 47s
Build and Deploy Next.js + Nginx Docker Image / deploy (push) Successful in 7s
fix sidebar
2026-08-19 20:05:18 +03:30

496 lines
13 KiB
JavaScript

"use client";
import {
Box,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerHeader,
DrawerOverlay,
HStack,
Icon,
IconButton,
Link,
SimpleGrid,
Stack,
Text,
Tooltip,
VStack,
useDisclosure,
} from "@chakra-ui/react";
import { Children, forwardRef, isValidElement, useRef } from "react";
import { FiMoreHorizontal } from "react-icons/fi/index.js";
import { IoExit } from "react-icons/io5/index.js";
import { logoutKeycloak } from "../core/keycloak.jsx";
import { usePermission } from "../core/permission.jsx";
const normalizeSidebarPath = (value = "") => {
const path = String(value).split(/[?#]/)[0] || "";
if (!path || path === "/") return path || "/";
return path.replace(/\/+$/, "");
};
export const isSidebarPathActive = (currentPath, item) => {
const normalizedPath = normalizeSidebarPath(currentPath);
const candidatePaths = [item?.path, ...(item?.aliases || [])]
.filter(Boolean)
.map(normalizeSidebarPath);
return candidatePaths.some(
(path) => normalizedPath === path || normalizedPath.startsWith(`${path}/`),
);
};
export const getActiveSidebarItem = (items = [], currentPath) =>
items.find((item) => isSidebarPathActive(currentPath, item));
const SidebarLink = forwardRef(
({ linkComponent, href, children, ...props }, ref) => {
const linkProps = linkComponent ? { as: linkComponent } : {};
return (
<Link ref={ref} href={href} {...linkProps} {...props}>
{children}
</Link>
);
},
);
SidebarLink.displayName = "SidebarLink";
const DesktopNavItem = ({
item,
isActive,
isDisabled,
linkComponent,
onNavigate,
}) => {
const commonProps = {
variant: "ghost",
position: "relative",
bg: "transparent",
_before: isActive
? {
content: '""',
position: "absolute",
top: 0,
bottom: 0,
insetInlineStart: 0,
insetInlineEnd: 0,
bg: "gray.200",
borderRadius: "md",
zIndex: 0,
}
: undefined,
sx: {
"> svg": {
position: "relative",
zIndex: 1,
},
},
icon: <Icon as={item.icon} />,
fontSize: "xl",
size: { base: "sm", lg: "md" },
"aria-label": item.title,
"aria-current": isActive ? "page" : undefined,
};
let content;
if (isDisabled || isActive) {
content = (
<IconButton
{...commonProps}
isDisabled={isDisabled}
cursor={isActive && !isDisabled ? "default" : undefined}
/>
);
} else {
const linkProps = linkComponent
? { as: linkComponent, href: item.path }
: { as: "a", href: item.path };
content = (
<IconButton {...commonProps} {...linkProps} onClick={onNavigate} />
);
}
return (
<Tooltip
label={isDisabled ? "عدم دسترسی" : item.title}
placement="left-start"
hasArrow
rounded="md"
py={2}
px={4}
fontWeight="bold"
closeDelay={150}
>
<Box as="span">{content}</Box>
</Tooltip>
);
};
const MobileNavigationButton = ({
item,
isActive,
isDisabled,
linkComponent,
onNavigate,
}) => {
const content = (
<VStack
as="span"
spacing={0.5}
w="full"
minW={0}
py={1}
px={1}
borderRadius="xl"
color={isActive ? "blue.600" : "gray.600"}
bg={isActive ? "blue.50" : "transparent"}
opacity={isDisabled ? 0.4 : 1}
transition="background-color 150ms ease, color 150ms ease"
>
<Icon as={item.icon} boxSize={5} />
<Text
as="span"
w="full"
fontSize="10px"
fontWeight={isActive ? "bold" : "medium"}
lineHeight="short"
textAlign="center"
noOfLines={1}
>
{item.mobileTitle || item.title}
</Text>
</VStack>
);
if (isDisabled || isActive) {
return (
<Box
flex="1"
minW={0}
display="flex"
alignItems="center"
aria-disabled={isDisabled ? "true" : undefined}
aria-current={isActive ? "page" : undefined}
>
{content}
</Box>
);
}
return (
<SidebarLink
linkComponent={linkComponent}
href={item.path}
flex="1"
minW={0}
display="flex"
alignItems="center"
_hover={{ textDecoration: "none" }}
onClick={onNavigate}
aria-current={isActive ? "page" : undefined}
>
{content}
</SidebarLink>
);
};
const SidebarExit = () => null;
const SidebarRoot = ({
items = [],
currentPath,
linkComponent,
onNavigate,
children,
primaryMobileCount = 4,
moreTitle = "بیشتر",
moreDrawerTitle = "سایر بخش‌های سامانه",
desktopProps,
mobileProps,
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const moreButtonRef = useRef();
const { cannot } = usePermission();
const exitElement = Children.toArray(children).find(
(child) => isValidElement(child) && child.type === SidebarExit,
);
const hasExit = Boolean(exitElement);
const logoutTitle = exitElement?.props?.title || "خروج";
const logoutIcon = exitElement?.props?.icon || IoExit;
const resolvedCurrentPath =
currentPath ??
(typeof window !== "undefined"
? `${window.location.pathname}${window.location.search}`
: "");
const primaryItems = items.slice(0, primaryMobileCount);
const secondaryItems = items.slice(primaryMobileCount);
const disabled = (item) => {
const permissions = Array.isArray(item?.perm) ? item.perm : [item?.perm];
return permissions.filter(Boolean).some((permission) => cannot(permission));
};
const active = (item) => isSidebarPathActive(resolvedCurrentPath, item);
const isSecondaryItemActive = secondaryItems.some(active);
const hasMoreContent = secondaryItems.length > 0 || hasExit;
const handleNavigate = (item) => {
onNavigate?.(item);
onClose();
};
const handleLogout = () => {
onClose();
logoutKeycloak();
};
return (
<>
<Stack
py={6}
as="nav"
flexShrink={0}
justify="space-between"
h="full"
borderLeft="1px"
borderColor="gray.200"
overflowY="scroll"
sx={{
"::-webkit-scrollbar": { display: "none" },
scrollbarWidth: "none",
msOverflowStyle: "none",
}}
display={{ base: "none", lg: "flex" }}
{...desktopProps}
>
<VStack spacing={2}>
{items.map((item) => (
<DesktopNavItem
key={item.path}
item={item}
isDisabled={disabled(item)}
isActive={active(item)}
linkComponent={linkComponent}
onNavigate={() => onNavigate?.(item)}
/>
))}
{hasExit && (
<Tooltip
label={logoutTitle}
placement="left-start"
hasArrow
rounded="md"
py={2}
px={4}
fontWeight="bold"
closeDelay={150}
>
<IconButton
variant="ghost"
icon={<Icon as={logoutIcon} />}
fontSize="xl"
size={{ base: "sm", lg: "lg" }}
aria-label={logoutTitle}
onClick={handleLogout}
/>
</Tooltip>
)}
</VStack>
</Stack>
<Box
as="nav"
aria-label="ناوبری اصلی موبایل"
display={{ base: "block", lg: "none" }}
position="fixed"
insetInline={0}
bottom={0}
zIndex={1200}
bg="white"
borderTopWidth="1px"
borderColor="gray.200"
boxShadow="0 -6px 18px rgba(0, 0, 0, 0.08)"
pb="env(safe-area-inset-bottom)"
{...mobileProps}
>
<HStack h="56px" px={1.5} spacing={1} align="stretch">
{primaryItems.map((item) => (
<MobileNavigationButton
key={item.path}
item={item}
isDisabled={disabled(item)}
isActive={active(item)}
linkComponent={linkComponent}
onNavigate={() => handleNavigate(item)}
/>
))}
{hasMoreContent && (
<Box
as="button"
ref={moreButtonRef}
type="button"
flex="1"
minW={0}
h="full"
p={0}
border={0}
bg="transparent"
font="inherit"
display="flex"
alignItems="center"
justifyContent="center"
cursor="pointer"
onClick={onOpen}
aria-label="نمایش سایر بخش‌ها"
>
<VStack
spacing={0.5}
w="full"
py={1}
px={1}
borderRadius="xl"
color={isSecondaryItemActive ? "blue.600" : "gray.600"}
bg={isSecondaryItemActive ? "blue.50" : "transparent"}
>
<Icon as={FiMoreHorizontal} boxSize={5} />
<Text
fontSize="10px"
fontWeight={isSecondaryItemActive ? "bold" : "medium"}
lineHeight="short"
>
{moreTitle}
</Text>
</VStack>
</Box>
)}
</HStack>
</Box>
{hasMoreContent && (
<Drawer
isOpen={isOpen}
placement="bottom"
onClose={onClose}
finalFocusRef={moreButtonRef}
>
<DrawerOverlay />
<DrawerContent
borderTopRadius="2xl"
maxH="min(78dvh, 620px)"
pb="env(safe-area-inset-bottom)"
>
<DrawerCloseButton insetInlineEnd={3} insetInlineStart="auto" />
<DrawerHeader borderBottomWidth="1px" fontSize="md">
{moreDrawerTitle}
</DrawerHeader>
<DrawerBody py={4} overflowY="auto">
<SimpleGrid columns={4} spacing={2}>
{secondaryItems.map((item) => {
const isDisabled = disabled(item);
const isActive = active(item);
const content = (
<VStack
h="70px"
minW={0}
justify="center"
spacing={1}
px={1}
borderWidth="1px"
borderColor={isActive ? "blue.300" : "gray.200"}
borderRadius="lg"
bg={isActive ? "blue.50" : "white"}
color={isActive ? "blue.700" : "gray.700"}
opacity={isDisabled ? 0.4 : 1}
>
<Icon as={item.icon} boxSize={5} flexShrink={0} />
<Text
w="full"
fontSize="10px"
lineHeight="shorter"
fontWeight={isActive ? "bold" : "medium"}
textAlign="center"
noOfLines={2}
>
{item.mobileTitle || item.title}
</Text>
</VStack>
);
if (isDisabled || isActive) {
return (
<Box
key={item.path}
aria-disabled={isDisabled ? "true" : undefined}
aria-current={isActive ? "page" : undefined}
>
{content}
</Box>
);
}
return (
<SidebarLink
key={item.path}
linkComponent={linkComponent}
href={item.path}
_hover={{ textDecoration: "none" }}
onClick={() => handleNavigate(item)}
aria-current={isActive ? "page" : undefined}
>
{content}
</SidebarLink>
);
})}
</SimpleGrid>
{hasExit && (
<Box mt={4} pt={4} borderTopWidth="1px" borderColor="gray.200">
<HStack
as="button"
type="button"
w="full"
justify="center"
py={3}
borderRadius="xl"
border={0}
color="red.600"
bg="red.50"
cursor="pointer"
onClick={handleLogout}
>
<Icon as={logoutIcon} boxSize={5} />
<Text fontWeight="bold">{logoutTitle}</Text>
</HStack>
</Box>
)}
</DrawerBody>
</DrawerContent>
</Drawer>
)}
</>
);
};
export const Sidebar = Object.assign(SidebarRoot, {
Exit: SidebarExit,
});