Files
James Ritchie 49de105862 Feat(webapp): collapsible side menu (#2939)
- Better separation between main feature pages and manage pages
- Toggle via keyboard shortcut Cmd/Ctrl + B or click collapse button
- Collapsed state persisted to database (survives page refresh)
- All menu items show tooltips when collapsed
- Smooth animations using Framer Motion and CSS transitions
- Impersonation-safe (preferences not modified when impersonating)


https://github.com/user-attachments/assets/35827922-b80a-418e-8341-eb22c9bb5ed4


<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2939">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-27 17:13:21 +00:00

70 lines
2.4 KiB
TypeScript

import { useNavigation } from "@remix-run/react";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
export function SideMenuHeader({
title,
children,
isCollapsed = false,
collapsedTitle,
}: {
title: string;
children?: React.ReactNode;
isCollapsed?: boolean;
/** When provided, this text stays visible when collapsed and the rest fades out */
collapsedTitle?: string;
}) {
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
const navigation = useNavigation();
useEffect(() => {
setHeaderMenuOpen(false);
}, [navigation.location?.pathname]);
// If collapsedTitle is provided and title starts with it, split the title
const hasCollapsedTitle = collapsedTitle && title.startsWith(collapsedTitle);
const visiblePart = hasCollapsedTitle ? collapsedTitle : title;
const fadingPart = hasCollapsedTitle ? title.slice(collapsedTitle.length) : "";
return (
<motion.div
className="group flex h-4 items-center justify-between overflow-hidden pl-1.5"
initial={false}
animate={{
opacity: hasCollapsedTitle ? 1 : isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
<h2 className="text-xs whitespace-nowrap">
{visiblePart}
{fadingPart && (
<motion.span
initial={false}
animate={{
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
{fadingPart}
</motion.span>
)}
</h2>
{children !== undefined ? (
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
<PopoverCustomTrigger className="p-1">
<EllipsisHorizontalIcon className="h-4 w-4 text-charcoal-500 transition group-hover:text-text-bright" />
</PopoverCustomTrigger>
<PopoverContent
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
align="start"
>
<div className="flex flex-col gap-1 p-1">{children}</div>
</PopoverContent>
</Popover>
) : null}
</motion.div>
);
}