245 lines
9.9 KiB
TypeScript
245 lines
9.9 KiB
TypeScript
'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 <Popover> ──────────────────────────────────────
|
|
* <Popover> 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 <Popover> 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<AccountPanelId | null>(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<HTMLElement>(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 ? (
|
|
<IconButton
|
|
ref={triggerRef}
|
|
variant="ghost"
|
|
label={label}
|
|
// The avatar draws its own initials from the name; a signed-out visit
|
|
// falls back to Avatar's person glyph rather than someone else's
|
|
// letter.
|
|
icon={
|
|
<Avatar name={user?.name || undefined} size="md" tooltip={false} />
|
|
}
|
|
className={CHIP_COLLAPSED}
|
|
onClick={toggle}
|
|
{...triggerProps}
|
|
data-testid="account-menu-trigger"
|
|
/>
|
|
) : (
|
|
<Item
|
|
ref={triggerRef}
|
|
// role="button" rather than Item's default: given a role, Item skips
|
|
// the inner <button> 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 <button> synthesises click from Enter and Space;
|
|
// role="button" does not, so it is spelled out.
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
toggle();
|
|
}
|
|
}}
|
|
{...triggerProps}
|
|
label={label}
|
|
description={user?.email ?? 'Not signed in'}
|
|
labelLines={1}
|
|
descriptionLines={1}
|
|
density="balanced"
|
|
className={CHIP}
|
|
// isHighlighted, not isSelected: Item turns isSelected into
|
|
// aria-current on a root that has no role permitting aria-selected,
|
|
// and "current item" is not what an open menu means. aria-expanded
|
|
// above already says it; this only has to look lit.
|
|
isHighlighted={isOpen}
|
|
startContent={
|
|
<Avatar name={user?.name || undefined} size="sm" tooltip={false} />
|
|
}
|
|
// The switcher glyph, not a chevron: this opens a panel of its own,
|
|
// it does not expand in place.
|
|
endContent={
|
|
<Icon icon={ICONS.switcher} size="sm" color="secondary" />
|
|
}
|
|
data-testid="account-menu-trigger"
|
|
/>
|
|
)}
|
|
|
|
{render(
|
|
// vAlign="end" keeps both cards' bottom edges flush with each other.
|
|
// The layer's own bottom edge is pinned to the chip, so a panel two
|
|
// that is taller than panel one grows upward instead of hanging below
|
|
// the trigger.
|
|
<HStack gap={2} vAlign="end">
|
|
<VStack
|
|
width={PANEL_ONE_WIDTH}
|
|
padding={2}
|
|
className={PANEL_SURFACE}
|
|
data-testid="account-panel-root"
|
|
>
|
|
<AccountRootPanel
|
|
activePanel={activePanel}
|
|
onBranch={handleBranch}
|
|
onLeaf={handleLeaf}
|
|
/>
|
|
</VStack>
|
|
|
|
{activePanel ? (
|
|
<VStack
|
|
// Keyed on the panel so moving between branches re-runs the
|
|
// entrance instead of swapping the rows in place.
|
|
key={activePanel}
|
|
ref={subPanelRef}
|
|
width={PANEL_TWO_WIDTH}
|
|
padding={2}
|
|
className={`${PANEL_SURFACE} ${PANEL_TWO_MOTION}`}
|
|
data-testid="account-panel-sub"
|
|
>
|
|
<AccountSubPanel
|
|
panel={activePanel}
|
|
locale={locale}
|
|
onLocale={setLocale}
|
|
onLeaf={handleLeaf}
|
|
/>
|
|
</VStack>
|
|
) : null}
|
|
</HStack>,
|
|
{
|
|
placement: 'above',
|
|
alignment: 'start',
|
|
className: LAYER_OFFSET,
|
|
xstyle: layerAnimations.above,
|
|
},
|
|
)}
|
|
</>
|
|
);
|
|
}
|