diff --git a/app/javascript/mastodon/components/menu/card.tsx b/app/javascript/mastodon/components/menu/card.tsx index 475650b4a0e..2f827f790b0 100644 --- a/app/javascript/mastodon/components/menu/card.tsx +++ b/app/javascript/mastodon/components/menu/card.tsx @@ -1,3 +1,5 @@ +import type { CSSProperties } from 'react'; + import classNames from 'classnames'; import type { Merge } from 'type-fest'; @@ -34,10 +36,13 @@ export const MenuCard = ({ {...props} className={classNames(className, classes.card)} data-elevation={elevation} - style={{ - maxWidth: typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth, - ...style, - }} + style={ + { + '--_max-card-width': + typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth, + ...style, + } as CSSProperties + } > {children} diff --git a/app/javascript/mastodon/components/menu/index.tsx b/app/javascript/mastodon/components/menu/index.tsx index de38d4f6107..19c257ea67a 100644 --- a/app/javascript/mastodon/components/menu/index.tsx +++ b/app/javascript/mastodon/components/menu/index.tsx @@ -21,8 +21,8 @@ export const menuItemClass = classes.item; export { MenuItemDivider, MenuItemGroup, - MenuItemBase, MenuItem, + MenuItemLink, MenuItemRadio, MenuItemCheckbox, } from './items'; @@ -36,10 +36,10 @@ interface PopoverState { reference: HTMLButtonElement | null; } -interface MenuButtonContextProps { +interface MenuTriggerContextProps { ref: (button: HTMLButtonElement | null) => void; id: string; - 'aria-haspopup': 'menu'; + 'aria-haspopup'?: 'menu'; 'aria-expanded': boolean; 'aria-controls'?: string; onKeyDown: React.KeyboardEventHandler; @@ -48,16 +48,19 @@ interface MenuButtonContextProps { interface MenuListContextProps { ref: (button: HTMLDivElement | null) => void; - role: 'menu'; + role?: 'menu'; // only for menus of type === 'actions' tabIndex: -1; id: string; 'aria-labelledby': string; onKeyDown: React.KeyboardEventHandler; } +type MenuType = 'actions' | 'navigation'; + interface MenuState { + type: MenuType; popover: PopoverState; - menuButtonProps: MenuButtonContextProps; + menuTriggerProps: MenuTriggerContextProps; menuListProps: MenuListContextProps; } @@ -67,7 +70,7 @@ export function useMenuContext(): MenuState { const context = use(MenuContext); if (!context) { - throw new Error('useMenu must be used within a component'); + throw new Error('useMenuContext must be used within a component'); } return context; @@ -81,23 +84,36 @@ function getAllMenuItems(menuListElement: HTMLDivElement) { ); } -export const Menu: React.FC<{ children: React.ReactNode }> = ({ children }) => { +interface MenuProps { + /** + * Set the type according the the menu's use case for accessible markup. + * Use 'navigation' for menus that are primarily used for site navigation. + * Note that navigation menus don't support `MenuItemRadio` and `MenuItemCheckbox`. + */ + type?: MenuType; + children: React.ReactNode; +} + +export const Menu: React.FC = ({ type = 'actions', children }) => { const id = useId(); - const buttonId = `${id}-button`; + const triggerId = `${id}-trigger`; const listId = `${id}-list`; - const [buttonElement, setButtonElement] = useState( - null, - ); + const [triggerElement, setTriggerElement] = + useState(null); const [listElement, setListElement] = useState(null); - const mountListElement = useCallback((element: HTMLDivElement | null) => { - setListElement(element); - if (element) { - const menuItems = getAllMenuItems(element); - const elementToFocus = menuItems[0] ?? element; - elementToFocus.focus(); - } - }, []); + const mountListElement = useCallback( + (element: HTMLDivElement | null) => { + setListElement(element); + + if (element && type === 'actions') { + const menuItems = getAllMenuItems(element); + const elementToFocus = menuItems[0] ?? element; + elementToFocus.focus(); + } + }, + [type], + ); const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -107,14 +123,19 @@ export const Menu: React.FC<{ children: React.ReactNode }> = ({ children }) => { const closeMenu = useCallback(() => { setIsMenuOpen(false); - buttonElement?.focus(); - }, [buttonElement]); + triggerElement?.focus(); + }, [triggerElement]); const toggleMenu = isMenuOpen ? closeMenu : openMenu; const handleMenuNavigation = useCallback( (event: React.KeyboardEvent) => { - if (!listElement) return; + if (!listElement) { + if (event.code === 'ArrowDown') { + openMenu(); + } + return; + } const menuItems = getAllMenuItems(listElement); if (menuItems.length === 0) return; @@ -173,14 +194,16 @@ export const Menu: React.FC<{ children: React.ReactNode }> = ({ children }) => { openMenu, closeMenu, toggleMenu, - reference: buttonElement, + reference: triggerElement, popover: listElement, }; - const menuButtonProps: MenuButtonContextProps = { - id: buttonId, - ref: setButtonElement, - 'aria-haspopup': 'menu', + const role = type === 'actions' ? 'menu' : undefined; + + const menuTriggerProps: MenuTriggerContextProps = { + id: triggerId, + ref: setTriggerElement, + 'aria-haspopup': role, 'aria-expanded': isMenuOpen, 'aria-controls': listElement ? listId : undefined, onClick: toggleMenu, @@ -190,26 +213,28 @@ export const Menu: React.FC<{ children: React.ReactNode }> = ({ children }) => { const menuListProps: MenuListContextProps = { id: listId, ref: mountListElement, - 'aria-labelledby': buttonId, - role: 'menu', + 'aria-labelledby': triggerId, + role, tabIndex: -1, onKeyDown: handleMenuNavigation, }; return { + type, popover, - menuButtonProps, + menuTriggerProps, menuListProps, }; }, [ + type, isMenuOpen, openMenu, closeMenu, toggleMenu, - buttonElement, + triggerElement, listElement, mountListElement, - buttonId, + triggerId, listId, handleMenuNavigation, ]); @@ -217,22 +242,22 @@ export const Menu: React.FC<{ children: React.ReactNode }> = ({ children }) => { return {children}; }; -export type MenuButtonProps = Merge< +export type MenuTriggerProps = Merge< React.ComponentProps, { as?: As; } >; -export const MenuButton = ({ +export const MenuTrigger = ({ as: asComp, children, ...props -}: MenuButtonProps) => { +}: MenuTriggerProps) => { const Component = asComp ?? Button; - const { menuButtonProps } = useMenuContext(); + const { menuTriggerProps } = useMenuContext(); return ( - + {children} ); @@ -247,7 +272,7 @@ export const MenuList = ({ children, ...props }: MenuListProps) => { - const { popover, menuListProps } = useMenuContext(); + const { popover, menuListProps, type } = useMenuContext(); return ( ({ {...props} {...menuListProps} > - {children} + {type === 'navigation' ?
    {children}
: children}
); }; diff --git a/app/javascript/mastodon/components/menu/items.tsx b/app/javascript/mastodon/components/menu/items.tsx index 760ad1c7082..60359ca7386 100644 --- a/app/javascript/mastodon/components/menu/items.tsx +++ b/app/javascript/mastodon/components/menu/items.tsx @@ -1,6 +1,8 @@ -import { useCallback, useId } from 'react'; +import { Fragment, useCallback, useId } from 'react'; import classNames from 'classnames'; +import type { NavLinkProps } from 'react-router-dom'; +import { NavLink } from 'react-router-dom'; import { CheckIcon } from '@phosphor-icons/react'; @@ -8,6 +10,7 @@ import { Toggle } from '../form_fields/redesign'; import { Icon } from '../icon'; import type { IconProp } from '../icon'; +import { useMenuContext } from '.'; import classes from './styles.module.scss'; interface MenuItemGroupProps extends React.ComponentProps<'div'> { @@ -18,15 +21,19 @@ export const MenuItemGroup: React.FC = ({ label, children, }) => { + const { type } = useMenuContext(); const uniqueId = useId(); + // Use list elements if we're in a navigation menu + const Wrapper = type === 'navigation' ? 'li' : 'div'; + return ( -
+
{label}
- {children} -
+ {type === 'navigation' ?
    {children}
: children} + ); }; @@ -46,7 +53,7 @@ type MenuItemProps = iconClassName?: string; }; -export const MenuItemBase = ({ +const MenuItemBase = ({ active, disabled, as: AsComp, @@ -80,7 +87,9 @@ export const MenuItemBase = ({ {children} - {trailingContent} + {trailingContent && ( + {trailingContent} + )}
); }; @@ -89,13 +98,77 @@ export const MenuItem: React.FC, 'as'>> = ({ children, ...props }) => { + const { type } = useMenuContext(); + + const Wrapper = type === 'actions' ? Fragment : 'li'; + return ( - - {children} - + + + {children} + + ); }; +type MenuItemLinkProps = Omit, 'as'> & + ( + | ({ as: 'a' } & React.ComponentProps<'a'>) + | ({ as?: 'link' } & NavLinkProps) + ); + +export const MenuItemLink: React.FC = ({ + as, + children, + onKeyDown, + ...props +}) => { + const { type } = useMenuContext(); + + const handleSpacebarPress = useCallback( + (e: React.KeyboardEvent) => { + if (type === 'actions' && e.code === 'Space') { + (e.target as HTMLElement).click(); + e.preventDefault(); + } + onKeyDown?.(e); + }, + [onKeyDown, type], + ); + + const Wrapper = type === 'actions' ? Fragment : 'li'; + const asElement = (as ?? 'link') === 'link' ? NavLink : 'a'; + + return ( + + + {children} + + + ); +}; + +// Helper to prevent item components from being used with incompatible menu types +function useAssertMenuType(componentName: string) { + const { type } = useMenuContext(); + + if (type === 'navigation') { + throw new Error( + `\`${componentName}\` can not be used inside of \`\`. Use \`type='actions'\` instead.`, + ); + } +} + interface MenuItemRadioProps extends Omit< MenuItemProps<'button'>, 'as' | 'onChange' | 'icon' @@ -112,6 +185,8 @@ export const MenuItemRadio: React.FC = ({ onChange, ...props }) => { + useAssertMenuType('MenuItemRadio'); + const handleChange = useCallback(() => { onChange?.({ value }); }, [value, onChange]); @@ -142,6 +217,8 @@ export const MenuItemCheckbox: React.FC = ({ onChange, ...props }) => { + useAssertMenuType('MenuItemCheckbox'); + const handleChange = useCallback(() => { onChange?.({ value, checked: !checked }); }, [onChange, value, checked]); diff --git a/app/javascript/mastodon/components/menu/menu.stories.tsx b/app/javascript/mastodon/components/menu/menu.stories.tsx index 4e18c24712b..4d0c72ab1a5 100644 --- a/app/javascript/mastodon/components/menu/menu.stories.tsx +++ b/app/javascript/mastodon/components/menu/menu.stories.tsx @@ -13,16 +13,16 @@ import { useToggle } from '@/mastodon/hooks/useToggle'; import { Menu, - MenuButton, + MenuTrigger, MenuList, MenuItem, MenuItemDivider, MenuItemCheckbox, MenuItemGroup, MenuItemRadio, + MenuItemLink, } from '.'; import type { MenuCardProps } from './card'; -import { MenuCard } from './card'; const meta = { title: 'Redesign/Menu', @@ -43,27 +43,12 @@ type Story = StoryObj; const handleMenuItemClick = action('menu item click'); -export const Simple: Story = { - render(args) { - return ( - - - First item - - - Second item - - - ); - }, -}; - -export const Popover: Story = { +export const Default: Story = { render(args) { return (
- Click to show dropdown + Show actions @@ -89,44 +74,70 @@ export const Complex: Story = { }, []); return ( - - First item +
+ + World settings - + + First item - - Daytime toggle - - - - - None - - - Rain - - - Snow - - - + + + + Daytime toggle + + + + + None + + + Rain + + + Snow + + + + +
+ ); + }, +}; + +export const Navigation: Story = { + render(args) { + return ( +
+ + More links + + + About + Terms & Conditions + + Privacy + + + +
); }, }; diff --git a/app/javascript/mastodon/components/menu/styles.module.scss b/app/javascript/mastodon/components/menu/styles.module.scss index 62408f05896..83dacd29389 100644 --- a/app/javascript/mastodon/components/menu/styles.module.scss +++ b/app/javascript/mastodon/components/menu/styles.module.scss @@ -10,6 +10,7 @@ background: var(--color-bg-primary); overflow: hidden; z-index: calc(infinity); + max-width: var(--_max-card-width, 100vw); &[data-elevation='2'] { @include mixins.elevation-2; @@ -44,6 +45,7 @@ border-radius: var(--radius-sm); gap: var(--space-xs); padding: var(--space-xs) var(--space-sm); + text-decoration: none; cursor: var(--cursor); transition: background 200ms, @@ -57,10 +59,19 @@ text-align: start; } + &:hover { + text-decoration: none; + } + &:hover:not([aria-disabled='true']) { background-color: var(--color-bg-highlight); } + &:focus { + // Override overeager global styles + border-radius: var(--radius-sm); + } + &:focus-visible { outline: var(--outline-focus-default); outline-offset: -2px; diff --git a/app/javascript/mastodon/features/compose/redesign/trigger.tsx b/app/javascript/mastodon/features/compose/redesign/trigger.tsx index 7a51db52934..fcbdfd0f732 100644 --- a/app/javascript/mastodon/features/compose/redesign/trigger.tsx +++ b/app/javascript/mastodon/features/compose/redesign/trigger.tsx @@ -14,7 +14,7 @@ import { IconButton } from '@/mastodon/components/button/redesign'; import { CircularProgress } from '@/mastodon/components/circular_progress'; import { Menu, - MenuButton, + MenuTrigger, MenuList, MenuItem, } from '@/mastodon/components/menu'; @@ -71,7 +71,7 @@ export const ComposeRedesignButton: React.FC = () => { return ( - { id='compose.new' defaultMessage='Write a new post or messsage' /> - + diff --git a/app/javascript/mastodon/features/compose/redesign/upload.tsx b/app/javascript/mastodon/features/compose/redesign/upload.tsx index 309700134aa..a1d764337de 100644 --- a/app/javascript/mastodon/features/compose/redesign/upload.tsx +++ b/app/javascript/mastodon/features/compose/redesign/upload.tsx @@ -14,7 +14,7 @@ import { Blurhash } from '@/mastodon/components/blurhash'; import { IconButton } from '@/mastodon/components/button/redesign'; import { Menu, - MenuButton, + MenuTrigger, MenuItem, MenuItemDivider, MenuList, @@ -73,7 +73,7 @@ export const ComposeUpload: React.FC<{ )} - - + diff --git a/app/javascript/mastodon/features/compose/redesign/visibility.tsx b/app/javascript/mastodon/features/compose/redesign/visibility.tsx index 6884acf85d8..abfe3637146 100644 --- a/app/javascript/mastodon/features/compose/redesign/visibility.tsx +++ b/app/javascript/mastodon/features/compose/redesign/visibility.tsx @@ -21,7 +21,7 @@ import { DisplayNameSimple } from '@/mastodon/components/display_name/simple'; import { Menu, MenuList, - MenuButton, + MenuTrigger, MenuItemDivider, MenuItemGroup, MenuItem, @@ -47,9 +47,9 @@ export const ComposeVisibility: React.FC<{ className?: string }> = ({ description='Before button that indicates who a post is for (Public, Followers, mentioned people)' /> - + - + {privacy !== 'direct' ? (