Skip to content

Commit 3b78e56

Browse files
Ke1syAlina Andrieieva
andauthored
Fixed group and submenu focus (#671)
* group focus fix * fixed focus when first item is group or submenu * added tests for group and submenu focus * moved the maps inside `refreshElements` function --------- Co-authored-by: Alina Andrieieva <[email protected]>
1 parent cb506f7 commit 3b78e56

File tree

4 files changed

+261
-131
lines changed

4 files changed

+261
-131
lines changed

src/Menu.tsx

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@ import classNames from 'classnames';
22
import type { CSSMotionProps } from 'rc-motion';
33
import Overflow from 'rc-overflow';
44
import useMergedState from 'rc-util/lib/hooks/useMergedState';
5+
import isEqual from 'rc-util/lib/isEqual';
56
import warning from 'rc-util/lib/warning';
67
import * as React from 'react';
78
import { useImperativeHandle } from 'react';
89
import { flushSync } from 'react-dom';
9-
import isEqual from 'rc-util/lib/isEqual';
10-
import { getMenuId, IdContext } from './context/IdContext';
10+
import { IdContext } from './context/IdContext';
1111
import MenuContextProvider from './context/MenuContext';
1212
import { PathRegisterContext, PathUserContext } from './context/PathContext';
1313
import PrivateContext from './context/PrivateContext';
14-
import useAccessibility from './hooks/useAccessibility';
14+
import {
15+
getFocusableElements,
16+
refreshElements,
17+
useAccessibility,
18+
} from './hooks/useAccessibility';
1519
import useKeyRecords, { OVERFLOW_KEY } from './hooks/useKeyRecords';
1620
import useMemoCallback from './hooks/useMemoCallback';
1721
import useUUID from './hooks/useUUID';
@@ -270,8 +274,9 @@ const Menu = React.forwardRef<MenuRef, MenuProps>((props, ref) => {
270274
};
271275

272276
// >>>>> Cache & Reset open keys when inlineCollapsed changed
273-
const [inlineCacheOpenKeys, setInlineCacheOpenKeys] =
274-
React.useState(mergedOpenKeys);
277+
const [inlineCacheOpenKeys, setInlineCacheOpenKeys] = React.useState(
278+
mergedOpenKeys,
279+
);
275280

276281
const mountRef = React.useRef(false);
277282

@@ -347,10 +352,9 @@ const Menu = React.forwardRef<MenuRef, MenuProps>((props, ref) => {
347352
[registerPath, unregisterPath],
348353
);
349354

350-
const pathUserContext = React.useMemo(
351-
() => ({ isSubPathKey }),
352-
[isSubPathKey],
353-
);
355+
const pathUserContext = React.useMemo(() => ({ isSubPathKey }), [
356+
isSubPathKey,
357+
]);
354358

355359
React.useEffect(() => {
356360
refreshOverflowKeys(
@@ -378,20 +382,31 @@ const Menu = React.forwardRef<MenuRef, MenuProps>((props, ref) => {
378382
setMergedActiveKey(undefined);
379383
});
380384

381-
useImperativeHandle(ref, () => ({
382-
list: containerRef.current,
383-
focus: options => {
384-
const shouldFocusKey =
385-
mergedActiveKey ?? childList.find(node => !node.props.disabled)?.key;
386-
if (shouldFocusKey) {
387-
containerRef.current
388-
?.querySelector<HTMLLIElement>(
389-
`li[data-menu-id='${getMenuId(uuid, shouldFocusKey as string)}']`,
390-
)
391-
?.focus?.(options);
392-
}
393-
},
394-
}));
385+
useImperativeHandle(ref, () => {
386+
return {
387+
list: containerRef.current,
388+
focus: options => {
389+
const keys = getKeys();
390+
const { elements, key2element, element2key } = refreshElements(
391+
keys,
392+
uuid,
393+
);
394+
const focusableElements = getFocusableElements(
395+
containerRef.current,
396+
elements,
397+
);
398+
399+
const shouldFocusKey =
400+
mergedActiveKey ?? element2key.get(focusableElements[0]);
401+
402+
const elementToFocus = key2element.get(shouldFocusKey);
403+
404+
if (shouldFocusKey && elementToFocus) {
405+
elementToFocus?.focus?.(options);
406+
}
407+
},
408+
};
409+
});
395410

396411
// ======================== Select ========================
397412
// >>>>> Select keys

src/hooks/useAccessibility.ts

Lines changed: 30 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import * as React from 'react';
1+
import { getFocusNodeList } from 'rc-util/lib/Dom/focus';
22
import KeyCode from 'rc-util/lib/KeyCode';
33
import raf from 'rc-util/lib/raf';
4-
import { getFocusNodeList } from 'rc-util/lib/Dom/focus';
5-
import type { MenuMode } from '../interface';
4+
import * as React from 'react';
65
import { getMenuId } from '../context/IdContext';
6+
import type { MenuMode } from '../interface';
77

88
// destruct to reduce minify size
99
const { LEFT, RIGHT, UP, DOWN, ENTER, ESC, HOME, END } = KeyCode;
@@ -134,7 +134,7 @@ function getFocusElement(
134134
/**
135135
* Get focusable elements from the element set under provided container
136136
*/
137-
function getFocusableElements(
137+
export function getFocusableElements(
138138
container: HTMLElement,
139139
elements: Set<HTMLElement>,
140140
) {
@@ -181,7 +181,27 @@ function getNextFocusElement(
181181
return sameLevelFocusableMenuElementList[focusIndex];
182182
}
183183

184-
export default function useAccessibility<T extends HTMLElement>(
184+
export const refreshElements = (keys: string[], id: string) => {
185+
const elements = new Set<HTMLElement>();
186+
const key2element = new Map<string, HTMLElement>();
187+
const element2key = new Map<HTMLElement, string>();
188+
189+
keys.forEach(key => {
190+
const element = document.querySelector(
191+
`[data-menu-id='${getMenuId(id, key)}']`,
192+
) as HTMLElement;
193+
194+
if (element) {
195+
elements.add(element);
196+
element2key.set(element, key);
197+
key2element.set(key, element);
198+
}
199+
});
200+
201+
return { elements, key2element, element2key };
202+
};
203+
204+
export function useAccessibility<T extends HTMLElement>(
185205
mode: MenuMode,
186206
activeKey: string,
187207
isRtl: boolean,
@@ -216,35 +236,10 @@ export default function useAccessibility<T extends HTMLElement>(
216236
const { which } = e;
217237

218238
if ([...ArrowKeys, ENTER, ESC, HOME, END].includes(which)) {
219-
// Convert key to elements
220-
let elements: Set<HTMLElement>;
221-
let key2element: Map<string, HTMLElement>;
222-
let element2key: Map<HTMLElement, string>;
223-
224-
// >>> Wrap as function since we use raf for some case
225-
const refreshElements = () => {
226-
elements = new Set<HTMLElement>();
227-
key2element = new Map();
228-
element2key = new Map();
229-
230-
const keys = getKeys();
231-
232-
keys.forEach(key => {
233-
const element = document.querySelector(
234-
`[data-menu-id='${getMenuId(id, key)}']`,
235-
) as HTMLElement;
236-
237-
if (element) {
238-
elements.add(element);
239-
element2key.set(element, key);
240-
key2element.set(key, element);
241-
}
242-
});
239+
const keys = getKeys();
243240

244-
return elements;
245-
};
246-
247-
refreshElements();
241+
let refreshedElements = refreshElements(keys, id);
242+
const { elements, key2element, element2key } = refreshedElements;
248243

249244
// First we should find current focused MenuItem/SubMenu element
250245
const activeElement = key2element.get(activeKey);
@@ -341,15 +336,15 @@ export default function useAccessibility<T extends HTMLElement>(
341336
cleanRaf();
342337
rafRef.current = raf(() => {
343338
// Async should resync elements
344-
refreshElements();
339+
refreshedElements = refreshElements(keys, id);
345340

346341
const controlId = focusMenuElement.getAttribute('aria-controls');
347342
const subQueryContainer = document.getElementById(controlId);
348343

349344
// Get sub focusable menu item
350345
const targetElement = getNextFocusElement(
351346
subQueryContainer,
352-
elements,
347+
refreshedElements.elements,
353348
);
354349

355350
// Focus menu item

tests/Focus.spec.tsx

Lines changed: 166 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
/* eslint-disable no-undef */
2-
import { fireEvent, render } from '@testing-library/react';
2+
import { act, fireEvent, render } from '@testing-library/react';
3+
import { spyElementPrototypes } from 'rc-util/lib/test/domHook';
34
import React from 'react';
4-
import Menu, { MenuItem, SubMenu } from '../src';
5+
import Menu, { MenuItem, MenuItemGroup, MenuRef, SubMenu } from '../src';
56

67
describe('Focus', () => {
8+
beforeAll(() => {
9+
// Mock to force make menu item visible
10+
spyElementPrototypes(HTMLElement, {
11+
offsetParent: {
12+
get() {
13+
return this.parentElement;
14+
},
15+
},
16+
});
17+
});
718

819
beforeEach(() => {
920
global.triggerProps = null;
@@ -15,13 +26,15 @@ describe('Focus', () => {
1526
jest.useRealTimers();
1627
});
1728

18-
it('Get focus', () => {
19-
const { container } = render(
20-
<Menu mode="inline" openKeys={['s']}>
21-
<SubMenu key="s" title="submenu">
22-
<MenuItem key="1">1</MenuItem>
23-
</SubMenu>
24-
</Menu>,
29+
it('Get focus', async () => {
30+
const { container } = await act(async () =>
31+
render(
32+
<Menu mode="inline" openKeys={['s']}>
33+
<SubMenu key="s" title="submenu">
34+
<MenuItem key="1">1</MenuItem>
35+
</SubMenu>
36+
</Menu>,
37+
),
2538
);
2639

2740
// Item focus
@@ -34,5 +47,149 @@ describe('Focus', () => {
3447
fireEvent.focus(container.querySelector('.rc-menu-submenu-title'));
3548
expect(container.querySelector('.rc-menu-submenu-active')).toBeTruthy();
3649
});
50+
51+
it('should support focus through ref', async () => {
52+
const menuRef = React.createRef<MenuRef>();
53+
const { getByTestId } = await act(async () =>
54+
render(
55+
<Menu ref={menuRef}>
56+
<SubMenu key="bamboo" title="Disabled" disabled>
57+
<MenuItem key="bamboo-child">Disabled child</MenuItem>
58+
</SubMenu>
59+
<MenuItem key="light" data-testid="first-focusable">
60+
Light
61+
</MenuItem>
62+
</Menu>,
63+
),
64+
);
65+
66+
act(() => menuRef.current.focus());
67+
68+
const firstFocusableItem = getByTestId('first-focusable');
69+
expect(document.activeElement).toBe(firstFocusableItem);
70+
expect(firstFocusableItem).toHaveClass('rc-menu-item-active');
71+
});
72+
73+
it('should focus active item through ref', async () => {
74+
const menuRef = React.createRef<MenuRef>();
75+
const { getByTestId } = await act(async () =>
76+
render(
77+
<Menu ref={menuRef} activeKey="cat">
78+
<MenuItem key="light">Light</MenuItem>
79+
<MenuItem key="cat" data-testid="active-key">
80+
Cat
81+
</MenuItem>
82+
</Menu>,
83+
),
84+
);
85+
act(() => menuRef.current.focus());
86+
87+
const activeKey = getByTestId('active-key');
88+
expect(document.activeElement).toBe(activeKey);
89+
expect(activeKey).toHaveClass('rc-menu-item-active');
90+
});
91+
92+
it('focus moves to the next accessible menu item if the first child is empty group', async () => {
93+
const menuRef = React.createRef<MenuRef>();
94+
const { getByTestId } = await act(async () =>
95+
render(
96+
<Menu ref={menuRef}>
97+
<MenuItemGroup title="group" key="group" />
98+
<SubMenu key="bamboo" title="Disabled" disabled>
99+
<MenuItem key="bamboo-child">Disabled child</MenuItem>
100+
</SubMenu>
101+
<MenuItem key="light" data-testid="first-focusable">
102+
Light
103+
</MenuItem>
104+
</Menu>,
105+
),
106+
);
107+
108+
act(() => menuRef.current.focus());
109+
110+
const firstFocusableItem = getByTestId('first-focusable');
111+
expect(document.activeElement).toBe(firstFocusableItem);
112+
expect(firstFocusableItem).toHaveClass('rc-menu-item-active');
113+
});
114+
115+
it('focus moves to the next accessible group item if the first child is non-empty group', async () => {
116+
const menuRef = React.createRef<MenuRef>();
117+
const { getByTestId } = await act(async () =>
118+
render(
119+
<Menu ref={menuRef}>
120+
<MenuItemGroup title="group" key="group">
121+
<MenuItem key="group-child-1" disabled>
122+
group-child-1
123+
</MenuItem>
124+
<MenuItem key="group-child-2" data-testid="first-focusable">
125+
group-child-2
126+
</MenuItem>
127+
</MenuItemGroup>
128+
<MenuItem key="light">Light</MenuItem>
129+
</Menu>,
130+
),
131+
);
132+
133+
act(() => menuRef.current.focus());
134+
135+
const firstFocusableItem = getByTestId('first-focusable');
136+
expect(document.activeElement).toBe(firstFocusableItem);
137+
expect(firstFocusableItem).toHaveClass('rc-menu-item-active');
138+
});
139+
140+
it('focus moves to nested group item correctly', async () => {
141+
const menuRef = React.createRef<MenuRef>();
142+
const { getByTestId } = await act(async () =>
143+
render(
144+
<Menu ref={menuRef}>
145+
<MenuItemGroup title="group" key="group">
146+
<MenuItem key="group-child-1" disabled>
147+
group-child-1
148+
</MenuItem>
149+
<MenuItemGroup title="nested group" key="nested-group">
150+
<MenuItem key="nested-group-child-1" disabled>
151+
nested-group-child-1
152+
</MenuItem>
153+
<MenuItem
154+
key="nested-group-child-2"
155+
data-testid="first-focusable"
156+
>
157+
nested-group-child-2
158+
</MenuItem>
159+
</MenuItemGroup>
160+
<MenuItem key="group-child-3">group-child-3</MenuItem>
161+
</MenuItemGroup>
162+
</Menu>,
163+
),
164+
);
165+
166+
act(() => menuRef.current.focus());
167+
168+
const firstFocusableItem = getByTestId('first-focusable');
169+
expect(document.activeElement).toBe(firstFocusableItem);
170+
expect(firstFocusableItem).toHaveClass('rc-menu-item-active');
171+
});
172+
173+
it('focus moves to submenu correctly', async () => {
174+
const menuRef = React.createRef<MenuRef>();
175+
const { getByTestId, getByTitle } = await act(async () =>
176+
render(
177+
<Menu ref={menuRef}>
178+
<SubMenu key="sub-menu-disabled" title="Disabled" disabled>
179+
<MenuItem key="sub-menu-disabled-child">Disabled child</MenuItem>
180+
</SubMenu>
181+
<SubMenu key="sub-menu" data-testid="sub-menu" title="Submenu">
182+
<MenuItem key="sub-menu-child-1">Submenu child</MenuItem>
183+
</SubMenu>
184+
<MenuItem key="light">Light</MenuItem>
185+
</Menu>,
186+
),
187+
);
188+
189+
act(() => menuRef.current.focus());
190+
191+
expect(document.activeElement).toBe(getByTitle('Submenu'));
192+
expect(getByTestId('sub-menu')).toHaveClass('rc-menu-submenu-active');
193+
});
37194
});
38195
/* eslint-enable */

0 commit comments

Comments
 (0)