From 488b71a00ae6107a96fd58659b44767e09041951 Mon Sep 17 00:00:00 2001 From: amblued Date: Wed, 19 Aug 2026 19:35:37 +0800 Subject: [PATCH] chore(sidebar): animate global navigator pill transitions Give the home/attachments/inbox navigator one shared pill anatomy so the active label expands and collapses as a single smooth motion: constant px-[7px] padding with the label in a grid column animating 0fr to 1fr, tooltips kept mounted via Base UI's disabled prop, and the scope control as one always-mounted uncontrolled DropdownMenu that vetoes the menu and navigates when off the scope routes. The unread badge scales in and out, and the scope chevron rotates while its menu is open. The tooltip anchors to a wrapper span because a disabled tooltip stamps data-trigger-disabled on its trigger element, which Base UI's shared floating logic would read as the menu trigger being disabled. --- web/src/components/AppSidebar/AppSidebar.tsx | 163 +++++++++++-------- web/tests/app-sidebar-logo.test.tsx | 29 ++-- 2 files changed, 112 insertions(+), 80 deletions(-) diff --git a/web/src/components/AppSidebar/AppSidebar.tsx b/web/src/components/AppSidebar/AppSidebar.tsx index 574c9750..446cd5d9 100644 --- a/web/src/components/AppSidebar/AppSidebar.tsx +++ b/web/src/components/AppSidebar/AppSidebar.tsx @@ -22,7 +22,7 @@ import { Trash2Icon, UserRoundIcon, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import toast from "react-hot-toast"; import { Link, matchPath, useLocation, useNavigate, useSearchParams } from "react-router-dom"; import ConfirmDialog from "@/components/ConfirmDialog"; @@ -62,7 +62,7 @@ import { User_Role, UserNotification_Status } from "@/types/proto/api/v1/user_se import { useTranslate } from "@/utils/i18n"; import MemosLogo from "../MemosLogo"; import { getSidebarRouteKind } from "./routes"; -import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_ICON_CLASSES, sidebarRowStateClasses } from "./SidebarRow"; +import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_FOCUS_CLASSES, SIDEBAR_ROW_ICON_CLASSES, sidebarRowStateClasses } from "./SidebarRow"; import SidebarSection, { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES, @@ -391,8 +391,41 @@ interface GlobalNavItem { icon: LucideIcon; active: boolean; count?: number; + alwaysExpanded?: boolean; } +/** + * Pills keep a constant px so the icon sits exactly where it does in the collapsed 30px + * square; all width change comes from the label column, which animates 0fr -> 1fr. That + * keeps the expand/collapse a single smooth motion with no padding jump. + */ +const navPillClasses = (active: boolean) => + cn( + "relative flex h-[30px] min-w-0 items-center rounded-md px-[7px] transition-colors", + SIDEBAR_ROW_FOCUS_CLASSES, + sidebarRowStateClasses(active), + ); + +const NavPillLabel = ({ expanded, label, children }: { expanded: boolean; label: ReactNode; children?: ReactNode }) => ( + + {/* Content is shrink-0 so the collapsing track clips it in place — a plain + left-to-right reveal instead of re-truncating the label on every frame. */} + + {label} + {children} + + +); + const GlobalNavigation = () => { const t = useTranslate(); const location = useLocation(); @@ -458,27 +491,11 @@ const GlobalNavigation = () => { path: ROUTES.EXPLORE, icon: EarthIcon, active: routeKind === "explore" || routeKind === "profile" || routeKind === "memo", + alwaysExpanded: true, }, { id: "about", label: t("common.about"), path: ROUTES.ABOUT, icon: InfoIcon, active: location.pathname === ROUTES.ABOUT }, ]; - const scopeTrigger = ( - - } - > - - {activeScopeItem.label} - - - ); - const scopeMenuContent = ( {scopeItems.map((item) => { @@ -505,68 +522,74 @@ const GlobalNavigation = () => {