Redesign: Menu component tweaks and fixes (#40448)

This commit is contained in:
diondiondion
2026-09-10 11:00:52 +00:00
committed by GitHub
parent 75a36d82a3
commit dde8ab583b
10 changed files with 158 additions and 66 deletions

View File

@@ -38,7 +38,7 @@ export const ColumnSettingsMenu: React.FC<ColumnSettingsMenuProps> = ({
/>
)}
</MenuTrigger>
<MenuList placement='bottom-end' strategy='absolute'>
<MenuList placement='bottom-end' strategy='fixed'>
{children}
</MenuList>
</Menu>

View File

@@ -1,6 +1,9 @@
import { useLayoutEffect, useRef } from 'react';
import classNames from 'classnames';
import { useBreakpoint } from '@/mastodon/features/ui/hooks/useBreakpoint';
import { useMergedRefs } from '@/mastodon/hooks/useMergedRefs';
import type { PolymorphicProps } from '@/types/polymorphic';
import { BottomSheet } from '../bottom_sheet';
@@ -27,12 +30,30 @@ export const MenuCard = <As extends React.ElementType = 'div'>({
elevation = 1,
maxWidth,
style,
// By default, `MenuCard` opens itself on the top layer using the
// native popover API. Set this prop to `undefined` to disable this.
popover = 'manual',
...props
}: MenuCardProps<As>) => {
const Component = asComp ?? 'div';
const cardRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const card = cardRef.current;
if (popover !== 'manual' || !card || !isPopoverAPISupported()) return;
card.showPopover();
return () => {
card.hidePopover();
};
}, [popover]);
return (
<Component
{...props}
ref={useMergedRefs(props.ref, cardRef)}
popover={popover}
className={classNames(className, classes.card)}
data-elevation={elevation}
style={
@@ -48,6 +69,10 @@ export const MenuCard = <As extends React.ElementType = 'div'>({
);
};
function isPopoverAPISupported() {
return 'popover' in HTMLElement.prototype;
}
export type PopoverMenuCardProps<As extends React.ElementType> =
MenuCardProps<As> & Omit<PopoverProps, 'children'>;

View File

@@ -90,12 +90,29 @@ interface MenuProps {
* Note that navigation menus don't support `MenuItemRadio` and `MenuItemCheckbox`.
*/
type?: MenuType;
/**
* Callback that is run before the menu is opened. Can be used for side effects
* or to prevent opening the menu by returning `false`.
*/
onOpen?: (() => void) | (() => boolean);
/**
* Callback that is run before the menu is closed. Can be used for side effects
* or to prevent closing the menu by returning `false`.
* Prefer the `keepMenuOpenOnClick` prop on `MenuItem`.
*/
onClose?: (() => void) | (() => boolean);
children: React.ReactNode;
/**
* Don't set initial focus on the first menu item when opening the menu.
* Not recommended for normal usage.
*/
noFocus?: boolean;
}
export const Menu: React.FC<MenuProps> = ({
type = 'actions',
onOpen,
onClose,
children,
noFocus,
}) => {
@@ -113,7 +130,7 @@ export const Menu: React.FC<MenuProps> = ({
if (element && type === 'actions' && !noFocus) {
const menuItems = getAllMenuItems(element);
const elementToFocus = menuItems[0] ?? element;
elementToFocus.focus();
elementToFocus.focus({ preventScroll: true });
}
},
[noFocus, type],
@@ -122,13 +139,19 @@ export const Menu: React.FC<MenuProps> = ({
const [isMenuOpen, setIsMenuOpen] = useState(false);
const openMenu = useCallback(() => {
const shouldOpen = onOpen?.();
if (shouldOpen === false) return;
setIsMenuOpen(true);
}, []);
}, [onOpen]);
const closeMenu = useCallback(() => {
const shouldClose = onClose?.();
if (shouldClose === false) return;
setIsMenuOpen(false);
triggerElement?.focus();
}, [triggerElement]);
}, [triggerElement, onClose]);
const toggleMenu = isMenuOpen ? closeMenu : openMenu;

View File

@@ -48,7 +48,9 @@ type MenuItemProps<As extends React.ElementType> =
className?: string;
active?: boolean;
disabled?: boolean;
destructive?: boolean;
icon?: IconProp | 'reserve-space';
description?: React.ReactNode;
trailingContent?: React.ReactNode;
iconClassName?: string;
keepMenuOpenOnClick?: boolean;
@@ -58,10 +60,12 @@ type MenuItemProps<As extends React.ElementType> =
const MenuItemBase = <As extends React.ElementType>({
active,
disabled,
destructive,
as: AsComp,
children,
className,
icon,
description,
trailingContent,
iconClassName,
keepMenuOpenOnClick,
@@ -71,8 +75,10 @@ const MenuItemBase = <As extends React.ElementType>({
const Component = AsComp ?? 'div';
const { popover } = useMenuContext();
const closeMenuOnClick = useCallback<React.MouseEventHandler>(
const handleItemClick = useCallback<React.MouseEventHandler>(
(e) => {
if (disabled) return;
if (!keepMenuOpenOnClick) {
// Closing with a short delay feels nicer than an instant close
setTimeout(() => {
@@ -82,9 +88,13 @@ const MenuItemBase = <As extends React.ElementType>({
onClick?.(e);
},
[keepMenuOpenOnClick, onClick, popover],
[disabled, keepMenuOpenOnClick, onClick, popover],
);
const id = useId();
const titleId = `${id}-title`;
const descId = `${id}-desc`;
return (
<Component
// If it's a button, set the type by default or it will submit forms.
@@ -95,9 +105,14 @@ const MenuItemBase = <As extends React.ElementType>({
className,
classes.item,
active && classes.itemActive,
destructive && classes.itemDestructive,
)}
aria-disabled={disabled}
onClick={closeMenuOnClick}
// When a description is present, we expose it via aria-description
// instead of making it part of the item's accessible name
aria-labelledby={description ? titleId : undefined}
aria-describedby={description ? descId : undefined}
onClick={handleItemClick}
>
{icon && icon !== 'reserve-space' && (
<Icon
@@ -108,7 +123,13 @@ const MenuItemBase = <As extends React.ElementType>({
)}
{icon === 'reserve-space' && <div className={classes.itemIcon} />}
{children}
<MenuItemContent
description={description}
descId={descId}
titleId={titleId}
>
{children}
</MenuItemContent>
{trailingContent && (
<span className={classes.itemTrailingContent}>{trailingContent}</span>
@@ -117,6 +138,25 @@ const MenuItemBase = <As extends React.ElementType>({
);
};
const MenuItemContent: React.FC<{
children: React.ReactNode;
description?: React.ReactNode;
titleId: string;
descId: string;
}> = ({ children, description, titleId, descId }) => {
if (!description) {
return children;
}
return (
<span>
<span id={titleId}>{children}</span>
<span id={descId} className={classes.itemDescription}>
{description}
</span>
</span>
);
};
export const MenuItem: React.FC<Omit<MenuItemProps<'button'>, 'as'>> = ({
children,
...props

View File

@@ -1,8 +1,10 @@
import { useCallback, useState } from 'react';
import {
BugBeetleIcon,
MoonIcon,
NumberCircleOneIcon,
NumberCircleThreeIcon,
NumberCircleTwoIcon,
SunIcon,
} from '@phosphor-icons/react';
@@ -54,9 +56,21 @@ export const Default: Story = {
<MenuItem icon={NumberCircleOneIcon} onClick={handleMenuItemClick}>
First item
</MenuItem>
<MenuItem icon={NumberCircleTwoIcon} onClick={handleMenuItemClick}>
<MenuItem
icon={NumberCircleTwoIcon}
description='Some informative hint text'
onClick={handleMenuItemClick}
>
Second item
</MenuItem>
<MenuItemDivider />
<MenuItem
destructive
icon={NumberCircleThreeIcon}
onClick={handleMenuItemClick}
>
Third item
</MenuItem>
</MenuList>
</Menu>
</div>
@@ -79,8 +93,12 @@ export const Complex: Story = {
<MenuTrigger>World settings</MenuTrigger>
<MenuList {...args}>
<MenuItem onClick={handleMenuItemClick} keepMenuOpenOnClick>
First item
<MenuItem
icon={BugBeetleIcon}
onClick={handleMenuItemClick}
keepMenuOpenOnClick
>
Spawn new insect species
</MenuItem>
<MenuItemDivider />
@@ -121,6 +139,14 @@ export const Complex: Story = {
Snow
</MenuItemRadio>
</MenuItemGroup>
<MenuItemDivider />
<MenuItem
destructive
onClick={handleMenuItemClick}
description='This action can not be undone'
>
Turn the world off
</MenuItem>
</MenuList>
</Menu>
</div>

View File

@@ -7,6 +7,7 @@
display: flex;
flex-direction: column;
padding: var(--space-2xs);
inset: unset; // override default [popover] UA styles
border-radius: var(--radius-md);
background: var(--color-bg-primary);
overflow: hidden;
@@ -103,6 +104,25 @@
}
}
.itemDestructive {
color: var(--color-text-error);
}
.itemDescription {
@include mixins.type-label-sm;
display: block;
margin-top: var(--space-3xs);
color: var(--color-text-secondary);
.item[aria-disabled='true'] &,
.item:active &,
.itemActive &,
.itemDestructive & {
color: inherit;
}
}
.itemIcon {
width: var(--space-lg);
height: var(--space-lg);

View File

@@ -114,7 +114,7 @@ export const Popover: React.FC<PopoverProps> = ({
popoverElement,
placement = 'bottom',
offset,
strategy = 'fixed',
strategy = 'absolute',
flip = true,
container,
matchReferenceWidth = false,

View File

@@ -1,5 +1,5 @@
import type React from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@@ -345,34 +345,22 @@ const StatusReblogButton: React.FC<{
{children}
</MenuTrigger>
<MenuList placement='bottom' maxWidth={180} container={document.body}>
<MenuList placement='bottom' maxWidth={180}>
<MenuItem
onClick={onReblog}
icon={ArrowsClockwiseIcon}
disabled={boostState.disabled}
description={boostState.meta && intl.formatMessage(boostState.meta)}
>
<p>
{intl.formatMessage(boostState.title)}
{boostState.meta && (
<span className={classes.actionDescription}>
{intl.formatMessage(boostState.meta)}
</span>
)}
</p>
{intl.formatMessage(boostState.title)}
</MenuItem>
<MenuItem
onClick={onQuote}
icon={QuotesFilledIcon}
disabled={quoteState.disabled}
description={quoteState.meta && intl.formatMessage(quoteState.meta)}
>
<p>
{intl.formatMessage(quoteState.title)}
{quoteState.meta && (
<span className={classes.actionDescription}>
{intl.formatMessage(quoteState.meta)}
</span>
)}
</p>
{intl.formatMessage(quoteState.title)}
</MenuItem>
</MenuList>
</Menu>
@@ -448,11 +436,10 @@ const StatusActionMenu: React.FC<{
}
dismissQuoteHint();
return true;
}, [dismissQuoteHint, dispatch, status.id, status.quote_approval]);
return (
<Menu>
<Menu onOpen={onOpen}>
<MenuTrigger
as={IconButton}
size='sm'
@@ -462,11 +449,10 @@ const StatusActionMenu: React.FC<{
<FormattedMessage id='status.more' defaultMessage='More' />
</MenuTrigger>
<MenuList placement='top-end' container={document.body}>
<MenuList placement='top-end'>
{menu.map((item, index) => (
<StatusActionItem key={index} item={item} />
))}
<StatusActionLoader onMount={onOpen} />
</MenuList>
</Menu>
);
@@ -480,15 +466,9 @@ const StatusActionItem: React.FC<{ item: DropdownItem }> = ({ item }) => {
const commonProps = {
icon: item.icon,
disabled: item.disabled,
className: classNames(item.dangerous && classes.actionDangerous),
children: item.description ? (
<p>
{item.text}
<span className={classes.actionDescription}>{item.description}</span>
</p>
) : (
item.text
),
destructive: item.dangerous,
children: item.text,
description: item.description,
} as const;
if ('to' in item) {
@@ -500,13 +480,6 @@ const StatusActionItem: React.FC<{ item: DropdownItem }> = ({ item }) => {
return <MenuItem {...commonProps} onClick={item.action} />;
};
const StatusActionLoader = ({ onMount }: { onMount: () => void }) => {
useEffect(() => {
onMount();
}, [onMount]);
return null;
};
interface MenuItemsParams {
status: StatusShape;
account?: Account;

View File

@@ -120,21 +120,6 @@
margin-inline-end: auto;
}
.actionDescription {
@include mixins.type-label-sm;
display: block;
color: var(--color-text-tertiary);
[data-menu-item='true'][aria-disabled='true'] & {
color: inherit;
}
}
.actionDangerous {
color: var(--color-text-error);
}
.buttonAlign {
margin-inline-start: calc(-1 * var(--space-sm));
}

View File

@@ -72,7 +72,7 @@ export const NavigationAccountCardAndMenu: React.FC = () => {
defaultMessage='Account settings'
/>
</MenuTrigger>
<MenuList placement='top' offset={8}>
<MenuList placement='top' offset={8} strategy='fixed'>
<AccountMenuItems />
</MenuList>
</Menu>