Add new Menu component (#40184)

This commit is contained in:
diondiondion
2026-08-19 13:51:10 +00:00
committed by GitHub
parent 78d0af926a
commit 656fcf857d
13 changed files with 629 additions and 704 deletions

View File

@@ -1,117 +0,0 @@
import { useState } from 'react';
import {
MoonIcon,
NumberCircleOneIcon,
NumberCircleTwoIcon,
SunIcon,
} from '@phosphor-icons/react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import { useToggle } from '@/mastodon/hooks/useToggle';
import { Button } from '../button/redesign';
import { ToggleField } from '../form_fields/redesign';
import type { DropdownProps } from './redesign';
import {
Dropdown,
DropdownItem,
DropdownItemButton,
DropdownPopover,
} from './redesign';
const meta = {
title: 'Redesign/Dropdown',
args: {
elevation: 1,
},
argTypes: {
elevation: {
control: 'inline-radio',
options: [1, 2],
},
},
} satisfies Meta<Omit<DropdownProps<'div'>, 'children'>>;
export default meta;
type Story = StoryObj<typeof meta>;
const handleMenuItemClick = action('menu item click');
export const Simple: Story = {
render(args) {
return (
<Dropdown {...args}>
<DropdownItemButton
leadingIcon={NumberCircleOneIcon}
onClick={handleMenuItemClick}
>
First item
</DropdownItemButton>
<DropdownItemButton
leadingIcon={NumberCircleTwoIcon}
onClick={handleMenuItemClick}
>
Second item
</DropdownItemButton>
</Dropdown>
);
},
};
export const Popover: Story = {
render(args) {
const [ref, setRef] = useState<HTMLButtonElement | null>(null);
const [open, { onToggle, onFalse }] = useToggle();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Button ref={setRef} onClick={onToggle}>
Click to show dropdown
</Button>
<DropdownPopover
reference={ref}
isOpen={open}
onClose={onFalse}
{...args}
>
<DropdownItemButton
leadingIcon={NumberCircleOneIcon}
onClick={handleMenuItemClick}
>
First item
</DropdownItemButton>
<DropdownItemButton
leadingIcon={NumberCircleTwoIcon}
onClick={handleMenuItemClick}
>
Second item
</DropdownItemButton>
</DropdownPopover>
</div>
);
},
};
export const Controls: Story = {
render(args) {
const [sun, { onToggle }] = useToggle();
return (
<Dropdown {...args}>
<DropdownItemButton onClick={handleMenuItemClick}>
First item
</DropdownItemButton>
<hr />
<DropdownItem leadingIcon={sun ? SunIcon : MoonIcon}>
<ToggleField label='Daytime toggle' size='sm' onChange={onToggle} />
</DropdownItem>
</Dropdown>
);
},
};

View File

@@ -1,170 +0,0 @@
import type React from 'react';
import classNames from 'classnames';
import type { Merge } from 'type-fest';
import { Icon } from '../icon';
import type { IconProp } from '../icon';
import type { PopoverProps } from '../popover';
import { Popover } from '../popover';
import classes from './redesign.module.scss';
export const menuItemClass = classes.menuItem;
export type DropdownProps<As extends React.ElementType> = Merge<
{
as?: As;
children: React.ReactNode;
className?: string;
elevation?: 1 | 2;
maxWidth?: number | string;
style?: React.CSSProperties;
},
React.ComponentProps<As>
>;
export const DropdownPopover = <As extends React.ElementType>({
isOpen,
onClose,
reference,
popoverElement,
container,
placement,
offset = 4,
flip,
strategy,
matchReferenceWidth,
closeOnClickOutside,
children,
className,
...props
}: DropdownProps<As> & Omit<PopoverProps, 'children'>) => {
const popoverProps = {
isOpen,
onClose,
reference,
popoverElement,
container,
placement,
offset,
flip,
strategy,
matchReferenceWidth,
closeOnClickOutside,
};
return (
<Popover {...popoverProps}>
{({ props: popoverChildProps }) => (
<Dropdown
{...props}
{...popoverChildProps}
className={classNames(
className,
props.maxWidth && classes.popoverMenu,
)}
>
{children}
</Dropdown>
)}
</Popover>
);
};
export const Dropdown = <As extends React.ElementType>({
as: asComp,
children,
className,
elevation = 1,
maxWidth,
style,
...props
}: DropdownProps<As>) => {
const Component = asComp ?? 'div';
return (
<Component
{...props}
className={classNames(className, classes.menu)}
data-elevation={elevation}
style={{
maxWidth: typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth,
...style,
}}
>
{children}
</Component>
);
};
type DropdownItemProps<As extends React.ElementType> = Merge<
{
as?: As;
children?: React.ReactNode;
className?: string;
active?: boolean;
disabled?: boolean;
leadingIcon?: IconProp;
trailingIcon?: IconProp;
iconClassName?: string;
},
React.ComponentPropsWithoutRef<As>
>;
export const DropdownItem = <As extends React.ElementType>({
active,
disabled,
as: AsComp,
children,
className,
leadingIcon,
trailingIcon,
iconClassName,
...props
}: DropdownItemProps<As>) => {
const Component = AsComp ?? 'div';
return (
<Component
{...props}
className={classNames(
className,
classes.menuItem,
active && classes.menuItemActive,
disabled && classes.menuItemDisabled,
)}
>
{leadingIcon && (
<Icon
id='menu'
icon={leadingIcon}
className={classNames(iconClassName, classes.menuItemIcon)}
/>
)}
{children}
{trailingIcon && (
<Icon
id='menu'
icon={trailingIcon}
className={classNames(iconClassName, classes.menuItemIcon)}
/>
)}
</Component>
);
};
export const DropdownItemButton: React.FC<
Omit<DropdownItemProps<'button'>, 'as'>
> = ({ children, className, ...props }) => {
return (
<DropdownItem
type='button'
{...props}
as='button'
className={classNames(className, classes.menuItemButton)}
>
{children}
</DropdownItem>
);
};

View File

@@ -0,0 +1,94 @@
import classNames from 'classnames';
import type { Merge } from 'type-fest';
import { Popover } from '../popover';
import type { PopoverProps } from '../popover';
import classes from './styles.module.scss';
export type MenuCardProps<As extends React.ElementType> = Merge<
{
as?: As;
children: React.ReactNode;
className?: string;
elevation?: 1 | 2;
maxWidth?: number | string;
style?: React.CSSProperties;
},
React.ComponentProps<As>
>;
export const MenuCard = <As extends React.ElementType>({
as: asComp,
children,
className,
elevation = 1,
maxWidth,
style,
...props
}: MenuCardProps<As>) => {
const Component = asComp ?? 'div';
return (
<Component
{...props}
className={classNames(className, classes.menuCard)}
data-elevation={elevation}
style={{
maxWidth: typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth,
...style,
}}
>
{children}
</Component>
);
};
export type PopoverMenuCardProps<As extends React.ElementType> =
MenuCardProps<As> & Omit<PopoverProps, 'children'>;
export const PopoverMenuCard = <As extends React.ElementType>({
isOpen,
onClose,
reference,
popoverElement,
container,
placement,
offset = 4,
flip,
strategy,
matchReferenceWidth,
closeOnClickOutside,
children,
className,
...props
}: PopoverMenuCardProps<As>) => {
return (
<Popover
isOpen={isOpen}
onClose={onClose}
reference={reference}
popoverElement={popoverElement}
container={container}
placement={placement}
offset={offset}
flip={flip}
strategy={strategy}
matchReferenceWidth={matchReferenceWidth}
closeOnClickOutside={closeOnClickOutside}
>
{({ props: popoverChildProps }) => (
<MenuCard
{...popoverChildProps}
{...props}
className={classNames(
className,
props.maxWidth && classes.popoverMenuCard,
)}
>
{children}
</MenuCard>
)}
</Popover>
);
};

View File

@@ -0,0 +1,336 @@
import type React from 'react';
import {
createContext,
use,
useCallback,
useId,
useMemo,
useState,
} from 'react';
import classNames from 'classnames';
import type { Merge } from 'type-fest';
import { Button } from '../button/redesign';
import { Icon } from '../icon';
import type { IconProp } from '../icon';
import { PopoverMenuCard } from './card';
import type { PopoverMenuCardProps } from './card';
import classes from './styles.module.scss';
export const menuItemClass = classes.menuItem;
interface PopoverState {
isMenuOpen: boolean;
toggleMenu: () => void;
openMenu: () => void;
closeMenu: () => void;
popover: HTMLDivElement | null;
reference: HTMLButtonElement | null;
}
interface MenuButtonContextProps {
ref: (button: HTMLButtonElement | null) => void;
id: string;
'aria-haspopup': 'menu';
'aria-expanded': boolean;
'aria-controls'?: string;
onKeyDown: React.KeyboardEventHandler<HTMLButtonElement>;
onClick: React.MouseEventHandler<HTMLButtonElement>;
}
interface MenuListContextProps {
ref: (button: HTMLDivElement | null) => void;
role: 'menu';
tabIndex: -1;
id: string;
'aria-labelledby': string;
onKeyDown: React.KeyboardEventHandler<HTMLDivElement>;
}
interface MenuState {
popover: PopoverState;
menuButtonProps: MenuButtonContextProps;
menuListProps: MenuListContextProps;
}
const MenuContext = createContext<MenuState | null>(null);
export function useMenuContext(): MenuState {
const context = use(MenuContext);
if (!context) {
throw new Error('useMenu must be used within a <Menu> component');
}
return context;
}
function getAllMenuItems(menuListElement: HTMLDivElement) {
return Array.from(
menuListElement.querySelectorAll<HTMLElement>(
':scope [data-menu-item]:not([disabled], [aria-disabled])',
),
);
}
export const Menu: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const id = useId();
const buttonId = `${id}-button`;
const listId = `${id}-list`;
const [buttonElement, setButtonElement] = 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 [isMenuOpen, setIsMenuOpen] = useState(false);
const openMenu = useCallback(() => {
setIsMenuOpen(true);
}, []);
const closeMenu = useCallback(() => {
setIsMenuOpen(false);
buttonElement?.focus();
}, [buttonElement]);
const toggleMenu = isMenuOpen ? closeMenu : openMenu;
const handleMenuNavigation = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (!listElement) return;
const menuItems = getAllMenuItems(listElement);
if (menuItems.length === 0) return;
const activeElement = document.activeElement as HTMLElement;
const currentIndex = menuItems.indexOf(activeElement);
switch (event.code) {
case 'ArrowDown': {
event.preventDefault();
if (isMenuOpen) {
const nextIndex =
currentIndex === -1 ? 0 : (currentIndex + 1) % menuItems.length;
menuItems[nextIndex]?.focus();
} else {
openMenu();
}
break;
}
case 'ArrowUp': {
event.preventDefault();
const prevIndex =
currentIndex === -1
? menuItems.length - 1
: (currentIndex - 1 + menuItems.length) % menuItems.length;
menuItems[prevIndex]?.focus();
break;
}
case 'Home': {
event.preventDefault();
menuItems[0]?.focus();
break;
}
case 'End': {
event.preventDefault();
menuItems[menuItems.length - 1]?.focus();
break;
}
case 'Escape': {
event.preventDefault();
closeMenu();
break;
}
}
},
[closeMenu, isMenuOpen, listElement, openMenu],
);
const contextValue = useMemo(() => {
const popover: PopoverState = {
isMenuOpen,
openMenu,
closeMenu,
toggleMenu,
reference: buttonElement,
popover: listElement,
};
const menuButtonProps: MenuButtonContextProps = {
id: buttonId,
ref: setButtonElement,
'aria-haspopup': 'menu',
'aria-expanded': isMenuOpen,
'aria-controls': listElement ? listId : undefined,
onClick: toggleMenu,
onKeyDown: handleMenuNavigation,
};
const menuListProps: MenuListContextProps = {
id: listId,
ref: mountListElement,
'aria-labelledby': buttonId,
role: 'menu',
tabIndex: -1,
onKeyDown: handleMenuNavigation,
};
return {
popover,
menuButtonProps,
menuListProps,
};
}, [
isMenuOpen,
openMenu,
closeMenu,
toggleMenu,
buttonElement,
listElement,
mountListElement,
buttonId,
listId,
handleMenuNavigation,
]);
return <MenuContext value={contextValue}>{children}</MenuContext>;
};
export type MenuButtonProps<As extends React.ElementType> = Merge<
React.ComponentProps<As>,
{
as?: As;
}
>;
export const MenuButton = <As extends React.ElementType>({
as: asComp,
children,
...props
}: MenuButtonProps<As>) => {
const Component = asComp ?? Button;
const { menuButtonProps } = useMenuContext();
return (
<Component {...props} {...menuButtonProps}>
{children}
</Component>
);
};
export type MenuListProps<As extends React.ElementType> = Omit<
PopoverMenuCardProps<As>,
'isOpen' | 'onClose' | 'reference' | 'popoverElement'
>;
export const MenuList = <As extends React.ElementType>({
children,
...props
}: MenuListProps<As>) => {
const { popover, menuListProps } = useMenuContext();
return (
<PopoverMenuCard
isOpen={popover.isMenuOpen}
onClose={popover.closeMenu}
reference={popover.reference}
popoverElement={popover.popover}
container={null}
{...props}
{...menuListProps}
>
{children}
</PopoverMenuCard>
);
};
type MenuItemProps<As extends React.ElementType> = Merge<
{
as?: As;
children?: React.ReactNode;
className?: string;
active?: boolean;
disabled?: boolean;
leadingIcon?: IconProp;
trailingIcon?: IconProp;
iconClassName?: string;
},
React.ComponentPropsWithoutRef<As>
>;
export const MenuItemBase = <As extends React.ElementType>({
active,
disabled,
as: AsComp,
children,
className,
leadingIcon,
trailingIcon,
iconClassName,
...props
}: MenuItemProps<As>) => {
const Component = AsComp ?? 'div';
return (
<Component
{...props}
data-menu-item
className={classNames(
className,
classes.menuItem,
active && classes.menuItemActive,
)}
aria-disabled={disabled}
>
{leadingIcon && (
<Icon
id='menu'
icon={leadingIcon}
className={classNames(iconClassName, classes.menuItemIcon)}
/>
)}
{children}
{trailingIcon && (
<Icon
id='menu'
icon={trailingIcon}
className={classNames(iconClassName, classes.menuItemIcon)}
/>
)}
</Component>
);
};
export const MenuItem: React.FC<Omit<MenuItemProps<'button'>, 'as'>> = ({
children,
className,
...props
}) => {
return (
<MenuItemBase
as='button'
type='button'
role='menuitem'
{...props}
className={className}
>
{children}
</MenuItemBase>
);
};

View File

@@ -0,0 +1,100 @@
import {
MoonIcon,
NumberCircleOneIcon,
NumberCircleTwoIcon,
SunIcon,
} from '@phosphor-icons/react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import { useToggle } from '@/mastodon/hooks/useToggle';
import { ToggleField } from '../form_fields/redesign';
import { Menu, MenuButton, MenuList, MenuItemBase, MenuItem } from '.';
import type { MenuCardProps } from './card';
import { MenuCard } from './card';
const meta = {
title: 'Redesign/Menu',
args: {
elevation: 1,
},
argTypes: {
elevation: {
control: 'inline-radio',
options: [1, 2],
},
},
} satisfies Meta<Omit<MenuCardProps<'div'>, 'children'>>;
export default meta;
type Story = StoryObj<typeof meta>;
const handleMenuItemClick = action('menu item click');
export const Simple: Story = {
render(args) {
return (
<MenuCard {...args}>
<MenuItem
leadingIcon={NumberCircleOneIcon}
onClick={handleMenuItemClick}
>
First item
</MenuItem>
<MenuItem
leadingIcon={NumberCircleTwoIcon}
onClick={handleMenuItemClick}
>
Second item
</MenuItem>
</MenuCard>
);
},
};
export const Popover: Story = {
render(args) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Menu>
<MenuButton>Click to show dropdown</MenuButton>
<MenuList {...args}>
<MenuItem
leadingIcon={NumberCircleOneIcon}
onClick={handleMenuItemClick}
>
First item
</MenuItem>
<MenuItem
leadingIcon={NumberCircleTwoIcon}
onClick={handleMenuItemClick}
>
Second item
</MenuItem>
</MenuList>
</Menu>
</div>
);
},
};
export const Controls: Story = {
render(args) {
const [sun, { onToggle }] = useToggle();
return (
<MenuCard {...args}>
<MenuItem onClick={handleMenuItemClick}>First item</MenuItem>
<hr />
<MenuItemBase leadingIcon={sun ? SunIcon : MoonIcon}>
<ToggleField label='Daytime toggle' size='sm' onChange={onToggle} />
</MenuItemBase>
</MenuCard>
);
},
};

View File

@@ -1,6 +1,6 @@
@use '@/styles/mastodon/mixins';
.menu {
.menuCard {
@include mixins.elevation-1;
display: flex;
@@ -9,6 +9,7 @@
border-radius: var(--radius-md);
background: var(--color-bg-primary);
overflow: hidden;
z-index: calc(infinity);
&[data-elevation='2'] {
@include mixins.elevation-2;
@@ -22,6 +23,10 @@
}
}
.popoverMenuCard {
width: 100%;
}
.menuItem {
@include mixins.type-body-compact;
@@ -37,17 +42,24 @@
background 200ms,
color 200ms;
&:where(button) {
appearance: none;
background: none;
border: none;
width: 100%;
}
label {
cursor: inherit;
font-weight: inherit;
font-size: var(--fs-sm);
}
&:hover:not(.menuItemDisabled) {
&:hover:not([aria-disabled='true']) {
background-color: var(--color-bg-highlight);
}
&:active:not(.menuItemDisabled),
&:active:not([aria-disabled='true']),
.menuItemActive {
background-color: var(--color-bg-inverted);
color: var(--color-text-inverted);
@@ -58,20 +70,13 @@
}
&:has(:disabled),
.menuItemDisabled {
[aria-disabled='true'] {
--cursor: not-allowed;
opacity: 0.5;
}
}
.menuItemButton {
appearance: none;
background: none;
border: none;
width: 100%;
}
.menuItemIcon {
width: var(--space-lg);
height: var(--space-lg);
@@ -84,7 +89,3 @@
border: none;
width: calc(100% - (2 * var(--space-sm)));
}
.popoverMenu {
width: 100%;
}

View File

@@ -190,18 +190,22 @@ export const Popover: React.FC<PopoverProps> = ({
return null;
}
const props: PopoverChildProps = {
style: floatingStyles,
'data-popover-placement': computedPlacement,
'data-popover-reference-hidden': middlewareData.hide?.referenceHidden,
'data-popover-escaped': middlewareData.hide?.escaped,
};
if (!popoverElement) {
props.ref = refs.setFloating;
}
return (
<Portal container={container}>
{children({
placement: computedPlacement,
update,
props: {
ref: popoverElement ? undefined : refs.setFloating,
style: floatingStyles,
'data-popover-placement': computedPlacement,
'data-popover-reference-hidden': middlewareData.hide?.referenceHidden,
'data-popover-escaped': middlewareData.hide?.escaped,
},
props,
})}
</Portal>
);

View File

@@ -12,7 +12,7 @@ import { emojiUse } from '@/mastodon/actions/emojis';
import { changeSetting } from '@/mastodon/actions/settings';
import { IconButton } from '@/mastodon/components/button/redesign';
import { CircularProgress } from '@/mastodon/components/circular_progress';
import { Dropdown } from '@/mastodon/components/dropdown/redesign';
import { MenuCard } from '@/mastodon/components/menu/card';
import type { PopoverChildProps } from '@/mastodon/components/popover';
import { Popover } from '@/mastodon/components/popover';
import { useToggle } from '@/mastodon/hooks/useToggle';
@@ -161,7 +161,7 @@ const ComposeEmojiDropdown: React.FC<
);
return (
<Dropdown
<MenuCard
{...props}
className={classNames(
'dropdown-animation',
@@ -216,7 +216,7 @@ const ComposeEmojiDropdown: React.FC<
</div>
</div>
</Suspense>
</Dropdown>
</MenuCard>
);
};

View File

@@ -7,7 +7,7 @@ import { TranslateIcon } from '@phosphor-icons/react';
import { changeComposeLanguage } from '@/mastodon/actions/compose';
import { IconButton } from '@/mastodon/components/button/redesign';
import { DropdownPopover } from '@/mastodon/components/dropdown/redesign';
import { PopoverMenuCard } from '@/mastodon/components/menu/card';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { LanguageDropdownMenu } from '../components/language_dropdown';
@@ -55,7 +55,7 @@ export const LanguageButton: React.FC = () => {
/>
</IconButton>
<DropdownPopover
<PopoverMenuCard
isOpen={open}
onClose={handleClose}
offset={4}
@@ -65,7 +65,7 @@ export const LanguageButton: React.FC = () => {
maxWidth={280}
>
<LanguageDropdown onClose={handleClose} />
</DropdownPopover>
</PopoverMenuCard>
</>
);
};

View File

@@ -1,6 +1,6 @@
/* eslint-disable jsx-a11y/no-autofocus */
import type React from 'react';
import { lazy, Suspense, useCallback, useState } from 'react';
import { lazy, Suspense, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
@@ -13,11 +13,12 @@ import {
import { IconButton } from '@/mastodon/components/button/redesign';
import { CircularProgress } from '@/mastodon/components/circular_progress';
import {
Dropdown,
DropdownItemButton,
DropdownPopover,
} from '@/mastodon/components/dropdown/redesign';
import { useToggle } from '@/mastodon/hooks/useToggle';
Menu,
MenuButton,
MenuList,
MenuItem,
} from '@/mastodon/components/menu';
import { MenuCard } from '@/mastodon/components/menu/card';
import { openNewComposer } from '@/mastodon/reducers/slices/composer';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { isRedesignEnabled } from '@/mastodon/utils/environment';
@@ -32,9 +33,6 @@ const ComposeLazyForm = lazy(() =>
);
export const ComposeRedesignButton: React.FC = () => {
const [ref, setRef] = useState<HTMLButtonElement | null>(null);
const [menuOpen, { onFalse: onMenuClose, onToggle: onMenuToggle }] =
useToggle();
const displayState = useAppSelector((state) => state.composer.displayState);
const dispatch = useAppDispatch();
@@ -46,10 +44,9 @@ export const ComposeRedesignButton: React.FC = () => {
} = event;
if (name === 'post' || name === 'message') {
dispatch(openNewComposer({ type: name }));
onMenuClose();
}
},
[dispatch, onMenuClose],
[dispatch],
);
if (!isRedesignEnabled()) {
@@ -58,9 +55,9 @@ export const ComposeRedesignButton: React.FC = () => {
if (displayState === 'minimized') {
return (
<Dropdown className={classes.composerMinimized} elevation={2}>
<MenuCard className={classes.composerMinimized} elevation={2}>
<ComposeFormHeader />
</Dropdown>
</MenuCard>
);
}
@@ -73,12 +70,11 @@ export const ComposeRedesignButton: React.FC = () => {
}
return (
<>
<IconButton
<Menu>
<MenuButton
as={IconButton}
icon={PenNibIcon}
color='neutral'
ref={setRef}
onClick={onMenuToggle}
className={classes.button}
size='lg'
>
@@ -86,24 +82,18 @@ export const ComposeRedesignButton: React.FC = () => {
id='compose.new'
defaultMessage='Write a new post or messsage'
/>
</IconButton>
</MenuButton>
<DropdownPopover
isOpen={menuOpen}
maxWidth={180}
reference={ref}
onClose={onMenuClose}
placement='top-end'
>
<DropdownItemButton
<MenuList maxWidth={180} placement='top-end'>
<MenuItem
name='post'
onClick={handleComposerOpen}
leadingIcon={NewspaperIcon}
>
<FormattedMessage id='compose.new.post' defaultMessage='Post' />
</DropdownItemButton>
</MenuItem>
<DropdownItemButton
<MenuItem
name='message'
onClick={handleComposerOpen}
leadingIcon={ChatCircleIcon}
@@ -113,8 +103,8 @@ export const ComposeRedesignButton: React.FC = () => {
defaultMessage='Message'
description='Message refers to a direct message. For languages where this is confusing, "chat" or "direct message" can be used.'
/>
</DropdownItemButton>
</DropdownPopover>
</>
</MenuItem>
</MenuList>
</Menu>
);
};

View File

@@ -12,10 +12,7 @@ import { openModal } from '@/mastodon/actions/modal';
import type { ApiAudioAttachmentJSON } from '@/mastodon/api_types/media_attachments';
import { Blurhash } from '@/mastodon/components/blurhash';
import { IconButton } from '@/mastodon/components/button/redesign';
import {
DropdownItemButton,
DropdownPopover,
} from '@/mastodon/components/dropdown/redesign';
import { MenuItem, MenuList } from '@/mastodon/components/menu';
import { useToggle } from '@/mastodon/hooks/useToggle';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
@@ -103,7 +100,7 @@ export const ComposeUpload: React.FC<{
/>
</IconButton>
<DropdownPopover
<MenuList
isOpen={open}
onClose={onFalse}
reference={target}
@@ -111,7 +108,7 @@ export const ComposeUpload: React.FC<{
offset={4}
maxWidth={170}
>
<DropdownItemButton onClick={handleEdit}>
<MenuItem onClick={handleEdit}>
{attachment.description ? (
<FormattedMessage
id='compose.upload.menu.edit_alt'
@@ -123,20 +120,20 @@ export const ComposeUpload: React.FC<{
defaultMessage='Add alt text'
/>
)}
</DropdownItemButton>
</MenuItem>
{!single && (
<DropdownItemButton onClick={handleRearrange}>
<MenuItem onClick={handleRearrange}>
<FormattedMessage
id='compose.upload.menu.rearrange'
defaultMessage='Rearrange&hellip;'
/>
</DropdownItemButton>
</MenuItem>
)}
<hr />
<DropdownItemButton
<MenuItem
className={classes.mediaMenuDelete}
onClick={handleDelete}
leadingIcon={TrashIcon}
@@ -145,8 +142,8 @@ export const ComposeUpload: React.FC<{
id='compose.upload.menu.delete'
defaultMessage='Remove image'
/>
</DropdownItemButton>
</DropdownPopover>
</MenuItem>
</MenuList>
{attachment.description && (
<span className={classes.mediaAlt}>

View File

@@ -1,5 +1,5 @@
import type React from 'react';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
@@ -16,20 +16,19 @@ import {
} from '@/mastodon/actions/compose_typed';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import { Button } from '@/mastodon/components/button/redesign';
import {
Dropdown,
DropdownItem,
DropdownItemButton,
} from '@/mastodon/components/dropdown/redesign';
import { Fieldset } from '@/mastodon/components/form_fields';
import {
ToggleField,
RadioButtonField,
} from '@/mastodon/components/form_fields/redesign';
import type { IconProp } from '@/mastodon/components/icon';
import { Popover } from '@/mastodon/components/popover';
import { useToggle } from '@/mastodon/hooks/useToggle';
import {
Menu,
MenuList,
MenuButton,
MenuItemBase,
MenuItem,
} from '@/mastodon/components/menu';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { selectComposeMentions, selectComposePrivacy } from './selectors';
@@ -38,8 +37,6 @@ import classes from './styles.module.scss';
export const ComposeVisibility: React.FC = () => {
const privacy = useAppSelector(selectComposePrivacy);
const mentions = useAppSelector(selectComposeMentions);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const [showMenu, { onToggle, onFalse }] = useToggle();
return (
<>
@@ -48,42 +45,31 @@ export const ComposeVisibility: React.FC = () => {
defaultMessage='To:'
description='Before button that indicates who a post is for (Public, Followers, mentioned people)'
/>
<Menu>
<MenuButton size='sm'>
{privacy !== 'private' && (
<FormattedMessage
id='privacy.public.short'
defaultMessage='Public'
/>
)}
{privacy === 'private' && (
<FormattedMessage
id='compose.post.privacy.followers'
defaultMessage='Followers {count, plural, =0 {} one {+ # other} other {+ # others}}'
description='Count is # of other people mentioned in the post. If zero, just output "Followers".'
values={{ count: mentions.size }}
/>
)}
</MenuButton>
<Button
size='sm'
onClick={onToggle}
ref={setTrigger}
aria-expanded={showMenu}
>
{privacy !== 'private' && (
<FormattedMessage id='privacy.public.short' defaultMessage='Public' />
)}
{privacy === 'private' && (
<FormattedMessage
id='compose.post.privacy.followers'
defaultMessage='Followers {count, plural, =0 {} one {+ # other} other {+ # others}}'
description='Count is # of other people mentioned in the post. If zero, just output "Followers".'
values={{ count: mentions.size }}
/>
)}
</Button>
<Popover
isOpen={showMenu}
onClose={onFalse}
reference={trigger}
placement='bottom-start'
offset={4}
>
{({ props }) => <ComposeVisibilityMenu {...props} />}
</Popover>
<ComposeVisibilityMenu />
</Menu>
</>
);
};
const ComposeVisibilityMenu: React.FC<Record<string, unknown>> = (
wrapperProps,
) => {
const ComposeVisibilityMenu: React.FC = () => {
const privacy = useAppSelector(selectComposePrivacy);
const defaultPrivacy = useAppSelector(
(state) => state.compose.get('default_privacy') as StatusVisibility,
@@ -150,7 +136,7 @@ const ComposeVisibilityMenu: React.FC<Record<string, unknown>> = (
}, [dispatch]);
return (
<Dropdown {...wrapperProps} maxWidth={280}>
<MenuList placement='bottom-start' offset={4} maxWidth={280}>
<Fieldset
name='visibility'
legend={
@@ -250,17 +236,14 @@ const ComposeVisibilityMenu: React.FC<Record<string, unknown>> = (
<hr />
<DropdownItemButton
leadingIcon={ChatCircleIcon}
onClick={handleSwitchToMessage}
>
<MenuItem leadingIcon={ChatCircleIcon} onClick={handleSwitchToMessage}>
<FormattedMessage
id='compose.post.to_message'
defaultMessage='Compose a message instead'
description='Message refers to a direct message. For languages where this is confusing, "chat" or "direct message" can be used.'
/>
</DropdownItemButton>
</Dropdown>
</MenuItem>
</MenuList>
);
};
@@ -275,7 +258,7 @@ const DropdownRadioCheckField: React.FC<
const { ref, onWrapperClick } = useDropdownControl();
return (
<DropdownItem onClick={onWrapperClick} disabled={disabled}>
<MenuItemBase onClick={onWrapperClick} disabled={disabled}>
<RadioButtonField
{...props}
ref={ref}
@@ -284,7 +267,7 @@ const DropdownRadioCheckField: React.FC<
disabled={disabled}
wrapperClassName={classes.dropdownItemControl}
/>
</DropdownItem>
</MenuItemBase>
);
};
@@ -297,7 +280,7 @@ const DropdownToggleField: React.FC<
const { ref, onWrapperClick } = useDropdownControl();
return (
<DropdownItem
<MenuItemBase
onClick={onWrapperClick}
leadingIcon={icon}
disabled={disabled}
@@ -310,7 +293,7 @@ const DropdownToggleField: React.FC<
label={children}
wrapperClassName={classes.dropdownItemControl}
/>
</DropdownItem>
</MenuItemBase>
);
};

View File

@@ -1,293 +0,0 @@
import { useCallback, useId, useMemo, useState } from 'react';
interface UseListFocusArgs {
/** Array of IDs. */
ids: string[];
/** The initially selected ID. */
initialId?: string;
/** Callback for when an item is selected. */
onSelectId?: (id: string) => void;
/** Callback to determine if a given ID is disabled. */
getIsIdDisabled?: (id: string) => boolean;
/** Callback when an item is clicked on. */
onClickId?: (id: string, event: React.MouseEvent) => void;
/** Callback when an item is focused. */
onFocusId?: (id: string, event: React.FocusEvent) => void;
/** Callback when a key is pressed when an item is focused. */
onKeyDownId?: (id: string, event: React.KeyboardEvent) => void;
/** Callback when the mouse enters an item space. */
onMouseEnterId?: (id: string, event: React.MouseEvent) => void;
}
type ItemComponentProps = {
'data-id': string;
'data-highlighted': boolean;
} & Required<
Pick<
React.HTMLAttributes<Element>,
| 'tabIndex'
| 'aria-disabled'
| 'aria-selected'
| 'onClick'
| 'onFocus'
| 'onKeyDown'
| 'onMouseEnter'
>
>;
export function useListFocus({
ids,
initialId: selectedInitialId,
getIsIdDisabled,
onMouseEnterId,
onFocusId,
onKeyDownId,
onClickId,
onSelectId,
}: UseListFocusArgs) {
const baseId = useId(); // The baseId is a unique ID prefix to avoid needing a wrapper ref.
const [selectedId, setSelectedId] = useState(selectedInitialId ?? null);
const isDisabled = useCallback(
(id: string) => {
if (getIsIdDisabled) {
return getIsIdDisabled(id);
}
const element = idToElement(id, baseId);
if (element?.ariaDisabled) {
return true;
}
return false;
},
[getIsIdDisabled, baseId],
);
// Calculate the initial ID as either the selected ID or the first non-disabled ID.
const initialId = useMemo(() => {
if (
selectedInitialId &&
ids.includes(selectedInitialId) &&
!isDisabled(selectedInitialId)
) {
return selectedInitialId;
}
return ids.find((id) => !isDisabled(id)) ?? null;
}, [ids, isDisabled, selectedInitialId]);
const [rawHighlightedId, setHighlightedId] = useState(initialId);
// Get the valid highlighted ID.
const highlightedId = useMemo(() => {
if (
rawHighlightedId !== null &&
ids.includes(rawHighlightedId) &&
!isDisabled(rawHighlightedId)
) {
return rawHighlightedId;
}
return initialId;
}, [ids, initialId, isDisabled, rawHighlightedId]);
// Set the correct highlight, triggering focus on the element.
const onHighlight = useCallback(
(id?: string | null, focus = true) => {
if (!id) {
setHighlightedId(null);
return;
}
if (isDisabled(id)) {
return;
}
setHighlightedId(id);
const element = document.querySelector(
`[data-id="${safeId(id, baseId)}"]`,
);
if (element instanceof HTMLElement && focus) {
element.focus();
}
},
[baseId, isDisabled],
);
// Select the item if it's not disabled.
const onSelect = useCallback(
(id: string) => {
if (isDisabled(id)) {
return;
}
onSelectId?.(id);
setSelectedId(id);
setHighlightedId(id);
},
[isDisabled, onSelectId],
);
// Handle keyboard shortcuts.
const onKeyDown = useCallback(
(id: string, event: React.KeyboardEvent) => {
onKeyDownId?.(id, event);
if (isDisabled(id)) {
return;
}
const currentIndex = ids.findIndex((indexId) => indexId === id);
if (currentIndex === -1) {
return;
}
const getValidIdInDirection = (
direction: 'prev' | 'next',
full = false,
) => {
if (full) {
return (
ids[direction === 'next' ? 'findLast' : 'find'](
(id) => !isDisabled(id),
) ?? null
);
}
const delta = direction === 'next' ? 1 : -1;
for (let offset = 1; offset <= ids.length; offset += 1) {
// Use a modulo to wrap the ids.
const index =
(currentIndex + offset * delta + ids.length) % ids.length;
const indexId = ids[index];
if (indexId && !isDisabled(indexId)) {
return indexId;
}
}
return null;
};
let foundKey = true;
switch (event.key) {
case ' ':
case 'Enter':
onSelect(id);
break;
case 'ArrowDown':
onHighlight(getValidIdInDirection('next'));
break;
case 'ArrowUp':
onHighlight(getValidIdInDirection('prev'));
break;
case 'Tab':
onHighlight(
event.shiftKey
? getValidIdInDirection('prev')
: getValidIdInDirection('next'),
);
break;
case 'Home':
onHighlight(getValidIdInDirection('prev', true));
break;
case 'End':
onHighlight(getValidIdInDirection('next', true));
break;
default:
foundKey = false;
}
if (foundKey) {
event.preventDefault();
}
},
[ids, isDisabled, onHighlight, onKeyDownId, onSelect],
);
// Callback to get props for a given item.
const getItemProps = useCallback(
(id: string): ItemComponentProps => {
const isHighlighted = id === highlightedId;
const isSelected = id === selectedId;
return {
'data-id': safeId(id, baseId),
'data-highlighted': isHighlighted,
'aria-disabled': isDisabled(id),
'aria-selected': isSelected,
// Only allow focus if the item is highlighted.
tabIndex: isHighlighted ? 0 : -1,
onClick: (event) => {
onClickId?.(id, event);
onSelect(id);
},
onFocus: (event) => {
onFocusId?.(id, event);
if (highlightedId !== id) {
setHighlightedId(id);
}
},
onKeyDown: (event) => {
onKeyDown(id, event);
},
onMouseEnter: (event) => {
onMouseEnterId?.(id, event);
if (highlightedId !== id) {
setHighlightedId(id);
}
},
};
},
[
baseId,
highlightedId,
isDisabled,
onClickId,
onFocusId,
onKeyDown,
onMouseEnterId,
onSelect,
selectedId,
],
);
return useMemo(
() => ({
// Has a stable referenced map of id to props in case getItemProps is causing unneeded re-renders.
idProps: ids.reduce<Record<string, ItemComponentProps>>((map, id) => {
map[id] = getItemProps(id);
return map;
}, {}),
getItemProps,
selectedId,
onSelect,
highlightedId,
onHighlight,
}),
[getItemProps, highlightedId, ids, onHighlight, onSelect, selectedId],
);
}
interface ListItem {
id: string | number;
disabled?: boolean;
}
export function useListItemsFocus({
items,
...rest
}: Omit<UseListFocusArgs, 'ids' | 'getIsIdDisabled'> & {
items: ListItem[];
}) {
const ids = useMemo(() => items.map(({ id }) => id.toString()), [items]);
const getIsIdDisabled = useCallback(
(id: string) => !!items.find(({ id: itemId }) => id === itemId)?.disabled,
[items],
);
return useListFocus({
ids,
getIsIdDisabled,
...rest,
});
}
function safeId(id: string, baseId: string) {
return CSS.escape(`${baseId}-${id}`);
}
function idToElement(id: string, baseId: string) {
return document.querySelector(`[data-id="${safeId(id, baseId)}"]`);
}