B2B-88: add starter kit structure and elements
This commit is contained in:
357
packages/ui/src/makerkit/sidebar.tsx
Normal file
357
packages/ui/src/makerkit/sidebar.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user