mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-12 18:25:55 -05:00
Redesign: Menu component tweaks and fixes (#40448)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -114,7 +114,7 @@ export const Popover: React.FC<PopoverProps> = ({
|
||||
popoverElement,
|
||||
placement = 'bottom',
|
||||
offset,
|
||||
strategy = 'fixed',
|
||||
strategy = 'absolute',
|
||||
flip = true,
|
||||
container,
|
||||
matchReferenceWidth = false,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user