Skip to content

Commit 1ef8806

Browse files
committed
RichMenu: shared trigger, menu, item components
1 parent 06316ed commit 1ef8806

1 file changed

Lines changed: 273 additions & 0 deletions

File tree

src/common/components/RichMenu.tsx

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import * as React from 'react';
2+
import { keyframes } from '@emotion/react';
3+
4+
import type { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types';
5+
import { Box, Button, IconButton, ListItemDecorator, Menu, MenuButton, MenuItem } from '@mui/joy';
6+
7+
import { themeZIndexOverMobileDrawer } from '~/common/app.theme';
8+
9+
10+
/**
11+
* RichMenu family: the app-wide trigger-button + dropdown-menu pattern.
12+
*
13+
* Two presentations, uniform across all adopters (attachment sources, prompts, voice engines, ...):
14+
* - rich (desktop): labeled pill trigger that docks to a rounded, bordered, animated menu
15+
* - compact (mobile): icon trigger with the default squared menu
16+
*
17+
* Components (for `Dropdown` usage): `RichMenuButton`, `RichMenu`, `RichMenuItem`.
18+
* Style primitives (for controlled popups, e.g. `CloseablePopup`): `richMenuSx`, `richMenuCompactSx`.
19+
*/
20+
21+
22+
// configuration
23+
export const RICH_MENU_BUTTON_RADIUS = '18px'; // pill trigger and rich menu corners - single knob for the family
24+
const RICH_MENU_MIN_WIDTH = 280;
25+
const PILL_MIN_WIDTH = 100;
26+
27+
28+
// animations: whole-menu fade, per-item slide-in (staggered per-item via nth-of-type, no wiring needed)
29+
const animationMenuEnter = keyframes` from {opacity: 0;} to {opacity: 1;}`;
30+
const animationItemEnter = keyframes` from {opacity: 0;transform: translateY(-6px);} to {opacity: 1;transform: translateY(0);}`;
31+
const ANIMATION_EASE = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)';
32+
33+
const _itemStaggerSx = (() => {
34+
const stagger: Record<string, { animationDelay: string }> = {};
35+
for (let i = 2; i <= 14; i++)
36+
stagger[`& li:nth-of-type(${i})`] = { animationDelay: `${((i - 1) * 0.02).toFixed(2)}s` };
37+
return stagger;
38+
})();
39+
40+
41+
/// Menu surface styles ///
42+
43+
/**
44+
* The rich (desktop) menu surface chrome.
45+
* Exported for controlled popups (CloseablePopup) to match RichMenu exactly; no zIndex (popper-level concern).
46+
*/
47+
export function richMenuSx(color?: ColorPaletteProp) {
48+
return {
49+
minWidth: RICH_MENU_MIN_WIDTH,
50+
'--List-padding': '0.5rem',
51+
animation: `${animationMenuEnter} 0.12s ${ANIMATION_EASE}`,
52+
boxShadow: 'md',
53+
borderRadius: RICH_MENU_BUTTON_RADIUS,
54+
border: '1px solid',
55+
borderColor: `${color || 'neutral'}.outlinedBorder`,
56+
backgroundColor: 'background.popup',
57+
overflow: 'hidden',
58+
} satisfies SxProps;
59+
}
60+
61+
// staggered per-item entrance ('both' keeps later items hidden until their delay) - opt-in via <RichMenu stagger>
62+
const _richMenuStaggerSx = {
63+
'& li': { animation: `${animationItemEnter} 0.12s ${ANIMATION_EASE} both` },
64+
..._itemStaggerSx,
65+
} satisfies SxProps;
66+
67+
/** The compact (mobile) menu surface: default squared Joy menu, just consistent padding. */
68+
export const richMenuCompactSx = {
69+
'--List-padding': '0.5rem',
70+
} as const satisfies SxProps;
71+
72+
73+
/// Trigger button ///
74+
75+
/**
76+
* Trigger for a RichMenu - must be a child of `Dropdown`.
77+
* With a `label`: the desktop pill (full width, left aligned, flattens its top corners while the menu is open).
78+
* Without: a plain IconButton trigger (mobile).
79+
*/
80+
export function RichMenuButton(props: {
81+
icon?: React.ReactNode,
82+
label?: undefined | React.ReactNode,
83+
color?: ColorPaletteProp,
84+
variant?: VariantProp, // icon trigger only - the pill is always 'plain' (+ optional standOut chrome)
85+
standOut?: boolean, // pill only: popup background + border, to detach from busy backdrops
86+
disabled?: boolean,
87+
sx?: SxProps,
88+
}) {
89+
90+
// icon trigger (mobile)
91+
if (props.label === undefined)
92+
return (
93+
<MenuButton slots={{ root: IconButton }} slotProps={{
94+
root: {
95+
color: props.color,
96+
variant: props.variant,
97+
disabled: props.disabled,
98+
sx: {
99+
// menu open: soft pressed look (mirrors the pill's expanded state)
100+
'&[aria-expanded="true"]': {
101+
// borderTopRightRadius: 0,
102+
// borderTopLeftRadius: 0,
103+
backgroundColor: `${props.color || 'neutral'}.softHoverBg`,
104+
},
105+
...props.sx,
106+
},
107+
},
108+
}}>
109+
{props.icon}
110+
</MenuButton>
111+
);
112+
113+
// pill trigger (desktop)
114+
return (
115+
<MenuButton
116+
slots={{ root: Button }}
117+
slotProps={{
118+
root: {
119+
variant: 'plain',
120+
color: props.color,
121+
disabled: props.disabled,
122+
startDecorator: props.icon,
123+
fullWidth: true, // to match other buttons in the col
124+
sx: {
125+
minWidth: PILL_MIN_WIDTH,
126+
justifyContent: 'flex-start',
127+
borderRadius: RICH_MENU_BUTTON_RADIUS,
128+
textWrap: 'nowrap',
129+
...(props.standOut && {
130+
backgroundColor: 'background.popup',
131+
border: '1px solid',
132+
borderColor: `${props.color || 'neutral'}.outlinedBorder`,
133+
}),
134+
// menu open: dock to the menu (flatten top corners)
135+
'&[aria-expanded="true"]': {
136+
borderTopRightRadius: 0,
137+
borderTopLeftRadius: 0,
138+
backgroundColor: `${props.color || 'neutral'}.softHoverBg`,
139+
},
140+
...props.sx,
141+
},
142+
},
143+
}}
144+
>
145+
{props.label}
146+
</MenuButton>
147+
);
148+
}
149+
150+
151+
/// Menu surface ///
152+
153+
const _richPopperOptions = {
154+
modifiers: [{ name: 'offset', options: { offset: [-10, -2] } }], // slight overlap to dock onto the pill
155+
};
156+
157+
/**
158+
* Menu surface for a RichMenu - must be a child of `Dropdown`.
159+
* Rich (default): docks on top of the pill trigger with the polished chrome.
160+
* Compact: the default Joy menu look (mobile).
161+
*/
162+
export function RichMenu(props: {
163+
compact?: boolean,
164+
stagger?: boolean, // staggered item entrance - for click-opened menus with many items
165+
color?: ColorPaletteProp,
166+
placement?: 'top-start' | 'top' | 'top-end' | 'bottom-start' | 'bottom' | 'bottom-end',
167+
zIndex?: number,
168+
sx?: SxProps,
169+
children: React.ReactNode,
170+
}) {
171+
172+
const zIndex = props.zIndex ?? themeZIndexOverMobileDrawer; // above dialogs that may host the trigger
173+
174+
if (props.compact)
175+
return (
176+
<Menu placement={props.placement} sx={{ ...richMenuCompactSx, ...(props.stagger && _richMenuStaggerSx), zIndex, ...props.sx }}>
177+
{props.children}
178+
</Menu>
179+
);
180+
181+
return (
182+
<Menu
183+
color={props.color}
184+
placement={props.placement ?? 'top-start'}
185+
popperOptions={_richPopperOptions}
186+
sx={{ ...richMenuSx(props.color), ...(props.stagger && _richMenuStaggerSx), zIndex, ...props.sx }}
187+
>
188+
{props.children}
189+
</Menu>
190+
);
191+
}
192+
193+
194+
/// Menu items ///
195+
196+
/**
197+
* Item styles, exported for custom rows that must align with RichMenuItem
198+
* (e.g. checkbox/toggle ListItems within the same menu).
199+
*/
200+
export const richMenuItemSx = {
201+
item: {
202+
py: 0.5,
203+
minHeight: 60,
204+
},
205+
content: {
206+
display: 'flex',
207+
flexDirection: 'column',
208+
gap: 0.125,
209+
},
210+
contentDisabled: {
211+
display: 'flex',
212+
flexDirection: 'column',
213+
gap: 0.125,
214+
opacity: 0.5,
215+
},
216+
name: {
217+
typography: 'title-sm',
218+
fontWeight: 600,
219+
},
220+
description: {
221+
fontSize: 'xs',
222+
color: 'text.tertiary',
223+
},
224+
endAction: {
225+
ml: 'auto',
226+
display: 'flex',
227+
alignItems: 'center',
228+
},
229+
} as const satisfies Record<string, SxProps>;
230+
231+
/**
232+
* The uniform two-line menu item: icon, name, optional description, optional end action.
233+
* Entrance animation is owned by the rich menu surface, not the item.
234+
*/
235+
export function RichMenuItem(props: {
236+
name: React.ReactNode;
237+
description?: React.ReactNode;
238+
Icon?: React.ComponentType; // when absent, an empty decorator keeps text alignment
239+
onClick: () => void;
240+
disabled?: boolean;
241+
selected?: boolean;
242+
color?: ColorPaletteProp;
243+
endAction?: React.ReactNode;
244+
}) {
245+
return (
246+
<MenuItem
247+
onClick={props.onClick}
248+
disabled={props.disabled}
249+
selected={props.selected}
250+
color={props.color}
251+
sx={richMenuItemSx.item}
252+
>
253+
<ListItemDecorator>
254+
{props.Icon && <props.Icon />}
255+
</ListItemDecorator>
256+
<Box sx={props.disabled ? richMenuItemSx.contentDisabled : richMenuItemSx.content}>
257+
<Box sx={richMenuItemSx.name}>
258+
{props.name}
259+
</Box>
260+
{props.description !== undefined && (
261+
<Box sx={richMenuItemSx.description}>
262+
{props.description}
263+
</Box>
264+
)}
265+
</Box>
266+
{props.endAction && (
267+
<Box sx={richMenuItemSx.endAction}>
268+
{props.endAction}
269+
</Box>
270+
)}
271+
</MenuItem>
272+
);
273+
}

0 commit comments

Comments
 (0)