Skip to content

Commit 5ac7532

Browse files
committed
feat(webapp): animate the free plan banner off-screen as the side menu collapses
The banner rides down with the bottom section and fades out as the menu collapses, then rises back and fades in when it reopens. Its icon no longer shrinks and the label truncates when space is tight.
1 parent d2b6250 commit 5ac7532

2 files changed

Lines changed: 95 additions & 24 deletions

File tree

apps/webapp/app/components/billing/FreePlanUsage.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb
2727
)}
2828
>
2929
<div className="flex items-center justify-between gap-2">
30-
<div className="flex items-center gap-1">
31-
<ArrowUpCircleIcon className="h-5 w-5 text-text-dimmed" />
32-
<span className="text-2sm text-text-bright">Free Plan</span>
30+
<div className="flex min-w-0 items-center gap-1">
31+
<ArrowUpCircleIcon className="h-5 w-5 shrink-0 text-text-dimmed" />
32+
<span className="truncate text-2sm text-text-bright">Free Plan</span>
3333
</div>
34-
<Link to={to} className="text-2sm text-text-link focus-custom">
34+
<Link to={to} className="shrink-0 text-2sm text-text-link focus-custom">
3535
Upgrade
3636
</Link>
3737
</div>

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 91 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,10 @@ export function SideMenu({
301301
// True during a click/⌘B/release-snap animation. With isDragging, marks any in-flight transition
302302
// (the gutter is only reserved once settled — see `showReservedGutter`).
303303
const [isAnimating, setIsAnimating] = useState(false);
304+
// Direction of an in-flight drag, for the Free-plan banner slide. A drag that started expanded is a
305+
// close (the banner tracks it down); one that started collapsed is an open (the banner stays hidden
306+
// until fully open, then rises). Only meaningful while `isDragging`.
307+
const [dragStartedCollapsed, setDragStartedCollapsed] = useState(false);
304308

305309
// --- Resize state (see the module constants above) ---
306310
const rootRef = useRef<HTMLDivElement>(null);
@@ -549,6 +553,7 @@ export function SideMenu({
549553
if (Math.abs(dx) < DRAG_CLICK_THRESHOLD) return;
550554
drag.didDrag = true;
551555
setIsDragging(true);
556+
setDragStartedCollapsed(drag.startedCollapsed);
552557
document.body.style.userSelect = "none";
553558
document.body.style.cursor = "col-resize";
554559
}
@@ -645,6 +650,23 @@ export function SideMenu({
645650
// gutter snapping away (see SIDE_MENU_SCROLL_PAD_RIGHT).
646651
const showReservedGutter = !isCollapsed && !isDragging && !isAnimating;
647652

653+
// Free-plan banner slide (see FreePlanBanner). "tracking" = a close in progress, so the banner
654+
// follows --sm-collapse down and is gone by the halfway point; "hidden" = collapsed or an open in
655+
// progress (stays off-screen); "shown" = settled open, so it rises back up. Drag and click/⌘B share
656+
// this: a click drives --sm-collapse through the same animation, with isAnimating standing in for
657+
// isDragging and isCollapsed giving the direction.
658+
const bannerPhase: "shown" | "tracking" | "hidden" = isDragging
659+
? dragStartedCollapsed
660+
? "hidden"
661+
: "tracking"
662+
: isAnimating
663+
? isCollapsed
664+
? "tracking"
665+
: "hidden"
666+
: isCollapsed
667+
? "hidden"
668+
: "shown";
669+
648670
return (
649671
<div
650672
ref={rootRef}
@@ -1066,12 +1088,11 @@ export function SideMenu({
10661088
onToggleCollapsed={handleToggleCollapsed}
10671089
/>
10681090
{isFreeUser && (
1069-
<CollapsibleHeight isCollapsed={isCollapsed}>
1070-
<FreePlanUsage
1071-
to={v3BillingPath(organization)}
1072-
percentage={currentPlan.v3Usage.usagePercentage}
1073-
/>
1074-
</CollapsibleHeight>
1091+
<FreePlanBanner
1092+
to={v3BillingPath(organization)}
1093+
percentage={currentPlan.v3Usage.usagePercentage}
1094+
phase={bannerPhase}
1095+
/>
10751096
)}
10761097
</motion.div>
10771098
</div>
@@ -1761,25 +1782,75 @@ function CollapsibleElement({
17611782
);
17621783
}
17631784

1764-
/** Helper component that fades out and collapses height completely */
1765-
function CollapsibleHeight({
1766-
isCollapsed,
1767-
children,
1768-
className,
1785+
/**
1786+
* The Free-plan banner at the foot of the menu. On close it doesn't collapse or slide on its own: its
1787+
* reserved height collapses (tracking --sm-collapse, gone by the halfway point) and, because the bottom
1788+
* section is pinned to the bottom, that pushes the whole section (Help & Feedback + this banner) down so
1789+
* the full-height banner slides off the bottom edge. On open it lags, waiting for the settled "shown"
1790+
* phase, then rises back up via translateY. Height is measured so the reclaimed space matches the banner.
1791+
*/
1792+
function FreePlanBanner({
1793+
to,
1794+
percentage,
1795+
phase,
17691796
}: {
1770-
isCollapsed: boolean;
1771-
children: ReactNode;
1772-
className?: string;
1797+
to: string;
1798+
percentage: number;
1799+
phase: "shown" | "tracking" | "hidden";
17731800
}) {
1801+
const contentRef = useRef<HTMLDivElement>(null);
1802+
const [height, setHeight] = useState(0);
1803+
1804+
useEffect(() => {
1805+
const el = contentRef.current;
1806+
if (!el) return;
1807+
const measure = () => setHeight(el.offsetHeight);
1808+
measure();
1809+
const ro = new ResizeObserver(measure);
1810+
ro.observe(el);
1811+
return () => ro.disconnect();
1812+
}, []);
1813+
1814+
// Close progress, doubled + clamped so the banner is fully gone by the time the menu is halfway shut.
1815+
const closeProgress = "min(var(--sm-collapse, 0) * 2, 1)";
1816+
// Slide a little past its own height to clear the section padding + the viewport edge.
1817+
const offset = height + 24;
1818+
1819+
const maxHeight =
1820+
phase === "shown"
1821+
? height
1822+
? `${height}px`
1823+
: "none"
1824+
: phase === "hidden"
1825+
? "0px"
1826+
: `calc((1 - ${closeProgress}) * ${height}px)`;
1827+
// On close the banner no longer slides itself: its reserved height collapses (maxHeight) and, because
1828+
// the bottom section is pinned to the bottom, that drops Help & Feedback down while the full-height
1829+
// banner overflows off the bottom edge. Only the pop-up-from-hidden rise uses translateY, so "hidden"
1830+
// parks it below the edge; "shown" and "tracking" both sit at 0.
1831+
const translateY = phase === "hidden" ? `${offset}px` : "0px";
1832+
// Fade out as it slides off (tracking --sm-collapse) and fade back in on the settled-open rise.
1833+
const opacity = phase === "shown" ? 1 : phase === "hidden" ? 0 : `calc(1 - ${closeProgress})`;
1834+
17741835
return (
17751836
<div
1776-
className={cn(
1777-
"grid transition-all duration-200 ease-in-out",
1778-
isCollapsed ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100",
1779-
className
1780-
)}
1837+
style={{
1838+
maxHeight,
1839+
// The full-height banner overflows off the bottom as maxHeight collapses; it isn't clipped.
1840+
overflow: "visible",
1841+
transform: `translateY(${translateY})`,
1842+
opacity,
1843+
// Only the settled-open rise animates; while tracking a drag/close we follow --sm-collapse
1844+
// frame-by-frame (a transition would lag the drag).
1845+
transition:
1846+
phase === "shown"
1847+
? "max-height 300ms ease, transform 300ms ease, opacity 300ms ease"
1848+
: "none",
1849+
}}
17811850
>
1782-
<div className="overflow-hidden">{children}</div>
1851+
<div ref={contentRef}>
1852+
<FreePlanUsage to={to} percentage={percentage} />
1853+
</div>
17831854
</div>
17841855
);
17851856
}

0 commit comments

Comments
 (0)