@@ -15,16 +15,18 @@ import {BaseTesterOpts, UserOpts} from './user';
1515import { triggerLongPress } from './events' ;
1616
1717export interface MenuOptions extends UserOpts , BaseTesterOpts {
18- user : any
18+ user ?: any ,
19+ isSubmenu ?: boolean
1920}
2021export class MenuTester {
2122 private user ;
2223 private _interactionType : UserOpts [ 'interactionType' ] ;
2324 private _advanceTimer : UserOpts [ 'advanceTimer' ] ;
24- private _trigger : HTMLElement ;
25+ private _trigger : HTMLElement | undefined ;
26+ private _isSubmenu : boolean = false ;
2527
2628 constructor ( opts : MenuOptions ) {
27- let { root, user, interactionType, advanceTimer} = opts ;
29+ let { root, user, interactionType, advanceTimer, isSubmenu } = opts ;
2830 this . user = user ;
2931 this . _interactionType = interactionType || 'mouse' ;
3032 this . _advanceTimer = advanceTimer ;
@@ -41,6 +43,8 @@ export class MenuTester {
4143 this . _trigger = root ;
4244 }
4345 }
46+
47+ this . _isSubmenu = isSubmenu || false ;
4448 }
4549
4650 setInteractionType = ( type : UserOpts [ 'interactionType' ] ) => {
@@ -49,12 +53,12 @@ export class MenuTester {
4953
5054 // TODO: this has been common to select as well, maybe make select use it? Or make a generic method. Will need to make error messages generic
5155 // One difference will be that it supports long press as well
52- open = async ( opts : { needsLongPress ?: boolean , interactionType ?: UserOpts [ 'interactionType' ] } = { } ) => {
56+ open = async ( opts : { needsLongPress ?: boolean , interactionType ?: UserOpts [ 'interactionType' ] , direction ?: 'up' | 'down' } = { } ) => {
5357 let {
5458 needsLongPress,
55- interactionType = this . _interactionType
59+ interactionType = this . _interactionType ,
60+ direction
5661 } = opts ;
57-
5862 let trigger = this . trigger ;
5963 let isDisabled = trigger . hasAttribute ( 'disabled' ) ;
6064 if ( interactionType === 'mouse' || interactionType === 'touch' ) {
@@ -70,8 +74,16 @@ export class MenuTester {
7074 await this . user . pointer ( { target : trigger , keys : '[TouchA]' } ) ;
7175 }
7276 } else if ( interactionType === 'keyboard' && ! isDisabled ) {
73- act ( ( ) => trigger . focus ( ) ) ;
74- await this . user . keyboard ( '[Enter]' ) ;
77+ if ( direction === 'up' ) {
78+ act ( ( ) => trigger . focus ( ) ) ;
79+ await this . user . keyboard ( '[ArrowUp]' ) ;
80+ } else if ( direction === 'down' ) {
81+ act ( ( ) => trigger . focus ( ) ) ;
82+ await this . user . keyboard ( '[ArrowDown]' ) ;
83+ } else {
84+ act ( ( ) => trigger . focus ( ) ) ;
85+ await this . user . keyboard ( '[Enter]' ) ;
86+ }
7587 }
7688
7789 await waitFor ( ( ) => {
@@ -95,42 +107,57 @@ export class MenuTester {
95107
96108 // TODO: also very similar to select, barring potential long press support
97109 // Close on select is also kinda specific?
98- selectOption = async ( opts : { option ?: HTMLElement , optionText ?: string , menuSelectionMode ?: 'single' | 'multiple' , needsLongPress ?: boolean , closesOnSelect ?: boolean , interactionType ?: UserOpts [ 'interactionType' ] } ) => {
110+ selectOption = async ( opts : {
111+ option ?: HTMLElement ,
112+ optionText ?: string ,
113+ menuSelectionMode ?: 'single' | 'multiple' ,
114+ needsLongPress ?: boolean ,
115+ closesOnSelect ?: boolean ,
116+ interactionType ?: UserOpts [ 'interactionType' ] ,
117+ keyboardActivation ?: 'Space' | 'Enter'
118+ } ) => {
99119 let {
100120 optionText,
101121 menuSelectionMode = 'single' ,
102122 needsLongPress,
103123 closesOnSelect = true ,
104124 option,
105- interactionType = this . _interactionType
125+ interactionType = this . _interactionType ,
126+ keyboardActivation = 'Enter'
106127 } = opts ;
107128 let trigger = this . trigger ;
108- if ( ! trigger . getAttribute ( 'aria-controls' ) ) {
129+
130+ if ( ! trigger . getAttribute ( 'aria-controls' ) && ! trigger . hasAttribute ( 'aria-expanded' ) ) {
109131 await this . open ( { needsLongPress} ) ;
110132 }
111133
112134 let menu = this . menu ;
113135 if ( menu ) {
114136 if ( ! option && optionText ) {
115- option = within ( menu ) . getByText ( optionText ) ;
137+ // @ts -ignore
138+ option = ( within ( menu ! ) . getByText ( optionText ) . closest ( '[role=menuitem], [role=menuitemradio], [role=menuitemcheckbox]' ) ) ! ;
139+ }
140+ if ( ! option ) {
141+ throw new Error ( 'No option found in the menu.' ) ;
116142 }
117143
118144 if ( interactionType === 'keyboard' ) {
119145 if ( document . activeElement !== menu || ! menu . contains ( document . activeElement ) ) {
120146 act ( ( ) => menu . focus ( ) ) ;
121147 }
122148
123- await this . user . keyboard ( optionText ) ;
124- await this . user . keyboard ( '[Enter]' ) ;
149+ await this . keyboardNavigateToOption ( { option } ) ;
150+ await this . user . keyboard ( `[ ${ keyboardActivation } ]` ) ;
125151 } else {
126152 if ( interactionType === 'mouse' ) {
127153 await this . user . click ( option ) ;
128154 } else {
129155 await this . user . pointer ( { target : option , keys : '[TouchA]' } ) ;
130156 }
131157 }
158+ act ( ( ) => { jest . runAllTimers ( ) ; } ) ;
132159
133- if ( option && option . getAttribute ( 'href' ) == null && option . getAttribute ( 'aria-haspopup' ) == null && menuSelectionMode === 'single' && closesOnSelect ) {
160+ if ( option && option . getAttribute ( 'href' ) == null && option . getAttribute ( 'aria-haspopup' ) == null && menuSelectionMode === 'single' && closesOnSelect && keyboardActivation !== 'Space' && ! this . _isSubmenu ) {
134161 await waitFor ( ( ) => {
135162 if ( document . activeElement !== trigger ) {
136163 throw new Error ( `Expected the document.activeElement after selecting an option to be the menu trigger but got ${ document . activeElement } ` ) ;
@@ -156,6 +183,7 @@ export class MenuTester {
156183 needsLongPress,
157184 interactionType = this . _interactionType
158185 } = opts ;
186+
159187 let trigger = this . trigger ;
160188 let isDisabled = trigger . hasAttribute ( 'disabled' ) ;
161189 if ( ! trigger . getAttribute ( 'aria-controls' ) && ! isDisabled ) {
@@ -171,8 +199,18 @@ export class MenuTester {
171199 submenu = within ( menu ) . getByText ( submenuTriggerText ) ;
172200 }
173201
174- let submenuTriggerTester = new MenuTester ( { user : this . user , interactionType : interactionType , root : submenu } ) ;
175- await submenuTriggerTester . open ( ) ;
202+ let submenuTriggerTester = new MenuTester ( { user : this . user , interactionType : this . _interactionType , root : submenu , isSubmenu : true } ) ;
203+ if ( interactionType === 'mouse' ) {
204+ await this . user . pointer ( { target : submenu } ) ;
205+ act ( ( ) => { jest . runAllTimers ( ) ; } ) ;
206+ } else if ( interactionType === 'keyboard' ) {
207+ await this . keyboardNavigateToOption ( { option : submenu } ) ;
208+ await this . user . keyboard ( '[ArrowRight]' ) ;
209+ act ( ( ) => { jest . runAllTimers ( ) ; } ) ;
210+ } else {
211+ await submenuTriggerTester . open ( ) ;
212+ }
213+
176214
177215 return submenuTriggerTester ;
178216 }
@@ -181,6 +219,28 @@ export class MenuTester {
181219 return null ;
182220 } ;
183221
222+ keyboardNavigateToOption = async ( opts : { option : HTMLElement } ) => {
223+ let { option} = opts ;
224+ let options = this . options ;
225+ let targetIndex = options . indexOf ( option ) ;
226+ if ( targetIndex === - 1 ) {
227+ throw new Error ( 'Option provided is not in the menu' ) ;
228+ }
229+ if ( document . activeElement === this . menu ) {
230+ await this . user . keyboard ( '[ArrowDown]' ) ;
231+ }
232+ let currIndex = options . indexOf ( document . activeElement as HTMLElement ) ;
233+ if ( targetIndex === - 1 ) {
234+ throw new Error ( 'ActiveElement is not in the menu' ) ;
235+ }
236+ let direction = targetIndex > currIndex ? 'down' : 'up' ;
237+
238+ for ( let i = 0 ; i < Math . abs ( targetIndex - currIndex ) ; i ++ ) {
239+ await this . user . keyboard ( `[${ direction === 'down' ? 'ArrowDown' : 'ArrowUp' } ]` ) ;
240+ }
241+ } ;
242+
243+
184244 close = async ( ) => {
185245 let menu = this . menu ;
186246 if ( menu ) {
@@ -202,6 +262,9 @@ export class MenuTester {
202262 } ;
203263
204264 get trigger ( ) {
265+ if ( ! this . _trigger ) {
266+ throw new Error ( 'No trigger element found for menu.' ) ;
267+ }
205268 return this . _trigger ;
206269 }
207270
@@ -210,9 +273,9 @@ export class MenuTester {
210273 return menuId ? document . getElementById ( menuId ) : undefined ;
211274 }
212275
213- get options ( ) : HTMLElement [ ] | never [ ] {
276+ get options ( ) : HTMLElement [ ] {
214277 let menu = this . menu ;
215- let options = [ ] ;
278+ let options : HTMLElement [ ] = [ ] ;
216279 if ( menu ) {
217280 options = within ( menu ) . queryAllByRole ( 'menuitem' ) ;
218281 if ( options . length === 0 ) {
0 commit comments