@react-aria/test-utils 1.0.0-alpha.3 → 1.0.0-alpha.4

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 (64) hide show
  1. package/dist/combobox.main.js +116 -82
  2. package/dist/combobox.main.js.map +1 -1
  3. package/dist/combobox.mjs +117 -83
  4. package/dist/combobox.module.js +117 -83
  5. package/dist/combobox.module.js.map +1 -1
  6. package/dist/events.main.js.map +1 -1
  7. package/dist/events.module.js.map +1 -1
  8. package/dist/gridlist.main.js +102 -59
  9. package/dist/gridlist.main.js.map +1 -1
  10. package/dist/gridlist.mjs +103 -60
  11. package/dist/gridlist.module.js +103 -60
  12. package/dist/gridlist.module.js.map +1 -1
  13. package/dist/listbox.main.js +135 -0
  14. package/dist/listbox.main.js.map +1 -0
  15. package/dist/listbox.mjs +130 -0
  16. package/dist/listbox.module.js +130 -0
  17. package/dist/listbox.module.js.map +1 -0
  18. package/dist/main.js.map +1 -1
  19. package/dist/menu.main.js +195 -161
  20. package/dist/menu.main.js.map +1 -1
  21. package/dist/menu.mjs +196 -162
  22. package/dist/menu.module.js +196 -162
  23. package/dist/menu.module.js.map +1 -1
  24. package/dist/module.js.map +1 -1
  25. package/dist/select.main.js +116 -70
  26. package/dist/select.main.js.map +1 -1
  27. package/dist/select.mjs +117 -71
  28. package/dist/select.module.js +117 -71
  29. package/dist/select.module.js.map +1 -1
  30. package/dist/table.main.js +170 -139
  31. package/dist/table.main.js.map +1 -1
  32. package/dist/table.mjs +171 -140
  33. package/dist/table.module.js +171 -140
  34. package/dist/table.module.js.map +1 -1
  35. package/dist/tabs.main.js +133 -0
  36. package/dist/tabs.main.js.map +1 -0
  37. package/dist/tabs.mjs +128 -0
  38. package/dist/tabs.module.js +128 -0
  39. package/dist/tabs.module.js.map +1 -0
  40. package/dist/tree.main.js +165 -0
  41. package/dist/tree.main.js.map +1 -0
  42. package/dist/tree.mjs +160 -0
  43. package/dist/tree.module.js +160 -0
  44. package/dist/tree.module.js.map +1 -0
  45. package/dist/types.d.ts +607 -145
  46. package/dist/types.d.ts.map +1 -1
  47. package/dist/user.main.js +15 -4
  48. package/dist/user.main.js.map +1 -1
  49. package/dist/user.mjs +15 -4
  50. package/dist/user.module.js +15 -4
  51. package/dist/user.module.js.map +1 -1
  52. package/package.json +2 -3
  53. package/src/combobox.ts +105 -36
  54. package/src/events.ts +1 -1
  55. package/src/gridlist.ts +146 -59
  56. package/src/index.ts +1 -1
  57. package/src/listbox.ts +226 -0
  58. package/src/menu.ts +152 -62
  59. package/src/select.ts +137 -44
  60. package/src/table.ts +129 -64
  61. package/src/tabs.ts +198 -0
  62. package/src/tree.ts +248 -0
  63. package/src/types.ts +133 -0
  64. package/src/user.ts +62 -37
package/src/gridlist.ts CHANGED
@@ -11,106 +11,193 @@
11
11
  */
12
12
 
13
13
  import {act, within} from '@testing-library/react';
14
- import {BaseTesterOpts, UserOpts} from './user';
15
- import {pressElement} from './events';
14
+ import {GridListTesterOpts, GridRowActionOpts, ToggleGridRowOpts, UserOpts} from './types';
15
+ import {pressElement, triggerLongPress} from './events';
16
+
17
+ interface GridListToggleRowOpts extends ToggleGridRowOpts {}
18
+ interface GridListRowActionOpts extends GridRowActionOpts {}
16
19
 
17
- export interface GridListOptions extends UserOpts, BaseTesterOpts {
18
- user?: any
19
- }
20
20
  export class GridListTester {
21
21
  private user;
22
22
  private _interactionType: UserOpts['interactionType'];
23
+ private _advanceTimer: UserOpts['advanceTimer'];
23
24
  private _gridlist: HTMLElement;
24
25
 
25
-
26
- constructor(opts: GridListOptions) {
27
- let {root, user, interactionType} = opts;
26
+ constructor(opts: GridListTesterOpts) {
27
+ let {root, user, interactionType, advanceTimer} = opts;
28
28
  this.user = user;
29
29
  this._interactionType = interactionType || 'mouse';
30
+ this._advanceTimer = advanceTimer;
30
31
  this._gridlist = root;
31
32
  }
32
33
 
33
- setInteractionType = (type: UserOpts['interactionType']) => {
34
+ /**
35
+ * Set the interaction type used by the gridlist tester.
36
+ */
37
+ setInteractionType(type: UserOpts['interactionType']) {
34
38
  this._interactionType = type;
39
+ }
40
+
41
+ /**
42
+ * Returns a row matching the specified index or text content.
43
+ */
44
+ findRow(opts: {rowIndexOrText: number | string}): HTMLElement {
45
+ let {
46
+ rowIndexOrText
47
+ } = opts;
48
+
49
+ let row;
50
+ if (typeof rowIndexOrText === 'number') {
51
+ row = this.rows[rowIndexOrText];
52
+ } else if (typeof rowIndexOrText === 'string') {
53
+ row = (within(this.gridlist!).getByText(rowIndexOrText).closest('[role=row]'))! as HTMLElement;
54
+ }
55
+
56
+ return row;
57
+ }
58
+
59
+ // TODO: RTL
60
+ private async keyboardNavigateToRow(opts: {row: HTMLElement}) {
61
+ let {row} = opts;
62
+ let rows = this.rows;
63
+ let targetIndex = rows.indexOf(row);
64
+ if (targetIndex === -1) {
65
+ throw new Error('Option provided is not in the gridlist');
66
+ }
67
+ if (document.activeElement === this._gridlist) {
68
+ await this.user.keyboard('[ArrowDown]');
69
+ } else if (this._gridlist.contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') {
70
+ do {
71
+ await this.user.keyboard('[ArrowLeft]');
72
+ } while (document.activeElement!.getAttribute('role') !== 'row');
73
+ }
74
+ let currIndex = rows.indexOf(document.activeElement as HTMLElement);
75
+ if (currIndex === -1) {
76
+ throw new Error('ActiveElement is not in the gridlist');
77
+ }
78
+ let direction = targetIndex > currIndex ? 'down' : 'up';
79
+
80
+ for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) {
81
+ await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`);
82
+ }
35
83
  };
36
84
 
37
- // TODO: support long press? This is also pretty much the same as table's toggleRowSelection so maybe can share
38
- // For now, don't include long press, see if people need it or if we should just expose long press as a separate util if it isn't very common
39
- // If the current way of passing in the user specified advance timers is ok, then I'd be find including long press
40
- // Maybe also support an option to force the click to happen on a specific part of the element (checkbox or row). That way
41
- // the user can test a specific type of interaction?
42
- toggleRowSelection = async (opts: {index?: number, text?: string, interactionType?: UserOpts['interactionType']} = {}) => {
43
- let {index, text, interactionType = this._interactionType} = opts;
85
+ /**
86
+ * Toggles the selection for the specified gridlist row. Defaults to using the interaction type set on the gridlist tester.
87
+ */
88
+ async toggleRowSelection(opts: GridListToggleRowOpts) {
89
+ let {
90
+ row,
91
+ needsLongPress,
92
+ checkboxSelection = true,
93
+ interactionType = this._interactionType
94
+ } = opts;
95
+
96
+ if (typeof row === 'string' || typeof row === 'number') {
97
+ row = this.findRow({rowIndexOrText: row});
98
+ }
99
+
100
+ if (!row) {
101
+ throw new Error('Target row not found in the gridlist.');
102
+ }
44
103
 
45
- let row = this.findRow({index, text});
46
104
  let rowCheckbox = within(row).queryByRole('checkbox');
47
- if (rowCheckbox) {
105
+
106
+ // TODO: we early return here because the checkbox/row can't be keyboard navigated to if the row is disabled usually
107
+ // but we may to check for disabledBehavior (aka if the disable row gets skipped when keyboard navigating or not)
108
+ if (interactionType === 'keyboard' && (rowCheckbox?.getAttribute('disabled') === '' || row?.getAttribute('aria-disabled') === 'true')) {
109
+ return;
110
+ }
111
+
112
+ // this would be better than the check to do nothing in events.ts
113
+ // also, it'd be good to be able to trigger selection on the row instead of having to go to the checkbox directly
114
+ if (interactionType === 'keyboard' && !checkboxSelection) {
115
+ await this.keyboardNavigateToRow({row});
116
+ await this.user.keyboard('{Space}');
117
+ return;
118
+ }
119
+ if (rowCheckbox && checkboxSelection) {
48
120
  await pressElement(this.user, rowCheckbox, interactionType);
49
121
  } else {
50
122
  let cell = within(row).getAllByRole('gridcell')[0];
51
- await pressElement(this.user, cell, interactionType);
52
- }
53
- };
123
+ if (needsLongPress && interactionType === 'touch') {
124
+ if (this._advanceTimer == null) {
125
+ throw new Error('No advanceTimers provided for long press.');
126
+ }
54
127
 
55
- // TODO: pretty much the same as table except it uses this.gridlist. Make common between the two by accepting an option for
56
- // an element?
57
- findRow = (opts: {index?: number, text?: string}) => {
58
- let {
59
- index,
60
- text
61
- } = opts;
128
+ // Note that long press interactions with rows is strictly touch only for grid rows
129
+ await triggerLongPress({element: cell, advanceTimer: this._advanceTimer, pointerOpts: {pointerType: 'touch'}});
62
130
 
63
- let row;
64
- if (index != null) {
65
- row = this.rows[index];
66
- } else if (text != null) {
67
- row = within(this?.gridlist).getByText(text);
68
- while (row && row.getAttribute('role') !== 'row') {
69
- row = row.parentElement;
131
+ } else {
132
+ await pressElement(this.user, cell, interactionType);
70
133
  }
71
134
  }
72
-
73
- return row;
74
- };
135
+ }
75
136
 
76
137
  // TODO: There is a more difficult use case where the row has/behaves as link, don't think we have a good way to determine that unless the
77
138
  // user specificlly tells us
78
- triggerRowAction = async (opts: {index?: number, text?: string, needsDoubleClick?: boolean, interactionType?: UserOpts['interactionType']}) => {
139
+ /**
140
+ * Triggers the action for the specified gridlist row. Defaults to using the interaction type set on the gridlist tester.
141
+ */
142
+ async triggerRowAction(opts: GridListRowActionOpts) {
79
143
  let {
80
- index,
81
- text,
144
+ row,
82
145
  needsDoubleClick,
83
146
  interactionType = this._interactionType
84
147
  } = opts;
85
148
 
86
- let row = this.findRow({index, text});
87
- if (row) {
88
- if (needsDoubleClick) {
89
- await this.user.dblClick(row);
90
- } else if (interactionType === 'keyboard') {
91
- act(() => row.focus());
92
- await this.user.keyboard('[Enter]');
93
- } else {
94
- await pressElement(this.user, row, interactionType);
149
+ if (typeof row === 'string' || typeof row === 'number') {
150
+ row = this.findRow({rowIndexOrText: row});
151
+ }
152
+
153
+ if (!row) {
154
+ throw new Error('Target row not found in the gridlist.');
155
+ }
156
+
157
+ if (needsDoubleClick) {
158
+ await this.user.dblClick(row);
159
+ } else if (interactionType === 'keyboard') {
160
+ if (row?.getAttribute('aria-disabled') === 'true') {
161
+ return;
162
+ }
163
+
164
+ if (document.activeElement !== this._gridlist || !this._gridlist.contains(document.activeElement)) {
165
+ act(() => this._gridlist.focus());
95
166
  }
167
+
168
+ await this.keyboardNavigateToRow({row});
169
+ await this.user.keyboard('[Enter]');
170
+ } else {
171
+ await pressElement(this.user, row, interactionType);
96
172
  }
97
- };
173
+ }
98
174
 
99
- // TODO: do we really need this getter? Theoretically the user already has the reference to the gridlist
100
- get gridlist() {
175
+ /**
176
+ * Returns the gridlist.
177
+ */
178
+ get gridlist(): HTMLElement {
101
179
  return this._gridlist;
102
180
  }
103
181
 
104
- get rows() {
182
+ /**
183
+ * Returns the gridlist's rows if any.
184
+ */
185
+ get rows(): HTMLElement[] {
105
186
  return within(this?.gridlist).queryAllByRole('row');
106
187
  }
107
188
 
108
- get selectedRows() {
189
+ /**
190
+ * Returns the gridlist's selected rows if any.
191
+ */
192
+ get selectedRows(): HTMLElement[] {
109
193
  return this.rows.filter(row => row.getAttribute('aria-selected') === 'true');
110
194
  }
111
195
 
112
- cells = (opts: {element?: HTMLElement} = {}) => {
113
- let {element} = opts;
114
- return within(element || this.gridlist).queryAllByRole('gridcell');
115
- };
196
+ /**
197
+ * Returns the gridlist's cells if any. Can be filtered against a specific row if provided via `element`.
198
+ */
199
+ cells(opts: {element?: HTMLElement} = {}): HTMLElement[] {
200
+ let {element = this.gridlist} = opts;
201
+ return within(element).queryAllByRole('gridcell');
202
+ }
116
203
  }
package/src/index.ts CHANGED
@@ -15,4 +15,4 @@ export {installMouseEvent, installPointerEvent} from './testSetup';
15
15
  export {pointerMap} from './userEventMaps';
16
16
  export {User} from './user';
17
17
 
18
- export type {UserOpts} from './user';
18
+ export type {UserOpts} from './types';
package/src/listbox.ts ADDED
@@ -0,0 +1,226 @@
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, within} from '@testing-library/react';
14
+ import {ListBoxTesterOpts, UserOpts} from './types';
15
+ import {pressElement, triggerLongPress} from './events';
16
+
17
+ interface ListBoxToggleOptionOpts {
18
+ /**
19
+ * What interaction type to use when toggling selection for an option. Defaults to the interaction type set on the tester.
20
+ */
21
+ interactionType?: UserOpts['interactionType'],
22
+ /**
23
+ * The index, text, or node of the option to toggle selection for.
24
+ */
25
+ option: number | string | HTMLElement,
26
+ /**
27
+ * Whether the option should be triggered by Space or Enter in keyboard modality.
28
+ * @default 'Enter'
29
+ */
30
+ keyboardActivation?: 'Space' | 'Enter',
31
+ /**
32
+ * Whether the option needs to be long pressed to be selected. Depends on the listbox's implementation.
33
+ */
34
+ needsLongPress?: boolean
35
+ }
36
+
37
+ interface ListBoxOptionActionOpts extends Omit<ListBoxToggleOptionOpts, 'keyboardActivation' | 'needsLongPress'> {
38
+ /**
39
+ * Whether or not the option needs a double click to trigger the option action. Depends on the listbox's implementation.
40
+ */
41
+ needsDoubleClick?: boolean
42
+ }
43
+
44
+ export class ListBoxTester {
45
+ private user;
46
+ private _interactionType: UserOpts['interactionType'];
47
+ private _advanceTimer: UserOpts['advanceTimer'];
48
+ private _listbox: HTMLElement;
49
+
50
+ constructor(opts: ListBoxTesterOpts) {
51
+ let {root, user, interactionType, advanceTimer} = opts;
52
+ this.user = user;
53
+ this._interactionType = interactionType || 'mouse';
54
+ this._listbox = root;
55
+ this._advanceTimer = advanceTimer;
56
+ }
57
+
58
+ /**
59
+ * Set the interaction type used by the listbox tester.
60
+ */
61
+ setInteractionType(type: UserOpts['interactionType']) {
62
+ this._interactionType = type;
63
+ }
64
+
65
+ // TODO: now that we have listbox, perhaps select can make use of this tester internally
66
+ /**
67
+ * Returns a option matching the specified index or text content.
68
+ */
69
+ findOption(opts: {optionIndexOrText: number | string}): HTMLElement {
70
+ let {
71
+ optionIndexOrText
72
+ } = opts;
73
+
74
+ let option;
75
+ let options = this.options();
76
+
77
+ if (typeof optionIndexOrText === 'number') {
78
+ option = options[optionIndexOrText];
79
+ } else if (typeof optionIndexOrText === 'string') {
80
+ option = (within(this.listbox!).getByText(optionIndexOrText).closest('[role=option]'))! as HTMLElement;
81
+ }
82
+
83
+ return option;
84
+ }
85
+
86
+ // TODO: this is basically the same as menu except for the error message, refactor later so that they share
87
+ // TODO: this also doesn't support grid layout yet
88
+ private async keyboardNavigateToOption(opts: {option: HTMLElement}) {
89
+ let {option} = opts;
90
+ let options = this.options();
91
+ let targetIndex = options.indexOf(option);
92
+ if (targetIndex === -1) {
93
+ throw new Error('Option provided is not in the listbox');
94
+ }
95
+
96
+ if (document.activeElement === this._listbox) {
97
+ await this.user.keyboard('[ArrowDown]');
98
+ }
99
+
100
+ // TODO: not sure about doing same while loop that exists in other implementations of keyboardNavigateToOption,
101
+ // feels like it could break easily
102
+ if (document.activeElement?.getAttribute('role') !== 'option') {
103
+ await act(async () => {
104
+ option.focus();
105
+ });
106
+ }
107
+
108
+ let currIndex = options.indexOf(document.activeElement as HTMLElement);
109
+ if (currIndex === -1) {
110
+ throw new Error('ActiveElement is not in the listbox');
111
+ }
112
+ let direction = targetIndex > currIndex ? 'down' : 'up';
113
+
114
+ for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) {
115
+ await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`);
116
+ }
117
+ };
118
+
119
+ /**
120
+ * Toggles the selection for the specified listbox option. Defaults to using the interaction type set on the listbox tester.
121
+ */
122
+ async toggleOptionSelection(opts: ListBoxToggleOptionOpts) {
123
+ let {option, needsLongPress, keyboardActivation = 'Enter', interactionType = this._interactionType} = opts;
124
+
125
+ if (typeof option === 'string' || typeof option === 'number') {
126
+ option = this.findOption({optionIndexOrText: option});
127
+ }
128
+
129
+ if (!option) {
130
+ throw new Error('Target option not found in the listbox.');
131
+ }
132
+
133
+ if (interactionType === 'keyboard') {
134
+ if (option?.getAttribute('aria-disabled') === 'true') {
135
+ return;
136
+ }
137
+
138
+ if (document.activeElement !== this._listbox || !this._listbox.contains(document.activeElement)) {
139
+ act(() => this._listbox.focus());
140
+ }
141
+
142
+ await this.keyboardNavigateToOption({option});
143
+ await this.user.keyboard(`[${keyboardActivation}]`);
144
+ } else {
145
+ if (needsLongPress && interactionType === 'touch') {
146
+ if (this._advanceTimer == null) {
147
+ throw new Error('No advanceTimers provided for long press.');
148
+ }
149
+
150
+ await triggerLongPress({element: option, advanceTimer: this._advanceTimer, pointerOpts: {pointerType: 'touch'}});
151
+ } else {
152
+ await pressElement(this.user, option, interactionType);
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Triggers the action for the specified listbox option. Defaults to using the interaction type set on the listbox tester.
159
+ */
160
+ async triggerOptionAction(opts: ListBoxOptionActionOpts) {
161
+ let {
162
+ option,
163
+ needsDoubleClick,
164
+ interactionType = this._interactionType
165
+ } = opts;
166
+
167
+ if (typeof option === 'string' || typeof option === 'number') {
168
+ option = this.findOption({optionIndexOrText: option});
169
+ }
170
+
171
+ if (!option) {
172
+ throw new Error('Target option not found in the listbox.');
173
+ }
174
+
175
+ if (needsDoubleClick) {
176
+ await this.user.dblClick(option);
177
+ } else if (interactionType === 'keyboard') {
178
+ if (option?.getAttribute('aria-disabled') === 'true') {
179
+ return;
180
+ }
181
+
182
+ if (document.activeElement !== this._listbox || !this._listbox.contains(document.activeElement)) {
183
+ act(() => this._listbox.focus());
184
+ }
185
+
186
+ await this.keyboardNavigateToOption({option});
187
+ await this.user.keyboard('[Enter]');
188
+ } else {
189
+ await pressElement(this.user, option, interactionType);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Returns the listbox.
195
+ */
196
+ get listbox(): HTMLElement {
197
+ return this._listbox;
198
+ }
199
+
200
+ /**
201
+ * Returns the listbox options. Can be filtered to a subsection of the listbox if provided via `element`.
202
+ */
203
+ options(opts: {element?: HTMLElement} = {}): HTMLElement[] {
204
+ let {element = this._listbox} = opts;
205
+ let options = [];
206
+ if (element) {
207
+ options = within(element).queryAllByRole('option');
208
+ }
209
+
210
+ return options;
211
+ }
212
+
213
+ /**
214
+ * Returns the listbox's selected options if any.
215
+ */
216
+ get selectedOptions(): HTMLElement[] {
217
+ return this.options().filter(row => row.getAttribute('aria-selected') === 'true');
218
+ }
219
+
220
+ /**
221
+ * Returns the listbox's sections if any.
222
+ */
223
+ get sections(): HTMLElement[] {
224
+ return within(this._listbox).queryAllByRole('group');
225
+ }
226
+ }