Skip to content

Commit 0d94d89

Browse files
authored
feat: Add CheckBoxGroup/Dialog/RadioGroup test utils (#9039)
* initial progress for dialog test util * test the dialog util * initial radiogroup tester * fix case with disabled radios * fix missing orientation in S2 Radiogroup * add checkbox group test util * fix tabs test util so that it properly keyboard navigates over disabled tabs * review comments * review comments * fix lint
1 parent 324609f commit 0d94d89

File tree

18 files changed

+905
-156
lines changed

18 files changed

+905
-156
lines changed
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*
2+
* Copyright 2025 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 {CheckboxGroupTesterOpts, UserOpts} from './types';
15+
import {pressElement} from './events';
16+
17+
interface TriggerCheckboxOptions {
18+
/**
19+
* What interaction type to use when triggering a checkbox. Defaults to the interaction type set on the tester.
20+
*/
21+
interactionType?: UserOpts['interactionType'],
22+
/**
23+
* The index, text, or node of the checkbox to toggle selection for.
24+
*/
25+
checkbox: number | string | HTMLElement
26+
}
27+
28+
export class CheckboxGroupTester {
29+
private user;
30+
private _interactionType: UserOpts['interactionType'];
31+
private _checkboxgroup: HTMLElement;
32+
33+
34+
constructor(opts: CheckboxGroupTesterOpts) {
35+
let {root, user, interactionType} = opts;
36+
this.user = user;
37+
this._interactionType = interactionType || 'mouse';
38+
39+
this._checkboxgroup = root;
40+
let checkboxgroup = within(root).queryAllByRole('group');
41+
if (checkboxgroup.length > 0) {
42+
this._checkboxgroup = checkboxgroup[0];
43+
}
44+
}
45+
46+
/**
47+
* Set the interaction type used by the checkbox group tester.
48+
*/
49+
setInteractionType(type: UserOpts['interactionType']): void {
50+
this._interactionType = type;
51+
}
52+
53+
/**
54+
* Returns a checkbox matching the specified index or text content.
55+
*/
56+
findCheckbox(opts: {checkboxIndexOrText: number | string}): HTMLElement {
57+
let {
58+
checkboxIndexOrText
59+
} = opts;
60+
61+
let checkbox;
62+
if (typeof checkboxIndexOrText === 'number') {
63+
checkbox = this.checkboxes[checkboxIndexOrText];
64+
} else if (typeof checkboxIndexOrText === 'string') {
65+
let label = within(this.checkboxgroup).getByText(checkboxIndexOrText);
66+
67+
// Label may wrap the checkbox, or the actual label may be a sibling span, or the checkbox div could have the label within it
68+
if (label) {
69+
checkbox = within(label).queryByRole('checkbox');
70+
if (!checkbox) {
71+
let labelWrapper = label.closest('label');
72+
if (labelWrapper) {
73+
checkbox = within(labelWrapper).queryByRole('checkbox');
74+
} else {
75+
checkbox = label.closest('[role=checkbox]');
76+
}
77+
}
78+
}
79+
}
80+
81+
return checkbox;
82+
}
83+
84+
private async keyboardNavigateToCheckbox(opts: {checkbox: HTMLElement}) {
85+
let {checkbox} = opts;
86+
let checkboxes = this.checkboxes;
87+
checkboxes = checkboxes.filter(checkbox => !(checkbox.hasAttribute('disabled') || checkbox.getAttribute('aria-disabled') === 'true'));
88+
if (checkboxes.length === 0) {
89+
throw new Error('Checkbox group doesnt have any non-disabled checkboxes. Please double check your checkbox group.');
90+
}
91+
92+
let targetIndex = checkboxes.indexOf(checkbox);
93+
if (targetIndex === -1) {
94+
throw new Error('Checkbox provided is not in the checkbox group.');
95+
}
96+
97+
if (!this.checkboxgroup.contains(document.activeElement)) {
98+
act(() => checkboxes[0].focus());
99+
}
100+
101+
let currIndex = checkboxes.indexOf(document.activeElement as HTMLElement);
102+
if (currIndex === -1) {
103+
throw new Error('Active element is not in the checkbox group.');
104+
}
105+
106+
for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) {
107+
await this.user.tab({shift: targetIndex < currIndex});
108+
}
109+
};
110+
111+
/**
112+
* Toggles the specified checkbox. Defaults to using the interaction type set on the checkbox tester.
113+
*/
114+
async toggleCheckbox(opts: TriggerCheckboxOptions): Promise<void> {
115+
let {
116+
checkbox,
117+
interactionType = this._interactionType
118+
} = opts;
119+
120+
if (typeof checkbox === 'string' || typeof checkbox === 'number') {
121+
checkbox = this.findCheckbox({checkboxIndexOrText: checkbox});
122+
}
123+
124+
if (!checkbox) {
125+
throw new Error('Target checkbox not found in the checkboxgroup.');
126+
} else if (checkbox.hasAttribute('disabled')) {
127+
throw new Error('Target checkbox is disabled.');
128+
}
129+
130+
if (interactionType === 'keyboard') {
131+
await this.keyboardNavigateToCheckbox({checkbox});
132+
await this.user.keyboard('[Space]');
133+
} else {
134+
await pressElement(this.user, checkbox, interactionType);
135+
}
136+
}
137+
138+
/**
139+
* Returns the checkboxgroup.
140+
*/
141+
get checkboxgroup(): HTMLElement {
142+
return this._checkboxgroup;
143+
}
144+
145+
/**
146+
* Returns the checkboxes.
147+
*/
148+
get checkboxes(): HTMLElement[] {
149+
return within(this.checkboxgroup).queryAllByRole('checkbox');
150+
}
151+
152+
/**
153+
* Returns the currently selected checkboxes in the checkboxgroup if any.
154+
*/
155+
get selectedCheckboxes(): HTMLElement[] {
156+
return this.checkboxes.filter(checkbox => (checkbox as HTMLInputElement).checked || checkbox.getAttribute('aria-checked') === 'true');
157+
}
158+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/*
2+
* Copyright 2025 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, waitFor, within} from '@testing-library/react';
14+
import {DialogTesterOpts, UserOpts} from './types';
15+
16+
interface DialogOpenOpts {
17+
/**
18+
* What interaction type to use when opening the dialog. Defaults to the interaction type set on the tester.
19+
*/
20+
interactionType?: UserOpts['interactionType']
21+
}
22+
23+
export class DialogTester {
24+
private user;
25+
private _interactionType: UserOpts['interactionType'];
26+
private _trigger: HTMLElement | undefined;
27+
private _dialog: HTMLElement | undefined;
28+
private _overlayType: DialogTesterOpts['overlayType'];
29+
30+
constructor(opts: DialogTesterOpts) {
31+
let {root, user, interactionType, overlayType} = opts;
32+
this.user = user;
33+
this._interactionType = interactionType || 'mouse';
34+
this._overlayType = overlayType || 'modal';
35+
36+
// Handle case where element provided is a wrapper of the trigger button
37+
let trigger = within(root).queryByRole('button');
38+
if (trigger) {
39+
this._trigger = trigger;
40+
} else {
41+
this._trigger = root;
42+
}
43+
}
44+
45+
/**
46+
* Set the interaction type used by the dialog tester.
47+
*/
48+
setInteractionType(type: UserOpts['interactionType']): void {
49+
this._interactionType = type;
50+
}
51+
52+
/**
53+
* Opens the dialog. Defaults to using the interaction type set on the dialog tester.
54+
*/
55+
async open(opts: DialogOpenOpts = {}): Promise<void> {
56+
let {
57+
interactionType = this._interactionType
58+
} = opts;
59+
let trigger = this.trigger;
60+
if (!trigger.hasAttribute('disabled')) {
61+
if (interactionType === 'mouse') {
62+
await this.user.click(trigger);
63+
} else if (interactionType === 'touch') {
64+
await this.user.pointer({target: trigger, keys: '[TouchA]'});
65+
} else if (interactionType === 'keyboard') {
66+
act(() => trigger.focus());
67+
await this.user.keyboard('[Enter]');
68+
}
69+
70+
if (this._overlayType === 'popover') {
71+
await waitFor(() => {
72+
if (trigger.getAttribute('aria-controls') == null) {
73+
throw new Error('No aria-controls found on dialog trigger element.');
74+
} else {
75+
return true;
76+
}
77+
});
78+
79+
let dialogId = trigger.getAttribute('aria-controls');
80+
await waitFor(() => {
81+
if (!dialogId || document.getElementById(dialogId) == null) {
82+
throw new Error(`Dialog with id of ${dialogId} not found in document.`);
83+
} else {
84+
this._dialog = document.getElementById(dialogId)!;
85+
return true;
86+
}
87+
});
88+
} else {
89+
let dialog;
90+
await waitFor(() => {
91+
dialog = document.querySelector('[role=dialog], [role=alertdialog]');
92+
if (dialog == null) {
93+
throw new Error('No dialog of type role="dialog" or role="alertdialog" found after pressing the trigger.');
94+
} else {
95+
return true;
96+
}
97+
});
98+
99+
if (dialog && document.activeElement !== this._trigger && dialog.contains(document.activeElement)) {
100+
this._dialog = dialog;
101+
} else {
102+
throw new Error('New modal dialog doesnt contain the active element OR the active element is still the trigger. Uncertain if the proper modal dialog was found');
103+
}
104+
}
105+
}
106+
}
107+
108+
/**
109+
* Closes the dialog via the Escape key.
110+
*/
111+
async close(): Promise<void> {
112+
let dialog = this._dialog;
113+
if (dialog) {
114+
await this.user.keyboard('[Escape]');
115+
await waitFor(() => {
116+
if (document.contains(dialog)) {
117+
throw new Error('Expected the dialog to not be in the document after closing it.');
118+
} else {
119+
this._dialog = undefined;
120+
return true;
121+
}
122+
});
123+
}
124+
}
125+
126+
/**
127+
* Returns the dialog's trigger.
128+
*/
129+
get trigger(): HTMLElement {
130+
if (!this._trigger) {
131+
throw new Error('No trigger element found for dialog.');
132+
}
133+
134+
return this._trigger;
135+
}
136+
137+
/**
138+
* Returns the dialog if present.
139+
*/
140+
get dialog(): HTMLElement | null {
141+
return this._dialog && document.contains(this._dialog) ? this._dialog : null;
142+
}
143+
}

0 commit comments

Comments
 (0)