add sidebar
This commit is contained in:
parent
fbd2ca137e
commit
c3d9a696f6
69
README.md
69
README.md
@ -38,7 +38,7 @@ yarn upgrade uikit
|
||||
| `uikit` | utility functions | - |
|
||||
| `uikit/utils` | utility functions | - |
|
||||
| `uikit/pagination` | pagination | Chakra + React + react-icons |
|
||||
| `uikit/layout` | Header | Chakra + React |
|
||||
| `uikit/layout` | Header, Sidebar | Chakra + React + React Icons |
|
||||
| `uikit/core` | API، BiProvider، Keycloak، Permission | Chakra + React Query + Axios + Keycloak + Query Params |
|
||||
| `uikit/table` | DataTable | Chakra + TanStack Table + Pagination |
|
||||
| `uikit/remote/ui` | Remote UI components مثل `GBadge` | Chakra + React + remote static file |
|
||||
@ -130,6 +130,73 @@ function AppHeader() {
|
||||
|
||||
---
|
||||
|
||||
# Sidebar
|
||||
|
||||
required dependencies:
|
||||
|
||||
```bash
|
||||
yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion react-icons
|
||||
yarn add @tanstack/react-query axios keycloak-js
|
||||
yarn add next-query-params@^5.1.0 use-query-params@^2.2.2
|
||||
```
|
||||
|
||||
`Sidebar` همان UI ناوبری سامانه را برای دسکتاپ و موبایل ارائه میدهد. در دسکتاپ به صورت نوار آیکون کناری و در موبایل به صورت bottom navigation + drawer «بیشتر» نمایش داده میشود.
|
||||
|
||||
آیتمها به صورت config به کامپوننت داده میشوند:
|
||||
|
||||
```jsx
|
||||
import { SlUserFollowing } from "react-icons/sl";
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
title: "آنالیز شبکه متعارف - فالوور/فالووینگ",
|
||||
mobileTitle: "ارتباط کاربران",
|
||||
icon: SlUserFollowing,
|
||||
path: "/user-connections/analyze",
|
||||
perm: ["analyze"],
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
نمونه استفاده در Next.js Pages Router:
|
||||
|
||||
```jsx
|
||||
import NextLink from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Sidebar } from "uikit/layout";
|
||||
|
||||
function AppSidebar() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
items={navigationItems}
|
||||
currentPath={router.asPath}
|
||||
linkComponent={NextLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`Sidebar` باید داخل `BiProvider` استفاده شود. Permission هر آیتم از `perm` توسط خود UI Kit بررسی میشود، خروج با Keycloak توسط خود UI Kit انجام میشود و دسترسی `panel_access` نیز داخل Sidebar کنترل میشود. سامانه مقصد نیازی به `usePermission` یا `logoutKeycloak` برای Sidebar ندارد.
|
||||
|
||||
`aliases` نیز پشتیبانی میشود و برای تشخیص آیتم active استفاده میشود:
|
||||
|
||||
```jsx
|
||||
{
|
||||
title: "آنالیز شبکه های اجتماعی خارجی",
|
||||
mobileTitle: "شبکه خارجی",
|
||||
icon: TbAnalyze,
|
||||
path: "/posts/final_analyze",
|
||||
aliases: ["/posts/analyze"],
|
||||
perm: ["analyze"],
|
||||
}
|
||||
```
|
||||
|
||||
اگر `perm` آرایه باشد، همه permissionهای داخل آرایه برای فعال بودن آیتم لازم هستند. آیتم بدون `perm` محدودیت permission ندارد.
|
||||
|
||||
---
|
||||
|
||||
# Core
|
||||
|
||||
required dependencies:
|
||||
|
||||
184
dist/chunks/permission-DL7ciWwc.js
vendored
Normal file
184
dist/chunks/permission-DL7ciWwc.js
vendored
Normal file
@ -0,0 +1,184 @@
|
||||
import { jsx as y } from "react/jsx-runtime";
|
||||
import { createContext as L, useState as K, useRef as O, useCallback as h, useEffect as k, useMemo as T, useContext as R } from "react";
|
||||
import { useQuery as b } from "@tanstack/react-query";
|
||||
import F from "keycloak-js";
|
||||
let x = null, r = null, E = null, C = !1;
|
||||
const G = L(null), _ = ({ url: e, realm: s, clientId: t }) => (r || (r = new F({
|
||||
url: e,
|
||||
realm: s,
|
||||
clientId: t
|
||||
})), r), j = (e) => (x = e, x), ee = () => C, J = () => !!(r != null && r.authenticated), Q = () => (r == null ? void 0 : r.token) ?? null, te = async (e = 30) => r ? r.updateToken(e) : !1, U = (e) => r == null ? void 0 : r.logout(e), re = (e) => r == null ? void 0 : r.login(e), W = ({ config: e, initOptions: s = {} } = {}) => {
|
||||
const t = e ?? x;
|
||||
if (!(t != null && t.url) || !(t != null && t.realm) || !(t != null && t.clientId))
|
||||
throw new Error("Keycloak config is required.");
|
||||
j(t);
|
||||
const n = _(t);
|
||||
return E || (E = n.init({
|
||||
onLoad: "login-required",
|
||||
checkLoginIframe: !1,
|
||||
flow: "implicit",
|
||||
...s
|
||||
}).then((o) => (C = !0, o)).catch((o) => {
|
||||
throw C = !0, o;
|
||||
})), E;
|
||||
}, se = ({
|
||||
children: e,
|
||||
config: s,
|
||||
initOptions: t,
|
||||
loading: n = null
|
||||
}) => {
|
||||
const [o, m] = K(!1), [p, w] = K(!1), [u, c] = K(null), l = O(!1), i = h(() => {
|
||||
r && (w(!!r.authenticated), c(r.token ?? null));
|
||||
}, []);
|
||||
k(() => (l.current = !0, W({ config: s, initOptions: t }).then((a) => {
|
||||
l.current && (w(!!a), c((r == null ? void 0 : r.token) ?? null), m(!0));
|
||||
}).catch(() => {
|
||||
l.current && (w(!1), c(null), m(!0));
|
||||
}), () => {
|
||||
l.current = !1;
|
||||
}), [s, t]);
|
||||
const P = h(() => r == null ? void 0 : r.login(), []), d = h((a) => r == null ? void 0 : r.logout(a), []), f = h(
|
||||
async (a = 30) => {
|
||||
if (!r) return !1;
|
||||
const z = await r.updateToken(a);
|
||||
return i(), z;
|
||||
},
|
||||
[i]
|
||||
), S = T(
|
||||
() => ({
|
||||
initialized: o,
|
||||
authenticated: p,
|
||||
token: u,
|
||||
login: P,
|
||||
logout: d,
|
||||
updateToken: f
|
||||
}),
|
||||
[o, p, u, P, d, f]
|
||||
);
|
||||
return o ? /* @__PURE__ */ y(G.Provider, { value: S, children: e }) : n;
|
||||
}, B = L(null), v = "permissions", Y = (e) => (e == null ? void 0 : e.storageKey) || v, ne = ({
|
||||
user: e = null,
|
||||
children: s,
|
||||
config: t,
|
||||
loading: n = /* @__PURE__ */ y("div", { children: "Loading permissions..." }),
|
||||
errorFallback: o = /* @__PURE__ */ y("div", { children: "Error loading permissions" })
|
||||
}) => {
|
||||
const m = Y(t), [p, w] = K(
|
||||
() => $(m)
|
||||
), { data: u, isLoading: c, error: l } = V(t), i = u ?? p;
|
||||
k(() => {
|
||||
u && (D(m, u), w(u));
|
||||
}, [u, m]);
|
||||
const P = T(
|
||||
() => I(i),
|
||||
[i]
|
||||
), d = h(
|
||||
(a = "") => M(P, a),
|
||||
[P]
|
||||
), f = h((a = "") => !d(a), [d]), S = T(
|
||||
() => ({
|
||||
user: e,
|
||||
can: d,
|
||||
cannot: f,
|
||||
permissions: i,
|
||||
isLoading: c,
|
||||
error: l
|
||||
}),
|
||||
[e, d, f, i, c, l]
|
||||
);
|
||||
return l && !i ? o : c && !i ? n : /* @__PURE__ */ y(B.Provider, { value: S, children: s });
|
||||
}, $ = (e = v) => {
|
||||
if (!(typeof window > "u"))
|
||||
try {
|
||||
const s = localStorage.getItem(e);
|
||||
return s ? JSON.parse(s) : void 0;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}, D = (e = v, s) => {
|
||||
if (!(typeof window > "u"))
|
||||
try {
|
||||
localStorage.setItem(e, JSON.stringify(s));
|
||||
} catch {
|
||||
}
|
||||
}, oe = (e = v) => {
|
||||
if (!(typeof window > "u"))
|
||||
try {
|
||||
localStorage.removeItem(e);
|
||||
} catch {
|
||||
}
|
||||
}, I = (e) => e ? new Set(e.map((s) => s.name)) : /* @__PURE__ */ new Set(), M = (e, s = "") => {
|
||||
if (!s) return !1;
|
||||
if (e.has(s) || [...e].some((n) => n.startsWith(s))) return !0;
|
||||
let t = s;
|
||||
for (; t.includes(":"); )
|
||||
if (t = t.slice(0, t.lastIndexOf(":")), e.has(`${t}:*`)) return !0;
|
||||
return !1;
|
||||
}, q = {
|
||||
all: ["permissions"],
|
||||
list: (e) => [...q.all, e]
|
||||
}, N = async (e) => {
|
||||
if (!(e != null && e.url) || !(e != null && e.clientId))
|
||||
throw new Error("Permission config requires url and clientId.");
|
||||
const s = Q(), t = new URL(e.url);
|
||||
t.searchParams.set("client_id", e.clientId);
|
||||
const n = await fetch(t.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...s ? { Authorization: `Bearer ${s}` } : {}
|
||||
}
|
||||
});
|
||||
if (!n.ok)
|
||||
throw new Error("Failed to load permissions");
|
||||
return n.json();
|
||||
}, A = () => {
|
||||
const e = R(B);
|
||||
if (!e)
|
||||
throw new Error("usePermission must be used within PermissionProvider");
|
||||
return e;
|
||||
}, V = (e) => b({
|
||||
queryKey: q.list(e == null ? void 0 : e.clientId),
|
||||
queryFn: () => N(e),
|
||||
enabled: !!(e != null && e.url) && !!(e != null && e.clientId) && J(),
|
||||
staleTime: 1 / 0,
|
||||
refetchOnWindowFocus: !1,
|
||||
refetchOnReconnect: !1
|
||||
}), ie = ({
|
||||
perm: e,
|
||||
children: s,
|
||||
fallback: t = /* @__PURE__ */ y("div", { children: "You are not allowed to do this" })
|
||||
}) => {
|
||||
const { can: n } = A();
|
||||
return n(e) ? s : t;
|
||||
}, ae = ({ children: e }) => {
|
||||
const { can: s, permissions: t } = A();
|
||||
return t && s("admin") ? e : null;
|
||||
}, ue = ({ children: e, forceNoAccess: s = !1 }) => {
|
||||
const { can: t, isLoading: n } = A();
|
||||
return k(() => {
|
||||
n || (s || !t("panel_access")) && U();
|
||||
}, [n, t, s]), e;
|
||||
};
|
||||
export {
|
||||
ae as A,
|
||||
ie as C,
|
||||
se as K,
|
||||
ne as P,
|
||||
te as a,
|
||||
J as b,
|
||||
W as c,
|
||||
ue as d,
|
||||
M as e,
|
||||
oe as f,
|
||||
Q as g,
|
||||
I as h,
|
||||
ee as i,
|
||||
$ as j,
|
||||
N as k,
|
||||
U as l,
|
||||
re as m,
|
||||
V as n,
|
||||
q as p,
|
||||
D as s,
|
||||
A as u
|
||||
};
|
||||
671
dist/core.js
vendored
671
dist/core.js
vendored
File diff suppressed because one or more lines are too long
35
dist/layout.d.ts
vendored
Normal file
35
dist/layout.d.ts
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
export type SidebarItem = {
|
||||
title: string;
|
||||
mobileTitle?: string;
|
||||
icon: any;
|
||||
path: string;
|
||||
aliases?: string[];
|
||||
perm?: string | string[];
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type SidebarProps = {
|
||||
items?: SidebarItem[];
|
||||
currentPath?: string;
|
||||
linkComponent?: any;
|
||||
onNavigate?: (item: SidebarItem) => void;
|
||||
logoutTitle?: string;
|
||||
logoutIcon?: any;
|
||||
primaryMobileCount?: number;
|
||||
moreTitle?: string;
|
||||
moreDrawerTitle?: string;
|
||||
desktopProps?: Record<string, any>;
|
||||
mobileProps?: Record<string, any>;
|
||||
};
|
||||
|
||||
export function Sidebar(props: SidebarProps): any;
|
||||
export function isSidebarPathActive(
|
||||
currentPath: string | undefined,
|
||||
item: SidebarItem,
|
||||
): boolean;
|
||||
export function getActiveSidebarItem(
|
||||
items: SidebarItem[],
|
||||
currentPath: string | undefined,
|
||||
): SidebarItem | undefined;
|
||||
|
||||
export const Header: any;
|
||||
445
dist/layout.js
vendored
445
dist/layout.js
vendored
File diff suppressed because one or more lines are too long
10
package.json
10
package.json
@ -15,7 +15,10 @@
|
||||
"import": "./dist/utils.js"
|
||||
},
|
||||
"./pagination": "./dist/pagination.js",
|
||||
"./layout": "./dist/layout.js",
|
||||
"./layout": {
|
||||
"types": "./dist/layout.d.ts",
|
||||
"import": "./dist/layout.js"
|
||||
},
|
||||
"./core": {
|
||||
"types": "./dist/core.d.ts",
|
||||
"import": "./dist/core.js"
|
||||
@ -36,11 +39,12 @@
|
||||
"build": "yarn build:lib && yarn build:remote && yarn copy-types",
|
||||
"build:lib": "vite build",
|
||||
"build:remote": "vite build --mode remote",
|
||||
"copy-types": "yarn copy-index-types && yarn copy-utils-types && yarn copy-core-types && yarn copy-remote-ui-types",
|
||||
"copy-types": "yarn copy-index-types && yarn copy-utils-types && yarn copy-core-types && yarn copy-layout-types && yarn copy-remote-ui-types",
|
||||
"copy-index-types": "cp src/index.d.ts dist/index.d.ts",
|
||||
"copy-utils-types": "cp src/utils/index.d.ts dist/utils.d.ts",
|
||||
"copy-core-types": "cp src/core/index.d.ts dist/core.d.ts",
|
||||
"copy-remote-ui-types": "cp src/remote/ui/index.d.ts dist/remote-ui.d.ts"
|
||||
"copy-remote-ui-types": "cp src/remote/ui/index.d.ts dist/remote-ui.d.ts",
|
||||
"copy-layout-types": "cp src/layout/index.d.ts dist/layout.d.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@chakra-ui/react": "^2.0.0",
|
||||
|
||||
441
src/layout/Sidebar.jsx
Normal file
441
src/layout/Sidebar.jsx
Normal file
@ -0,0 +1,441 @@
|
||||
"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 { forwardRef, useEffect, useRef } from "react";
|
||||
import { FiMoreHorizontal } from "react-icons/fi";
|
||||
import { IoExit } from "react-icons/io5";
|
||||
import { logoutKeycloak } from "../core/keycloak.jsx";
|
||||
import { usePermission } from "../core/permission.jsx";
|
||||
|
||||
export const isSidebarPathActive = (currentPath, item) => {
|
||||
const normalizedPath = currentPath?.split("?")[0] || "";
|
||||
const candidatePaths = [item?.path, ...(item?.aliases || [])].filter(Boolean);
|
||||
|
||||
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 content = (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
icon={<Icon as={item.icon} />}
|
||||
fontSize="xl"
|
||||
size={{ base: "sm", lg: "lg" }}
|
||||
isDisabled={isDisabled}
|
||||
isActive={isActive}
|
||||
aria-label={item.title}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={isDisabled ? undefined : onNavigate}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={isDisabled ? "عدم دسترسی" : item.title}
|
||||
placement="left-start"
|
||||
hasArrow
|
||||
rounded="md"
|
||||
py={2}
|
||||
px={4}
|
||||
fontWeight="bold"
|
||||
closeDelay={150}
|
||||
>
|
||||
{isDisabled ? (
|
||||
<Box as="span">{content}</Box>
|
||||
) : (
|
||||
<SidebarLink
|
||||
linkComponent={linkComponent}
|
||||
href={item.path}
|
||||
_hover={{ textDecoration: "none" }}
|
||||
>
|
||||
{content}
|
||||
</SidebarLink>
|
||||
)}
|
||||
</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) {
|
||||
return (
|
||||
<Box
|
||||
flex="1"
|
||||
minW={0}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
aria-disabled="true"
|
||||
>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
export const Sidebar = ({
|
||||
items = [],
|
||||
currentPath,
|
||||
linkComponent,
|
||||
onNavigate,
|
||||
logoutTitle = "خروج",
|
||||
logoutIcon = IoExit,
|
||||
primaryMobileCount = 4,
|
||||
moreTitle = "بیشتر",
|
||||
moreDrawerTitle = "سایر بخشهای سامانه",
|
||||
desktopProps,
|
||||
mobileProps,
|
||||
}) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const moreButtonRef = useRef();
|
||||
const { cannot, can, isLoading } = usePermission();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
if (!can("panel_access")) {
|
||||
logoutKeycloak();
|
||||
}
|
||||
}, [can, isLoading]);
|
||||
|
||||
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 = true;
|
||||
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<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) {
|
||||
return <Box key={item.path}>{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>
|
||||
|
||||
<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">خروج از سامانه</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
35
src/layout/index.d.ts
vendored
Normal file
35
src/layout/index.d.ts
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
export type SidebarItem = {
|
||||
title: string;
|
||||
mobileTitle?: string;
|
||||
icon: any;
|
||||
path: string;
|
||||
aliases?: string[];
|
||||
perm?: string | string[];
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type SidebarProps = {
|
||||
items?: SidebarItem[];
|
||||
currentPath?: string;
|
||||
linkComponent?: any;
|
||||
onNavigate?: (item: SidebarItem) => void;
|
||||
logoutTitle?: string;
|
||||
logoutIcon?: any;
|
||||
primaryMobileCount?: number;
|
||||
moreTitle?: string;
|
||||
moreDrawerTitle?: string;
|
||||
desktopProps?: Record<string, any>;
|
||||
mobileProps?: Record<string, any>;
|
||||
};
|
||||
|
||||
export function Sidebar(props: SidebarProps): any;
|
||||
export function isSidebarPathActive(
|
||||
currentPath: string | undefined,
|
||||
item: SidebarItem,
|
||||
): boolean;
|
||||
export function getActiveSidebarItem(
|
||||
items: SidebarItem[],
|
||||
currentPath: string | undefined,
|
||||
): SidebarItem | undefined;
|
||||
|
||||
export const Header: any;
|
||||
@ -1 +1,6 @@
|
||||
export { Header } from "./Header.jsx";
|
||||
export {
|
||||
Sidebar,
|
||||
isSidebarPathActive,
|
||||
getActiveSidebarItem,
|
||||
} from "./Sidebar.jsx";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user