@@ -64,9 +64,10 @@ export class MenuTester {
6464 private _advanceTimer : UserOpts [ 'advanceTimer' ] ;
6565 private _trigger : HTMLElement | undefined ;
6666 private _isSubmenu : boolean = false ;
67+ private _rootMenu : HTMLElement | undefined ;
6768
6869 constructor ( opts : MenuTesterOpts ) {
69- let { root, user, interactionType, advanceTimer, isSubmenu} = opts ;
70+ let { root, user, interactionType, advanceTimer, isSubmenu, rootMenu } = opts ;
7071 this . user = user ;
7172 this . _interactionType = interactionType || 'mouse' ;
7273 this . _advanceTimer = advanceTimer ;
@@ -85,6 +86,7 @@ export class MenuTester {
8586 }
8687
8788 this . _isSubmenu = isSubmenu || false ;
89+ this . _rootMenu = rootMenu ;
8890 }
8991
9092 /**
@@ -226,20 +228,56 @@ export class MenuTester {
226228 await this . user . pointer ( { target : option , keys : '[TouchA]' } ) ;
227229 }
228230 }
229- act ( ( ) => { jest . runAllTimers ( ) ; } ) ;
230231
231- if ( option . getAttribute ( 'href' ) == null && option . getAttribute ( 'aria-haspopup' ) == null && menuSelectionMode === 'single' && closesOnSelect && keyboardActivation !== 'Space' && ! this . _isSubmenu ) {
232+ // This chain of waitFors is needed in place of running all timers since we don't know how long transitions may take, or what action
233+ // the menu option select may trigger.
234+ if (
235+ ! ( menuSelectionMode === 'single' && ! closesOnSelect ) &&
236+ ! ( menuSelectionMode === 'multiple' && ( keyboardActivation === 'Space' || interactionType === 'mouse' ) )
237+ ) {
238+ // For RSP, clicking on a submenu option seems to briefly lose focus to the body before moving to the clicked option in the test so we need to wait
239+ // for focus to be coerced to somewhere else in place of running all timers.
240+ if ( this . _isSubmenu ) {
241+ await waitFor ( ( ) => {
242+ if ( document . activeElement === document . body ) {
243+ throw new Error ( 'Expected focus to move to somewhere other than the body after selecting a submenu option.' ) ;
244+ } else {
245+ return true ;
246+ }
247+ } ) ;
248+ }
249+
250+ // If user isn't trying to select multiple menu options or closeOnSelect is true then we can assume that
251+ // the menu will close or some action is triggered. In cases like that focus should move somewhere after the menu closes
252+ // but we can't really know where so just make sure it doesn't get lost to the body.
232253 await waitFor ( ( ) => {
233- if ( document . activeElement !== trigger ) {
234- throw new Error ( ` Expected the document.activeElement after selecting an option to be the menu trigger but got ${ document . activeElement } ` ) ;
254+ if ( document . activeElement === option ) {
255+ throw new Error ( ' Expected focus after selecting an option to move away from the option.' ) ;
235256 } else {
236257 return true ;
237258 }
238259 } ) ;
239260
240- if ( document . contains ( menu ) ) {
241- throw new Error ( 'Expected menu element to not be in the document after selecting an option' ) ;
261+ // We'll also want to wait for focus to move away from the original submenu trigger since the entire submenu tree should
262+ // close. In React 16, focus actually makes it all the way to the root menu's submenu trigger so we need check the root menu
263+ if ( this . _isSubmenu ) {
264+ await waitFor ( ( ) => {
265+ if ( document . activeElement === this . trigger || this . _rootMenu ?. contains ( document . activeElement ) ) {
266+ throw new Error ( 'Expected focus after selecting an submenu option to move away from the original submenu trigger.' ) ;
267+ } else {
268+ return true ;
269+ }
270+ } ) ;
242271 }
272+
273+ // Finally wait for focus to be coerced somewhere final when the menu tree is removed from the DOM
274+ await waitFor ( ( ) => {
275+ if ( document . activeElement === document . body ) {
276+ throw new Error ( 'Expected focus to move to somewhere other than the body after selecting a menu option.' ) ;
277+ } else {
278+ return true ;
279+ }
280+ } ) ;
243281 }
244282 } else {
245283 throw new Error ( "Attempted to select a option in the menu, but menu wasn't found." ) ;
@@ -269,18 +307,30 @@ export class MenuTester {
269307 submenuTrigger = ( within ( menu ! ) . getByText ( submenuTrigger ) . closest ( '[role=menuitem]' ) ) ! as HTMLElement ;
270308 }
271309
272- let submenuTriggerTester = new MenuTester ( { user : this . user , interactionType : this . _interactionType , root : submenuTrigger , isSubmenu : true } ) ;
310+ let submenuTriggerTester = new MenuTester ( {
311+ user : this . user ,
312+ interactionType : this . _interactionType ,
313+ root : submenuTrigger ,
314+ isSubmenu : true ,
315+ advanceTimer : this . _advanceTimer ,
316+ rootMenu : ( this . _isSubmenu ? this . _rootMenu : this . menu ) || undefined
317+ } ) ;
273318 if ( interactionType === 'mouse' ) {
274319 await this . user . pointer ( { target : submenuTrigger } ) ;
275- act ( ( ) => { jest . runAllTimers ( ) ; } ) ;
276320 } else if ( interactionType === 'keyboard' ) {
277321 await this . keyboardNavigateToOption ( { option : submenuTrigger } ) ;
278322 await this . user . keyboard ( '[ArrowRight]' ) ;
279- act ( ( ) => { jest . runAllTimers ( ) ; } ) ;
280323 } else {
281324 await submenuTriggerTester . open ( ) ;
282325 }
283326
327+ await waitFor ( ( ) => {
328+ if ( submenuTriggerTester . _trigger ?. getAttribute ( 'aria-expanded' ) !== 'true' ) {
329+ throw new Error ( 'aria-expanded for the submenu trigger wasn\'t changed to "true", unable to confirm the existance of the submenu' ) ;
330+ } else {
331+ return true ;
332+ }
333+ } ) ;
284334
285335 return submenuTriggerTester ;
286336 }
0 commit comments