Files
loyaly-merchant/src/shared/layouts/workspace/AccountMenu.tsx

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,
},
)}
</>
);
}