@simplybusiness/mobius 5.5.0 → 5.6.1

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 (69) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/cjs/components/Combobox/Combobox.js +56 -33
  3. package/dist/cjs/components/Combobox/Combobox.js.map +1 -1
  4. package/dist/cjs/components/Combobox/Listbox.js +58 -0
  5. package/dist/cjs/components/Combobox/Listbox.js.map +1 -0
  6. package/dist/cjs/components/Combobox/Option.js +44 -0
  7. package/dist/cjs/components/Combobox/Option.js.map +1 -0
  8. package/dist/cjs/components/Combobox/fixtures.js +115 -0
  9. package/dist/cjs/components/Combobox/fixtures.js.map +1 -1
  10. package/dist/cjs/components/Combobox/useComboboxHighlight.js +86 -0
  11. package/dist/cjs/components/Combobox/useComboboxHighlight.js.map +1 -0
  12. package/dist/cjs/components/Combobox/utils.js +46 -0
  13. package/dist/cjs/components/Combobox/utils.js.map +1 -0
  14. package/dist/cjs/components/TextArea/TextArea.js +4 -4
  15. package/dist/cjs/components/TextArea/TextArea.js.map +1 -1
  16. package/dist/cjs/components/TextAreaInput/TextAreaInput.js +6 -3
  17. package/dist/cjs/components/TextAreaInput/TextAreaInput.js.map +1 -1
  18. package/dist/cjs/components/TextField/TextField.js +4 -3
  19. package/dist/cjs/components/TextField/TextField.js.map +1 -1
  20. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  21. package/dist/esm/components/Combobox/Combobox.js +55 -32
  22. package/dist/esm/components/Combobox/Combobox.js.map +1 -1
  23. package/dist/esm/components/Combobox/Listbox.js +43 -0
  24. package/dist/esm/components/Combobox/Listbox.js.map +1 -0
  25. package/dist/esm/components/Combobox/Option.js +29 -0
  26. package/dist/esm/components/Combobox/Option.js.map +1 -0
  27. package/dist/esm/components/Combobox/fixtures.js +109 -0
  28. package/dist/esm/components/Combobox/fixtures.js.map +1 -1
  29. package/dist/esm/components/Combobox/types.js.map +1 -1
  30. package/dist/esm/components/Combobox/useComboboxHighlight.js +76 -0
  31. package/dist/esm/components/Combobox/useComboboxHighlight.js.map +1 -0
  32. package/dist/esm/components/Combobox/utils.js +20 -0
  33. package/dist/esm/components/Combobox/utils.js.map +1 -0
  34. package/dist/esm/components/TextArea/TextArea.js +4 -4
  35. package/dist/esm/components/TextArea/TextArea.js.map +1 -1
  36. package/dist/esm/components/TextAreaInput/TextAreaInput.js +6 -3
  37. package/dist/esm/components/TextAreaInput/TextAreaInput.js.map +1 -1
  38. package/dist/esm/components/TextField/TextField.js +4 -3
  39. package/dist/esm/components/TextField/TextField.js.map +1 -1
  40. package/dist/types/components/Combobox/Combobox.d.ts +1 -1
  41. package/dist/types/components/Combobox/Combobox.stories.d.ts +4 -1
  42. package/dist/types/components/Combobox/Listbox.d.ts +10 -0
  43. package/dist/types/components/Combobox/Option.d.ts +2 -0
  44. package/dist/types/components/Combobox/fixtures.d.ts +5 -0
  45. package/dist/types/components/Combobox/types.d.ts +17 -2
  46. package/dist/types/components/Combobox/useComboboxHighlight.d.ts +10 -0
  47. package/dist/types/components/Combobox/useComboboxHighlight.test.d.ts +1 -0
  48. package/dist/types/components/Combobox/utils.d.ts +6 -0
  49. package/dist/types/components/Combobox/utils.test.d.ts +1 -0
  50. package/dist/types/components/TextArea/TextArea.d.ts +2 -2
  51. package/dist/types/components/TextAreaInput/TextAreaInput.d.ts +3 -1
  52. package/package.json +1 -1
  53. package/src/components/Combobox/Combobox.css +51 -4
  54. package/src/components/Combobox/Combobox.mdx +147 -0
  55. package/src/components/Combobox/Combobox.stories.tsx +47 -2
  56. package/src/components/Combobox/Combobox.test.tsx +535 -316
  57. package/src/components/Combobox/Combobox.tsx +78 -58
  58. package/src/components/Combobox/Listbox.tsx +74 -0
  59. package/src/components/Combobox/Option.tsx +41 -0
  60. package/src/components/Combobox/fixtures.tsx +111 -0
  61. package/src/components/Combobox/types.tsx +22 -4
  62. package/src/components/Combobox/useComboboxHighlight.test.tsx +242 -0
  63. package/src/components/Combobox/useComboboxHighlight.tsx +88 -0
  64. package/src/components/Combobox/utils.test.tsx +120 -0
  65. package/src/components/Combobox/utils.tsx +50 -0
  66. package/src/components/TextArea/TextArea.tsx +6 -6
  67. package/src/components/TextAreaInput/TextAreaInput.tsx +16 -4
  68. package/src/components/TextField/TextField.test.tsx +8 -0
  69. package/src/components/TextField/TextField.tsx +3 -1
@@ -1,25 +1,26 @@
1
- import { render, screen } from "@testing-library/react";
1
+ import { act, render, screen, within } from "@testing-library/react";
2
2
  import userEvent from "@testing-library/user-event";
3
3
  import { Combobox } from "./Combobox";
4
- import { FRUITS, FRUITS_OBJECTS } from "./fixtures";
4
+ import { FRUITS, FRUITS_OBJECTS, FRUITS_GROUPS } from "./fixtures";
5
5
 
6
6
  describe("Combobox with string options", () => {
7
+ // Basic rendering and setup
7
8
  it("should render without crashing", () => {
8
9
  render(<Combobox options={FRUITS} />);
9
10
  });
10
11
 
11
- it("should use the default value provided", () => {
12
- render(<Combobox options={FRUITS} defaultValue="Apple" />);
13
- const input = screen.getByRole("combobox") as HTMLInputElement;
14
- expect(input.value).toBe("Apple");
15
- });
16
-
17
12
  it("should render a combobox", () => {
18
13
  render(<Combobox options={FRUITS} />);
19
14
  const input = screen.getByRole("combobox");
20
15
  expect(input).toBeInTheDocument();
21
16
  });
22
17
 
18
+ it("should use the default value provided", () => {
19
+ render(<Combobox options={FRUITS} defaultValue="Apple" />);
20
+ const input = screen.getByRole("combobox") as HTMLInputElement;
21
+ expect(input.value).toBe("Apple");
22
+ });
23
+
23
24
  it("should accept a ref and forward it to the input", () => {
24
25
  const ref = { current: null };
25
26
  render(<Combobox options={FRUITS} ref={ref} />);
@@ -32,169 +33,260 @@ describe("Combobox with string options", () => {
32
33
  expect(listbox).not.toBeInTheDocument();
33
34
  });
34
35
 
35
- it("should render a listbox when the input is focused", async () => {
36
- render(<Combobox options={FRUITS} />);
36
+ it("should set placeholder in input if provided", async () => {
37
+ render(<Combobox options={FRUITS} placeholder="Please pick a fruit" />);
37
38
  const input = screen.getByRole("combobox");
38
- await userEvent.click(input);
39
- const listbox = screen.getByRole("listbox");
40
- expect(listbox).toBeInTheDocument();
39
+ expect(input).toHaveAttribute("placeholder", "Please pick a fruit");
41
40
  });
42
41
 
43
- it("should render all of the options in the listbox", async () => {
44
- render(<Combobox options={FRUITS} />);
45
- const input = screen.getByRole("combobox");
46
- await userEvent.click(input);
47
- const options = screen.getAllByRole("option");
48
- expect(options).toHaveLength(FRUITS.length);
49
- });
42
+ describe("mouse interactions", () => {
43
+ it("should render a listbox when the input is focused", async () => {
44
+ render(<Combobox options={FRUITS} />);
45
+ const input = screen.getByRole("combobox");
46
+ await userEvent.click(input);
47
+ const listbox = screen.getByRole("listbox");
48
+ expect(listbox).toBeInTheDocument();
49
+ });
50
50
 
51
- it("should call the onSelected callback when an option is selected", async () => {
52
- const onSelected = jest.fn();
53
- render(<Combobox options={FRUITS} onSelected={onSelected} />);
54
- const input = screen.getByRole("combobox");
55
- await userEvent.click(input);
56
- const options = screen.getAllByRole("option");
57
- await userEvent.click(options[0]);
58
- expect(onSelected).toHaveBeenCalledWith(FRUITS[0]);
59
- });
51
+ it("should call the onSelected callback when an option is selected", async () => {
52
+ const onSelected = jest.fn();
53
+ render(<Combobox options={FRUITS} onSelected={onSelected} />);
54
+ const input = screen.getByRole("combobox");
55
+ await userEvent.click(input);
56
+ const options = screen.getAllByRole("option");
57
+ await userEvent.click(options[0]);
58
+ expect(onSelected).toHaveBeenCalledWith(FRUITS[0]);
59
+ });
60
60
 
61
- it("should update the input value when an option is selected", async () => {
62
- render(<Combobox options={FRUITS} />);
63
- const input = screen.getByRole("combobox");
64
- await userEvent.click(input);
65
- const options = screen.getAllByRole("option");
66
- await userEvent.click(options[0]);
67
- expect(input).toHaveValue(FRUITS[0]);
68
- });
61
+ it("should update the input value when an option is selected", async () => {
62
+ render(<Combobox options={FRUITS} />);
63
+ const input = screen.getByRole("combobox");
64
+ await userEvent.click(input);
65
+ const options = screen.getAllByRole("option");
66
+ await userEvent.click(options[0]);
67
+ expect(input).toHaveValue(FRUITS[0]);
68
+ });
69
69
 
70
- it("should close the listbox when the input is blurred", async () => {
71
- render(<Combobox options={FRUITS} />);
72
- const input = screen.getByRole("combobox");
73
- await userEvent.click(input);
74
- await userEvent.tab();
75
- const listbox = screen.queryByRole("listbox");
76
- expect(listbox).not.toBeInTheDocument();
77
- });
70
+ it("should close the listbox when the input is blurred", async () => {
71
+ jest.useFakeTimers({
72
+ advanceTimers: true,
73
+ timerLimit: 1000,
74
+ });
75
+ render(<Combobox options={FRUITS} />);
76
+ const input = screen.getByRole("combobox");
77
+ await userEvent.click(input);
78
+ await act(async () => {
79
+ await userEvent.tab();
80
+ jest.advanceTimersByTime(200);
81
+ });
82
+ const listbox = screen.queryByRole("listbox");
83
+ expect(listbox).not.toBeInTheDocument();
84
+ });
78
85
 
79
- it("should close the listbox when an option is selected", async () => {
80
- render(<Combobox options={FRUITS} />);
81
- const input = screen.getByRole("combobox");
82
- await userEvent.click(input);
83
- const options = screen.getAllByRole("option");
84
- await userEvent.click(options[0]);
85
- const listbox = screen.queryByRole("listbox");
86
- expect(listbox).not.toBeInTheDocument();
87
- });
86
+ it("should close the listbox when an option is selected", async () => {
87
+ render(<Combobox options={FRUITS} />);
88
+ const input = screen.getByRole("combobox");
89
+ await userEvent.click(input);
90
+ const options = screen.getAllByRole("option");
91
+ await userEvent.click(options[0]);
92
+ const listbox = screen.queryByRole("listbox");
93
+ expect(listbox).not.toBeInTheDocument();
94
+ });
88
95
 
89
- it("should filter the options based on the input value", async () => {
90
- render(<Combobox options={FRUITS} />);
91
- const input = screen.getByRole("combobox");
92
- await userEvent.type(input, "Ap");
93
- const options = screen.getAllByRole("option");
94
- expect(options).toHaveLength(6);
95
- });
96
+ it("should close the listbox when clicking outside", async () => {
97
+ jest.useFakeTimers({
98
+ advanceTimers: true,
99
+ timerLimit: 1000,
100
+ });
101
+ render(
102
+ <div>
103
+ <Combobox options={FRUITS} />
104
+ <button type="button">Outside</button>
105
+ </div>,
106
+ );
107
+ const input = screen.getByRole("combobox");
108
+ const outside = screen.getByRole("button");
109
+ await userEvent.click(input);
110
+ await userEvent.click(outside);
111
+ await act(async () => {
112
+ await userEvent.tab();
113
+ jest.advanceTimersByTime(200);
114
+ });
115
+ const listbox = screen.queryByRole("listbox");
116
+ expect(listbox).not.toBeInTheDocument();
117
+ });
96
118
 
97
- it("should ignore case when filtering options", async () => {
98
- render(<Combobox options={FRUITS} />);
99
- const input = screen.getByRole("combobox");
100
- await userEvent.type(input, "AP");
101
- const options = screen.getAllByRole("option");
102
- expect(options).toHaveLength(6);
119
+ it("should not close the listbox on right click", async () => {
120
+ render(<Combobox options={FRUITS} />);
121
+ const input = screen.getByRole("combobox");
122
+ await userEvent.click(input);
123
+ await userEvent.pointer({ target: input, keys: "[MouseRight]" });
124
+ const listbox = screen.getByRole("listbox");
125
+ expect(listbox).toBeInTheDocument();
126
+ });
103
127
  });
104
128
 
105
- it("should select the top option when pressing Enter", async () => {
106
- const onSelected = jest.fn();
107
- render(<Combobox options={FRUITS} onSelected={onSelected} />);
108
- const input = screen.getByRole("combobox");
109
- await userEvent.type(input, "Ap");
110
- await userEvent.type(input, "{enter}");
111
- expect(onSelected).toHaveBeenCalledWith("Apple");
112
- });
129
+ describe("keyboard interactions", () => {
130
+ it("should select the top option when pressing Enter", async () => {
131
+ const onSelected = jest.fn();
132
+ render(<Combobox options={FRUITS} onSelected={onSelected} />);
133
+ const input = screen.getByRole("combobox");
134
+ await userEvent.type(input, "Ap");
135
+ await userEvent.type(input, "{enter}");
136
+ expect(onSelected).toHaveBeenCalledWith("Apple");
137
+ });
113
138
 
114
- it("should not select an option when pressing Enter with no options", async () => {
115
- const onSelected = jest.fn();
116
- render(<Combobox options={FRUITS} onSelected={onSelected} />);
117
- const input = screen.getByRole("combobox");
118
- await userEvent.type(input, "Zz");
119
- await userEvent.type(input, "{enter}");
120
- expect(onSelected).not.toHaveBeenCalled();
121
- });
139
+ it("should not select an option when pressing Enter with no options", async () => {
140
+ const onSelected = jest.fn();
141
+ render(<Combobox options={FRUITS} onSelected={onSelected} />);
142
+ const input = screen.getByRole("combobox");
143
+ await userEvent.type(input, "Zz");
144
+ await userEvent.type(input, "{enter}");
145
+ expect(onSelected).not.toHaveBeenCalled();
146
+ });
122
147
 
123
- it("should select the highlighted option when pressing Enter", async () => {
124
- const onSelected = jest.fn();
125
- render(<Combobox options={FRUITS} onSelected={onSelected} />);
126
- const input = screen.getByRole("combobox");
127
- await userEvent.click(input);
128
- await userEvent.keyboard("{arrowdown}");
129
- await userEvent.keyboard("{enter}");
130
- expect(onSelected).toHaveBeenCalledWith(FRUITS[0]);
131
- });
148
+ it("should select the highlighted option when pressing Enter", async () => {
149
+ const onSelected = jest.fn();
150
+ render(<Combobox options={FRUITS} onSelected={onSelected} />);
151
+ const input = screen.getByRole("combobox");
152
+ await userEvent.click(input);
153
+ await userEvent.keyboard("{arrowdown}");
154
+ await userEvent.keyboard("{enter}");
155
+ expect(onSelected).toHaveBeenCalledWith(FRUITS[0]);
156
+ });
132
157
 
133
- it("should open the listbox when pressing ArrowDown", async () => {
134
- render(<Combobox options={FRUITS} />);
135
- screen.getByRole("combobox");
136
- await userEvent.tab();
137
- await userEvent.keyboard("{arrowdown}");
138
- const listbox = screen.getByRole("listbox");
139
- expect(listbox).toBeInTheDocument();
140
- });
158
+ it("should open the listbox when pressing ArrowDown", async () => {
159
+ render(<Combobox options={FRUITS} />);
160
+ screen.getByRole("combobox");
161
+ await userEvent.tab();
162
+ await userEvent.keyboard("{arrowdown}");
163
+ const listbox = screen.getByRole("listbox");
164
+ expect(listbox).toBeInTheDocument();
165
+ });
141
166
 
142
- it("should highlight the first option when pressing ArrowDown", async () => {
143
- render(<Combobox options={FRUITS} />);
144
- screen.getByRole("combobox");
145
- await userEvent.tab();
146
- await userEvent.keyboard("{arrowdown}");
147
- const options = screen.getAllByRole("option");
148
- expect(options[0]).toHaveAttribute("aria-selected", "true");
149
- });
167
+ it("should highlight the first option when pressing ArrowDown", async () => {
168
+ render(<Combobox options={FRUITS} />);
169
+ screen.getByRole("combobox");
170
+ await userEvent.tab();
171
+ await userEvent.keyboard("{arrowdown}");
172
+ const options = screen.getAllByRole("option");
173
+ expect(options[0]).toHaveAttribute("aria-selected", "true");
174
+ });
150
175
 
151
- it("should open the listbox when pressing ArrowUp", async () => {
152
- render(<Combobox options={FRUITS} />);
153
- screen.getByRole("combobox");
154
- await userEvent.tab();
155
- await userEvent.keyboard("{arrowup}");
156
- const listbox = screen.getByRole("listbox");
157
- expect(listbox).toBeInTheDocument();
158
- });
176
+ it("should open the listbox when pressing ArrowUp", async () => {
177
+ render(<Combobox options={FRUITS} />);
178
+ screen.getByRole("combobox");
179
+ await userEvent.tab();
180
+ await userEvent.keyboard("{arrowup}");
181
+ const listbox = screen.getByRole("listbox");
182
+ expect(listbox).toBeInTheDocument();
183
+ });
159
184
 
160
- it("should highlight the last option when pressing ArrowUp", async () => {
161
- render(<Combobox options={FRUITS} />);
162
- screen.getByRole("combobox");
163
- await userEvent.tab();
164
- await userEvent.keyboard("{arrowup}");
165
- const options = screen.getAllByRole("option");
166
- expect(options[options.length - 1]).toHaveAttribute(
167
- "aria-selected",
168
- "true",
169
- );
170
- });
185
+ it("should close the listbox when pressing Escape", async () => {
186
+ render(<Combobox options={FRUITS} />);
187
+ const input = screen.getByRole("combobox");
188
+ await userEvent.click(input);
189
+ await userEvent.keyboard("{escape}");
190
+ const listbox = screen.queryByRole("listbox");
191
+ expect(listbox).not.toBeInTheDocument();
192
+ });
171
193
 
172
- it("should close the listbox when pressing Escape", async () => {
173
- render(<Combobox options={FRUITS} />);
174
- const input = screen.getByRole("combobox");
175
- await userEvent.click(input);
176
- await userEvent.keyboard("{escape}");
177
- const listbox = screen.queryByRole("listbox");
178
- expect(listbox).not.toBeInTheDocument();
179
- });
194
+ it("should not call onSelected when pressing Escape", async () => {
195
+ const onSelected = jest.fn();
196
+ render(<Combobox options={FRUITS} onSelected={onSelected} />);
197
+ const input = screen.getByRole("combobox");
198
+ await userEvent.click(input);
199
+ await userEvent.keyboard("{escape}");
200
+ expect(onSelected).not.toHaveBeenCalled();
201
+ });
180
202
 
181
- it("should not call onSelected when pressing Escape", async () => {
182
- const onSelected = jest.fn();
183
- render(<Combobox options={FRUITS} onSelected={onSelected} />);
184
- const input = screen.getByRole("combobox");
185
- await userEvent.click(input);
186
- await userEvent.keyboard("{escape}");
187
- expect(onSelected).not.toHaveBeenCalled();
203
+ it("should reset the highlighted index when the input value changes", async () => {
204
+ render(<Combobox options={FRUITS} />);
205
+ const input = screen.getByRole("combobox");
206
+ await userEvent.click(input);
207
+ await userEvent.keyboard("{arrowdown}");
208
+ await userEvent.type(input, "Ap");
209
+ const options = screen.getAllByRole("option");
210
+ expect(options[0]).toHaveAttribute("aria-selected", "true");
211
+ });
212
+
213
+ it("should select the first option when pressing Home", async () => {
214
+ const onSelected = jest.fn();
215
+ render(<Combobox options={FRUITS} onSelected={onSelected} />);
216
+ const input = screen.getByRole("combobox");
217
+ await userEvent.click(input);
218
+ await userEvent.keyboard("{arrowdown}{arrowdown}");
219
+ await userEvent.keyboard("{home}");
220
+ const options = screen.getAllByRole("option");
221
+ expect(options[0]).toHaveAttribute("aria-selected", "true");
222
+ });
223
+
224
+ it("should select the last option when pressing End", async () => {
225
+ const onSelected = jest.fn();
226
+ render(<Combobox options={FRUITS} onSelected={onSelected} />);
227
+ const input = screen.getByRole("combobox");
228
+ await userEvent.click(input);
229
+ await userEvent.keyboard("{end}");
230
+ const options = screen.getAllByRole("option");
231
+ expect(options[options.length - 1]).toHaveAttribute(
232
+ "aria-selected",
233
+ "true",
234
+ );
235
+ });
236
+
237
+ describe("Home/End with grouped options", () => {
238
+ it("should select the first option of first group when pressing Home", async () => {
239
+ render(<Combobox options={FRUITS_GROUPS} />);
240
+ const input = screen.getByRole("combobox");
241
+ await userEvent.click(input);
242
+ await userEvent.keyboard("{arrowdown}{arrowdown}");
243
+ await userEvent.keyboard("{home}");
244
+ const firstGroup = screen.getAllByRole("group")[0];
245
+ const firstOption = within(firstGroup).getAllByRole("option")[0];
246
+ expect(firstOption).toHaveAttribute("aria-selected", "true");
247
+ });
248
+
249
+ it("should select the last option of last group when pressing End", async () => {
250
+ render(<Combobox options={FRUITS_GROUPS} />);
251
+ const input = screen.getByRole("combobox");
252
+ await userEvent.click(input);
253
+ await userEvent.keyboard("{end}");
254
+ const lastGroup =
255
+ screen.getAllByRole("group")[FRUITS_GROUPS.length - 1];
256
+ const options = within(lastGroup).getAllByRole("option");
257
+ const lastOption = options[options.length - 1];
258
+ expect(lastOption).toHaveAttribute("aria-selected", "true");
259
+ });
260
+ });
261
+
262
+ it.todo("should select the first option when pressing Home");
263
+ it.todo("should select the last option when pressing End");
188
264
  });
189
265
 
190
- it("should reset the highlighted index when the input value changes", async () => {
191
- render(<Combobox options={FRUITS} />);
192
- const input = screen.getByRole("combobox");
193
- await userEvent.click(input);
194
- await userEvent.keyboard("{arrowdown}");
195
- await userEvent.type(input, "Ap");
196
- const options = screen.getAllByRole("option");
197
- expect(options[0]).not.toHaveAttribute("aria-selected", "true");
266
+ describe("filtering", () => {
267
+ it("should render all of the options in the listbox", async () => {
268
+ render(<Combobox options={FRUITS} />);
269
+ const input = screen.getByRole("combobox");
270
+ await userEvent.click(input);
271
+ const options = screen.getAllByRole("option");
272
+ expect(options).toHaveLength(FRUITS.length);
273
+ });
274
+
275
+ it("should filter the options based on the input value", async () => {
276
+ render(<Combobox options={FRUITS} />);
277
+ const input = screen.getByRole("combobox");
278
+ await userEvent.type(input, "Ap");
279
+ const options = screen.getAllByRole("option");
280
+ expect(options).toHaveLength(6);
281
+ });
282
+
283
+ it("should ignore case when filtering options", async () => {
284
+ render(<Combobox options={FRUITS} />);
285
+ const input = screen.getByRole("combobox");
286
+ await userEvent.type(input, "AP");
287
+ const options = screen.getAllByRole("option");
288
+ expect(options).toHaveLength(6);
289
+ });
198
290
  });
199
291
 
200
292
  describe("classnames", () => {
@@ -235,6 +327,15 @@ describe("Combobox with string options", () => {
235
327
  const options = screen.getAllByRole("option");
236
328
  expect(options[0]).toHaveClass("mobius-combobox__option--is-highlighted");
237
329
  });
330
+
331
+ it("should support custom class names", async () => {
332
+ const customClassName = "my-class";
333
+ const { container } = render(
334
+ <Combobox options={FRUITS} className={customClassName} />,
335
+ );
336
+
337
+ expect(container.firstChild).toHaveClass(customClassName);
338
+ });
238
339
  });
239
340
 
240
341
  describe("ARIA", () => {
@@ -271,7 +372,7 @@ describe("Combobox with string options", () => {
271
372
  expect(input).toHaveAttribute("aria-expanded", "true");
272
373
  });
273
374
 
274
- it("should set aria-selected to false for all options", async () => {
375
+ it("should set aria-selected to false for all options except first option", async () => {
275
376
  render(<Combobox options={FRUITS} />);
276
377
  const input = screen.getByRole("combobox");
277
378
  await userEvent.click(input);
@@ -286,17 +387,11 @@ describe("Combobox with string options", () => {
286
387
  const input = screen.getByRole("combobox");
287
388
  await userEvent.click(input);
288
389
  const options = screen.getAllByRole("option");
289
- await userEvent.keyboard("{arrowdown}{arrowdown}");
290
- expect(options[1]).toHaveAttribute("aria-selected", "true");
291
- });
292
-
293
- it("should set aria-activedescendant to undefined by default", () => {
294
- render(<Combobox options={FRUITS} />);
295
- const input = screen.getByRole("combobox");
296
- expect(input).not.toHaveAttribute("aria-activedescendant");
390
+ await userEvent.keyboard("{arrowdown}");
391
+ expect(options[0]).toHaveAttribute("aria-selected", "true");
297
392
  });
298
393
 
299
- it("should set aria-activedescendant based on the highlightedIndex", async () => {
394
+ it("should set aria-activedescendant based on the highlightedIndex and highlightedGroupIndex", async () => {
300
395
  render(<Combobox options={FRUITS} />);
301
396
  const input = screen.getByRole("combobox");
302
397
  await userEvent.tab();
@@ -311,22 +406,23 @@ describe("Combobox with string options", () => {
311
406
  });
312
407
 
313
408
  describe("Combobox with object options", () => {
409
+ // Basic rendering and setup
314
410
  it("should render without crashing", () => {
315
411
  render(<Combobox options={FRUITS_OBJECTS} />);
316
412
  });
317
413
 
318
- it("should use the default value provided", () => {
319
- render(<Combobox options={FRUITS_OBJECTS} defaultValue="apple" />);
320
- const input = screen.getByRole("combobox") as HTMLInputElement;
321
- expect(input.value).toBe("apple");
322
- });
323
-
324
414
  it("should render a combobox", () => {
325
415
  render(<Combobox options={FRUITS_OBJECTS} />);
326
416
  const input = screen.getByRole("combobox");
327
417
  expect(input).toBeInTheDocument();
328
418
  });
329
419
 
420
+ it("should use the default value provided", () => {
421
+ render(<Combobox options={FRUITS_OBJECTS} defaultValue="apple" />);
422
+ const input = screen.getByRole("combobox") as HTMLInputElement;
423
+ expect(input.value).toBe("apple");
424
+ });
425
+
330
426
  it("should accept a ref and forward it to the input", () => {
331
427
  const ref = { current: null };
332
428
  render(<Combobox options={FRUITS_OBJECTS} ref={ref} />);
@@ -339,169 +435,220 @@ describe("Combobox with object options", () => {
339
435
  expect(listbox).not.toBeInTheDocument();
340
436
  });
341
437
 
342
- it("should render a listbox when the input is focused", async () => {
343
- render(<Combobox options={FRUITS_OBJECTS} />);
344
- const input = screen.getByRole("combobox");
345
- await userEvent.click(input);
346
- const listbox = screen.getByRole("listbox");
347
- expect(listbox).toBeInTheDocument();
348
- });
438
+ describe("mouse interactions", () => {
439
+ it("should render a listbox when the input is focused", async () => {
440
+ render(<Combobox options={FRUITS_OBJECTS} />);
441
+ const input = screen.getByRole("combobox");
442
+ await userEvent.click(input);
443
+ const listbox = screen.getByRole("listbox");
444
+ expect(listbox).toBeInTheDocument();
445
+ });
349
446
 
350
- it("should render all of the options in the listbox", async () => {
351
- render(<Combobox options={FRUITS_OBJECTS} />);
352
- const input = screen.getByRole("combobox");
353
- await userEvent.click(input);
354
- const options = screen.getAllByRole("option");
355
- expect(options).toHaveLength(FRUITS_OBJECTS.length);
356
- });
447
+ it("should call the onSelected callback when an option is selected", async () => {
448
+ const onSelected = jest.fn();
449
+ render(<Combobox options={FRUITS_OBJECTS} onSelected={onSelected} />);
450
+ const input = screen.getByRole("combobox");
451
+ await userEvent.click(input);
452
+ const options = screen.getAllByRole("option");
453
+ await userEvent.click(options[0]);
454
+ expect(onSelected).toHaveBeenCalledWith(FRUITS_OBJECTS[0].value);
455
+ });
357
456
 
358
- it("should call the onSelected callback when an option is selected", async () => {
359
- const onSelected = jest.fn();
360
- render(<Combobox options={FRUITS_OBJECTS} onSelected={onSelected} />);
361
- const input = screen.getByRole("combobox");
362
- await userEvent.click(input);
363
- const options = screen.getAllByRole("option");
364
- await userEvent.click(options[0]);
365
- expect(onSelected).toHaveBeenCalledWith(FRUITS_OBJECTS[0].value);
366
- });
457
+ it("should update the input value when an option is selected", async () => {
458
+ render(<Combobox options={FRUITS_OBJECTS} />);
459
+ const input = screen.getByRole("combobox");
460
+ await userEvent.click(input);
461
+ const options = screen.getAllByRole("option");
462
+ await userEvent.click(options[0]);
463
+ expect(input).toHaveValue(FRUITS_OBJECTS[0].value);
464
+ });
367
465
 
368
- it("should update the input value when an option is selected", async () => {
369
- render(<Combobox options={FRUITS_OBJECTS} />);
370
- const input = screen.getByRole("combobox");
371
- await userEvent.click(input);
372
- const options = screen.getAllByRole("option");
373
- await userEvent.click(options[0]);
374
- expect(input).toHaveValue(FRUITS_OBJECTS[0].value);
375
- });
466
+ it("should close the listbox when the input is blurred", async () => {
467
+ jest.useFakeTimers({
468
+ advanceTimers: true,
469
+ timerLimit: 1000,
470
+ });
471
+ render(<Combobox options={FRUITS_OBJECTS} />);
472
+ const input = screen.getByRole("combobox");
473
+ await userEvent.click(input);
474
+ await act(async () => {
475
+ await userEvent.tab();
476
+ jest.advanceTimersByTime(200);
477
+ });
478
+ const listbox = screen.queryByRole("listbox");
479
+ expect(listbox).not.toBeInTheDocument();
480
+ });
376
481
 
377
- it("should close the listbox when the input is blurred", async () => {
378
- render(<Combobox options={FRUITS_OBJECTS} />);
379
- const input = screen.getByRole("combobox");
380
- await userEvent.click(input);
381
- await userEvent.tab();
382
- const listbox = screen.queryByRole("listbox");
383
- expect(listbox).not.toBeInTheDocument();
384
- });
482
+ it("should close the listbox when an option is selected", async () => {
483
+ render(<Combobox options={FRUITS_OBJECTS} />);
484
+ const input = screen.getByRole("combobox");
485
+ await userEvent.click(input);
486
+ const options = screen.getAllByRole("option");
487
+ await userEvent.click(options[0]);
488
+ const listbox = screen.queryByRole("listbox");
489
+ expect(listbox).not.toBeInTheDocument();
490
+ });
385
491
 
386
- it("should close the listbox when an option is selected", async () => {
387
- render(<Combobox options={FRUITS_OBJECTS} />);
388
- const input = screen.getByRole("combobox");
389
- await userEvent.click(input);
390
- const options = screen.getAllByRole("option");
391
- await userEvent.click(options[0]);
392
- const listbox = screen.queryByRole("listbox");
393
- expect(listbox).not.toBeInTheDocument();
492
+ it("should close the listbox when clicking outside", async () => {
493
+ jest.useFakeTimers({
494
+ advanceTimers: true,
495
+ timerLimit: 1000,
496
+ });
497
+ render(
498
+ <div>
499
+ <Combobox options={FRUITS_OBJECTS} />
500
+ <button type="button">Outside</button>
501
+ </div>,
502
+ );
503
+ const input = screen.getByRole("combobox");
504
+ const outside = screen.getByRole("button");
505
+ await userEvent.click(input);
506
+ await userEvent.click(outside);
507
+ await act(async () => {
508
+ await userEvent.tab();
509
+ jest.advanceTimersByTime(200);
510
+ });
511
+ const listbox = screen.queryByRole("listbox");
512
+ expect(listbox).not.toBeInTheDocument();
513
+ });
394
514
  });
395
515
 
396
- it("should filter the options based on the input value", async () => {
397
- render(<Combobox options={FRUITS_OBJECTS} />);
398
- const input = screen.getByRole("combobox");
399
- await userEvent.type(input, "Ap");
400
- const options = screen.getAllByRole("option");
401
- expect(options).toHaveLength(6);
402
- });
516
+ describe("keyboard interactions", () => {
517
+ it("should select the top option when pressing Enter", async () => {
518
+ const onSelected = jest.fn();
519
+ render(<Combobox options={FRUITS_OBJECTS} onSelected={onSelected} />);
520
+ const input = screen.getByRole("combobox");
521
+ await userEvent.type(input, "Ap");
522
+ await userEvent.type(input, "{enter}");
523
+ expect(onSelected).toHaveBeenCalledWith(FRUITS_OBJECTS[0].value);
524
+ });
403
525
 
404
- it("should ignore case when filtering options", async () => {
405
- render(<Combobox options={FRUITS_OBJECTS} />);
406
- const input = screen.getByRole("combobox");
407
- await userEvent.type(input, "AP");
408
- const options = screen.getAllByRole("option");
409
- expect(options).toHaveLength(6);
410
- });
526
+ it("should not select an option when pressing Enter with no options", async () => {
527
+ const onSelected = jest.fn();
528
+ render(<Combobox options={FRUITS_OBJECTS} onSelected={onSelected} />);
529
+ const input = screen.getByRole("combobox");
530
+ await userEvent.type(input, "Zz");
531
+ await userEvent.type(input, "{enter}");
532
+ expect(onSelected).not.toHaveBeenCalled();
533
+ });
411
534
 
412
- it("should select the top option when pressing Enter", async () => {
413
- const onSelected = jest.fn();
414
- render(<Combobox options={FRUITS_OBJECTS} onSelected={onSelected} />);
415
- const input = screen.getByRole("combobox");
416
- await userEvent.type(input, "Ap");
417
- await userEvent.type(input, "{enter}");
418
- expect(onSelected).toHaveBeenCalledWith(FRUITS_OBJECTS[0].value);
419
- });
535
+ it("should select the highlighted option when pressing Enter", async () => {
536
+ const onSelected = jest.fn();
537
+ render(<Combobox options={FRUITS_OBJECTS} onSelected={onSelected} />);
538
+ const input = screen.getByRole("combobox");
539
+ await userEvent.click(input);
540
+ await userEvent.keyboard("{arrowdown}");
541
+ await userEvent.keyboard("{enter}");
542
+ expect(onSelected).toHaveBeenCalledWith(FRUITS_OBJECTS[0].value);
543
+ });
420
544
 
421
- it("should not select an option when pressing Enter with no options", async () => {
422
- const onSelected = jest.fn();
423
- render(<Combobox options={FRUITS_OBJECTS} onSelected={onSelected} />);
424
- const input = screen.getByRole("combobox");
425
- await userEvent.type(input, "Zz");
426
- await userEvent.type(input, "{enter}");
427
- expect(onSelected).not.toHaveBeenCalled();
428
- });
545
+ it("should open the listbox when pressing ArrowDown", async () => {
546
+ render(<Combobox options={FRUITS_OBJECTS} />);
547
+ screen.getByRole("combobox");
548
+ await userEvent.tab();
549
+ await userEvent.keyboard("{arrowdown}");
550
+ const listbox = screen.getByRole("listbox");
551
+ expect(listbox).toBeInTheDocument();
552
+ });
429
553
 
430
- it("should select the highlighted option when pressing Enter", async () => {
431
- const onSelected = jest.fn();
432
- render(<Combobox options={FRUITS_OBJECTS} onSelected={onSelected} />);
433
- const input = screen.getByRole("combobox");
434
- await userEvent.click(input);
435
- await userEvent.keyboard("{arrowdown}");
436
- await userEvent.keyboard("{enter}");
437
- expect(onSelected).toHaveBeenCalledWith(FRUITS_OBJECTS[0].value);
438
- });
554
+ it("should highlight the second option when pressing ArrowDown", async () => {
555
+ render(<Combobox options={FRUITS_OBJECTS} />);
556
+ screen.getByRole("combobox");
557
+ await userEvent.tab();
558
+ await userEvent.keyboard("{arrowdown}");
559
+ const options = screen.getAllByRole("option");
560
+ expect(options[0]).toHaveAttribute("aria-selected", "true");
561
+ });
439
562
 
440
- it("should open the listbox when pressing ArrowDown", async () => {
441
- render(<Combobox options={FRUITS_OBJECTS} />);
442
- screen.getByRole("combobox");
443
- await userEvent.tab();
444
- await userEvent.keyboard("{arrowdown}");
445
- const listbox = screen.getByRole("listbox");
446
- expect(listbox).toBeInTheDocument();
447
- });
563
+ it("should open the listbox when pressing ArrowUp", async () => {
564
+ render(<Combobox options={FRUITS_OBJECTS} />);
565
+ screen.getByRole("combobox");
566
+ await userEvent.tab();
567
+ await userEvent.keyboard("{arrowup}");
568
+ const listbox = screen.getByRole("listbox");
569
+ expect(listbox).toBeInTheDocument();
570
+ });
448
571
 
449
- it("should highlight the first option when pressing ArrowDown", async () => {
450
- render(<Combobox options={FRUITS_OBJECTS} />);
451
- screen.getByRole("combobox");
452
- await userEvent.tab();
453
- await userEvent.keyboard("{arrowdown}");
454
- const options = screen.getAllByRole("option");
455
- expect(options[0]).toHaveAttribute("aria-selected", "true");
456
- });
572
+ it("should select the first option when pressing Home", async () => {
573
+ const onSelected = jest.fn();
574
+ render(<Combobox options={FRUITS} onSelected={onSelected} />);
575
+ const input = screen.getByRole("combobox");
576
+ await userEvent.click(input);
577
+ await userEvent.keyboard("{arrowdown}{arrowdown}");
578
+ await userEvent.keyboard("{home}");
579
+ const options = screen.getAllByRole("option");
580
+ expect(options[0]).toHaveAttribute("aria-selected", "true");
581
+ });
457
582
 
458
- it("should open the listbox when pressing ArrowUp", async () => {
459
- render(<Combobox options={FRUITS_OBJECTS} />);
460
- screen.getByRole("combobox");
461
- await userEvent.tab();
462
- await userEvent.keyboard("{arrowup}");
463
- const listbox = screen.getByRole("listbox");
464
- expect(listbox).toBeInTheDocument();
465
- });
583
+ it("should select the last option when pressing End", async () => {
584
+ const onSelected = jest.fn();
585
+ render(<Combobox options={FRUITS} onSelected={onSelected} />);
586
+ const input = screen.getByRole("combobox");
587
+ await userEvent.click(input);
588
+ await userEvent.keyboard("{end}");
589
+ const options = screen.getAllByRole("option");
590
+ expect(options[options.length - 1]).toHaveAttribute(
591
+ "aria-selected",
592
+ "true",
593
+ );
594
+ });
466
595
 
467
- it("should highlight the last option when pressing ArrowUp", async () => {
468
- render(<Combobox options={FRUITS_OBJECTS} />);
469
- screen.getByRole("combobox");
470
- await userEvent.tab();
471
- await userEvent.keyboard("{arrowup}");
472
- const options = screen.getAllByRole("option");
473
- expect(options[options.length - 1]).toHaveAttribute(
474
- "aria-selected",
475
- "true",
476
- );
477
- });
596
+ it.todo("should select the first option when pressing Home");
597
+ it.todo("should select the last option when pressing End");
478
598
 
479
- it("should close the listbox when pressing Escape", async () => {
480
- render(<Combobox options={FRUITS_OBJECTS} />);
481
- const input = screen.getByRole("combobox");
482
- await userEvent.click(input);
483
- await userEvent.keyboard("{escape}");
484
- const listbox = screen.queryByRole("listbox");
485
- expect(listbox).not.toBeInTheDocument();
486
- });
599
+ it("should close the listbox when pressing Escape", async () => {
600
+ render(<Combobox options={FRUITS_OBJECTS} />);
601
+ const input = screen.getByRole("combobox");
602
+ await userEvent.click(input);
603
+ await userEvent.keyboard("{escape}");
604
+ const listbox = screen.queryByRole("listbox");
605
+ expect(listbox).not.toBeInTheDocument();
606
+ });
487
607
 
488
- it("should not call onSelected when pressing Escape", async () => {
489
- const onSelected = jest.fn();
490
- render(<Combobox options={FRUITS_OBJECTS} onSelected={onSelected} />);
491
- const input = screen.getByRole("combobox");
492
- await userEvent.click(input);
493
- await userEvent.keyboard("{escape}");
494
- expect(onSelected).not.toHaveBeenCalled();
608
+ it("should not call onSelected when pressing Escape", async () => {
609
+ const onSelected = jest.fn();
610
+ render(<Combobox options={FRUITS_OBJECTS} onSelected={onSelected} />);
611
+ const input = screen.getByRole("combobox");
612
+ await userEvent.click(input);
613
+ await userEvent.keyboard("{escape}");
614
+ expect(onSelected).not.toHaveBeenCalled();
615
+ });
616
+
617
+ it("should reset the highlighted index when the input value changes", async () => {
618
+ render(<Combobox options={FRUITS_OBJECTS} />);
619
+ const input = screen.getByRole("combobox");
620
+ await userEvent.click(input);
621
+ await userEvent.keyboard("{arrowdown}");
622
+ await userEvent.type(input, "Ap");
623
+ const options = screen.getAllByRole("option");
624
+ expect(options[0]).toHaveAttribute("aria-selected", "true");
625
+ });
495
626
  });
496
627
 
497
- it("should reset the highlighted index when the input value changes", async () => {
498
- render(<Combobox options={FRUITS_OBJECTS} />);
499
- const input = screen.getByRole("combobox");
500
- await userEvent.click(input);
501
- await userEvent.keyboard("{arrowdown}");
502
- await userEvent.type(input, "Ap");
503
- const options = screen.getAllByRole("option");
504
- expect(options[0]).not.toHaveAttribute("aria-selected", "true");
628
+ describe("filtering", () => {
629
+ it("should render all of the options in the listbox", async () => {
630
+ render(<Combobox options={FRUITS_OBJECTS} />);
631
+ const input = screen.getByRole("combobox");
632
+ await userEvent.click(input);
633
+ const options = screen.getAllByRole("option");
634
+ expect(options).toHaveLength(FRUITS_OBJECTS.length);
635
+ });
636
+
637
+ it("should filter the options based on the input value", async () => {
638
+ render(<Combobox options={FRUITS_OBJECTS} />);
639
+ const input = screen.getByRole("combobox");
640
+ await userEvent.type(input, "Ap");
641
+ const options = screen.getAllByRole("option");
642
+ expect(options).toHaveLength(6);
643
+ });
644
+
645
+ it("should ignore case when filtering options", async () => {
646
+ render(<Combobox options={FRUITS_OBJECTS} />);
647
+ const input = screen.getByRole("combobox");
648
+ await userEvent.type(input, "AP");
649
+ const options = screen.getAllByRole("option");
650
+ expect(options).toHaveLength(6);
651
+ });
505
652
  });
506
653
 
507
654
  describe("ARIA", () => {
@@ -557,12 +704,6 @@ describe("Combobox with object options", () => {
557
704
  expect(options[1]).toHaveAttribute("aria-selected", "true");
558
705
  });
559
706
 
560
- it("should set aria-activedescendant to undefined by default", () => {
561
- render(<Combobox options={FRUITS_OBJECTS} />);
562
- const input = screen.getByRole("combobox");
563
- expect(input).not.toHaveAttribute("aria-activedescendant");
564
- });
565
-
566
707
  it("should set aria-activedescendant based on the highlightedIndex", async () => {
567
708
  render(<Combobox options={FRUITS_OBJECTS} />);
568
709
  const input = screen.getByRole("combobox");
@@ -576,3 +717,81 @@ describe("Combobox with object options", () => {
576
717
  });
577
718
  });
578
719
  });
720
+
721
+ describe("Combobox with grouped options", () => {
722
+ // Basic rendering and setup
723
+ it("should render without crashing", () => {
724
+ render(<Combobox options={FRUITS_GROUPS} />);
725
+ });
726
+
727
+ describe("rendering", () => {
728
+ it("should render a listbox when the input is focused", async () => {
729
+ render(<Combobox options={FRUITS_GROUPS} />);
730
+ const input = screen.getByRole("combobox");
731
+ await userEvent.click(input);
732
+ const listbox = screen.getByRole("listbox");
733
+ expect(listbox).toBeInTheDocument();
734
+ });
735
+
736
+ it("should render all of the groups in the listbox", async () => {
737
+ render(<Combobox options={FRUITS_GROUPS} />);
738
+ const input = screen.getByRole("combobox");
739
+ await userEvent.click(input);
740
+ const groups = screen.getAllByRole("group");
741
+ expect(groups).toHaveLength(FRUITS_GROUPS.length);
742
+ });
743
+
744
+ it("should render all of the options in each group", async () => {
745
+ render(<Combobox options={FRUITS_GROUPS} />);
746
+ const input = screen.getByRole("combobox");
747
+ await userEvent.click(input);
748
+ FRUITS_GROUPS.forEach(group => {
749
+ const groupElement = screen.getByRole("group", { name: group.heading });
750
+ const options = within(groupElement).queryAllByRole("option");
751
+ expect(options).toHaveLength(group.options.length);
752
+ });
753
+ });
754
+ });
755
+
756
+ describe("filtering", () => {
757
+ it("should filter the options based on the input value", async () => {
758
+ render(<Combobox options={FRUITS} />);
759
+ const input = screen.getByRole("combobox");
760
+ await userEvent.type(input, "Ap");
761
+ const options = screen.getAllByRole("option");
762
+ expect(options).toHaveLength(6);
763
+ });
764
+
765
+ it("should ignore case when filtering options", async () => {
766
+ render(<Combobox options={FRUITS} />);
767
+ const input = screen.getByRole("combobox");
768
+ await userEvent.type(input, "AP");
769
+ const options = screen.getAllByRole("option");
770
+ expect(options).toHaveLength(6);
771
+ });
772
+ });
773
+
774
+ describe("classnames", () => {
775
+ it("should have the correct classnames for the groups", async () => {
776
+ render(<Combobox options={FRUITS_GROUPS} />);
777
+ const input = screen.getByRole("combobox");
778
+ await userEvent.click(input);
779
+ const groups = screen.getAllByRole("group");
780
+ groups.forEach(group => {
781
+ expect(group).toHaveClass("mobius-combobox__group");
782
+ });
783
+ });
784
+
785
+ it("should have the correct classnames for the group labels", async () => {
786
+ render(<Combobox options={FRUITS_GROUPS} />);
787
+ const input = screen.getByRole("combobox");
788
+ await userEvent.click(input);
789
+ const groupLabels = screen
790
+ .getAllByRole("group")
791
+ .map(group => group.firstChild);
792
+ groupLabels.forEach(label => {
793
+ expect(label).toHaveClass("mobius-combobox__group-label");
794
+ });
795
+ });
796
+ });
797
+ });