Add collapsible Agents section in sidebar (#4605)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useSidebarStore } from "@/store/SidebarStore";
|
import { useSidebarStore } from "@/store/SidebarStore";
|
||||||
import { cn } from "@/util/utils";
|
import { cn } from "@/util/utils";
|
||||||
import { NavLink, useMatches } from "react-router-dom";
|
import { NavLink, useMatches } from "react-router-dom";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile.ts";
|
import { useIsMobile } from "@/hooks/useIsMobile.ts";
|
||||||
|
import { ChevronDownIcon } from "@radix-ui/react-icons";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -14,21 +16,108 @@ type Props = {
|
|||||||
beta?: boolean;
|
beta?: boolean;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
}>;
|
}>;
|
||||||
|
collapsible?: boolean;
|
||||||
|
initialVisibleCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function NavLinkGroup({ title, links }: Props) {
|
type LinkItem = Props["links"][number];
|
||||||
|
|
||||||
|
function NavLinkItem({
|
||||||
|
link,
|
||||||
|
isMobile,
|
||||||
|
sidebarCollapsed,
|
||||||
|
groupIsActive,
|
||||||
|
isPartiallyHidden,
|
||||||
|
}: {
|
||||||
|
link: LinkItem;
|
||||||
|
isMobile: boolean;
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
|
groupIsActive: boolean;
|
||||||
|
isPartiallyHidden?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={link.to}
|
||||||
|
target={link.newTab ? "_blank" : undefined}
|
||||||
|
rel={link.newTab ? "noopener noreferrer" : undefined}
|
||||||
|
className={({ isActive }) => {
|
||||||
|
return cn(
|
||||||
|
"block rounded-lg py-2 pl-3 text-slate-400 hover:bg-muted hover:text-primary",
|
||||||
|
{ "py-1 pl-0 text-[0.8rem]": isMobile },
|
||||||
|
{
|
||||||
|
"bg-muted": isActive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text-primary": groupIsActive,
|
||||||
|
"px-3": sidebarCollapsed,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
isPartiallyHidden
|
||||||
|
? {
|
||||||
|
maskImage:
|
||||||
|
"linear-gradient(to bottom, black 0%, transparent 100%)",
|
||||||
|
WebkitMaskImage:
|
||||||
|
"linear-gradient(to bottom, black 0%, transparent 100%)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
tabIndex={isPartiallyHidden ? -1 : undefined}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{link.icon}
|
||||||
|
{!sidebarCollapsed && link.label}
|
||||||
|
</div>
|
||||||
|
{!sidebarCollapsed && link.disabled && (
|
||||||
|
<Badge
|
||||||
|
className="rounded-[40px] px-2 py-1"
|
||||||
|
style={{
|
||||||
|
backgroundColor: groupIsActive ? "#301615" : "#1E1016",
|
||||||
|
color: groupIsActive ? "#EA580C" : "#8D3710",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{link.beta ? "Beta" : "Training"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLinkGroup({
|
||||||
|
title,
|
||||||
|
links,
|
||||||
|
collapsible = false,
|
||||||
|
initialVisibleCount = 3,
|
||||||
|
}: Props) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { collapsed } = useSidebarStore();
|
const { collapsed: sidebarCollapsed } = useSidebarStore();
|
||||||
const matches = useMatches();
|
const matches = useMatches();
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
const groupIsActive = matches.some((match) => {
|
const groupIsActive = matches.some((match) => {
|
||||||
const inputs = links.map((link) => link.to);
|
const inputs = links.map((link) => link.to);
|
||||||
return inputs.includes(match.pathname);
|
return inputs.includes(match.pathname);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shouldCollapse =
|
||||||
|
collapsible && !sidebarCollapsed && links.length > initialVisibleCount;
|
||||||
|
const alwaysVisibleLinks = shouldCollapse
|
||||||
|
? links.slice(0, initialVisibleCount)
|
||||||
|
: links;
|
||||||
|
const collapsibleLinks = shouldCollapse
|
||||||
|
? links.slice(initialVisibleCount)
|
||||||
|
: [];
|
||||||
|
const peekLink = collapsibleLinks[0];
|
||||||
|
const hiddenCount = collapsibleLinks.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("flex flex-col gap-[0.625rem]", {
|
className={cn("flex flex-col gap-[0.625rem]", {
|
||||||
"items-center": collapsed,
|
"items-center": sidebarCollapsed,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -39,54 +128,88 @@ function NavLinkGroup({ title, links }: Props) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn({
|
className={cn({
|
||||||
"text-center": collapsed,
|
"text-center": sidebarCollapsed,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-[1px]]">
|
<div className="relative space-y-[1px]">
|
||||||
{links.map((link) => {
|
{/* Always visible links */}
|
||||||
return (
|
{alwaysVisibleLinks.map((link) => (
|
||||||
<NavLink
|
<NavLinkItem
|
||||||
key={link.to}
|
key={link.to}
|
||||||
to={link.to}
|
link={link}
|
||||||
target={link.newTab ? "_blank" : undefined}
|
isMobile={isMobile}
|
||||||
rel={link.newTab ? "noopener noreferrer" : undefined}
|
sidebarCollapsed={sidebarCollapsed}
|
||||||
className={({ isActive }) => {
|
groupIsActive={groupIsActive}
|
||||||
return cn(
|
/>
|
||||||
"block rounded-lg py-2 pl-3 text-slate-400 hover:bg-muted hover:text-primary",
|
))}
|
||||||
{ "py-1 pl-0 text-[0.8rem]": isMobile },
|
|
||||||
{
|
{/* Collapsible section */}
|
||||||
"bg-muted": isActive,
|
{shouldCollapse && !sidebarCollapsed && (
|
||||||
},
|
<>
|
||||||
{
|
{/* Peek item - fades out when collapsed */}
|
||||||
"text-primary": groupIsActive,
|
<div
|
||||||
"px-3": collapsed,
|
className="transition-all duration-300 ease-in-out"
|
||||||
},
|
style={{
|
||||||
);
|
opacity: isExpanded ? 0 : 1,
|
||||||
|
height: isExpanded ? 0 : "auto",
|
||||||
|
overflow: "hidden",
|
||||||
|
pointerEvents: isExpanded ? "none" : "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between">
|
{peekLink && (
|
||||||
<div className="flex items-center gap-2">
|
<NavLinkItem
|
||||||
{link.icon}
|
link={peekLink}
|
||||||
{!collapsed && link.label}
|
isMobile={isMobile}
|
||||||
</div>
|
sidebarCollapsed={sidebarCollapsed}
|
||||||
{!collapsed && link.disabled && (
|
groupIsActive={groupIsActive}
|
||||||
<Badge
|
isPartiallyHidden={true}
|
||||||
className="rounded-[40px] px-2 py-1"
|
/>
|
||||||
style={{
|
)}
|
||||||
backgroundColor: groupIsActive ? "#301615" : "#1E1016",
|
</div>
|
||||||
color: groupIsActive ? "#EA580C" : "#8D3710",
|
|
||||||
}}
|
{/* Expandable content using CSS grid animation */}
|
||||||
>
|
<div
|
||||||
{link.beta ? "Beta" : "Training"}
|
className="grid transition-[grid-template-rows] duration-300 ease-in-out"
|
||||||
</Badge>
|
style={{
|
||||||
)}
|
gridTemplateRows: isExpanded ? "1fr" : "0fr",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
{collapsibleLinks.map((link) => (
|
||||||
|
<NavLinkItem
|
||||||
|
key={link.to}
|
||||||
|
link={link}
|
||||||
|
isMobile={isMobile}
|
||||||
|
sidebarCollapsed={sidebarCollapsed}
|
||||||
|
groupIsActive={groupIsActive}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</NavLink>
|
</div>
|
||||||
);
|
|
||||||
})}
|
{/* Expand/collapse button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-lg py-2 pl-3 text-slate-400 hover:bg-muted hover:text-primary",
|
||||||
|
{ "py-1 pl-0 text-[0.8rem]": isMobile },
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-flex transition-transform duration-300 ease-in-out"
|
||||||
|
style={{
|
||||||
|
transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-6" />
|
||||||
|
</span>
|
||||||
|
<span>{isExpanded ? "Show less" : `${hiddenCount} more`}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user