Files
medreport_mrb2b/packages/ui/src/makerkit/sidebar.tsx
2025-06-08 16:18:30 +03:00

358 lines
8.7 KiB
TypeScript

'use client';
import { useContext, useId, useRef, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cva } from 'class-variance-authority';
import { ChevronDown } from 'lucide-react';
import { z } from 'zod';
import { cn, isRouteActive } from '../lib/utils';
import { Button } from '../shadcn/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../shadcn/tooltip';
import { SidebarContext } from './context/sidebar.context';
import { If } from './if';
import type { NavigationConfigSchema } from './navigation-config.schema';
import { Trans } from './trans';
export type SidebarConfig = z.infer<typeof NavigationConfigSchema>;
export { SidebarContext };
/**
* @deprecated
* This component is deprecated and will be removed in a future version.
* Please use the Shadcn Sidebar component instead.
*/
export function Sidebar(props: {
collapsed?: boolean;
expandOnHover?: boolean;
className?: string;
children:
| React.ReactNode
| ((props: {
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
}) => React.ReactNode);
}) {
const [collapsed, setCollapsed] = useState(props.collapsed ?? false);
const isExpandedRef = useRef<boolean>(false);
const expandOnHover =
props.expandOnHover ??
process.env.NEXT_PUBLIC_EXPAND_SIDEBAR_ON_HOVER === 'true';
const sidebarSizeClassName = getSidebarSizeClassName(
collapsed,
isExpandedRef.current,
);
const className = getClassNameBuilder(
cn(props.className ?? '', sidebarSizeClassName, {}),
)();
const containerClassName = cn(sidebarSizeClassName, 'bg-inherit', {
'max-w-[4rem]': expandOnHover && isExpandedRef.current,
});
const ctx = { collapsed, setCollapsed };
const onMouseEnter =
props.collapsed && expandOnHover
? () => {
setCollapsed(false);
isExpandedRef.current = true;
}
: undefined;
const onMouseLeave =
props.collapsed && expandOnHover
? () => {
if (!isRadixPopupOpen()) {
setCollapsed(true);
isExpandedRef.current = false;
} else {
onRadixPopupClose(() => {
setCollapsed(true);
isExpandedRef.current = false;
});
}
}
: undefined;
return (
<SidebarContext.Provider value={ctx}>
<div
className={containerClassName}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<div aria-expanded={!collapsed} className={className}>
{typeof props.children === 'function'
? props.children(ctx)
: props.children}
</div>
</div>
</SidebarContext.Provider>
);
}
export function SidebarContent({
children,
className: customClassName,
}: React.PropsWithChildren<{
className?: string;
}>) {
const { collapsed } = useContext(SidebarContext);
const className = cn(
'flex w-full flex-col space-y-1.5 py-1',
customClassName,
{
'px-4': !collapsed,
'px-2': collapsed,
},
);
return <div className={className}>{children}</div>;
}
export function SidebarGroup({
label,
collapsed = false,
collapsible = true,
children,
}: React.PropsWithChildren<{
label: string | React.ReactNode;
collapsible?: boolean;
collapsed?: boolean;
}>) {
const { collapsed: sidebarCollapsed } = useContext(SidebarContext);
const [isGroupCollapsed, setIsGroupCollapsed] = useState(collapsed);
const id = useId();
const Title = (props: React.PropsWithChildren) => {
if (sidebarCollapsed) {
return null;
}
return (
<span className={'text-muted-foreground text-xs font-semibold uppercase'}>
{props.children}
</span>
);
};
const Wrapper = () => {
const className = cn(
'px-container group flex items-center justify-between space-x-2.5',
{
'py-2.5': !sidebarCollapsed,
},
);
if (collapsible) {
return (
<button
aria-expanded={!isGroupCollapsed}
aria-controls={id}
onClick={() => setIsGroupCollapsed(!isGroupCollapsed)}
className={className}
>
<Title>{label}</Title>
<If condition={collapsible}>
<ChevronDown
className={cn(`h-3 transition duration-300`, {
'rotate-180': !isGroupCollapsed,
})}
/>
</If>
</button>
);
}
return (
<div className={className}>
<Title>{label}</Title>
</div>
);
};
return (
<div
className={cn('flex flex-col', {
'gap-y-2 py-1': !collapsed,
})}
>
<Wrapper />
<If condition={collapsible ? !isGroupCollapsed : true}>
<div id={id} className={'flex flex-col space-y-1.5'}>
{children}
</div>
</If>
</div>
);
}
export function SidebarDivider() {
return (
<div className={'dark:border-dark-800 my-2 border-t border-gray-100'} />
);
}
export function SidebarItem({
end,
path,
children,
Icon,
}: React.PropsWithChildren<{
path: string;
Icon: React.ReactNode;
end?: boolean | ((path: string) => boolean);
}>) {
const { collapsed } = useContext(SidebarContext);
const currentPath = usePathname() ?? '';
const active = isRouteActive(path, currentPath, end ?? false);
const variant = active ? 'secondary' : 'ghost';
return (
<TooltipProvider delayDuration={0}>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<Button
asChild
className={cn(
'active:bg-secondary/60 flex w-full text-sm shadow-none',
{
'justify-start space-x-2.5': !collapsed,
'hover:bg-initial': active,
},
)}
size={'sm'}
variant={variant}
>
<Link href={path}>
{Icon}
<span
className={cn('w-auto transition-opacity duration-300', {
'w-0 opacity-0': collapsed,
})}
>
{children}
</span>
</Link>
</Button>
</TooltipTrigger>
<If condition={collapsed}>
<TooltipContent side={'right'} sideOffset={10}>
{children}
</TooltipContent>
</If>
</Tooltip>
</TooltipProvider>
);
}
function getClassNameBuilder(className: string) {
return cva([
cn(
'group/sidebar transition-width fixed box-content flex h-screen w-2/12 flex-col bg-inherit backdrop-blur-xs duration-200',
className,
),
]);
}
function getSidebarSizeClassName(collapsed: boolean, isExpanded: boolean) {
return cn(['z-50 flex w-full flex-col'], {
'dark:shadow-primary/20 lg:w-[17rem]': !collapsed,
'lg:w-[4rem]': collapsed,
shadow: isExpanded,
});
}
function getRadixPopup() {
return document.querySelector('[data-radix-popper-content-wrapper]');
}
function isRadixPopupOpen() {
return getRadixPopup() !== null;
}
function onRadixPopupClose(callback: () => void) {
const element = getRadixPopup();
if (element) {
const observer = new MutationObserver(() => {
if (!getRadixPopup()) {
callback();
observer.disconnect();
}
});
observer.observe(element.parentElement!, {
childList: true,
subtree: true,
});
}
}
export function SidebarNavigation({
config,
}: React.PropsWithChildren<{
config: SidebarConfig;
}>) {
return (
<>
{config.routes.map((item, index) => {
if ('divider' in item) {
return <SidebarDivider key={index} />;
}
if ('children' in item) {
return (
<SidebarGroup
key={item.label}
label={<Trans i18nKey={item.label} defaults={item.label} />}
collapsible={item.collapsible}
collapsed={item.collapsed}
>
{item.children.map((child) => {
if ('collapsible' in child && child.collapsible) {
throw new Error(
'Collapsible groups are not supported in the old Sidebar. Please migrate to the new Sidebar.',
);
}
if ('path' in child) {
return (
<SidebarItem
key={child.path}
end={child.end}
path={child.path}
Icon={child.Icon}
>
<Trans i18nKey={child.label} defaults={child.label} />
</SidebarItem>
);
}
})}
</SidebarGroup>
);
}
})}
</>
);
}