Skip to content

Commit 488b71a

Browse files
committed
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.
1 parent 38412eb commit 488b71a

2 files changed

Lines changed: 112 additions & 80 deletions

File tree

web/src/components/AppSidebar/AppSidebar.tsx

Lines changed: 93 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
2626
import toast from "react-hot-toast";
2727
import { Link, matchPath, useLocation, useNavigate, useSearchParams } from "react-router-dom";
2828
import ConfirmDialog from "@/components/ConfirmDialog";
@@ -62,7 +62,7 @@ import { User_Role, UserNotification_Status } from "@/types/proto/api/v1/user_se
6262
import { useTranslate } from "@/utils/i18n";
6363
import MemosLogo from "../MemosLogo";
6464
import { 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";
6666
import 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+
396429
const 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
);

web/tests/app-sidebar-logo.test.tsx

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,19 @@ const render = (ui: Parameters<typeof testingLibraryRender>[0]) =>
105105
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>{ui}</QueryClientProvider>,
106106
);
107107

108+
const expectCollapsedNavPill = (pill: HTMLElement, label: string) => {
109+
expect(pill).toHaveClass("h-[30px]", "px-[7px]");
110+
const labelTrack = pill.querySelector('span[aria-hidden="true"]');
111+
expect(labelTrack).toHaveClass("grid-cols-[0fr]", "pl-0");
112+
expect(labelTrack).toHaveTextContent(label);
113+
};
114+
115+
const expectActiveNavPill = (pill: HTMLElement, label: string) => {
116+
expect(pill).toHaveAttribute("aria-current", "page");
117+
expect(pill.querySelector('span[aria-hidden="true"]')).toBeNull();
118+
expect(pill).toHaveTextContent(label);
119+
};
120+
108121
describe("App sidebar logo", () => {
109122
beforeEach(() => {
110123
authState.currentUser = { name: "users/test" };
@@ -243,20 +256,17 @@ describe("App sidebar logo", () => {
243256
);
244257

245258
const scopeTrigger = screen.getByRole("button", { name: "common.home" });
246-
expect(scopeTrigger).toHaveClass("size-[30px]");
247-
expect(scopeTrigger).not.toHaveTextContent("common.home");
259+
expectCollapsedNavPill(scopeTrigger, "common.home");
248260

249261
const inbox = screen.getByRole("link", { name: "common.inbox" });
250-
expect(inbox).toHaveClass("size-[30px]");
251-
expect(inbox).not.toHaveTextContent("common.inbox");
262+
expectCollapsedNavPill(inbox, "common.inbox");
252263

253264
const attachments = screen.getByRole("link", { name: "common.attachments" });
254-
expect(attachments).toHaveAttribute("aria-current", "page");
255-
expect(attachments).toHaveTextContent("common.attachments");
265+
expectActiveNavPill(attachments, "common.attachments");
256266

257267
fireEvent.click(scopeTrigger);
258268
expect(await screen.findByText("Calendar")).toBeInTheDocument();
259-
expect(screen.getByRole("button", { name: "common.home" })).toHaveTextContent("common.home");
269+
expectActiveNavPill(screen.getByRole("button", { name: "common.home" }), "common.home");
260270
expect(screen.queryByRole("menuitem", { name: "common.explore" })).not.toBeInTheDocument();
261271
});
262272

@@ -287,11 +297,10 @@ describe("App sidebar logo", () => {
287297
);
288298

289299
const scopeTrigger = screen.getByRole("button", { name: label });
290-
expect(scopeTrigger).toHaveClass("size-[30px]");
291-
expect(scopeTrigger).not.toHaveTextContent(label);
300+
expectCollapsedNavPill(scopeTrigger, label);
292301

293302
fireEvent.click(scopeTrigger);
294-
expect(await screen.findByRole("button", { name: label })).toHaveTextContent(label);
303+
expectActiveNavPill(await screen.findByRole("button", { name: label, current: "page" }), label);
295304
});
296305

297306
it("keeps the mobile brand beside navigation without a duplicate search action", () => {

0 commit comments

Comments
 (0)