Skip to content

Commit 957e16f

Browse files
Accessible dropdown (#182)
* Accessible dropdown * Latest rc-menu with Home, End keys support * Accessibility unit test * no message * node-version: '14' * Roll back node version update for CI. It must be updated in a separate PR. * Missing tsconfig is causing CI to fail * Lint fix * Fix merge issues * Test fix * Coverage increase Co-authored-by: Y Tron Hy <[email protected]>
1 parent be90980 commit 957e16f

File tree

5 files changed

+137
-5
lines changed

5 files changed

+137
-5
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
"jquery": "^3.3.1",
5353
"less": "^3.11.1",
5454
"np": "^6.0.0",
55-
"rc-menu": "^8.0.0-alpha.2",
55+
"rc-menu": "^9.2.1",
5656
"rc-util": "^5.2.0",
5757
"react": "^16.11.0",
5858
"react-dom": "^16.0.0",

src/Dropdown.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
ActionType,
1010
} from 'rc-trigger/lib/interface';
1111
import Placements from './placements';
12+
import useAccessibility from './hooks/useAccessibility';
1213

1314
export interface DropdownProps
1415
extends Pick<
@@ -66,6 +67,18 @@ function Dropdown(props: DropdownProps, ref) {
6667
const triggerRef = React.useRef(null);
6768
React.useImperativeHandle(ref, () => triggerRef.current);
6869

70+
const menuRef = React.useRef(null);
71+
const menuClassName = `${prefixCls}-menu`;
72+
73+
const { returnFocus } = useAccessibility({
74+
visible: mergedVisible,
75+
setTriggerVisible,
76+
triggerRef,
77+
menuRef,
78+
menuClassName,
79+
onVisibleChange: props.onVisibleChange,
80+
});
81+
6982
const getOverlayElement = (): React.ReactElement => {
7083
const { overlay } = props;
7184
let overlayElement: React.ReactElement;
@@ -88,6 +101,7 @@ function Dropdown(props: DropdownProps, ref) {
88101
if (overlayProps.onClick) {
89102
overlayProps.onClick(e);
90103
}
104+
returnFocus();
91105
};
92106

93107
const onVisibleChange = (newVisible: boolean) => {
@@ -101,7 +115,7 @@ function Dropdown(props: DropdownProps, ref) {
101115
const getMenuElement = () => {
102116
const overlayElement = getOverlayElement();
103117
const extraOverlayProps = {
104-
prefixCls: `${prefixCls}-menu`,
118+
prefixCls: menuClassName,
105119
onClick,
106120
};
107121
if (typeof overlayElement.type === 'string') {
@@ -110,7 +124,7 @@ function Dropdown(props: DropdownProps, ref) {
110124
return (
111125
<>
112126
{arrow && <div className={`${prefixCls}-arrow`} />}
113-
{React.cloneElement(overlayElement, extraOverlayProps)}
127+
<div ref={menuRef}>{React.cloneElement(overlayElement, extraOverlayProps)}</div>
114128
</>
115129
);
116130
};

src/hooks/useAccessibility.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import * as React from 'react';
2+
import KeyCode from 'rc-util/lib/KeyCode';
3+
4+
const { ESC, TAB } = KeyCode;
5+
6+
interface UseAccessibilityProps {
7+
visible: boolean;
8+
setTriggerVisible: (visible: boolean) => void;
9+
triggerRef: React.RefObject<any>;
10+
menuRef: React.RefObject<HTMLUListElement>;
11+
menuClassName: string;
12+
onVisibleChange?: (visible: boolean) => void;
13+
}
14+
15+
export default function useAccessibility({
16+
visible,
17+
setTriggerVisible,
18+
triggerRef,
19+
menuRef,
20+
menuClassName,
21+
onVisibleChange,
22+
}: UseAccessibilityProps) {
23+
const handleCloseMenuAndReturnFocus = () => {
24+
if (visible && triggerRef.current) {
25+
if (triggerRef.current.triggerRef.current) {
26+
triggerRef.current.triggerRef.current.focus();
27+
}
28+
setTriggerVisible(false);
29+
if (typeof onVisibleChange === 'function') {
30+
onVisibleChange(false);
31+
}
32+
}
33+
};
34+
const handleKeyDown = (event) => {
35+
switch (event.keyCode) {
36+
case ESC:
37+
handleCloseMenuAndReturnFocus();
38+
break;
39+
case TAB:
40+
handleCloseMenuAndReturnFocus();
41+
break;
42+
}
43+
};
44+
const focusOpenedMenu = () => {
45+
if (menuRef.current) {
46+
const menuList = menuRef.current.getElementsByClassName(menuClassName)[0];
47+
if (menuList) {
48+
menuList['focus'](); // eslint-disable-line @typescript-eslint/dot-notation
49+
}
50+
}
51+
};
52+
53+
React.useEffect(() => {
54+
if (visible) {
55+
setTimeout(() => {
56+
focusOpenedMenu();
57+
window.addEventListener('keydown', handleKeyDown);
58+
}, 100);
59+
return () => {
60+
window.removeEventListener('keydown', handleKeyDown);
61+
};
62+
}
63+
return () => null;
64+
}, [visible]); // eslint-disable-line react-hooks/exhaustive-deps
65+
66+
const returnFocus = () => {
67+
if (visible && triggerRef.current) {
68+
if (triggerRef.current.triggerRef.current) {
69+
setTimeout(() => {
70+
triggerRef.current.triggerRef.current.focus();
71+
}, 100);
72+
}
73+
}
74+
};
75+
76+
return {
77+
returnFocus,
78+
};
79+
}

tests/basic.test.js

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
/* eslint-disable react/button-has-type,react/no-find-dom-node,react/no-render-return-value,object-shorthand,func-names,max-len */
22
import React from 'react';
3-
import { mount } from 'enzyme';
3+
import ReactDOM from 'react-dom';
4+
import { mount, shallow } from 'enzyme';
5+
import { act } from 'react-dom/test-utils';
46
import Menu, { Divider, Item as MenuItem } from 'rc-menu';
57
import { spyElementPrototypes } from 'rc-util/lib/test/domHook';
68
import { getPopupDomNode, sleep } from './utils';
@@ -284,4 +286,42 @@ describe('dropdown', () => {
284286
getPopupDomNode(dropdown).firstElementChild.classList.contains('rc-dropdown-arrow'),
285287
).toBe(true);
286288
});
289+
290+
it('Keyboard navigation works', async () => {
291+
const overlay = (
292+
<Menu>
293+
<MenuItem key="1">
294+
<span className="my-menuitem">one</span>
295+
</MenuItem>
296+
<MenuItem key="2">two</MenuItem>
297+
</Menu>
298+
);
299+
const dropdown = mount(
300+
<Dropdown trigger={['click']} overlay={overlay} className="trigger-button">
301+
<button className="my-button">open</button>
302+
</Dropdown>,
303+
{ attachTo: document.body },
304+
);
305+
const trigger = dropdown.find('.my-button');
306+
307+
// Open menu
308+
trigger.simulate('click');
309+
await sleep(200);
310+
expect(getPopupDomNode(dropdown).classList.contains('rc-dropdown-hidden')).toBe(false);
311+
312+
// Close menu with Esc
313+
window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 27 })); // Esc
314+
await sleep(200);
315+
expect(document.activeElement.className).toContain('my-button');
316+
317+
// Open menu
318+
trigger.simulate('click');
319+
await sleep(200);
320+
expect(getPopupDomNode(dropdown).classList.contains('rc-dropdown-hidden')).toBe(false);
321+
322+
// Close menu with Tab
323+
window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab
324+
await sleep(200);
325+
expect(document.activeElement.className).toContain('my-button');
326+
});
287327
});

tsconfig.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,3 @@
2222
"tslint-config-prettier"
2323
]
2424
}
25-

0 commit comments

Comments
 (0)