update ui update and fix layout issue
This commit is contained in:
244
src/shared/layouts/workspace/AccountMenu.tsx
Normal file
244
src/shared/layouts/workspace/AccountMenu.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
'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,
|
||||
},
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user