358 lines
8.7 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
})}
|
|
</>
|
|
);
|
|
}
|