Skip to content

Commit b09a14e

Browse files
reidbarberdannify
andauthored
Fix Submenu Tray experience for iOS VoiceOver (#6263)
* focus first item after submenu tray opened * fix condition * fix test to expect first item to be focused * attempt to fix test in 16/17 * query via menuRef * cleanup * fix 18 test --------- Co-authored-by: Danni <darobins@adobe.com>
1 parent b8df08e commit b09a14e

File tree

2 files changed

+26
-6
lines changed

2 files changed

+26
-6
lines changed

packages/@react-spectrum/menu/src/Menu.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ function Menu<T extends object>(props: SpectrumMenuProps<T>, ref: DOMRef<HTMLDiv
7979
hasOpenSubmenu={hasOpenSubmenu}
8080
isSubmenu={isSubmenu}
8181
parentMenuTreeState={parentMenuTreeState}
82-
rootMenuTriggerState={rootMenuTriggerState}>
82+
rootMenuTriggerState={rootMenuTriggerState}
83+
menuRef={domRef}>
8384
<div
8485
{...menuProps}
8586
style={mergeProps(styleProps.style, menuProps.style)}
@@ -125,7 +126,7 @@ function Menu<T extends object>(props: SpectrumMenuProps<T>, ref: DOMRef<HTMLDiv
125126
}
126127

127128
export function TrayHeaderWrapper(props) {
128-
let {children, isSubmenu, hasOpenSubmenu, parentMenuTreeState, rootMenuTriggerState, onBackButtonPress, wrapperKeyDown} = props;
129+
let {children, isSubmenu, hasOpenSubmenu, parentMenuTreeState, rootMenuTriggerState, onBackButtonPress, wrapperKeyDown, menuRef} = props;
129130
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/menu');
130131
let backButtonText = parentMenuTreeState?.collection.getItem(rootMenuTriggerState?.UNSTABLE_expandedKeysStack.slice(-1)[0])?.textValue;
131132
let backButtonLabel = stringFormatter.format('backButton', {
@@ -158,6 +159,23 @@ export function TrayHeaderWrapper(props) {
158159
};
159160
}, []);
160161

162+
// When opening submenu in tray, focus the first item in the submenu after animation completes
163+
// This fixes an issue with iOS VO where the closed submenu was getting focus
164+
let focusTimeoutRef = useRef(null);
165+
useEffect(() => {
166+
if (isMobile && isSubmenu && !hasOpenSubmenu && traySubmenuAnimation === 'spectrum-TraySubmenu-enter') {
167+
focusTimeoutRef.current = setTimeout(() => {
168+
let firstItem = menuRef.current.querySelector('[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]') as HTMLElement;
169+
firstItem?.focus();
170+
}, 220);
171+
}
172+
return () => {
173+
if (focusTimeoutRef.current) {
174+
clearTimeout(focusTimeoutRef.current);
175+
}
176+
};
177+
}, [hasOpenSubmenu, isMobile, isSubmenu, menuRef, traySubmenuAnimation]);
178+
161179
return (
162180
<>
163181
<div

packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -826,7 +826,8 @@ describe('Submenu', function () {
826826
expect(menuWrappers[1]).toContainElement(menus[1]);
827827

828828
let submenu1 = menus[0];
829-
expect(document.activeElement).toBe(submenu1);
829+
let submenu1Items = within(submenu1).getAllByRole('menuitem');
830+
expect(document.activeElement).toBe(submenu1Items[0]);
830831
expect(submenu1).toHaveAttribute('aria-label', submenuTrigger1.textContent);
831832
let trayDialog = within(tray).getByRole('dialog');
832833
expect(trayDialog).toBeTruthy();
@@ -835,7 +836,6 @@ describe('Submenu', function () {
835836
let menuHeader = within(trayDialog).getAllByText(submenuTrigger1.textContent)[0];
836837
expect(menuHeader).toBeVisible();
837838
expect(menuHeader.tagName).toBe('H1');
838-
let submenu1Items = within(submenu1).getAllByRole('menuitem');
839839
let submenuTrigger2 = submenu1Items[2];
840840
triggerTouch(submenuTrigger2);
841841
act(() => {jest.runAllTimers();});
@@ -850,7 +850,8 @@ describe('Submenu', function () {
850850
expect(menuWrappers[2]).toContainElement(menus[2]);
851851

852852
let submenu2 = menus[0];
853-
expect(document.activeElement).toBe(submenu2);
853+
let submenu2Items = within(submenu2).getAllByRole('menuitem');
854+
expect(document.activeElement).toBe(submenu2Items[0]);
854855
expect(submenu2).toHaveAttribute('aria-label', submenuTrigger2.textContent);
855856
trayDialog = within(tray).getByRole('dialog');
856857
backButton = within(trayDialog).getByRole('button');
@@ -896,7 +897,8 @@ describe('Submenu', function () {
896897
expect(menus).toHaveLength(2);
897898
menuItems = within(menus[0]).getAllByRole('menuitem');
898899
expect(menuItems[0]).toHaveTextContent('Lvl 2');
899-
expect(document.activeElement).toBe(submenuTrigger2);
900+
act(() => {jest.runAllTimers();});
901+
expect(document.activeElement).toBe(menuItems[0]);
900902
buttons = within(tray).getAllByRole('button');
901903
expect(buttons).toHaveLength(3);
902904
expect(buttons[1]).toHaveAttribute('aria-label', `Return to ${submenuTrigger1.textContent}`);

0 commit comments

Comments
 (0)