From 82a6ff091f79c4933a10dc901db1411625629013 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 15 Jul 2025 09:52:34 +0200 Subject: [PATCH] fix: Improve `Dropdown` component accessibility (#35373) --- .../mastodon/components/dropdown_menu.tsx | 80 ++++++------------- .../mastodon/components/icon_button.tsx | 13 --- .../navigation_panel/components/more_link.tsx | 26 +++--- .../styles/mastodon/components.scss | 8 +- 4 files changed, 47 insertions(+), 80 deletions(-) diff --git a/app/javascript/mastodon/components/dropdown_menu.tsx b/app/javascript/mastodon/components/dropdown_menu.tsx index 23d77f0dda..d9c87e93a7 100644 --- a/app/javascript/mastodon/components/dropdown_menu.tsx +++ b/app/javascript/mastodon/components/dropdown_menu.tsx @@ -5,6 +5,7 @@ import { useCallback, cloneElement, Children, + useId, } from 'react'; import classNames from 'classnames'; @@ -16,6 +17,7 @@ import Overlay from 'react-overlays/Overlay'; import type { OffsetValue, UsePopperOptions, + Placement, } from 'react-overlays/esm/usePopper'; import { fetchRelationships } from 'mastodon/actions/accounts'; @@ -295,6 +297,11 @@ interface DropdownProps { title?: string; disabled?: boolean; scrollable?: boolean; + placement?: Placement; + /** + * Prevent the `ScrollableList` with this scrollKey + * from being scrolled while the dropdown is open + */ scrollKey?: string; status?: ImmutableMap; forceDropdown?: boolean; @@ -316,6 +323,7 @@ export const Dropdown = ({ title = 'Menu', disabled, scrollable, + placement = 'bottom', status, forceDropdown = false, renderItem, @@ -331,16 +339,15 @@ export const Dropdown = ({ ); const [currentId] = useState(id++); const open = currentId === openDropdownId; - const activeElement = useRef(null); - const targetRef = useRef(null); + const buttonRef = useRef(null); + const menuId = useId(); const prefetchAccountId = status ? status.getIn(['account', 'id']) : undefined; const handleClose = useCallback(() => { - if (activeElement.current) { - activeElement.current.focus({ preventScroll: true }); - activeElement.current = null; + if (buttonRef.current) { + buttonRef.current.focus({ preventScroll: true }); } dispatch( @@ -375,7 +382,7 @@ export const Dropdown = ({ [handleClose, onItemClick, items], ); - const handleClick = useCallback( + const toggleDropdown = useCallback( (e: React.MouseEvent | React.KeyboardEvent) => { const { type } = e; @@ -423,38 +430,6 @@ export const Dropdown = ({ ], ); - 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(() => { return () => { if (currentId === openDropdownId) { @@ -465,14 +440,16 @@ export const Dropdown = ({ let button: React.ReactElement; + const buttonProps = { + disabled, + onClick: toggleDropdown, + 'aria-expanded': open, + 'aria-controls': menuId, + ref: buttonRef, + }; + if (children) { - button = cloneElement(Children.only(children), { - onClick: handleClick, - onMouseDown: handleMouseDown, - onKeyDown: handleButtonKeyDown, - onKeyPress: handleKeyPress, - ref: targetRef, - }); + button = cloneElement(Children.only(children), buttonProps); } else if (icon && iconComponent) { button = ( ({ iconComponent={iconComponent} title={title} active={open} - disabled={disabled} - onClick={handleClick} - onMouseDown={handleMouseDown} - onKeyDown={handleButtonKeyDown} - onKeyPress={handleKeyPress} - ref={targetRef} + {...buttonProps} /> ); } else { @@ -499,13 +471,13 @@ export const Dropdown = ({ {({ props, arrowProps, placement }) => ( -
+
; onMouseDown?: React.MouseEventHandler; onKeyDown?: React.KeyboardEventHandler; - onKeyPress?: React.KeyboardEventHandler; active?: boolean; expanded?: boolean; style?: React.CSSProperties; @@ -45,7 +44,6 @@ export const IconButton = forwardRef( activeStyle, onClick, onKeyDown, - onKeyPress, onMouseDown, active = false, disabled = false, @@ -85,16 +83,6 @@ export const IconButton = forwardRef( [disabled, onClick], ); - const handleKeyPress: React.KeyboardEventHandler = - useCallback( - (e) => { - if (!disabled) { - onKeyPress?.(e); - } - }, - [disabled, onKeyPress], - ); - const handleMouseDown: React.MouseEventHandler = useCallback( (e) => { @@ -161,7 +149,6 @@ export const IconButton = forwardRef( onClick={handleClick} onMouseDown={handleMouseDown} onKeyDown={handleKeyDown} - onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated style={buttonStyle} tabIndex={tabIndex} disabled={disabled} diff --git a/app/javascript/mastodon/features/navigation_panel/components/more_link.tsx b/app/javascript/mastodon/features/navigation_panel/components/more_link.tsx index a26935eacf..a3477ec4e5 100644 --- a/app/javascript/mastodon/features/navigation_panel/components/more_link.tsx +++ b/app/javascript/mastodon/features/navigation_panel/components/more_link.tsx @@ -50,16 +50,22 @@ export const MoreLink: React.FC = () => { const menu = useMemo(() => { 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), - to: '/domain_blocks', + href: '/filters', + 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, { href: '/settings/privacy', @@ -77,7 +83,7 @@ export const MoreLink: React.FC = () => { href: '/settings/export', text: intl.formatMessage(messages.importExport), }, - ); + ]; if (canManageReports(permissions)) { arr.push(null, { @@ -106,7 +112,7 @@ export const MoreLink: React.FC = () => { }, [intl, dispatch, permissions]); return ( - +