fix: Improve Dropdown component accessibility (#35373)

This commit is contained in:
diondiondion 2025-07-15 09:52:34 +02:00 committed by GitHub
parent 4b8e60682d
commit 82a6ff091f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 47 additions and 80 deletions

View File

@ -5,6 +5,7 @@ import {
useCallback, useCallback,
cloneElement, cloneElement,
Children, Children,
useId,
} from 'react'; } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
@ -16,6 +17,7 @@ import Overlay from 'react-overlays/Overlay';
import type { import type {
OffsetValue, OffsetValue,
UsePopperOptions, UsePopperOptions,
Placement,
} from 'react-overlays/esm/usePopper'; } from 'react-overlays/esm/usePopper';
import { fetchRelationships } from 'mastodon/actions/accounts'; import { fetchRelationships } from 'mastodon/actions/accounts';
@ -295,6 +297,11 @@ interface DropdownProps<Item = MenuItem> {
title?: string; title?: string;
disabled?: boolean; disabled?: boolean;
scrollable?: boolean; scrollable?: boolean;
placement?: Placement;
/**
* Prevent the `ScrollableList` with this scrollKey
* from being scrolled while the dropdown is open
*/
scrollKey?: string; scrollKey?: string;
status?: ImmutableMap<string, unknown>; status?: ImmutableMap<string, unknown>;
forceDropdown?: boolean; forceDropdown?: boolean;
@ -316,6 +323,7 @@ export const Dropdown = <Item = MenuItem,>({
title = 'Menu', title = 'Menu',
disabled, disabled,
scrollable, scrollable,
placement = 'bottom',
status, status,
forceDropdown = false, forceDropdown = false,
renderItem, renderItem,
@ -331,16 +339,15 @@ export const Dropdown = <Item = MenuItem,>({
); );
const [currentId] = useState(id++); const [currentId] = useState(id++);
const open = currentId === openDropdownId; const open = currentId === openDropdownId;
const activeElement = useRef<HTMLElement | null>(null); const buttonRef = useRef<HTMLButtonElement | null>(null);
const targetRef = useRef<HTMLButtonElement | null>(null); const menuId = useId();
const prefetchAccountId = status const prefetchAccountId = status
? status.getIn(['account', 'id']) ? status.getIn(['account', 'id'])
: undefined; : undefined;
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
if (activeElement.current) { if (buttonRef.current) {
activeElement.current.focus({ preventScroll: true }); buttonRef.current.focus({ preventScroll: true });
activeElement.current = null;
} }
dispatch( dispatch(
@ -375,7 +382,7 @@ export const Dropdown = <Item = MenuItem,>({
[handleClose, onItemClick, items], [handleClose, onItemClick, items],
); );
const handleClick = useCallback( const toggleDropdown = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => { (e: React.MouseEvent | React.KeyboardEvent) => {
const { type } = e; const { type } = e;
@ -423,38 +430,6 @@ export const Dropdown = <Item = MenuItem,>({
], ],
); );
const handleMouseDown = useCallback(() => {
if (!open && document.activeElement instanceof HTMLElement) {
activeElement.current = document.activeElement;
}
}, [open]);
const handleButtonKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case ' ':
case 'Enter':
handleMouseDown();
break;
}
},
[handleMouseDown],
);
const handleKeyPress = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case ' ':
case 'Enter':
handleClick(e);
e.stopPropagation();
e.preventDefault();
break;
}
},
[handleClick],
);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (currentId === openDropdownId) { if (currentId === openDropdownId) {
@ -465,14 +440,16 @@ export const Dropdown = <Item = MenuItem,>({
let button: React.ReactElement; let button: React.ReactElement;
const buttonProps = {
disabled,
onClick: toggleDropdown,
'aria-expanded': open,
'aria-controls': menuId,
ref: buttonRef,
};
if (children) { if (children) {
button = cloneElement(Children.only(children), { button = cloneElement(Children.only(children), buttonProps);
onClick: handleClick,
onMouseDown: handleMouseDown,
onKeyDown: handleButtonKeyDown,
onKeyPress: handleKeyPress,
ref: targetRef,
});
} else if (icon && iconComponent) { } else if (icon && iconComponent) {
button = ( button = (
<IconButton <IconButton
@ -480,12 +457,7 @@ export const Dropdown = <Item = MenuItem,>({
iconComponent={iconComponent} iconComponent={iconComponent}
title={title} title={title}
active={open} active={open}
disabled={disabled} {...buttonProps}
onClick={handleClick}
onMouseDown={handleMouseDown}
onKeyDown={handleButtonKeyDown}
onKeyPress={handleKeyPress}
ref={targetRef}
/> />
); );
} else { } else {
@ -499,13 +471,13 @@ export const Dropdown = <Item = MenuItem,>({
<Overlay <Overlay
show={open} show={open}
offset={offset} offset={offset}
placement='bottom' placement={placement}
flip flip
target={targetRef} target={buttonRef}
popperConfig={popperConfig} popperConfig={popperConfig}
> >
{({ props, arrowProps, placement }) => ( {({ props, arrowProps, placement }) => (
<div {...props}> <div {...props} id={menuId}>
<div className={`dropdown-animation dropdown-menu ${placement}`}> <div className={`dropdown-animation dropdown-menu ${placement}`}>
<div <div
className={`dropdown-menu__arrow ${placement}`} className={`dropdown-menu__arrow ${placement}`}

View File

@ -14,7 +14,6 @@ interface Props {
onClick?: React.MouseEventHandler<HTMLButtonElement>; onClick?: React.MouseEventHandler<HTMLButtonElement>;
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>; onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>; onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
active?: boolean; active?: boolean;
expanded?: boolean; expanded?: boolean;
style?: React.CSSProperties; style?: React.CSSProperties;
@ -45,7 +44,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
activeStyle, activeStyle,
onClick, onClick,
onKeyDown, onKeyDown,
onKeyPress,
onMouseDown, onMouseDown,
active = false, active = false,
disabled = false, disabled = false,
@ -85,16 +83,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
[disabled, onClick], [disabled, onClick],
); );
const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> =
useCallback(
(e) => {
if (!disabled) {
onKeyPress?.(e);
}
},
[disabled, onKeyPress],
);
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
useCallback( useCallback(
(e) => { (e) => {
@ -161,7 +149,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
onClick={handleClick} onClick={handleClick}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
style={buttonStyle} style={buttonStyle}
tabIndex={tabIndex} tabIndex={tabIndex}
disabled={disabled} disabled={disabled}

View File

@ -50,16 +50,22 @@ export const MoreLink: React.FC = () => {
const menu = useMemo(() => { const menu = useMemo(() => {
const arr: MenuItem[] = [ const arr: MenuItem[] = [
{ text: intl.formatMessage(messages.filters), href: '/filters' },
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },
{ {
text: intl.formatMessage(messages.domainBlocks), href: '/filters',
to: '/domain_blocks', text: intl.formatMessage(messages.filters),
},
{
to: '/mutes',
text: intl.formatMessage(messages.mutes),
},
{
to: '/blocks',
text: intl.formatMessage(messages.blocks),
},
{
to: '/domain_blocks',
text: intl.formatMessage(messages.domainBlocks),
}, },
];
arr.push(
null, null,
{ {
href: '/settings/privacy', href: '/settings/privacy',
@ -77,7 +83,7 @@ export const MoreLink: React.FC = () => {
href: '/settings/export', href: '/settings/export',
text: intl.formatMessage(messages.importExport), text: intl.formatMessage(messages.importExport),
}, },
); ];
if (canManageReports(permissions)) { if (canManageReports(permissions)) {
arr.push(null, { arr.push(null, {
@ -106,7 +112,7 @@ export const MoreLink: React.FC = () => {
}, [intl, dispatch, permissions]); }, [intl, dispatch, permissions]);
return ( return (
<Dropdown items={menu}> <Dropdown items={menu} placement='bottom-start'>
<button className='column-link column-link--transparent'> <button className='column-link column-link--transparent'>
<Icon id='' icon={MoreHorizIcon} className='column-link__icon' /> <Icon id='' icon={MoreHorizIcon} className='column-link__icon' />

View File

@ -3874,16 +3874,18 @@ a.account__display-name {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: 100%;
padding: 12px;
font-size: 16px; font-size: 16px;
font-weight: 400; font-weight: 400;
padding: 12px;
text-decoration: none; text-decoration: none;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
border: 0;
background: transparent;
color: $secondary-text-color; color: $secondary-text-color;
background: transparent;
border: 0;
border-left: 4px solid transparent; border-left: 4px solid transparent;
box-sizing: border-box;
&:hover, &:hover,
&:focus, &:focus,