Fix dropdown menu not focusing first item when opened via keyboard (#36804)
This commit is contained in:
@@ -42,16 +42,10 @@ import { IconButton } from './icon_button';
|
||||
|
||||
let id = 0;
|
||||
|
||||
export interface RenderItemFnHandlers {
|
||||
onClick: React.MouseEventHandler;
|
||||
onKeyUp: React.KeyboardEventHandler;
|
||||
}
|
||||
|
||||
export type RenderItemFn<Item = MenuItem> = (
|
||||
item: Item,
|
||||
index: number,
|
||||
handlers: RenderItemFnHandlers,
|
||||
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void,
|
||||
onClick: React.MouseEventHandler,
|
||||
) => React.ReactNode;
|
||||
|
||||
type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void;
|
||||
@@ -101,7 +95,6 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
onItemClick,
|
||||
}: DropdownMenuProps<Item>) => {
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
const focusedItemRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentClick = (e: MouseEvent) => {
|
||||
@@ -163,8 +156,11 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
|
||||
if (focusedItemRef.current && openedViaKeyboard) {
|
||||
focusedItemRef.current.focus({ preventScroll: true });
|
||||
if (openedViaKeyboard) {
|
||||
const firstMenuItem = nodeRef.current?.querySelector<
|
||||
HTMLAnchorElement | HTMLButtonElement
|
||||
>('li:first-child > :is(a, button)');
|
||||
firstMenuItem?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -175,13 +171,6 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
};
|
||||
}, [onClose, openedViaKeyboard]);
|
||||
|
||||
const handleFocusedItemRef = useCallback(
|
||||
(c: HTMLAnchorElement | HTMLButtonElement | null) => {
|
||||
focusedItemRef.current = c as HTMLElement;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
@@ -207,15 +196,6 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
[onClose, onItemClick, items],
|
||||
);
|
||||
|
||||
const handleItemKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleItemClick(e);
|
||||
}
|
||||
},
|
||||
[handleItemClick],
|
||||
);
|
||||
|
||||
const nativeRenderItem = (option: Item, i: number) => {
|
||||
if (!isMenuItem(option)) {
|
||||
return null;
|
||||
@@ -232,9 +212,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
if (isActionItem(option)) {
|
||||
element = (
|
||||
<button
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
@@ -248,9 +226,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
target={option.target ?? '_target'}
|
||||
data-method={option.method}
|
||||
rel='noopener'
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
<DropdownMenuItemContent item={option} />
|
||||
@@ -258,13 +234,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
);
|
||||
} else {
|
||||
element = (
|
||||
<Link
|
||||
to={option.to}
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
<Link to={option.to} onClick={handleItemClick} data-index={i}>
|
||||
<DropdownMenuItemContent item={option} />
|
||||
</Link>
|
||||
);
|
||||
@@ -307,15 +277,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
})}
|
||||
>
|
||||
{items.map((option, i) =>
|
||||
renderItemMethod(
|
||||
option,
|
||||
i,
|
||||
{
|
||||
onClick: handleItemClick,
|
||||
onKeyUp: handleItemKeyUp,
|
||||
},
|
||||
i === 0 ? handleFocusedItemRef : undefined,
|
||||
),
|
||||
renderItemMethod(option, i, handleItemClick),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
@@ -399,7 +361,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
}, [dispatch, currentId]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
(e: React.MouseEvent) => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = items?.[i];
|
||||
|
||||
@@ -420,10 +382,20 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
[handleClose, onItemClick, items],
|
||||
);
|
||||
|
||||
const toggleDropdown = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const { type } = e;
|
||||
const isKeypressRef = useRef(false);
|
||||
|
||||
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) {
|
||||
handleClose();
|
||||
} else {
|
||||
@@ -450,10 +422,11 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
dispatch(
|
||||
openDropdownMenu({
|
||||
id: currentId,
|
||||
keyboard: type !== 'click',
|
||||
keyboard: isKeypressRef.current,
|
||||
scrollKey,
|
||||
}),
|
||||
);
|
||||
isKeypressRef.current = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -484,6 +457,9 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
const buttonProps = {
|
||||
disabled,
|
||||
onClick: toggleDropdown,
|
||||
onKeyDown: handleKeyDown,
|
||||
onKeyUp: unsetIsKeypress,
|
||||
onBlur: unsetIsKeypress,
|
||||
'aria-expanded': open,
|
||||
'aria-controls': menuId,
|
||||
ref: buttonRef,
|
||||
|
||||
@@ -58,17 +58,7 @@ export const EditedTimestamp: React.FC<{
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(
|
||||
item: HistoryItem,
|
||||
index: number,
|
||||
{
|
||||
onClick,
|
||||
onKeyUp,
|
||||
}: {
|
||||
onClick: React.MouseEventHandler;
|
||||
onKeyUp: React.KeyboardEventHandler;
|
||||
},
|
||||
) => {
|
||||
(item: HistoryItem, index: number, onClick: React.MouseEventHandler) => {
|
||||
const formattedDate = (
|
||||
<RelativeTimestamp
|
||||
timestamp={item.get('created_at') as string}
|
||||
@@ -98,7 +88,7 @@ export const EditedTimestamp: React.FC<{
|
||||
className='dropdown-menu__item edited-timestamp__history__item'
|
||||
key={item.get('created_at') as string}
|
||||
>
|
||||
<button data-index={index} onClick={onClick} onKeyUp={onKeyUp}>
|
||||
<button data-index={index} onClick={onClick} type='button'>
|
||||
{label}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Status } from '@/mastodon/models/status';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
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 { IconButton } from '../icon_button';
|
||||
|
||||
@@ -74,18 +74,12 @@ const StandaloneBoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderMenuItem: RenderItemFn<ActionMenuItem> = (
|
||||
item,
|
||||
index,
|
||||
handlers,
|
||||
focusRefCallback,
|
||||
) => (
|
||||
const renderMenuItem: RenderItemFn<ActionMenuItem> = (item, index, onClick) => (
|
||||
<ReblogMenuItem
|
||||
index={index}
|
||||
item={item}
|
||||
handlers={handlers}
|
||||
onClick={onClick}
|
||||
key={`${item.text}-${index}`}
|
||||
focusRefCallback={focusRefCallback}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -208,16 +202,10 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
|
||||
interface ReblogMenuItemProps {
|
||||
item: ActionMenuItem;
|
||||
index: number;
|
||||
handlers: RenderItemFnHandlers;
|
||||
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void;
|
||||
onClick: React.MouseEventHandler;
|
||||
}
|
||||
|
||||
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
|
||||
index,
|
||||
item,
|
||||
handlers,
|
||||
focusRefCallback,
|
||||
}) => {
|
||||
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({ index, item, onClick }) => {
|
||||
const { text, highlighted, disabled } = item;
|
||||
|
||||
return (
|
||||
@@ -227,12 +215,7 @@ const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
|
||||
})}
|
||||
key={`${text}-${index}`}
|
||||
>
|
||||
<button
|
||||
{...handlers}
|
||||
ref={focusRefCallback}
|
||||
aria-disabled={disabled}
|
||||
data-index={index}
|
||||
>
|
||||
<button onClick={onClick} aria-disabled={disabled} data-index={index}>
|
||||
<DropdownMenuItemContent item={item} />
|
||||
</button>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user