Sidebar: make it possible to set the sidebar as collapsed (#72)

* Sidebar: make it possible to set the sidebar as collapsed
This commit is contained in:
Giancarlo Buomprisco
2024-10-14 11:31:18 +02:00
committed by GitHub
parent d137df2675
commit b2c27eb25b
19 changed files with 221 additions and 100 deletions

View File

@@ -7,6 +7,11 @@ const RouteMatchingEnd = z
export const NavigationConfigSchema = z.object({
style: z.enum(['custom', 'sidebar', 'header']).default('sidebar'),
sidebarCollapsed: z
.enum(['false', 'true'])
.default('false')
.optional()
.transform((value) => value === `true`),
routes: z.array(
z.union([
z.object({

View File

@@ -30,14 +30,14 @@ function PageWithSidebar(props: PageProps) {
return (
<div
className={cn('flex bg-gray-50/50 dark:bg-background', props.className)}
className={cn('flex bg-gray-50/95 dark:bg-background/85', props.className)}
>
{Navigation}
<div
className={
props.contentContainerClassName ??
'mx-auto flex h-screen w-full flex-col overflow-y-auto px-4 lg:px-0'
'mx-auto flex h-screen w-full flex-col overflow-y-auto px-4 lg:px-0 bg-inherit'
}
>
{MobileNavigation}
@@ -115,7 +115,7 @@ export function PageBody(
}
export function PageNavigation(props: React.PropsWithChildren) {
return <div className={'hidden flex-1 lg:flex'}>{props.children}</div>;
return <div className={'hidden flex-1 lg:flex bg-inherit'}>{props.children}</div>;
}
export function PageDescription(props: React.PropsWithChildren) {

View File

@@ -1,6 +1,6 @@
'use client';
import { useContext, useId, useState } from 'react';
import { useContext, useId, useRef, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
@@ -24,8 +24,11 @@ import { Trans } from './trans';
export type SidebarConfig = z.infer<typeof NavigationConfigSchema>;
export { SidebarContext };
export function Sidebar(props: {
collapsed?: boolean;
expandOnHover?: boolean;
className?: string;
children:
| React.ReactNode
@@ -35,19 +38,62 @@ export function Sidebar(props: {
}) => React.ReactNode);
}) {
const [collapsed, setCollapsed] = useState(props.collapsed ?? false);
const isExpandedRef = useRef<boolean>(false);
const className = getClassNameBuilder(props.className ?? '')({
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={className}>
{typeof props.children === 'function'
? props.children(ctx)
: props.children}
<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>
);
@@ -55,17 +101,22 @@ export function Sidebar(props: {
export function SidebarContent({
children,
className,
className: customClassName,
}: React.PropsWithChildren<{
className?: string;
}>) {
return (
<div
className={cn('flex w-full flex-col space-y-1.5 px-4 py-1', className)}
>
{children}
</div>
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({
@@ -96,7 +147,7 @@ export function SidebarGroup({
const Wrapper = () => {
const className = cn(
'group flex items-center justify-between px-container space-x-2.5',
'px-container group flex items-center justify-between space-x-2.5',
{
'py-2.5': !sidebarCollapsed,
},
@@ -131,7 +182,11 @@ export function SidebarGroup({
};
return (
<div className={'flex flex-col space-y-1 py-1'}>
<div
className={cn('flex flex-col', {
'space-y-1 py-1': !collapsed,
})}
>
<Wrapper />
<If condition={collapsible ? !isGroupCollapsed : true}>
@@ -164,48 +219,84 @@ export function SidebarItem({
const active = isRouteActive(path, currentPath, end ?? false);
const variant = active ? 'secondary' : 'ghost';
const size = collapsed ? 'icon' : 'sm';
return (
<Button
asChild
className={cn('flex w-full text-sm shadow-none active:bg-secondary/60', {
'justify-start space-x-2.5': !collapsed,
'hover:bg-initial': active,
})}
size={size}
variant={variant}
>
<Link key={path} href={path}>
<If condition={collapsed} fallback={Icon}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{Icon}</TooltipTrigger>
<TooltipProvider delayDuration={0}>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<Button
asChild
className={cn(
'flex w-full text-sm shadow-none active:bg-secondary/60',
{
'justify-start space-x-2.5': !collapsed,
'hover:bg-initial': active,
},
)}
size={'sm'}
variant={variant}
>
<Link key={path} href={path}>
{Icon}
<span className={cn({ hidden: collapsed })}>{children}</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side={'right'} sideOffset={20}>
{children}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<If condition={collapsed}>
<TooltipContent side={'right'} sideOffset={10}>
{children}
</TooltipContent>
</If>
<span className={cn({ hidden: collapsed })}>{children}</span>
</Link>
</Button>
</Tooltip>
</TooltipProvider>
);
}
function getClassNameBuilder(className: string) {
return cva([cn('flex box-content h-screen flex-col relative', className)], {
variants: {
collapsed: {
true: `w-[6rem]`,
false: `w-2/12 lg:w-[17rem]`,
},
},
return cva([
cn(
'group/sidebar transition-width fixed box-content flex h-screen w-2/12 flex-col bg-inherit backdrop-blur-sm duration-100',
className,
),
]);
}
function getSidebarSizeClassName(collapsed: boolean, isExpanded: boolean) {
return cn(['z-10 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<{