diff --git a/app/javascript/mastodon/components/column_header/column_settings_menu.tsx b/app/javascript/mastodon/components/column_header/column_settings_menu.tsx index 5384b08bedf..6babe64de41 100644 --- a/app/javascript/mastodon/components/column_header/column_settings_menu.tsx +++ b/app/javascript/mastodon/components/column_header/column_settings_menu.tsx @@ -38,7 +38,7 @@ export const ColumnSettingsMenu: React.FC = ({ /> )} - + {children} diff --git a/app/javascript/mastodon/components/menu/card.tsx b/app/javascript/mastodon/components/menu/card.tsx index d0a11f22a37..2b340bc8dfd 100644 --- a/app/javascript/mastodon/components/menu/card.tsx +++ b/app/javascript/mastodon/components/menu/card.tsx @@ -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 = ({ 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) => { const Component = asComp ?? 'div'; + const cardRef = useRef(null); + + useLayoutEffect(() => { + const card = cardRef.current; + if (popover !== 'manual' || !card || !isPopoverAPISupported()) return; + + card.showPopover(); + + return () => { + card.hidePopover(); + }; + }, [popover]); + return ( ({ ); }; +function isPopoverAPISupported() { + return 'popover' in HTMLElement.prototype; +} + export type PopoverMenuCardProps = MenuCardProps & Omit; diff --git a/app/javascript/mastodon/components/menu/index.tsx b/app/javascript/mastodon/components/menu/index.tsx index 34d70f41652..0c9ebb33be4 100644 --- a/app/javascript/mastodon/components/menu/index.tsx +++ b/app/javascript/mastodon/components/menu/index.tsx @@ -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 = ({ type = 'actions', + onOpen, + onClose, children, noFocus, }) => { @@ -113,7 +130,7 @@ export const Menu: React.FC = ({ 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 = ({ 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; diff --git a/app/javascript/mastodon/components/menu/items.tsx b/app/javascript/mastodon/components/menu/items.tsx index f73420108b2..eb3473684c7 100644 --- a/app/javascript/mastodon/components/menu/items.tsx +++ b/app/javascript/mastodon/components/menu/items.tsx @@ -48,7 +48,9 @@ type MenuItemProps = 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 = const MenuItemBase = ({ active, disabled, + destructive, as: AsComp, children, className, icon, + description, trailingContent, iconClassName, keepMenuOpenOnClick, @@ -71,8 +75,10 @@ const MenuItemBase = ({ const Component = AsComp ?? 'div'; const { popover } = useMenuContext(); - const closeMenuOnClick = useCallback( + const handleItemClick = useCallback( (e) => { + if (disabled) return; + if (!keepMenuOpenOnClick) { // Closing with a short delay feels nicer than an instant close setTimeout(() => { @@ -82,9 +88,13 @@ const MenuItemBase = ({ onClick?.(e); }, - [keepMenuOpenOnClick, onClick, popover], + [disabled, keepMenuOpenOnClick, onClick, popover], ); + const id = useId(); + const titleId = `${id}-title`; + const descId = `${id}-desc`; + return ( ({ 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 === 'reserve-space' &&
} - {children} + + {children} + {trailingContent && ( {trailingContent} @@ -117,6 +138,25 @@ const MenuItemBase = ({ ); }; +const MenuItemContent: React.FC<{ + children: React.ReactNode; + description?: React.ReactNode; + titleId: string; + descId: string; +}> = ({ children, description, titleId, descId }) => { + if (!description) { + return children; + } + return ( + + {children} + + {description} + + + ); +}; + export const MenuItem: React.FC, 'as'>> = ({ children, ...props diff --git a/app/javascript/mastodon/components/menu/menu.stories.tsx b/app/javascript/mastodon/components/menu/menu.stories.tsx index 963fed89d32..ea283ba18e1 100644 --- a/app/javascript/mastodon/components/menu/menu.stories.tsx +++ b/app/javascript/mastodon/components/menu/menu.stories.tsx @@ -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 = { First item - + Second item + + + Third item +
@@ -79,8 +93,12 @@ export const Complex: Story = { World settings - - First item + + Spawn new insect species @@ -121,6 +139,14 @@ export const Complex: Story = { Snow + + + Turn the world off + diff --git a/app/javascript/mastodon/components/menu/styles.module.scss b/app/javascript/mastodon/components/menu/styles.module.scss index babf3f0afa3..3040f26666b 100644 --- a/app/javascript/mastodon/components/menu/styles.module.scss +++ b/app/javascript/mastodon/components/menu/styles.module.scss @@ -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); diff --git a/app/javascript/mastodon/components/popover/index.tsx b/app/javascript/mastodon/components/popover/index.tsx index e171879d5d8..95aa3e814e6 100644 --- a/app/javascript/mastodon/components/popover/index.tsx +++ b/app/javascript/mastodon/components/popover/index.tsx @@ -114,7 +114,7 @@ export const Popover: React.FC = ({ popoverElement, placement = 'bottom', offset, - strategy = 'fixed', + strategy = 'absolute', flip = true, container, matchReferenceWidth = false, diff --git a/app/javascript/mastodon/components/status/action_bar.tsx b/app/javascript/mastodon/components/status/action_bar.tsx index 43a78ca52f1..e46f538b188 100644 --- a/app/javascript/mastodon/components/status/action_bar.tsx +++ b/app/javascript/mastodon/components/status/action_bar.tsx @@ -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} - + -

- {intl.formatMessage(boostState.title)} - {boostState.meta && ( - - {intl.formatMessage(boostState.meta)} - - )} -

+ {intl.formatMessage(boostState.title)}
-

- {intl.formatMessage(quoteState.title)} - {quoteState.meta && ( - - {intl.formatMessage(quoteState.meta)} - - )} -

+ {intl.formatMessage(quoteState.title)}
@@ -448,11 +436,10 @@ const StatusActionMenu: React.FC<{ } dismissQuoteHint(); - return true; }, [dismissQuoteHint, dispatch, status.id, status.quote_approval]); return ( - + - + {menu.map((item, index) => ( ))} - ); @@ -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 ? ( -

- {item.text} - {item.description} -

- ) : ( - 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 ; }; -const StatusActionLoader = ({ onMount }: { onMount: () => void }) => { - useEffect(() => { - onMount(); - }, [onMount]); - return null; -}; - interface MenuItemsParams { status: StatusShape; account?: Account; diff --git a/app/javascript/mastodon/components/status/styles.module.scss b/app/javascript/mastodon/components/status/styles.module.scss index c3397d365bf..178ee10cb1c 100644 --- a/app/javascript/mastodon/components/status/styles.module.scss +++ b/app/javascript/mastodon/components/status/styles.module.scss @@ -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)); } diff --git a/app/javascript/mastodon/features/navigation_panel/redesign/account_card_and_menu.tsx b/app/javascript/mastodon/features/navigation_panel/redesign/account_card_and_menu.tsx index d4d18f89d1b..c6504fd5773 100644 --- a/app/javascript/mastodon/features/navigation_panel/redesign/account_card_and_menu.tsx +++ b/app/javascript/mastodon/features/navigation_panel/redesign/account_card_and_menu.tsx @@ -72,7 +72,7 @@ export const NavigationAccountCardAndMenu: React.FC = () => { defaultMessage='Account settings' /> - +