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;
|
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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user