@@ -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