@@ -22,7 +22,7 @@ import {
2222 Trash2Icon ,
2323 UserRoundIcon ,
2424} from "lucide-react" ;
25- import { useEffect , useState } from "react" ;
25+ import { type ReactNode , useEffect , useState } from "react" ;
2626import toast from "react-hot-toast" ;
2727import { Link , matchPath , useLocation , useNavigate , useSearchParams } from "react-router-dom" ;
2828import ConfirmDialog from "@/components/ConfirmDialog" ;
@@ -62,7 +62,7 @@ import { User_Role, UserNotification_Status } from "@/types/proto/api/v1/user_se
6262import { useTranslate } from "@/utils/i18n" ;
6363import MemosLogo from "../MemosLogo" ;
6464import { getSidebarRouteKind } from "./routes" ;
65- import SidebarRow , { SIDEBAR_ROW_CLASSES , SIDEBAR_ROW_ICON_CLASSES , sidebarRowStateClasses } from "./SidebarRow" ;
65+ import SidebarRow , { SIDEBAR_ROW_CLASSES , SIDEBAR_ROW_FOCUS_CLASSES , SIDEBAR_ROW_ICON_CLASSES , sidebarRowStateClasses } from "./SidebarRow" ;
6666import SidebarSection , {
6767 SIDEBAR_SECTION_ACTION_BUTTON_CLASSES ,
6868 SIDEBAR_SECTION_ACTION_ICON_CLASSES ,
@@ -391,8 +391,41 @@ interface GlobalNavItem {
391391 icon : LucideIcon ;
392392 active : boolean ;
393393 count ?: number ;
394+ alwaysExpanded ?: boolean ;
394395}
395396
397+ /**
398+ * Pills keep a constant px so the icon sits exactly where it does in the collapsed 30px
399+ * square; all width change comes from the label column, which animates 0fr -> 1fr. That
400+ * keeps the expand/collapse a single smooth motion with no padding jump.
401+ */
402+ const navPillClasses = ( active : boolean ) =>
403+ cn (
404+ "relative flex h-[30px] min-w-0 items-center rounded-md px-[7px] transition-colors" ,
405+ SIDEBAR_ROW_FOCUS_CLASSES ,
406+ sidebarRowStateClasses ( active ) ,
407+ ) ;
408+
409+ const NavPillLabel = ( { expanded, label, children } : { expanded : boolean ; label : ReactNode ; children ?: ReactNode } ) => (
410+ < span
411+ aria-hidden = { ! expanded || undefined }
412+ className = { cn (
413+ // The icon-label gap is padding on this element because overflow-hidden clips
414+ // content but never padding — it must animate to zero with the track, or collapsed
415+ // pills keep an 8px tail.
416+ "grid min-w-0 transition-[grid-template-columns,padding] duration-200 ease-out motion-reduce:transition-none" ,
417+ expanded ? "grid-cols-[1fr] pl-2" : "grid-cols-[0fr] pl-0" ,
418+ ) }
419+ >
420+ { /* Content is shrink-0 so the collapsing track clips it in place — a plain
421+ left-to-right reveal instead of re-truncating the label on every frame. */ }
422+ < span className = "flex min-w-0 items-center gap-2 overflow-hidden" >
423+ < span className = "max-w-[5.5rem] shrink-0 truncate text-[12px]" > { label } </ span >
424+ { children }
425+ </ span >
426+ </ span >
427+ ) ;
428+
396429const GlobalNavigation = ( ) => {
397430 const t = useTranslate ( ) ;
398431 const location = useLocation ( ) ;
@@ -458,27 +491,11 @@ const GlobalNavigation = () => {
458491 path : ROUTES . EXPLORE ,
459492 icon : EarthIcon ,
460493 active : routeKind === "explore" || routeKind === "profile" || routeKind === "memo" ,
494+ alwaysExpanded : true ,
461495 } ,
462496 { id : "about" , label : t ( "common.about" ) , path : ROUTES . ABOUT , icon : InfoIcon , active : location . pathname === ROUTES . ABOUT } ,
463497 ] ;
464498
465- const scopeTrigger = (
466- < DropdownMenuTrigger
467- render = {
468- < button
469- type = "button"
470- aria-label = { activeScopeItem . label }
471- aria-current = "page"
472- className = "flex h-[30px] min-w-0 items-center gap-2 rounded-md bg-sidebar-accent px-2 font-medium text-sidebar-accent-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
473- />
474- }
475- >
476- < ActiveScopeIcon className = "size-4 shrink-0" strokeWidth = { 1.8 } />
477- < span className = "max-w-[5.5rem] truncate text-[12px]" > { activeScopeItem . label } </ span >
478- < ChevronDownIcon className = "size-3 shrink-0 opacity-55" strokeWidth = { 1.8 } />
479- </ DropdownMenuTrigger >
480- ) ;
481-
482499 const scopeMenuContent = (
483500 < DropdownMenuContent align = "start" sideOffset = { 4 } className = "flex w-36 flex-col gap-0.5" >
484501 { scopeItems . map ( ( item ) => {
@@ -505,68 +522,74 @@ const GlobalNavigation = () => {
505522 < TooltipProvider >
506523 < nav className = { cn ( "flex h-9 items-center gap-1" , SIDEBAR_HORIZONTAL_PADDING ) } aria-label = "Primary" >
507524 { currentUser && (
508- < >
509- { scopeRouteActive ? (
510- < DropdownMenu >
511- { scopeTrigger }
512- { scopeMenuContent }
513- </ DropdownMenu >
514- ) : (
515- < Tooltip >
516- < TooltipTrigger
525+ < DropdownMenu
526+ onOpenChange = { ( open , eventDetails ) => {
527+ // Off the scope routes the pill is a plain navigation button: veto the
528+ // menu and navigate to the scope instead.
529+ if ( open && ! scopeRouteActive ) {
530+ eventDetails . cancel ( ) ;
531+ navigateToScope ( resolvedScope ) ;
532+ }
533+ } }
534+ >
535+ < Tooltip disabled = { scopeRouteActive } >
536+ { /* The tooltip anchors to a wrapper span rather than the button: a disabled
537+ tooltip stamps data-trigger-disabled on its trigger element, and Base UI's
538+ shared floating logic would read that as the MENU trigger being disabled. */ }
539+ < TooltipTrigger render = { < span className = "flex min-w-0" /> } >
540+ < DropdownMenuTrigger
517541 render = {
518542 < button
519543 type = "button"
520544 aria-label = { activeScopeItem . label }
521- className = "flex size-[30px] items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-sidebar-accent/65 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
522- onClick = { ( ) => navigateToScope ( resolvedScope ) }
545+ aria-current = { scopeRouteActive ? "page" : undefined }
546+ className = { cn ( "group/scope" , navPillClasses ( scopeRouteActive ) ) }
523547 />
524548 }
525549 >
526- < ActiveScopeIcon className = "size-4" strokeWidth = { 1.8 } />
527- </ TooltipTrigger >
528- < TooltipContent side = "bottom" > { activeScopeItem . label } </ TooltipContent >
529- </ Tooltip >
530- ) }
531- </ >
550+ < ActiveScopeIcon className = "size-4 shrink-0" strokeWidth = { 1.8 } />
551+ < NavPillLabel expanded = { scopeRouteActive } label = { activeScopeItem . label } >
552+ < ChevronDownIcon
553+ className = "-mr-0.5 size-3 shrink-0 opacity-55 transition-transform duration-200 ease-out group-data-[popup-open]/scope:rotate-180 motion-reduce:transition-none"
554+ strokeWidth = { 1.8 }
555+ />
556+ </ NavPillLabel >
557+ </ DropdownMenuTrigger >
558+ </ TooltipTrigger >
559+ < TooltipContent side = "bottom" > { activeScopeItem . label } </ TooltipContent >
560+ </ Tooltip >
561+ { scopeMenuContent }
562+ </ DropdownMenu >
532563 ) }
533564 { items . map ( ( item ) => {
534565 const Icon = item . icon ;
535- const alwaysShowLabel = ! currentUser && item . id === "explore" ;
536- const itemClassName = cn (
537- "relative flex min-w-0 items-center justify-center gap-2 rounded-md text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" ,
538- item . active || alwaysShowLabel ? "h-[30px] px-2" : "size-[30px] px-0" ,
539- item . active
540- ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
541- : "hover:bg-sidebar-accent/65 hover:text-foreground" ,
542- ) ;
543- const itemContent = (
544- < >
545- < Icon className = "size-4 shrink-0" strokeWidth = { 1.8 } />
546- { ( item . active || alwaysShowLabel ) && < span className = "max-w-[5.5rem] truncate text-[12px]" > { item . label } </ span > }
547- { ! ! item . count && item . count > 0 && (
548- < span className = "absolute -right-0.5 -top-0.5 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-semibold leading-4 text-primary-foreground" >
549- { item . count > 99 ? "99+" : item . count }
550- </ span >
551- ) }
552- </ >
553- ) ;
554- const content = (
555- < Link
556- key = { item . id }
557- to = { item . path }
558- onClick = { ( ) => setMobileOpen ( false ) }
559- aria-label = { item . label }
560- aria-current = { item . active ? "page" : undefined }
561- className = { itemClassName }
562- >
563- { itemContent }
564- </ Link >
565- ) ;
566- if ( item . active ) return content ;
566+ const expanded = item . active || ! ! item . alwaysExpanded ;
567567 return (
568- < Tooltip key = { item . id } >
569- < TooltipTrigger render = { < span /> } > { content } </ TooltipTrigger >
568+ < Tooltip key = { item . id } disabled = { expanded } >
569+ < TooltipTrigger
570+ render = {
571+ < Link
572+ to = { item . path }
573+ onClick = { ( ) => setMobileOpen ( false ) }
574+ aria-label = { item . label }
575+ aria-current = { item . active ? "page" : undefined }
576+ className = { navPillClasses ( item . active ) }
577+ />
578+ }
579+ >
580+ < Icon className = "size-4 shrink-0" strokeWidth = { 1.8 } />
581+ < NavPillLabel expanded = { expanded } label = { item . label } />
582+ { item . count != null && (
583+ < span
584+ className = { cn (
585+ "absolute -right-0.5 -top-0.5 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-semibold leading-4 text-primary-foreground transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none" ,
586+ item . count > 0 ? "scale-100 opacity-100" : "scale-50 opacity-0" ,
587+ ) }
588+ >
589+ { item . count > 0 && ( item . count > 99 ? "99+" : item . count ) }
590+ </ span >
591+ ) }
592+ </ TooltipTrigger >
570593 < TooltipContent side = "bottom" > { item . label } </ TooltipContent >
571594 </ Tooltip >
572595 ) ;
0 commit comments