@@ -15,16 +15,18 @@ import {BaseTesterOpts, UserOpts} from './user';
15
15
import { triggerLongPress } from './events' ;
16
16
17
17
export interface MenuOptions extends UserOpts , BaseTesterOpts {
18
- user : any
18
+ user ?: any ,
19
+ isSubmenu ?: boolean
19
20
}
20
21
export class MenuTester {
21
22
private user ;
22
23
private _interactionType : UserOpts [ 'interactionType' ] ;
23
24
private _advanceTimer : UserOpts [ 'advanceTimer' ] ;
24
- private _trigger : HTMLElement ;
25
+ private _trigger : HTMLElement | undefined ;
26
+ private _isSubmenu : boolean = false ;
25
27
26
28
constructor ( opts : MenuOptions ) {
27
- let { root, user, interactionType, advanceTimer} = opts ;
29
+ let { root, user, interactionType, advanceTimer, isSubmenu } = opts ;
28
30
this . user = user ;
29
31
this . _interactionType = interactionType || 'mouse' ;
30
32
this . _advanceTimer = advanceTimer ;
@@ -41,6 +43,8 @@ export class MenuTester {
41
43
this . _trigger = root ;
42
44
}
43
45
}
46
+
47
+ this . _isSubmenu = isSubmenu || false ;
44
48
}
45
49
46
50
setInteractionType = ( type : UserOpts [ 'interactionType' ] ) => {
@@ -49,12 +53,12 @@ export class MenuTester {
49
53
50
54
// 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
51
55
// 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' } = { } ) => {
53
57
let {
54
58
needsLongPress,
55
- interactionType = this . _interactionType
59
+ interactionType = this . _interactionType ,
60
+ direction
56
61
} = opts ;
57
-
58
62
let trigger = this . trigger ;
59
63
let isDisabled = trigger . hasAttribute ( 'disabled' ) ;
60
64
if ( interactionType === 'mouse' || interactionType === 'touch' ) {
@@ -70,8 +74,16 @@ export class MenuTester {
70
74
await this . user . pointer ( { target : trigger , keys : '[TouchA]' } ) ;
71
75
}
72
76
} 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
+ }
75
87
}
76
88
77
89
await waitFor ( ( ) => {
@@ -95,42 +107,57 @@ export class MenuTester {
95
107
96
108
// TODO: also very similar to select, barring potential long press support
97
109
// 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
+ } ) => {
99
119
let {
100
120
optionText,
101
121
menuSelectionMode = 'single' ,
102
122
needsLongPress,
103
123
closesOnSelect = true ,
104
124
option,
105
- interactionType = this . _interactionType
125
+ interactionType = this . _interactionType ,
126
+ keyboardActivation = 'Enter'
106
127
} = opts ;
107
128
let trigger = this . trigger ;
108
- if ( ! trigger . getAttribute ( 'aria-controls' ) ) {
129
+
130
+ if ( ! trigger . getAttribute ( 'aria-controls' ) && ! trigger . hasAttribute ( 'aria-expanded' ) ) {
109
131
await this . open ( { needsLongPress} ) ;
110
132
}
111
133
112
134
let menu = this . menu ;
113
135
if ( menu ) {
114
136
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.' ) ;
116
142
}
117
143
118
144
if ( interactionType === 'keyboard' ) {
119
145
if ( document . activeElement !== menu || ! menu . contains ( document . activeElement ) ) {
120
146
act ( ( ) => menu . focus ( ) ) ;
121
147
}
122
148
123
- await this . user . keyboard ( optionText ) ;
124
- await this . user . keyboard ( '[Enter]' ) ;
149
+ await this . keyboardNavigateToOption ( { option } ) ;
150
+ await this . user . keyboard ( `[ ${ keyboardActivation } ]` ) ;
125
151
} else {
126
152
if ( interactionType === 'mouse' ) {
127
153
await this . user . click ( option ) ;
128
154
} else {
129
155
await this . user . pointer ( { target : option , keys : '[TouchA]' } ) ;
130
156
}
131
157
}
158
+ act ( ( ) => { jest . runAllTimers ( ) ; } ) ;
132
159
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 ) {
134
161
await waitFor ( ( ) => {
135
162
if ( document . activeElement !== trigger ) {
136
163
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 {
156
183
needsLongPress,
157
184
interactionType = this . _interactionType
158
185
} = opts ;
186
+
159
187
let trigger = this . trigger ;
160
188
let isDisabled = trigger . hasAttribute ( 'disabled' ) ;
161
189
if ( ! trigger . getAttribute ( 'aria-controls' ) && ! isDisabled ) {
@@ -171,8 +199,18 @@ export class MenuTester {
171
199
submenu = within ( menu ) . getByText ( submenuTriggerText ) ;
172
200
}
173
201
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
+
176
214
177
215
return submenuTriggerTester ;
178
216
}
@@ -181,6 +219,28 @@ export class MenuTester {
181
219
return null ;
182
220
} ;
183
221
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
+
184
244
close = async ( ) => {
185
245
let menu = this . menu ;
186
246
if ( menu ) {
@@ -202,6 +262,9 @@ export class MenuTester {
202
262
} ;
203
263
204
264
get trigger ( ) {
265
+ if ( ! this . _trigger ) {
266
+ throw new Error ( 'No trigger element found for menu.' ) ;
267
+ }
205
268
return this . _trigger ;
206
269
}
207
270
@@ -210,9 +273,9 @@ export class MenuTester {
210
273
return menuId ? document . getElementById ( menuId ) : undefined ;
211
274
}
212
275
213
- get options ( ) : HTMLElement [ ] | never [ ] {
276
+ get options ( ) : HTMLElement [ ] {
214
277
let menu = this . menu ;
215
- let options = [ ] ;
278
+ let options : HTMLElement [ ] = [ ] ;
216
279
if ( menu ) {
217
280
options = within ( menu ) . queryAllByRole ( 'menuitem' ) ;
218
281
if ( options . length === 0 ) {
0 commit comments