mirror of
https://github.com/mastodon/mastodon.git
synced 2025-10-05 08:33:00 +00:00
fix: Improve Dropdown
component accessibility (#35373)
This commit is contained in:
parent
ef6f5f9357
commit
a79dbf8334
|
@ -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}`}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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' />
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue
Block a user