Redesign: Add 'navigation' type to Menu component (#40222)

This commit is contained in:
diondiondion
2026-08-21 08:50:20 +00:00
committed by GitHub
parent 9831c5aac2
commit 1f5adeb345
8 changed files with 245 additions and 116 deletions

View File

@@ -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 = <As extends React.ElementType>({
{...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}
</Component>

View File

@@ -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<HTMLButtonElement>;
@@ -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<HTMLDivElement>;
}
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 <Menu> component');
throw new Error('useMenuContext must be used within a <Menu> 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<MenuProps> = ({ type = 'actions', children }) => {
const id = useId();
const buttonId = `${id}-button`;
const triggerId = `${id}-trigger`;
const listId = `${id}-list`;
const [buttonElement, setButtonElement] = useState<HTMLButtonElement | null>(
null,
);
const [triggerElement, setTriggerElement] =
useState<HTMLButtonElement | null>(null);
const [listElement, setListElement] = useState<HTMLDivElement | null>(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<HTMLElement>) => {
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 <MenuContext value={contextValue}>{children}</MenuContext>;
};
export type MenuButtonProps<As extends React.ElementType> = Merge<
export type MenuTriggerProps<As extends React.ElementType> = Merge<
React.ComponentProps<As>,
{
as?: As;
}
>;
export const MenuButton = <As extends React.ElementType>({
export const MenuTrigger = <As extends React.ElementType>({
as: asComp,
children,
...props
}: MenuButtonProps<As>) => {
}: MenuTriggerProps<As>) => {
const Component = asComp ?? Button;
const { menuButtonProps } = useMenuContext();
const { menuTriggerProps } = useMenuContext();
return (
<Component {...props} {...menuButtonProps}>
<Component {...props} {...menuTriggerProps}>
{children}
</Component>
);
@@ -247,7 +272,7 @@ export const MenuList = <As extends React.ElementType>({
children,
...props
}: MenuListProps<As>) => {
const { popover, menuListProps } = useMenuContext();
const { popover, menuListProps, type } = useMenuContext();
return (
<PopoverMenuCard
@@ -259,7 +284,7 @@ export const MenuList = <As extends React.ElementType>({
{...props}
{...menuListProps}
>
{children}
{type === 'navigation' ? <ul>{children}</ul> : children}
</PopoverMenuCard>
);
};

View File

@@ -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<MenuItemGroupProps> = ({
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 (
<div aria-labelledby={uniqueId} role='group'>
<Wrapper aria-labelledby={uniqueId} role='group'>
<div id={uniqueId} className={classes.itemGroupLabel}>
{label}
</div>
{children}
</div>
{type === 'navigation' ? <ul>{children}</ul> : children}
</Wrapper>
);
};
@@ -46,7 +53,7 @@ type MenuItemProps<As extends React.ElementType> =
iconClassName?: string;
};
export const MenuItemBase = <As extends React.ElementType>({
const MenuItemBase = <As extends React.ElementType>({
active,
disabled,
as: AsComp,
@@ -80,7 +87,9 @@ export const MenuItemBase = <As extends React.ElementType>({
{children}
<span className={classes.itemTrailingContent}>{trailingContent}</span>
{trailingContent && (
<span className={classes.itemTrailingContent}>{trailingContent}</span>
)}
</Component>
);
};
@@ -89,13 +98,77 @@ export const MenuItem: React.FC<Omit<MenuItemProps<'button'>, 'as'>> = ({
children,
...props
}) => {
const { type } = useMenuContext();
const Wrapper = type === 'actions' ? Fragment : 'li';
return (
<MenuItemBase as='button' type='button' role='menuitem' {...props}>
{children}
</MenuItemBase>
<Wrapper>
<MenuItemBase
as='button'
type='button'
role={type === 'actions' ? 'menuitem' : undefined}
{...props}
>
{children}
</MenuItemBase>
</Wrapper>
);
};
type MenuItemLinkProps = Omit<MenuItemProps<'a'>, 'as'> &
(
| ({ as: 'a' } & React.ComponentProps<'a'>)
| ({ as?: 'link' } & NavLinkProps)
);
export const MenuItemLink: React.FC<MenuItemLinkProps> = ({
as,
children,
onKeyDown,
...props
}) => {
const { type } = useMenuContext();
const handleSpacebarPress = useCallback(
(e: React.KeyboardEvent<HTMLAnchorElement>) => {
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 (
<Wrapper>
<MenuItemBase
as={asElement}
role={type === 'actions' ? 'menuitem' : undefined}
onKeyDown={handleSpacebarPress}
{...props}
>
{children}
</MenuItemBase>
</Wrapper>
);
};
// 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 \`<Menu type='navigation'>\`. Use \`type='actions'\` instead.`,
);
}
}
interface MenuItemRadioProps extends Omit<
MenuItemProps<'button'>,
'as' | 'onChange' | 'icon'
@@ -112,6 +185,8 @@ export const MenuItemRadio: React.FC<MenuItemRadioProps> = ({
onChange,
...props
}) => {
useAssertMenuType('MenuItemRadio');
const handleChange = useCallback(() => {
onChange?.({ value });
}, [value, onChange]);
@@ -142,6 +217,8 @@ export const MenuItemCheckbox: React.FC<MenuItemCheckboxProps> = ({
onChange,
...props
}) => {
useAssertMenuType('MenuItemCheckbox');
const handleChange = useCallback(() => {
onChange?.({ value, checked: !checked });
}, [onChange, value, checked]);

View File

@@ -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<typeof meta>;
const handleMenuItemClick = action('menu item click');
export const Simple: Story = {
render(args) {
return (
<MenuCard {...args}>
<MenuItem icon={NumberCircleOneIcon} onClick={handleMenuItemClick}>
First item
</MenuItem>
<MenuItem icon={NumberCircleTwoIcon} onClick={handleMenuItemClick}>
Second item
</MenuItem>
</MenuCard>
);
},
};
export const Popover: Story = {
export const Default: Story = {
render(args) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Menu>
<MenuButton>Click to show dropdown</MenuButton>
<MenuTrigger>Show actions</MenuTrigger>
<MenuList {...args}>
<MenuItem icon={NumberCircleOneIcon} onClick={handleMenuItemClick}>
@@ -89,44 +74,70 @@ export const Complex: Story = {
}, []);
return (
<MenuCard {...args}>
<MenuItem onClick={handleMenuItemClick}>First item</MenuItem>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Menu>
<MenuTrigger>World settings</MenuTrigger>
<MenuItemDivider />
<MenuList {...args}>
<MenuItem onClick={handleMenuItemClick}>First item</MenuItem>
<MenuItemCheckbox
icon={sun ? SunIcon : MoonIcon}
checked={sun}
onChange={onToggle}
value='daytime'
>
Daytime toggle
</MenuItemCheckbox>
<MenuItemDivider />
<MenuItemGroup label='Precipitation'>
<MenuItemRadio
value='none'
checked={precip === 'none'}
onChange={handlePrecipChange}
>
None
</MenuItemRadio>
<MenuItemRadio
value='rain'
checked={precip === 'rain'}
onChange={handlePrecipChange}
>
Rain
</MenuItemRadio>
<MenuItemRadio
value='snow'
checked={precip === 'snow'}
onChange={handlePrecipChange}
>
Snow
</MenuItemRadio>
</MenuItemGroup>
</MenuCard>
<MenuItemDivider />
<MenuItemCheckbox
icon={sun ? SunIcon : MoonIcon}
checked={sun}
onChange={onToggle}
value='daytime'
>
Daytime toggle
</MenuItemCheckbox>
<MenuItemDivider />
<MenuItemGroup label='Precipitation'>
<MenuItemRadio
value='none'
checked={precip === 'none'}
onChange={handlePrecipChange}
>
None
</MenuItemRadio>
<MenuItemRadio
value='rain'
checked={precip === 'rain'}
onChange={handlePrecipChange}
>
Rain
</MenuItemRadio>
<MenuItemRadio
value='snow'
checked={precip === 'snow'}
onChange={handlePrecipChange}
>
Snow
</MenuItemRadio>
</MenuItemGroup>
</MenuList>
</Menu>
</div>
);
},
};
export const Navigation: Story = {
render(args) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Menu type='navigation'>
<MenuTrigger>More links</MenuTrigger>
<MenuList {...args}>
<MenuItemLink to='/about'>About</MenuItemLink>
<MenuItemLink to='/privacy'>Terms & Conditions</MenuItemLink>
<MenuItemLink href='/terms' as='a'>
Privacy
</MenuItemLink>
</MenuList>
</Menu>
</div>
);
},
};

View File

@@ -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;

View File

@@ -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 (
<Menu>
<MenuButton
<MenuTrigger
as={IconButton}
icon={PenNibIcon}
color='neutral'
@@ -82,7 +82,7 @@ export const ComposeRedesignButton: React.FC = () => {
id='compose.new'
defaultMessage='Write a new post or messsage'
/>
</MenuButton>
</MenuTrigger>
<MenuList maxWidth={180} placement='top-end'>
<MenuItem name='post' onClick={handleComposerOpen} icon={NewspaperIcon}>

View File

@@ -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<{
)}
<Menu>
<MenuButton
<MenuTrigger
as={IconButton}
icon={DotsThreeIcon}
size='sm'
@@ -84,7 +84,7 @@ export const ComposeUpload: React.FC<{
id='compose.upload.menu'
defaultMessage='Add alt text or remove the image'
/>
</MenuButton>
</MenuTrigger>
<ComposeUploadMenu attachment={attachment} single={single} />
</Menu>

View File

@@ -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)'
/>
<Menu>
<MenuButton size='sm'>
<MenuTrigger size='sm'>
<ComposeVisibilityButtonText privacy={privacy} />
</MenuButton>
</MenuTrigger>
{privacy !== 'direct' ? (
<ComposeVisibilityMenu />