@react-aria/test-utils 1.0.0-nightly.5042 → 1.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +70 -0
  2. package/dist/import.mjs +6 -4
  3. package/dist/main.js +15 -23
  4. package/dist/main.js.map +1 -1
  5. package/dist/module.js +6 -4
  6. package/dist/module.js.map +1 -1
  7. package/dist/private/act.cjs +33 -0
  8. package/dist/private/act.cjs.map +1 -0
  9. package/dist/private/act.js +28 -0
  10. package/dist/private/act.js.map +1 -0
  11. package/dist/private/checkboxgroup.cjs +107 -0
  12. package/dist/private/checkboxgroup.cjs.map +1 -0
  13. package/dist/private/checkboxgroup.js +102 -0
  14. package/dist/private/checkboxgroup.js.map +1 -0
  15. package/dist/private/combobox.cjs +199 -0
  16. package/dist/private/combobox.cjs.map +1 -0
  17. package/dist/private/combobox.js +194 -0
  18. package/dist/private/combobox.js.map +1 -0
  19. package/dist/private/dialog.cjs +110 -0
  20. package/dist/private/dialog.cjs.map +1 -0
  21. package/dist/private/dialog.js +105 -0
  22. package/dist/private/dialog.js.map +1 -0
  23. package/dist/private/gridlist.cjs +173 -0
  24. package/dist/private/gridlist.cjs.map +1 -0
  25. package/dist/private/gridlist.js +168 -0
  26. package/dist/private/gridlist.js.map +1 -0
  27. package/dist/private/listbox.cjs +163 -0
  28. package/dist/private/listbox.cjs.map +1 -0
  29. package/dist/private/listbox.js +158 -0
  30. package/dist/private/listbox.js.map +1 -0
  31. package/dist/private/menu.cjs +265 -0
  32. package/dist/private/menu.cjs.map +1 -0
  33. package/dist/private/menu.js +260 -0
  34. package/dist/private/menu.js.map +1 -0
  35. package/dist/private/radiogroup.cjs +122 -0
  36. package/dist/private/radiogroup.cjs.map +1 -0
  37. package/dist/private/radiogroup.js +117 -0
  38. package/dist/private/radiogroup.js.map +1 -0
  39. package/dist/private/select.cjs +169 -0
  40. package/dist/private/select.cjs.map +1 -0
  41. package/dist/private/select.js +164 -0
  42. package/dist/private/select.js.map +1 -0
  43. package/dist/private/table.cjs +346 -0
  44. package/dist/private/table.cjs.map +1 -0
  45. package/dist/private/table.js +341 -0
  46. package/dist/private/table.js.map +1 -0
  47. package/dist/private/tabs.cjs +131 -0
  48. package/dist/private/tabs.cjs.map +1 -0
  49. package/dist/private/tabs.js +126 -0
  50. package/dist/private/tabs.js.map +1 -0
  51. package/dist/private/testSetup.cjs +87 -0
  52. package/dist/private/testSetup.cjs.map +1 -0
  53. package/dist/private/testSetup.js +81 -0
  54. package/dist/private/testSetup.js.map +1 -0
  55. package/dist/private/tree.cjs +181 -0
  56. package/dist/private/tree.cjs.map +1 -0
  57. package/dist/private/tree.js +176 -0
  58. package/dist/private/tree.js.map +1 -0
  59. package/dist/private/user.cjs +85 -0
  60. package/dist/private/user.cjs.map +1 -0
  61. package/dist/private/user.js +76 -0
  62. package/dist/private/user.js.map +1 -0
  63. package/dist/{userEventMaps.main.js → private/userEventMaps.cjs} +3 -3
  64. package/dist/private/userEventMaps.cjs.map +1 -0
  65. package/dist/{userEventMaps.mjs → private/userEventMaps.js} +3 -3
  66. package/dist/private/userEventMaps.js.map +1 -0
  67. package/dist/private/utils.cjs +136 -0
  68. package/dist/private/utils.cjs.map +1 -0
  69. package/dist/private/utils.js +127 -0
  70. package/dist/private/utils.js.map +1 -0
  71. package/dist/types/src/act.d.ts +4 -0
  72. package/dist/types/src/checkboxgroup.d.ts +47 -0
  73. package/dist/types/src/combobox.d.ts +87 -0
  74. package/dist/types/src/dialog.d.ts +37 -0
  75. package/dist/types/src/events.d.ts +25 -0
  76. package/dist/types/src/gridlist.d.ts +56 -0
  77. package/dist/types/src/index.d.ts +16 -0
  78. package/dist/types/src/listbox.d.ts +91 -0
  79. package/dist/types/src/menu.d.ts +112 -0
  80. package/dist/types/src/radiogroup.d.ts +47 -0
  81. package/dist/types/src/select.d.ts +74 -0
  82. package/dist/types/src/table.d.ts +120 -0
  83. package/dist/types/src/tabs.d.ts +59 -0
  84. package/dist/types/src/testSetup.d.ts +6 -0
  85. package/dist/types/src/tree.d.ts +62 -0
  86. package/dist/types/src/types.d.ts +143 -0
  87. package/dist/types/src/user.d.ts +49 -0
  88. package/dist/types/src/userEventMaps.d.ts +2 -0
  89. package/dist/types/src/utils.d.ts +29 -0
  90. package/package.json +26 -18
  91. package/src/act.ts +35 -0
  92. package/src/checkboxgroup.ts +165 -0
  93. package/src/combobox.ts +307 -0
  94. package/src/dialog.ts +155 -0
  95. package/src/gridlist.ts +278 -0
  96. package/src/index.ts +17 -3
  97. package/src/listbox.ts +300 -0
  98. package/src/menu.ts +479 -0
  99. package/src/radiogroup.ts +179 -0
  100. package/src/select.ts +273 -0
  101. package/src/table.ts +589 -0
  102. package/src/tabs.ts +204 -0
  103. package/src/testSetup.ts +41 -36
  104. package/src/tree.ts +290 -0
  105. package/src/types.ts +171 -0
  106. package/src/user.ts +153 -0
  107. package/src/userEventMaps.ts +1 -1
  108. package/src/utils.ts +155 -0
  109. package/dist/events.main.js +0 -37
  110. package/dist/events.main.js.map +0 -1
  111. package/dist/events.mjs +0 -31
  112. package/dist/events.module.js +0 -31
  113. package/dist/events.module.js.map +0 -1
  114. package/dist/testSetup.main.js +0 -82
  115. package/dist/testSetup.main.js.map +0 -1
  116. package/dist/testSetup.mjs +0 -76
  117. package/dist/testSetup.module.js +0 -76
  118. package/dist/testSetup.module.js.map +0 -1
  119. package/dist/types.d.ts +0 -16
  120. package/dist/types.d.ts.map +0 -1
  121. package/dist/userEventMaps.main.js.map +0 -1
  122. package/dist/userEventMaps.module.js +0 -38
  123. package/dist/userEventMaps.module.js.map +0 -1
  124. package/src/events.ts +0 -28
@@ -0,0 +1,278 @@
1
+ /*
2
+ * Copyright 2024 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import {act} from './act';
14
+ import {
15
+ Direction,
16
+ GridListTesterOpts,
17
+ GridRowActionOpts,
18
+ ToggleGridRowOpts,
19
+ UserOpts
20
+ } from './types';
21
+ import {formatTargetNode, getAltKey, getMetaKey, pressElement, triggerLongPress} from './utils';
22
+ import {within} from '@testing-library/dom';
23
+
24
+ interface GridListToggleRowOpts extends ToggleGridRowOpts {}
25
+ interface GridListRowActionOpts extends GridRowActionOpts {}
26
+
27
+ export class GridListTester {
28
+ private user;
29
+ private _interactionType: UserOpts['interactionType'];
30
+ private _advanceTimer: UserOpts['advanceTimer'];
31
+ private _direction: Direction;
32
+ private _gridlist: HTMLElement;
33
+ private _layout: GridListTesterOpts['layout'];
34
+
35
+ constructor(opts: GridListTesterOpts) {
36
+ let {root, user, interactionType, advanceTimer, direction, layout} = opts;
37
+ this.user = user;
38
+ this._interactionType = interactionType || 'mouse';
39
+ this._advanceTimer = advanceTimer;
40
+ this._direction = direction || 'ltr';
41
+ this._layout = layout || 'stack';
42
+ this._gridlist = root;
43
+ if (root.getAttribute('role') !== 'grid') {
44
+ let gridlist = within(root).queryByRole('grid');
45
+ if (gridlist) {
46
+ this._gridlist = gridlist;
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Set the interaction type used by the gridlist tester.
53
+ */
54
+ setInteractionType(type: UserOpts['interactionType']): void {
55
+ this._interactionType = type;
56
+ }
57
+
58
+ /**
59
+ * Returns a row matching the specified index or text content.
60
+ */
61
+ findRow(opts: {indexOrText: number | string}): HTMLElement {
62
+ let {indexOrText} = opts;
63
+
64
+ let row;
65
+ if (typeof indexOrText === 'number') {
66
+ row = this.getRows()[indexOrText];
67
+ } else if (typeof indexOrText === 'string') {
68
+ row = within(this.getGridlist()!)
69
+ .getByText(indexOrText)
70
+ .closest('[role=row]')! as HTMLElement;
71
+ }
72
+
73
+ return row;
74
+ }
75
+
76
+ private async keyboardNavigateToRow(opts: {
77
+ row: HTMLElement;
78
+ selectionOnNav?: 'default' | 'none';
79
+ }) {
80
+ let {row, selectionOnNav = 'default'} = opts;
81
+ let altKey = getAltKey();
82
+ let rows = this.getRows();
83
+ let targetIndex = rows.indexOf(row);
84
+ if (targetIndex === -1) {
85
+ throw new Error('Row provided is not in the gridlist');
86
+ }
87
+
88
+ if (
89
+ document.activeElement !== this._gridlist &&
90
+ !this._gridlist.contains(document.activeElement)
91
+ ) {
92
+ act(() => this._gridlist.focus());
93
+ }
94
+
95
+ let focusPrevKey = this._direction === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
96
+ if (document.activeElement === this._gridlist) {
97
+ await this.user.keyboard(
98
+ `${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`
99
+ );
100
+ } else if (
101
+ this._gridlist.contains(document.activeElement) &&
102
+ document.activeElement!.getAttribute('role') !== 'row'
103
+ ) {
104
+ do {
105
+ await this.user.keyboard(`[${focusPrevKey}]`);
106
+ } while (document.activeElement!.getAttribute('role') !== 'row');
107
+ }
108
+ let currIndex = rows.indexOf(document.activeElement as HTMLElement);
109
+ if (currIndex === -1) {
110
+ throw new Error('ActiveElement is not in the gridlist');
111
+ }
112
+
113
+ if (selectionOnNav === 'none') {
114
+ await this.user.keyboard(`[${altKey}>]`);
115
+ }
116
+ if (this._layout === 'grid') {
117
+ while (document.activeElement !== row) {
118
+ let curr = (document.activeElement as HTMLElement).getBoundingClientRect();
119
+ let target = row.getBoundingClientRect();
120
+ let key: string;
121
+ // basically compare current position with desired position to determine if we need to go up/down/left/right
122
+ // use 1 in the comparison here for subpixels since getBoundingClientRect returns subpixels precision
123
+ if (Math.abs(curr.top - target.top) > 1) {
124
+ key = curr.top < target.top ? 'ArrowDown' : 'ArrowUp';
125
+ } else if (Math.abs(curr.left - target.left) > 1) {
126
+ key = curr.left < target.left ? 'ArrowRight' : 'ArrowLeft';
127
+ } else {
128
+ // if the diff in current vs desired is < 1 but it is claiming we arent focused on the target
129
+ // then we might be in a case where getBoundingClientRect isnt mocked
130
+ throw new Error(
131
+ 'Could not navigate to target row in grid layout. Did the test mock getBoundingClientRect?'
132
+ );
133
+ }
134
+ await this.user.keyboard(`[${key}]`);
135
+ }
136
+ } else {
137
+ let direction = targetIndex > currIndex ? 'down' : 'up';
138
+ for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) {
139
+ await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`);
140
+ }
141
+ }
142
+ if (selectionOnNav === 'none') {
143
+ await this.user.keyboard(`[/${altKey}]`);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Toggles the selection for the specified gridlist row. Defaults to using the interaction type
149
+ * set on the gridlist tester. Note that this will endevor to always add/remove JUST the provided
150
+ * row to the set of selected rows.
151
+ */
152
+ async toggleRowSelection(opts: GridListToggleRowOpts): Promise<void> {
153
+ let {
154
+ row,
155
+ needsLongPress,
156
+ checkboxSelection = true,
157
+ interactionType = this._interactionType,
158
+ selectionBehavior = 'toggle'
159
+ } = opts;
160
+
161
+ let altKey = getAltKey();
162
+ let metaKey = getMetaKey();
163
+
164
+ if (typeof row === 'string' || typeof row === 'number') {
165
+ row = this.findRow({indexOrText: row});
166
+ }
167
+
168
+ if (!row) {
169
+ throw new Error(`Target row "${formatTargetNode(opts.row)}" not found in the gridlist.`);
170
+ }
171
+
172
+ let rowCheckbox = within(row).queryByRole('checkbox');
173
+
174
+ if (
175
+ rowCheckbox?.getAttribute('disabled') === '' ||
176
+ row?.getAttribute('aria-disabled') === 'true'
177
+ ) {
178
+ throw new Error(`Cannot toggle selection on disabled row "${formatTargetNode(opts.row)}".`);
179
+ }
180
+
181
+ // this would be better than the check to do nothing in events.ts
182
+ // also, it'd be good to be able to trigger selection on the row instead of having to go to the checkbox directly
183
+ if (interactionType === 'keyboard' && (!checkboxSelection || !rowCheckbox)) {
184
+ await this.keyboardNavigateToRow({
185
+ row,
186
+ selectionOnNav: selectionBehavior === 'replace' ? 'none' : 'default'
187
+ });
188
+ if (selectionBehavior === 'replace') {
189
+ await this.user.keyboard(`[${altKey}>]`);
190
+ }
191
+ await this.user.keyboard('[Space]');
192
+ if (selectionBehavior === 'replace') {
193
+ await this.user.keyboard(`[/${altKey}]`);
194
+ }
195
+ return;
196
+ }
197
+ if (rowCheckbox && checkboxSelection) {
198
+ await pressElement(this.user, rowCheckbox, interactionType);
199
+ } else {
200
+ let cell = within(row).getAllByRole('gridcell')[0];
201
+ if (needsLongPress && interactionType === 'touch') {
202
+ // Note that long press interactions with rows is strictly touch only for grid rows
203
+ await triggerLongPress({
204
+ element: cell,
205
+ advanceTimer: this._advanceTimer!,
206
+ pointerOpts: {pointerType: 'touch'}
207
+ });
208
+ } else {
209
+ if (selectionBehavior === 'replace' && interactionType !== 'touch') {
210
+ await this.user.keyboard(`[${metaKey}>]`);
211
+ }
212
+ await pressElement(this.user, row, interactionType);
213
+ if (selectionBehavior === 'replace' && interactionType !== 'touch') {
214
+ await this.user.keyboard(`[/${metaKey}]`);
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Triggers the action for the specified gridlist row. Defaults to using the interaction type set
222
+ * on the gridlist tester.
223
+ */
224
+ async triggerRowAction(opts: GridListRowActionOpts): Promise<void> {
225
+ let {row, needsDoubleClick, interactionType = this._interactionType} = opts;
226
+
227
+ if (typeof row === 'string' || typeof row === 'number') {
228
+ row = this.findRow({indexOrText: row});
229
+ }
230
+
231
+ if (!row) {
232
+ throw new Error(`Target row "${formatTargetNode(opts.row)}" not found in the gridlist.`);
233
+ }
234
+
235
+ if (row.getAttribute('aria-disabled') === 'true') {
236
+ throw new Error(`Cannot trigger row action on disabled row "${formatTargetNode(opts.row)}".`);
237
+ }
238
+
239
+ if (needsDoubleClick) {
240
+ await this.user.dblClick(row);
241
+ } else if (interactionType === 'keyboard') {
242
+ await this.keyboardNavigateToRow({row, selectionOnNav: 'none'});
243
+ await this.user.keyboard('[Enter]');
244
+ } else {
245
+ await pressElement(this.user, row, interactionType);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Returns the gridlist.
251
+ */
252
+ getGridlist(): HTMLElement {
253
+ return this._gridlist;
254
+ }
255
+
256
+ /**
257
+ * Returns the gridlist's rows if any.
258
+ */
259
+ getRows(): HTMLElement[] {
260
+ return within(this.getGridlist()).queryAllByRole('row');
261
+ }
262
+
263
+ /**
264
+ * Returns the gridlist's selected rows if any.
265
+ */
266
+ getSelectedRows(): HTMLElement[] {
267
+ return this.getRows().filter(row => row.getAttribute('aria-selected') === 'true');
268
+ }
269
+
270
+ /**
271
+ * Returns the gridlist's cells if any. Can be filtered against a specific row if provided via
272
+ * `element`.
273
+ */
274
+ getCells(opts: {element?: HTMLElement} = {}): HTMLElement[] {
275
+ let {element = this.getGridlist()} = opts;
276
+ return within(element).queryAllByRole('gridcell');
277
+ }
278
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,20 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- export * from './events';
14
- export * from './testSetup';
15
- export * from './userEventMaps';
13
+ export {triggerLongPress} from './utils';
14
+ export {installMouseEvent, installPointerEvent} from './testSetup';
15
+ export {pointerMap} from './userEventMaps';
16
+ export {User} from './user';
17
+ export type {CheckboxGroupTester} from './checkboxgroup';
18
+ export type {ComboBoxTester} from './combobox';
19
+ export type {DialogTester} from './dialog';
20
+ export type {GridListTester} from './gridlist';
21
+ export type {ListBoxTester} from './listbox';
22
+ export type {MenuTester} from './menu';
23
+ export type {RadioGroupTester} from './radiogroup';
24
+ export type {SelectTester} from './select';
25
+ export type {TableTester} from './table';
26
+ export type {TabsTester} from './tabs';
27
+ export type {TreeTester} from './tree';
28
+
29
+ export type {UserOpts} from './types';
package/src/listbox.ts ADDED
@@ -0,0 +1,300 @@
1
+ /*
2
+ * Copyright 2024 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import {act} from './act';
14
+ import {formatTargetNode, getAltKey, getMetaKey, pressElement, triggerLongPress} from './utils';
15
+ import {ListBoxTesterOpts, UserOpts} from './types';
16
+ import {within} from '@testing-library/dom';
17
+
18
+ interface ListBoxToggleOptionOpts {
19
+ /**
20
+ * What interaction type to use when toggling selection for an option. Defaults to the interaction
21
+ * type set on the tester.
22
+ */
23
+ interactionType?: UserOpts['interactionType'];
24
+ /**
25
+ * The index, text, or node of the option to toggle selection for.
26
+ */
27
+ option: number | string | HTMLElement;
28
+ /**
29
+ * Whether the option should be triggered by Space or Enter in keyboard modality.
30
+ *
31
+ * @default 'Enter'
32
+ */
33
+ keyboardActivation?: 'Space' | 'Enter';
34
+ /**
35
+ * Whether the option needs to be long pressed to be selected. Depends on the listbox's
36
+ * implementation.
37
+ */
38
+ needsLongPress?: boolean;
39
+ /**
40
+ * Whether the listbox has a selectionBehavior of "toggle" or "replace" (aka highlight selection).
41
+ * This affects the user operations required to toggle option selection by adding modifier keys
42
+ * during user actions, useful when performing multi-option selection in a "selectionBehavior:
43
+ * 'replace'" listbox. If you would like to still simulate user actions (aka press) without these
44
+ * modifiers keys for a "selectionBehavior: replace" listbox, simply omit this option. See the
45
+ * [RAC Listbox docs](https://react-spectrum.adobe.com/react-aria/ListBox.html#selection-behavior)
46
+ * for more info on this behavior.
47
+ *
48
+ * @default 'toggle'
49
+ */
50
+ selectionBehavior?: 'toggle' | 'replace';
51
+ }
52
+
53
+ interface ListBoxOptionActionOpts extends Omit<
54
+ ListBoxToggleOptionOpts,
55
+ 'keyboardActivation' | 'needsLongPress'
56
+ > {
57
+ /**
58
+ * Whether or not the option needs a double click to trigger the option action. Depends on the
59
+ * listbox's implementation.
60
+ */
61
+ needsDoubleClick?: boolean;
62
+ }
63
+
64
+ export class ListBoxTester {
65
+ private user;
66
+ private _interactionType: UserOpts['interactionType'];
67
+ private _advanceTimer: UserOpts['advanceTimer'];
68
+ private _listbox: HTMLElement;
69
+ private _layout: ListBoxTesterOpts['layout'];
70
+
71
+ constructor(opts: ListBoxTesterOpts) {
72
+ let {root, user, interactionType, advanceTimer, layout} = opts;
73
+ this.user = user;
74
+ this._interactionType = interactionType || 'mouse';
75
+ this._advanceTimer = advanceTimer;
76
+ this._layout = layout || 'stack';
77
+ this._listbox = root;
78
+ if (root.getAttribute('role') !== 'listbox') {
79
+ let listbox = within(root).queryByRole('listbox');
80
+ if (listbox) {
81
+ this._listbox = listbox;
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Set the interaction type used by the listbox tester.
88
+ */
89
+ setInteractionType(type: UserOpts['interactionType']): void {
90
+ this._interactionType = type;
91
+ }
92
+
93
+ /**
94
+ * Returns a option matching the specified index or text content.
95
+ */
96
+ findOption(opts: {indexOrText: number | string}): HTMLElement {
97
+ let {indexOrText} = opts;
98
+
99
+ let option;
100
+ let options = this.getOptions();
101
+
102
+ if (typeof indexOrText === 'number') {
103
+ option = options[indexOrText];
104
+ } else if (typeof indexOrText === 'string') {
105
+ option = within(this.getListbox()!)
106
+ .getByText(indexOrText)
107
+ .closest('[role=option]')! as HTMLElement;
108
+ }
109
+
110
+ return option;
111
+ }
112
+
113
+ private async keyboardNavigateToOption(opts: {
114
+ option: HTMLElement;
115
+ selectionOnNav?: 'default' | 'none';
116
+ }) {
117
+ let {option, selectionOnNav = 'default'} = opts;
118
+ let altKey = getAltKey();
119
+ let options = this.getOptions();
120
+ let targetIndex = options.indexOf(option);
121
+ if (targetIndex === -1) {
122
+ throw new Error('Option provided is not in the listbox');
123
+ }
124
+
125
+ if (
126
+ document.activeElement !== this._listbox &&
127
+ !this._listbox.contains(document.activeElement)
128
+ ) {
129
+ act(() => this._listbox.focus());
130
+ await this.user.keyboard(
131
+ `${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`
132
+ );
133
+ }
134
+
135
+ let currIndex = options.indexOf(document.activeElement as HTMLElement);
136
+ if (currIndex === -1) {
137
+ throw new Error('ActiveElement is not in the listbox');
138
+ }
139
+
140
+ if (selectionOnNav === 'none') {
141
+ await this.user.keyboard(`[${altKey}>]`);
142
+ }
143
+ if (this._layout === 'grid') {
144
+ while (document.activeElement !== option) {
145
+ let curr = (document.activeElement as HTMLElement).getBoundingClientRect();
146
+ let target = option.getBoundingClientRect();
147
+ let key: string;
148
+ // compare current position with desired position to determine if we need to go up/down/left/right
149
+ // use 1 in the comparison here for subpixels since getBoundingClientRect returns subpixels precision
150
+ if (Math.abs(curr.top - target.top) > 1) {
151
+ key = curr.top < target.top ? 'ArrowDown' : 'ArrowUp';
152
+ } else if (Math.abs(curr.left - target.left) > 1) {
153
+ key = curr.left < target.left ? 'ArrowRight' : 'ArrowLeft';
154
+ } else {
155
+ // if the diff in current vs desired is < 1 but it is claiming we arent focused on the target
156
+ // then we might be in a case where getBoundingClientRect isnt mocked
157
+ throw new Error(
158
+ 'Could not navigate to target option in grid layout. Did the test mock getBoundingClientRect?'
159
+ );
160
+ }
161
+ await this.user.keyboard(`[${key}]`);
162
+ }
163
+ } else {
164
+ let direction = targetIndex > currIndex ? 'down' : 'up';
165
+ for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) {
166
+ await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`);
167
+ }
168
+ }
169
+ if (selectionOnNav === 'none') {
170
+ await this.user.keyboard(`[/${altKey}]`);
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Toggles the selection for the specified listbox option. Defaults to using the interaction type
176
+ * set on the listbox tester.
177
+ */
178
+ async toggleOptionSelection(opts: ListBoxToggleOptionOpts): Promise<void> {
179
+ let {
180
+ option,
181
+ needsLongPress,
182
+ keyboardActivation = 'Enter',
183
+ interactionType = this._interactionType,
184
+ selectionBehavior = 'toggle'
185
+ } = opts;
186
+
187
+ let altKey = getAltKey();
188
+ let metaKey = getMetaKey();
189
+
190
+ if (typeof option === 'string' || typeof option === 'number') {
191
+ option = this.findOption({indexOrText: option});
192
+ }
193
+
194
+ if (!option) {
195
+ throw new Error(`Target option "${formatTargetNode(opts.option)}" not found in the listbox.`);
196
+ }
197
+
198
+ if (interactionType === 'keyboard') {
199
+ if (option?.getAttribute('aria-disabled') === 'true') {
200
+ throw new Error(
201
+ `Cannot toggle selection on disabled option "${formatTargetNode(opts.option)}".`
202
+ );
203
+ }
204
+
205
+ await this.keyboardNavigateToOption({
206
+ option,
207
+ selectionOnNav: selectionBehavior === 'replace' ? 'none' : 'default'
208
+ });
209
+ if (selectionBehavior === 'replace') {
210
+ await this.user.keyboard(`[${altKey}>]`);
211
+ }
212
+ await this.user.keyboard(`[${keyboardActivation}]`);
213
+ if (selectionBehavior === 'replace') {
214
+ await this.user.keyboard(`[/${altKey}]`);
215
+ }
216
+ } else {
217
+ if (needsLongPress && interactionType === 'touch') {
218
+ await triggerLongPress({
219
+ element: option,
220
+ advanceTimer: this._advanceTimer!,
221
+ pointerOpts: {pointerType: 'touch'}
222
+ });
223
+ } else {
224
+ if (selectionBehavior === 'replace' && interactionType !== 'touch') {
225
+ await this.user.keyboard(`[${metaKey}>]`);
226
+ }
227
+ await pressElement(this.user, option, interactionType);
228
+ if (selectionBehavior === 'replace' && interactionType !== 'touch') {
229
+ await this.user.keyboard(`[/${metaKey}]`);
230
+ }
231
+ }
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Triggers the action for the specified listbox option. Defaults to using the interaction type
237
+ * set on the listbox tester.
238
+ */
239
+ async triggerOptionAction(opts: ListBoxOptionActionOpts): Promise<void> {
240
+ let {option, needsDoubleClick, interactionType = this._interactionType} = opts;
241
+
242
+ if (typeof option === 'string' || typeof option === 'number') {
243
+ option = this.findOption({indexOrText: option});
244
+ }
245
+
246
+ if (!option) {
247
+ throw new Error(`Target option "${formatTargetNode(opts.option)}" not found in the listbox.`);
248
+ }
249
+
250
+ if (needsDoubleClick) {
251
+ await this.user.dblClick(option);
252
+ } else if (interactionType === 'keyboard') {
253
+ if (option?.getAttribute('aria-disabled') === 'true') {
254
+ throw new Error(
255
+ `Cannot trigger action on disabled option "${formatTargetNode(opts.option)}".`
256
+ );
257
+ }
258
+
259
+ await this.keyboardNavigateToOption({option, selectionOnNav: 'none'});
260
+ await this.user.keyboard('[Enter]');
261
+ } else {
262
+ await pressElement(this.user, option, interactionType);
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Returns the listbox.
268
+ */
269
+ getListbox(): HTMLElement {
270
+ return this._listbox;
271
+ }
272
+
273
+ /**
274
+ * Returns the listbox options. Can be filtered to a subsection of the listbox if provided via
275
+ * `element`.
276
+ */
277
+ getOptions(opts: {element?: HTMLElement} = {}): HTMLElement[] {
278
+ let {element = this._listbox} = opts;
279
+ let options = [];
280
+ if (element) {
281
+ options = within(element).queryAllByRole('option');
282
+ }
283
+
284
+ return options;
285
+ }
286
+
287
+ /**
288
+ * Returns the listbox's selected options if any.
289
+ */
290
+ getSelectedOptions(): HTMLElement[] {
291
+ return this.getOptions().filter(row => row.getAttribute('aria-selected') === 'true');
292
+ }
293
+
294
+ /**
295
+ * Returns the listbox's sections if any.
296
+ */
297
+ getSections(): HTMLElement[] {
298
+ return within(this._listbox).queryAllByRole('group');
299
+ }
300
+ }