'use client'; import {useCallback, useEffect, useRef, useState} from 'react'; import {Avatar} from '@astryxdesign/core/Avatar'; import {Icon} from '@astryxdesign/core/Icon'; import {IconButton} from '@astryxdesign/core/IconButton'; import {Item} from '@astryxdesign/core/Item'; import {HStack, VStack} from '@astryxdesign/core/Layout'; import {layerAnimations} from '@astryxdesign/core/Layer'; import {usePopover} from '@astryxdesign/core/Popover'; import {useSession} from '@/features/auth/providers/SessionProvider'; import {ICONS} from '@/shared/utils/icons'; import type {AccountPanelId} from './account-menu'; import {useAccountMenu} from './AccountMenuProvider'; import { AccountRootPanel, AccountSubPanel, PANEL_ONE_WIDTH, PANEL_SURFACE, PANEL_TWO_MOTION, PANEL_TWO_WIDTH, } from './AccountPanels'; import {useSidebar} from './SidebarProvider'; import {useAccountActions} from './useAccountActions'; /** * A cascading menu, not a dropdown. * * The distinction is behavioural, and it is the whole point: clicking a row in * panel one opens a SECOND panel beside the first and navigates nowhere. Both * panels stay up until the merchant either clicks a real destination in panel * two, clicks outside, or presses Escape. This is how a desktop application's * account menu behaves; the dropdown it replaces navigated on the first click * and dismissed itself before you had chosen anything. * * ── Why usePopover and not ────────────────────────────────────── * paints the surface — background, radius, shadow — on a wrapper * OUTSIDE the content it is given. One surface around two panels is exactly * the thing a cascade must not be: the fill would bridge the gap between them * and the pair would read as one wide card with a seam down it. `usePopover` * takes `hasSurface: false`, which does not forward, so the layer * stays transparent and each panel paints its own card (see PANEL_SURFACE). * * Everything else the component gave us is still here, because it all lives in * the hook: CSS anchor positioning, the focus trap, native light dismiss and * Escape. The only thing hand-wired is the trigger, thirty lines below. * * ── Dismissal ──────────────────────────────────────────────────────────── * Outside click and Escape both close EVERYTHING, panel two included — they * are the native popover's own light dismiss acting on the single layer that * holds both panels, so there is no state in which one panel outlives the * other. Panel two is reset on close rather than on open, so re-opening the * menu always starts at panel one. */ /** * 8px between the chip and the panel. --spacing-2 via the Tailwind bridge, and * it has to be a utility rather than a Stack prop because the element it * applies to is the layer itself, which useLayer renders. * * It wins over useLayer's own `margin: 0` on layer sorting alone: astryx.css * declares @layer astryx-base and Tailwind's utilities layer is declared after * it, so no !important is involved. */ const LAYER_OFFSET = 'mb-2'; /** The chip. Rounded like a nav item, and lit while its menu is open. */ const CHIP = 'rounded-lg transition-colors duration-150 cursor-pointer hover:bg-white/[0.06] p-2'; /** * The collapsed chip, sized to the rail rather than to IconButton's default. * * AppSideNav widens the collapsed rail to 84px and restyles its items to 44px * around 24px icons, but that rule is scoped to `.astryx-side-nav-item` and * this is an IconButton — it would otherwise sit at 32px under a column of * 44px targets. Both values are spacing tokens: size-11 is 44px, size-6 is 24. */ const CHIP_COLLAPSED = 'size-11 mx-auto [&_.astryx-avatar]:size-6'; /** * The account chip and its cascading menu, for the inline rail. * * Rendered in SideNav's `footer`, so it sits in the bottom-left corner at * every rail width. Below the rail breakpoint this is not mounted at all — * the drawer gets AccountSheetTrigger and the sheet instead. */ export function AccountMenu() { const {isCollapsed} = useSidebar(); const {user} = useSession(); const {locale, setLocale} = useAccountMenu(); const [activePanel, setActivePanel] = useState(null); // Set only when a branch is opened from the keyboard, and consumed by the // effect below. A ref, not state: it must not cause a render of its own, and // the effect that reads it already runs on the render `activePanel` causes. const wantsSubFocus = useRef(false); const subPanelRef = useRef(null); const {hide, isOpen, render, toggle, triggerProps, triggerRef} = usePopover({ dialogLabel: 'Account menu', // See the header note. This is the reason the hook is used directly. hasSurface: false, // Reset on CLOSE rather than on open, and from the hide callback rather // than an effect watching isOpen: this runs for every way the menu can // go away — light dismiss, Escape, a chosen row — and never schedules a // second render pass the way `useEffect(() => setState())` would. onHide: () => setActivePanel(null), }); const close = useCallback(() => hide(), [hide]); const handleLeaf = useAccountActions(close); // Mouse users are already looking at panel two; keyboard users are not, and // leaving focus on the branch row would make them tab through the rest of // panel one to reach the panel they just asked for. useEffect(() => { if (!activePanel || !wantsSubFocus.current) return; wantsSubFocus.current = false; subPanelRef.current?.querySelector('button')?.focus(); }, [activePanel]); const handleBranch = useCallback( (panel: AccountPanelId, viaKeyboard: boolean) => { wantsSubFocus.current = viaKeyboard; // Re-selecting the open branch closes panel two, so the same key that // opened it also puts it away. setActivePanel((current) => (current === panel ? null : panel)); }, [], ); const label = user?.name ?? 'Account'; return ( <> {isCollapsed ? ( } className={CHIP_COLLAPSED} onClick={toggle} {...triggerProps} data-testid="account-menu-trigger" /> ) : ( it would otherwise wrap the label in and puts // the handler on the root — which is the element the ARIA below has // to describe, and the element the popover is anchored to. With the // inner button in play those would be two different nodes. role="button" tabIndex={0} onClick={toggle} // A native