forked from akai/readest
fix(a11y): fixed keyboard activation of dropdown menu (#3762)
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import { DropdownProvider } from '@/context/DropdownContext';
|
||||
|
||||
vi.mock('@/hooks/useTranslation', () => ({
|
||||
useTranslation: () => (s: string) => s,
|
||||
}));
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
// Simulates the browser behavior where pressing Enter/Space on a focused
|
||||
// <button> fires both a keydown event and a click event. JSDOM does not
|
||||
// dispatch the click automatically, so we do it here.
|
||||
const pressActivationKey = (el: HTMLElement, key: 'Enter' | ' ') => {
|
||||
fireEvent.keyDown(el, { key });
|
||||
fireEvent.click(el);
|
||||
};
|
||||
|
||||
const renderDropdown = () =>
|
||||
render(
|
||||
<DropdownProvider>
|
||||
<Dropdown label='Test Menu' toggleButton={<span>Toggle</span>} showTooltip={false}>
|
||||
<div>
|
||||
<button type='button'>Menu Item</button>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</DropdownProvider>,
|
||||
);
|
||||
|
||||
describe('Dropdown keyboard activation', () => {
|
||||
it('opens when Enter is pressed on the toggle button', () => {
|
||||
renderDropdown();
|
||||
const toggle = screen.getByRole('button', { name: 'Test Menu' });
|
||||
|
||||
toggle.focus();
|
||||
pressActivationKey(toggle, 'Enter');
|
||||
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('opens when Space is pressed on the toggle button', () => {
|
||||
renderDropdown();
|
||||
const toggle = screen.getByRole('button', { name: 'Test Menu' });
|
||||
|
||||
toggle.focus();
|
||||
pressActivationKey(toggle, ' ');
|
||||
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('closes when Enter is pressed again on an open dropdown', () => {
|
||||
renderDropdown();
|
||||
const toggle = screen.getByRole('button', { name: 'Test Menu' });
|
||||
|
||||
toggle.focus();
|
||||
pressActivationKey(toggle, 'Enter');
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
|
||||
pressActivationKey(toggle, 'Enter');
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('stops propagation on Enter to prevent global shortcuts from firing', () => {
|
||||
renderDropdown();
|
||||
const toggle = screen.getByRole('button', { name: 'Test Menu' });
|
||||
|
||||
const onWindowKeyDown = vi.fn();
|
||||
window.addEventListener('keydown', onWindowKeyDown);
|
||||
try {
|
||||
toggle.focus();
|
||||
fireEvent.keyDown(toggle, { key: 'Enter' });
|
||||
expect(onWindowKeyDown).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
window.removeEventListener('keydown', onWindowKeyDown);
|
||||
}
|
||||
});
|
||||
|
||||
it('stops propagation on Space to prevent global shortcuts from firing', () => {
|
||||
renderDropdown();
|
||||
const toggle = screen.getByRole('button', { name: 'Test Menu' });
|
||||
|
||||
const onWindowKeyDown = vi.fn();
|
||||
window.addEventListener('keydown', onWindowKeyDown);
|
||||
try {
|
||||
toggle.focus();
|
||||
fireEvent.keyDown(toggle, { key: ' ' });
|
||||
expect(onWindowKeyDown).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
window.removeEventListener('keydown', onWindowKeyDown);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -96,7 +96,11 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (!isOpen) setIsDropdownOpen(true);
|
||||
// Let the native button click (dispatched by the browser for Enter/Space
|
||||
// on a focused button) drive the toggle — toggling here would race with
|
||||
// that click and cancel it out. We still stop propagation so global
|
||||
// shortcuts bound to Enter/Space (e.g. next page in the reader) don't
|
||||
// fire while a dropdown button is focused.
|
||||
e.stopPropagation();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsDropdownOpen(false);
|
||||
|
||||
@@ -111,7 +111,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
|
||||
<details open={detailsOpen} onToggle={(e) => setIsDetailsOpen(e.currentTarget.open)}>
|
||||
<summary
|
||||
role='button'
|
||||
tabIndex={-1}
|
||||
tabIndex={0}
|
||||
aria-expanded={isDetailsOpen}
|
||||
className={clsx(
|
||||
'hover:bg-base-300 text-base-content cursor-pointer rounded-md p-1 py-[10px] pr-3',
|
||||
|
||||
Reference in New Issue
Block a user