2
0

Fix dropdown menu not focusing first item when opened via keyboard (#36804)

This commit is contained in:
diondiondion
2025-11-10 12:44:10 +01:00
committed by Claire
parent a9a7ad62f1
commit 30103fd2c8
3 changed files with 35 additions and 86 deletions

View File

@@ -42,16 +42,10 @@ import { IconButton } from './icon_button';
let id = 0; let id = 0;
export interface RenderItemFnHandlers {
onClick: React.MouseEventHandler;
onKeyUp: React.KeyboardEventHandler;
}
export type RenderItemFn<Item = MenuItem> = ( export type RenderItemFn<Item = MenuItem> = (
item: Item, item: Item,
index: number, index: number,
handlers: RenderItemFnHandlers, onClick: React.MouseEventHandler,
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void,
) => React.ReactNode; ) => React.ReactNode;
type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void; type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void;
@@ -101,7 +95,6 @@ export const DropdownMenu = <Item = MenuItem,>({
onItemClick, onItemClick,
}: DropdownMenuProps<Item>) => { }: DropdownMenuProps<Item>) => {
const nodeRef = useRef<HTMLDivElement>(null); const nodeRef = useRef<HTMLDivElement>(null);
const focusedItemRef = useRef<HTMLElement | null>(null);
useEffect(() => { useEffect(() => {
const handleDocumentClick = (e: MouseEvent) => { const handleDocumentClick = (e: MouseEvent) => {
@@ -163,8 +156,11 @@ export const DropdownMenu = <Item = MenuItem,>({
document.addEventListener('click', handleDocumentClick, { capture: true }); document.addEventListener('click', handleDocumentClick, { capture: true });
document.addEventListener('keydown', handleKeyDown, { capture: true }); document.addEventListener('keydown', handleKeyDown, { capture: true });
if (focusedItemRef.current && openedViaKeyboard) { if (openedViaKeyboard) {
focusedItemRef.current.focus({ preventScroll: true }); const firstMenuItem = nodeRef.current?.querySelector<
HTMLAnchorElement | HTMLButtonElement
>('li:first-child > :is(a, button)');
firstMenuItem?.focus({ preventScroll: true });
} }
return () => { return () => {
@@ -175,13 +171,6 @@ export const DropdownMenu = <Item = MenuItem,>({
}; };
}, [onClose, openedViaKeyboard]); }, [onClose, openedViaKeyboard]);
const handleFocusedItemRef = useCallback(
(c: HTMLAnchorElement | HTMLButtonElement | null) => {
focusedItemRef.current = c as HTMLElement;
},
[],
);
const handleItemClick = useCallback( const handleItemClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => { (e: React.MouseEvent | React.KeyboardEvent) => {
const i = Number(e.currentTarget.getAttribute('data-index')); const i = Number(e.currentTarget.getAttribute('data-index'));
@@ -207,15 +196,6 @@ export const DropdownMenu = <Item = MenuItem,>({
[onClose, onItemClick, items], [onClose, onItemClick, items],
); );
const handleItemKeyUp = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
handleItemClick(e);
}
},
[handleItemClick],
);
const nativeRenderItem = (option: Item, i: number) => { const nativeRenderItem = (option: Item, i: number) => {
if (!isMenuItem(option)) { if (!isMenuItem(option)) {
return null; return null;
@@ -232,9 +212,7 @@ export const DropdownMenu = <Item = MenuItem,>({
if (isActionItem(option)) { if (isActionItem(option)) {
element = ( element = (
<button <button
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleItemClick} onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i} data-index={i}
aria-disabled={disabled} aria-disabled={disabled}
> >
@@ -248,9 +226,7 @@ export const DropdownMenu = <Item = MenuItem,>({
target={option.target ?? '_target'} target={option.target ?? '_target'}
data-method={option.method} data-method={option.method}
rel='noopener' rel='noopener'
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleItemClick} onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i} data-index={i}
> >
<DropdownMenuItemContent item={option} /> <DropdownMenuItemContent item={option} />
@@ -258,13 +234,7 @@ export const DropdownMenu = <Item = MenuItem,>({
); );
} else { } else {
element = ( element = (
<Link <Link to={option.to} onClick={handleItemClick} data-index={i}>
to={option.to}
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i}
>
<DropdownMenuItemContent item={option} /> <DropdownMenuItemContent item={option} />
</Link> </Link>
); );
@@ -307,15 +277,7 @@ export const DropdownMenu = <Item = MenuItem,>({
})} })}
> >
{items.map((option, i) => {items.map((option, i) =>
renderItemMethod( renderItemMethod(option, i, handleItemClick),
option,
i,
{
onClick: handleItemClick,
onKeyUp: handleItemKeyUp,
},
i === 0 ? handleFocusedItemRef : undefined,
),
)} )}
</ul> </ul>
)} )}
@@ -399,7 +361,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
}, [dispatch, currentId]); }, [dispatch, currentId]);
const handleItemClick = useCallback( const handleItemClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => { (e: React.MouseEvent) => {
const i = Number(e.currentTarget.getAttribute('data-index')); const i = Number(e.currentTarget.getAttribute('data-index'));
const item = items?.[i]; const item = items?.[i];
@@ -420,10 +382,20 @@ export const Dropdown = <Item extends object | null = MenuItem>({
[handleClose, onItemClick, items], [handleClose, onItemClick, items],
); );
const toggleDropdown = useCallback( const isKeypressRef = useRef(false);
(e: React.MouseEvent | React.KeyboardEvent) => {
const { type } = e;
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') {
isKeypressRef.current = true;
}
}, []);
const unsetIsKeypress = useCallback(() => {
isKeypressRef.current = false;
}, []);
const toggleDropdown = useCallback(
(e: React.MouseEvent) => {
if (open) { if (open) {
handleClose(); handleClose();
} else { } else {
@@ -450,10 +422,11 @@ export const Dropdown = <Item extends object | null = MenuItem>({
dispatch( dispatch(
openDropdownMenu({ openDropdownMenu({
id: currentId, id: currentId,
keyboard: type !== 'click', keyboard: isKeypressRef.current,
scrollKey, scrollKey,
}), }),
); );
isKeypressRef.current = false;
} }
} }
}, },
@@ -484,6 +457,9 @@ export const Dropdown = <Item extends object | null = MenuItem>({
const buttonProps = { const buttonProps = {
disabled, disabled,
onClick: toggleDropdown, onClick: toggleDropdown,
onKeyDown: handleKeyDown,
onKeyUp: unsetIsKeypress,
onBlur: unsetIsKeypress,
'aria-expanded': open, 'aria-expanded': open,
'aria-controls': menuId, 'aria-controls': menuId,
ref: buttonRef, ref: buttonRef,

View File

@@ -58,17 +58,7 @@ export const EditedTimestamp: React.FC<{
}, []); }, []);
const renderItem = useCallback( const renderItem = useCallback(
( (item: HistoryItem, index: number, onClick: React.MouseEventHandler) => {
item: HistoryItem,
index: number,
{
onClick,
onKeyUp,
}: {
onClick: React.MouseEventHandler;
onKeyUp: React.KeyboardEventHandler;
},
) => {
const formattedDate = ( const formattedDate = (
<RelativeTimestamp <RelativeTimestamp
timestamp={item.get('created_at') as string} timestamp={item.get('created_at') as string}
@@ -98,7 +88,7 @@ export const EditedTimestamp: React.FC<{
className='dropdown-menu__item edited-timestamp__history__item' className='dropdown-menu__item edited-timestamp__history__item'
key={item.get('created_at') as string} key={item.get('created_at') as string}
> >
<button data-index={index} onClick={onClick} onKeyUp={onKeyUp}> <button data-index={index} onClick={onClick} type='button'>
{label} {label}
</button> </button>
</li> </li>

View File

@@ -14,7 +14,7 @@ import type { Status } from '@/mastodon/models/status';
import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import type { SomeRequired } from '@/mastodon/utils/types'; import type { SomeRequired } from '@/mastodon/utils/types';
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu'; import type { RenderItemFn } from '../dropdown_menu';
import { Dropdown, DropdownMenuItemContent } from '../dropdown_menu'; import { Dropdown, DropdownMenuItemContent } from '../dropdown_menu';
import { IconButton } from '../icon_button'; import { IconButton } from '../icon_button';
@@ -74,18 +74,12 @@ const StandaloneBoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
); );
}; };
const renderMenuItem: RenderItemFn<ActionMenuItem> = ( const renderMenuItem: RenderItemFn<ActionMenuItem> = (item, index, onClick) => (
item,
index,
handlers,
focusRefCallback,
) => (
<ReblogMenuItem <ReblogMenuItem
index={index} index={index}
item={item} item={item}
handlers={handlers} onClick={onClick}
key={`${item.text}-${index}`} key={`${item.text}-${index}`}
focusRefCallback={focusRefCallback}
/> />
); );
@@ -208,16 +202,10 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
interface ReblogMenuItemProps { interface ReblogMenuItemProps {
item: ActionMenuItem; item: ActionMenuItem;
index: number; index: number;
handlers: RenderItemFnHandlers; onClick: React.MouseEventHandler;
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void;
} }
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({ const ReblogMenuItem: FC<ReblogMenuItemProps> = ({ index, item, onClick }) => {
index,
item,
handlers,
focusRefCallback,
}) => {
const { text, highlighted, disabled } = item; const { text, highlighted, disabled } = item;
return ( return (
@@ -227,12 +215,7 @@ const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
})} })}
key={`${text}-${index}`} key={`${text}-${index}`}
> >
<button <button onClick={onClick} aria-disabled={disabled} data-index={index}>
{...handlers}
ref={focusRefCallback}
aria-disabled={disabled}
data-index={index}
>
<DropdownMenuItemContent item={item} /> <DropdownMenuItemContent item={item} />
</button> </button>
</li> </li>