@leafygreen-ui/combobox 0.9.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.
@@ -0,0 +1,1136 @@
1
+ /* eslint-disable jest/no-disabled-tests */
2
+ /* eslint-disable jest/no-standalone-expect */
3
+ /* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect", "expectSelection"] }] */
4
+ import {
5
+ waitForElementToBeRemoved,
6
+ act,
7
+ waitFor,
8
+ queryByText,
9
+ } from '@testing-library/react';
10
+ import userEvent from '@testing-library/user-event';
11
+ import { axe } from 'jest-axe';
12
+ import { flatten, isUndefined, startCase } from 'lodash';
13
+ import {
14
+ defaultOptions,
15
+ groupedOptions,
16
+ NestedObject,
17
+ renderCombobox,
18
+ Select,
19
+ testif,
20
+ } from './ComboboxTestUtils';
21
+ import { OptionObject } from './util';
22
+
23
+ /**
24
+ * Tests
25
+ */
26
+ describe('packages/combobox', () => {
27
+ describe('A11y', () => {
28
+ test('does not have basic accessibility violations', async () => {
29
+ const { container, inputEl } = renderCombobox();
30
+ act(() => inputEl.focus()); // we focus the input to ensure the listbox gets rendered
31
+ const results = await axe(container);
32
+ expect(results).toHaveNoViolations();
33
+ });
34
+ });
35
+
36
+ // DarkMode prop
37
+ test.todo('Darkmode prop applies the correct styles');
38
+
39
+ // size prop
40
+ test.todo('Size prop applies the correct styles');
41
+
42
+ /**
43
+ * Overflow prop
44
+ */
45
+ test.todo('expand-y');
46
+ test.todo('expand-x');
47
+ test.todo('scroll-x');
48
+
49
+ const tests = [['single'], ['multiple']] as Array<Array<Select>>;
50
+
51
+ describe.each(tests)('%s select', select => {
52
+ /** Run tests for single select only */
53
+ const testSingleSelect = (name: string, fn?: jest.ProvidesCallback) =>
54
+ isUndefined(fn) ? test.todo(name) : testif(select === 'single')(name, fn);
55
+
56
+ /** Run tests for multi-select only */
57
+ const testMultiSelect = (name: string, fn?: jest.ProvidesCallback) =>
58
+ isUndefined(fn)
59
+ ? test.todo(name)
60
+ : testif(select === 'multiple')(name, fn);
61
+
62
+ describe('Basic rendering', () => {
63
+ // Label prop
64
+ test('Label is rendered', () => {
65
+ const { labelEl } = renderCombobox(select, { label: 'Some label' });
66
+ expect(labelEl).toBeInTheDocument();
67
+ });
68
+
69
+ // Desctiption prop
70
+ test('Description is rendered', () => {
71
+ const description = 'Lorem ipsum';
72
+ const { queryByText } = renderCombobox(select, { description });
73
+ const descriptionEl = queryByText(description);
74
+ expect(descriptionEl).not.toBeNull();
75
+ expect(descriptionEl).toBeInTheDocument();
76
+ });
77
+
78
+ // Placeholder prop
79
+ test('Placeholder is rendered', () => {
80
+ const placeholder = 'Placeholder text';
81
+ const { inputEl } = renderCombobox(select, { placeholder });
82
+ expect(inputEl.placeholder).toEqual(placeholder);
83
+ });
84
+
85
+ // errorMessage & state prop
86
+ test('Error message is rendered when state == `error`', () => {
87
+ const errorMessage = 'Some error message';
88
+ const { queryByText } = renderCombobox(select, {
89
+ errorMessage,
90
+ state: 'error',
91
+ });
92
+ const errorEl = queryByText(errorMessage);
93
+ expect(errorEl).not.toBeNull();
94
+ expect(errorEl).toBeInTheDocument();
95
+ });
96
+
97
+ test('Error message is not rendered when state !== `error`', () => {
98
+ const errorMessage = 'Some error message';
99
+ const { queryByText } = renderCombobox(select, {
100
+ errorMessage,
101
+ });
102
+ const errorEl = queryByText(errorMessage);
103
+ expect(errorEl).not.toBeInTheDocument();
104
+ });
105
+
106
+ // Clear button
107
+ test('Clear button is rendered when selection is set', () => {
108
+ const initialValue = select === 'multiple' ? ['apple'] : 'apple';
109
+ const { clearButtonEl } = renderCombobox(select, {
110
+ initialValue,
111
+ });
112
+ expect(clearButtonEl).toBeInTheDocument();
113
+ });
114
+
115
+ test('Clear button is not rendered when there is no selection', () => {
116
+ const { clearButtonEl } = renderCombobox(select);
117
+ expect(clearButtonEl).not.toBeInTheDocument();
118
+ });
119
+
120
+ test('Clear button is not rendered when clearable == `false`', () => {
121
+ const initialValue = select === 'multiple' ? ['apple'] : 'apple';
122
+ const { clearButtonEl } = renderCombobox(select, {
123
+ initialValue,
124
+ clearable: false,
125
+ });
126
+ expect(clearButtonEl).not.toBeInTheDocument();
127
+ });
128
+ });
129
+
130
+ /**
131
+ * Option Rendering
132
+ */
133
+ describe('Option rendering', () => {
134
+ test('All options render in the menu', () => {
135
+ const { openMenu } = renderCombobox(select);
136
+ const { optionElements } = openMenu();
137
+ expect(optionElements).toHaveLength(defaultOptions.length);
138
+ });
139
+
140
+ test('Options render with provided displayName', async () => {
141
+ const { openMenu } = renderCombobox(select);
142
+ const { optionElements } = openMenu();
143
+ // Note on `foo!` operator https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-0.html#non-null-assertion-operator
144
+ Array.from(optionElements!).forEach((optionEl, index) => {
145
+ expect(optionEl).toHaveTextContent(defaultOptions[index].displayName);
146
+ });
147
+ });
148
+
149
+ test('Option is rendered with provided value when no displayName is provided', () => {
150
+ const options = [{ value: 'abc-def' }];
151
+ // @ts-expect-error `options` will not match the expected type
152
+ const { openMenu } = renderCombobox(select, { options });
153
+ const { optionElements } = openMenu();
154
+ const [optionEl] = Array.from(optionElements!);
155
+ expect(optionEl).toHaveTextContent('abc-def');
156
+ });
157
+ // Grouped Options
158
+ describe('Grouped Options', () => {
159
+ test('Grouped items should render', () => {
160
+ const { openMenu } = renderCombobox(select, {
161
+ options: groupedOptions,
162
+ });
163
+ const { menuContainerEl } = openMenu();
164
+
165
+ flatten(
166
+ groupedOptions.map(({ children }: NestedObject) => children),
167
+ ).forEach((option: OptionObject | string) => {
168
+ const displayName =
169
+ typeof option === 'string' ? option : option.displayName;
170
+ const optionEl = queryByText(menuContainerEl!, displayName);
171
+ expect(optionEl).toBeInTheDocument();
172
+ });
173
+ });
174
+
175
+ test('Grouped item labels should render', () => {
176
+ const { openMenu } = renderCombobox(select, {
177
+ options: groupedOptions,
178
+ });
179
+ const { menuContainerEl } = openMenu();
180
+
181
+ const [fruitLabel, veggieLabel] = [
182
+ queryByText(menuContainerEl!, 'Fruit'),
183
+ queryByText(menuContainerEl!, 'Vegetables'),
184
+ ];
185
+ expect(fruitLabel).toBeInTheDocument();
186
+ expect(veggieLabel).toBeInTheDocument();
187
+ });
188
+ });
189
+ });
190
+
191
+ describe('When disabled', () => {
192
+ // disabled prop
193
+ test('Combobox is not clickable when `disabled`', () => {
194
+ const { comboboxEl } = renderCombobox(select, { disabled: true });
195
+ userEvent.click(comboboxEl);
196
+ expect(document.body).toHaveFocus();
197
+ });
198
+
199
+ test('Combobox is not focusable when `disabled`', () => {
200
+ renderCombobox(select, { disabled: true });
201
+ userEvent.type(document.body, '{tab');
202
+ expect(document.body).toHaveFocus();
203
+ });
204
+ });
205
+
206
+ /**
207
+ * Initial Value
208
+ */
209
+ describe('#initialValue prop', () => {
210
+ testSingleSelect('Initial value prop renders text input value', () => {
211
+ const initialValue = 'apple';
212
+ const { inputEl } = renderCombobox(select, { initialValue });
213
+ expect(inputEl).toHaveValue('Apple');
214
+ });
215
+
216
+ testMultiSelect('Initial value prop renders chips', () => {
217
+ const initialValue = ['apple', 'banana'];
218
+ const { queryChipsByName, queryAllChips } = renderCombobox(select, {
219
+ initialValue,
220
+ });
221
+ waitFor(() => {
222
+ const allChips = queryChipsByName(['Apple', 'Banana']);
223
+ allChips?.forEach(chip => expect(chip).toBeInTheDocument());
224
+ expect(queryAllChips()).toHaveLength(2);
225
+ });
226
+ });
227
+
228
+ testSingleSelect(
229
+ 'Selected single select option renders with a checkmark icon',
230
+ () => {
231
+ const initialValue = 'apple';
232
+ const { openMenu } = renderCombobox('single', { initialValue });
233
+ const { selectedElements } = openMenu();
234
+ expect(selectedElements?.querySelector('svg')).not.toBeNull();
235
+ },
236
+ );
237
+
238
+ testMultiSelect(
239
+ 'Each multiple select option renders with a checkbox input',
240
+ () => {
241
+ const initialValue = ['apple', 'banana'];
242
+ const { openMenu } = renderCombobox('multiple', { initialValue });
243
+ const { selectedElements } = openMenu();
244
+ expect(
245
+ selectedElements?.every(element => element?.querySelector('input')),
246
+ ).toBeTruthy();
247
+ },
248
+ );
249
+ });
250
+
251
+ /**
252
+ * Controlled
253
+ * (i.e. `value` prop)
254
+ */
255
+ describe('When value is controlled', () => {
256
+ testSingleSelect('Text input renders with value update', () => {
257
+ let value = 'apple';
258
+ const { inputEl, rerenderCombobox } = renderCombobox(select, {
259
+ value,
260
+ });
261
+ expect(inputEl).toHaveValue('Apple');
262
+ value = 'banana';
263
+ rerenderCombobox({ value });
264
+ expect(inputEl).toHaveValue('Banana');
265
+ });
266
+
267
+ testSingleSelect('Invalid option passed as value is not selected', () => {
268
+ const value = 'jellybean';
269
+ const { inputEl } = renderCombobox(select, { value });
270
+ expect(inputEl).toHaveValue('');
271
+ });
272
+
273
+ testMultiSelect('Updating `value` updates the chips', () => {
274
+ let value = ['apple', 'banana'];
275
+ const {
276
+ queryChipsByName,
277
+ queryAllChips,
278
+ rerenderCombobox,
279
+ } = renderCombobox(select, {
280
+ value,
281
+ });
282
+ waitFor(() => {
283
+ const allChips = queryChipsByName(['Apple', 'Banana']);
284
+ allChips?.forEach(chip => expect(chip).toBeInTheDocument());
285
+ expect(queryAllChips()).toHaveLength(2);
286
+ value = ['banana', 'carrot'];
287
+ rerenderCombobox({ value });
288
+ waitFor(() => {
289
+ const allChips = queryChipsByName(['Carrot', 'Banana']);
290
+ allChips?.forEach(chip => expect(chip).toBeInTheDocument());
291
+ expect(queryAllChips()).toHaveLength(2);
292
+ });
293
+ });
294
+ });
295
+
296
+ testMultiSelect('Invalid options are not selected', () => {
297
+ const value = ['apple', 'jellybean'];
298
+ const { queryChipsByName, queryAllChips } = renderCombobox(select, {
299
+ value,
300
+ });
301
+ waitFor(() => {
302
+ const allChips = queryChipsByName(['Apple']);
303
+ allChips?.forEach(chip => expect(chip).toBeInTheDocument());
304
+ expect(queryChipsByName('Jellybean')).not.toBeInTheDocument();
305
+ expect(queryAllChips()).toHaveLength(1);
306
+ });
307
+ });
308
+ });
309
+
310
+ /**
311
+ * Mouse interaction
312
+ */
313
+ describe('Mouse interaction', () => {
314
+ test('Menu is not initially opened', () => {
315
+ const { getMenuElements } = renderCombobox(select);
316
+ const { menuContainerEl } = getMenuElements();
317
+ expect(menuContainerEl).not.toBeInTheDocument();
318
+ });
319
+
320
+ test('Clicking the combobox sets focus to the input', () => {
321
+ const { comboboxEl, inputEl } = renderCombobox(select);
322
+ userEvent.click(comboboxEl);
323
+ expect(inputEl).toHaveFocus();
324
+ });
325
+
326
+ test('Menu appears when input is focused', () => {
327
+ const { inputEl, getMenuElements } = renderCombobox(select);
328
+ act(() => inputEl.focus());
329
+ const { menuContainerEl } = getMenuElements();
330
+ expect(menuContainerEl).toBeInTheDocument();
331
+ });
332
+
333
+ test('Menu appears when box is clicked', () => {
334
+ const { comboboxEl, getMenuElements } = renderCombobox(select);
335
+ userEvent.click(comboboxEl);
336
+ const { menuContainerEl } = getMenuElements();
337
+ expect(menuContainerEl).not.toBeNull();
338
+ expect(menuContainerEl).toBeInTheDocument();
339
+ });
340
+
341
+ test('Menu closes on click-away', async () => {
342
+ const { containerEl, openMenu } = renderCombobox(select);
343
+ const { menuContainerEl } = openMenu();
344
+ userEvent.click(containerEl.parentElement!);
345
+ await waitForElementToBeRemoved(menuContainerEl);
346
+ expect(menuContainerEl).not.toBeInTheDocument();
347
+ expect(containerEl).not.toContainFocus();
348
+ });
349
+
350
+ testSingleSelect('Clicking selected option closes menu', async () => {
351
+ const { openMenu } = renderCombobox(select, {
352
+ initialValue: 'apple',
353
+ });
354
+ const { optionElements, menuContainerEl } = openMenu();
355
+ expect(optionElements).not.toBeUndefined();
356
+ userEvent.click((optionElements as HTMLCollectionOf<HTMLLIElement>)[0]);
357
+ await waitForElementToBeRemoved(menuContainerEl);
358
+ expect(menuContainerEl).not.toBeInTheDocument();
359
+ });
360
+
361
+ test('Menu does not close on interaction with the menu', () => {
362
+ const { getMenuElements, openMenu } = renderCombobox(select);
363
+ const { optionElements } = openMenu();
364
+ expect(optionElements).not.toBeUndefined();
365
+ userEvent.click((optionElements as HTMLCollectionOf<HTMLLIElement>)[1]);
366
+ const { menuContainerEl } = getMenuElements();
367
+ expect(menuContainerEl).toBeInTheDocument();
368
+ });
369
+
370
+ test('Clicking an option sets selection', () => {
371
+ const { openMenu, queryChipsByName, inputEl } = renderCombobox(select);
372
+ const { optionElements } = openMenu();
373
+ expect(optionElements).not.toBeUndefined();
374
+ userEvent.click((optionElements as HTMLCollectionOf<HTMLLIElement>)[2]);
375
+ if (select === 'multiple') {
376
+ expect(queryChipsByName('Carrot')).toBeInTheDocument();
377
+ } else {
378
+ expect(inputEl).toHaveValue('Carrot');
379
+ }
380
+ });
381
+
382
+ testSingleSelect(
383
+ 'Input value is set to selection value when menu closes',
384
+ () => {
385
+ const initialValue = 'apple';
386
+ const { inputEl } = renderCombobox(select, {
387
+ initialValue,
388
+ });
389
+ userEvent.type(inputEl, '{backspace}{backspace}{esc}');
390
+ expect(inputEl).toHaveValue('Apple');
391
+ },
392
+ );
393
+
394
+ testMultiSelect('Clicking chip X button removes option', async () => {
395
+ const initialValue = ['apple', 'banana', 'carrot'];
396
+ const { queryChipsByName, queryAllChips } = renderCombobox(select, {
397
+ initialValue,
398
+ });
399
+ const appleChip = queryChipsByName('Apple');
400
+ expect(appleChip).not.toBeNull();
401
+ const appleChipButton = appleChip!.querySelector('button')!;
402
+ userEvent.click(appleChipButton);
403
+ await waitFor(() => {
404
+ expect(appleChip).not.toBeInTheDocument();
405
+ const allChips = queryChipsByName(['Banana', 'Carrot']);
406
+ allChips?.forEach(chip => expect(chip).toBeInTheDocument());
407
+ expect(queryAllChips()).toHaveLength(2);
408
+ });
409
+ });
410
+
411
+ testMultiSelect('Clicking chip text focuses the chip', () => {
412
+ const initialValue = ['apple', 'banana', 'carrot'];
413
+ const { queryChipsByName, queryAllChips } = renderCombobox(select, {
414
+ initialValue,
415
+ });
416
+ const appleChip = queryChipsByName('Apple');
417
+ userEvent.click(appleChip!);
418
+ expect(appleChip!).toContainFocus();
419
+ expect(queryAllChips()).toHaveLength(3);
420
+ });
421
+
422
+ testMultiSelect(
423
+ 'Clicking chip X button does nothing when disabled',
424
+ async () => {
425
+ const initialValue = ['apple', 'banana', 'carrot'];
426
+ const { queryChipsByName, queryAllChips } = renderCombobox(select, {
427
+ initialValue,
428
+ disabled: true,
429
+ });
430
+ const carrotChip = queryChipsByName('Carrot');
431
+ const carrotChipButton = carrotChip!.querySelector('button');
432
+ userEvent.click(carrotChipButton!);
433
+ await waitFor(() => {
434
+ expect(queryAllChips()).toHaveLength(3);
435
+ });
436
+ },
437
+ );
438
+
439
+ testMultiSelect(
440
+ 'Removing a chip sets focus to the next chip',
441
+ async () => {
442
+ const initialValue = ['apple', 'banana', 'carrot'];
443
+ const { queryChipsByName } = renderCombobox(select, {
444
+ initialValue,
445
+ });
446
+ const appleChip = queryChipsByName('Apple');
447
+ const bananaChip = queryChipsByName('Banana');
448
+ const appleChipButton = appleChip!.querySelector('button');
449
+ const bananaChipButton = bananaChip!.querySelector('button');
450
+ userEvent.click(appleChipButton!);
451
+ await waitFor(() => {
452
+ expect(appleChip).not.toBeInTheDocument();
453
+ expect(bananaChipButton!).toHaveFocus();
454
+ });
455
+ },
456
+ );
457
+
458
+ test('Clicking clear all button clears selection', () => {
459
+ const initialValue =
460
+ select === 'single' ? 'apple' : ['apple', 'banana', 'carrot'];
461
+ const { inputEl, clearButtonEl, queryAllChips } = renderCombobox(
462
+ select,
463
+ {
464
+ initialValue,
465
+ },
466
+ );
467
+ expect(clearButtonEl).not.toBeNull();
468
+ userEvent.click(clearButtonEl!);
469
+ if (select === 'multiple') {
470
+ expect(queryAllChips()).toHaveLength(0);
471
+ } else {
472
+ expect(inputEl).toHaveValue('');
473
+ }
474
+ });
475
+
476
+ test('Clicking clear all button does nothing when disabled', () => {
477
+ const initialValue =
478
+ select === 'single' ? 'apple' : ['apple', 'banana', 'carrot'];
479
+ const { inputEl, clearButtonEl, queryAllChips } = renderCombobox(
480
+ select,
481
+ {
482
+ initialValue,
483
+ disabled: true,
484
+ },
485
+ );
486
+ expect(clearButtonEl).not.toBeNull();
487
+ userEvent.click(clearButtonEl!);
488
+ if (select === 'multiple') {
489
+ expect(queryAllChips()).toHaveLength(initialValue.length);
490
+ } else {
491
+ expect(inputEl).toHaveValue(startCase(initialValue as string));
492
+ }
493
+ });
494
+
495
+ testSingleSelect(
496
+ "Unfocusing the menu should keep text if it's a valid selection",
497
+ async () => {
498
+ const { inputEl, containerEl, openMenu } = renderCombobox(select);
499
+ const { menuContainerEl } = openMenu();
500
+ userEvent.type(inputEl, 'Apple');
501
+ userEvent.click(document.body);
502
+ await waitForElementToBeRemoved(menuContainerEl);
503
+ expect(containerEl).not.toContainFocus();
504
+ expect(inputEl).toHaveValue('Apple');
505
+ },
506
+ );
507
+
508
+ testSingleSelect(
509
+ 'Unfocusing the menu should NOT keep text if not a valid selection',
510
+ async () => {
511
+ const { inputEl, containerEl, openMenu } = renderCombobox(select);
512
+ const { menuContainerEl } = openMenu();
513
+ userEvent.type(inputEl, 'abc');
514
+ userEvent.click(document.body);
515
+ await waitForElementToBeRemoved(menuContainerEl);
516
+ expect(containerEl).not.toContainFocus();
517
+ expect(inputEl).toHaveValue('');
518
+ },
519
+ );
520
+
521
+ testMultiSelect(
522
+ 'Unfocusing the menu should keep text as typed',
523
+ async () => {
524
+ const { inputEl, containerEl, openMenu } = renderCombobox(select);
525
+ const { menuContainerEl } = openMenu();
526
+ userEvent.type(inputEl, 'abc');
527
+ userEvent.click(document.body);
528
+ await waitForElementToBeRemoved(menuContainerEl);
529
+ expect(containerEl).not.toContainFocus();
530
+ expect(inputEl).toHaveValue('abc');
531
+ },
532
+ );
533
+
534
+ test.todo(
535
+ 'Clicking in the middle of the input text should set the cursor there',
536
+ );
537
+ });
538
+
539
+ /**
540
+ * Keyboard navigation
541
+ */
542
+ describe('Keyboard interaction', () => {
543
+ test('First option is highlighted on menu open', () => {
544
+ const { openMenu } = renderCombobox(select);
545
+ const { optionElements } = openMenu();
546
+ expect(optionElements).not.toBeUndefined();
547
+ expect(
548
+ (optionElements as HTMLCollectionOf<HTMLLIElement>)[0],
549
+ ).toHaveAttribute('aria-selected', 'true');
550
+ });
551
+
552
+ test('Enter key selects highlighted option', () => {
553
+ const { inputEl, openMenu, queryChipsByName } = renderCombobox(select);
554
+ openMenu();
555
+ userEvent.type(inputEl!, '{arrowdown}{enter}');
556
+ if (select === 'multiple') {
557
+ expect(queryChipsByName('Banana')).toBeInTheDocument();
558
+ } else {
559
+ expect(inputEl).toHaveValue('Banana');
560
+ }
561
+ });
562
+
563
+ test('Space key selects highlighted option', () => {
564
+ const { inputEl, openMenu, queryChipsByName } = renderCombobox(select);
565
+ openMenu();
566
+ userEvent.type(inputEl, '{arrowdown}{space}');
567
+ if (select === 'multiple') {
568
+ expect(queryChipsByName('Banana')).toBeInTheDocument();
569
+ } else {
570
+ expect(inputEl).toHaveValue('Banana');
571
+ }
572
+ });
573
+
574
+ test('Escape key closes menu', async () => {
575
+ const { inputEl, openMenu } = renderCombobox(select);
576
+ const { menuContainerEl } = openMenu();
577
+ userEvent.type(inputEl, '{esc}');
578
+ await waitForElementToBeRemoved(menuContainerEl);
579
+ expect(menuContainerEl).not.toBeInTheDocument();
580
+ });
581
+
582
+ describe('Tab key', () => {
583
+ test('Closes menu when no selection is made', async () => {
584
+ const { openMenu } = renderCombobox(select);
585
+ const { menuContainerEl } = openMenu();
586
+ userEvent.tab();
587
+ await waitForElementToBeRemoved(menuContainerEl);
588
+ expect(menuContainerEl).not.toBeInTheDocument();
589
+ });
590
+
591
+ test('Focuses clear button when it exists', async () => {
592
+ const initialValue = select === 'multiple' ? ['apple'] : 'apple';
593
+ const { clearButtonEl, openMenu } = renderCombobox(select, {
594
+ initialValue,
595
+ });
596
+ openMenu();
597
+ userEvent.tab();
598
+ expect(clearButtonEl).toHaveFocus();
599
+ });
600
+
601
+ testMultiSelect('Focuses next Chip when a Chip is selected', () => {
602
+ const initialValue = ['apple', 'banana', 'carrot'];
603
+ const { queryAllChips } = renderCombobox(select, { initialValue });
604
+ const [firstChip, secondChip] = queryAllChips();
605
+ userEvent.click(firstChip);
606
+ userEvent.tab();
607
+ expect(secondChip).toContainFocus();
608
+ });
609
+
610
+ testMultiSelect('Focuses input when the last Chip is selected', () => {
611
+ const initialValue = ['apple', 'banana', 'carrot'];
612
+ const { inputEl, queryChipsByIndex } = renderCombobox(select, {
613
+ initialValue,
614
+ });
615
+ const lastChip = queryChipsByIndex('last');
616
+ userEvent.click(lastChip!);
617
+ userEvent.tab();
618
+ expect(inputEl).toHaveFocus();
619
+ });
620
+ });
621
+
622
+ describe('Backspace key', () => {
623
+ test('Deletes text when cursor is NOT at beginning of selection', () => {
624
+ const { inputEl } = renderCombobox(select);
625
+ userEvent.type(inputEl, 'app{backspace}');
626
+ expect(inputEl).toHaveFocus();
627
+ expect(inputEl).toHaveValue('ap');
628
+ });
629
+
630
+ testSingleSelect(
631
+ 'Deletes text after making a single selection',
632
+ async () => {
633
+ const { inputEl, openMenu } = renderCombobox('single');
634
+ const { optionElements, menuContainerEl } = openMenu();
635
+ const firstOption = optionElements![0];
636
+ userEvent.click(firstOption);
637
+ await waitForElementToBeRemoved(menuContainerEl);
638
+ userEvent.type(inputEl, '{backspace}');
639
+ expect(inputEl).toHaveFocus();
640
+ expect(inputEl).toHaveValue('Appl');
641
+ },
642
+ );
643
+
644
+ testSingleSelect('Re-opens menu after making a selection', async () => {
645
+ const { inputEl, openMenu, getMenuElements } = renderCombobox(
646
+ 'single',
647
+ );
648
+ const { optionElements, menuContainerEl } = openMenu();
649
+ const firstOption = optionElements![0];
650
+ userEvent.click(firstOption);
651
+ await waitForElementToBeRemoved(menuContainerEl);
652
+ userEvent.type(inputEl, '{backspace}');
653
+ await waitFor(() => {
654
+ const { menuContainerEl: newMenuContainerEl } = getMenuElements();
655
+ expect(newMenuContainerEl).not.toBeNull();
656
+ expect(newMenuContainerEl).toBeInTheDocument();
657
+ });
658
+ });
659
+
660
+ testMultiSelect(
661
+ 'Focuses last chip when cursor is at beginning of selection',
662
+ () => {
663
+ const initialValue = ['apple'];
664
+ const { inputEl, queryAllChips } = renderCombobox(select, {
665
+ initialValue,
666
+ });
667
+ userEvent.type(inputEl, '{backspace}');
668
+ expect(queryAllChips()).toHaveLength(1);
669
+ expect(queryAllChips()[0]).toContainFocus();
670
+ },
671
+ );
672
+
673
+ testMultiSelect('Focuses last Chip after making a selection', () => {
674
+ const { inputEl, openMenu, queryAllChips } = renderCombobox(select);
675
+ const { optionElements } = openMenu();
676
+ const firstOption = optionElements![0];
677
+ userEvent.click(firstOption);
678
+ userEvent.type(inputEl, '{backspace}');
679
+ expect(queryAllChips()).toHaveLength(1);
680
+ expect(queryAllChips()[0]).toContainFocus();
681
+ });
682
+ });
683
+
684
+ describe('Up & Down arrow keys', () => {
685
+ test('Down arrow moves highlight down', async () => {
686
+ const { inputEl, openMenu, findByRole } = renderCombobox(select);
687
+ openMenu();
688
+ userEvent.type(inputEl, '{arrowdown}');
689
+ const highlight = await findByRole('option', {
690
+ selected: true,
691
+ });
692
+ expect(highlight).toHaveTextContent('Banana');
693
+ });
694
+
695
+ test('Up arrow moves highlight up', async () => {
696
+ const { inputEl, openMenu, findByRole } = renderCombobox(select);
697
+ openMenu();
698
+ userEvent.type(inputEl, '{arrowdown}{arrowdown}{arrowup}');
699
+ const highlight = await findByRole('option', {
700
+ selected: true,
701
+ });
702
+ expect(highlight).toHaveTextContent('Banana');
703
+ });
704
+
705
+ test('Down arrow key opens menu when its closed', async () => {
706
+ const { inputEl, openMenu, findByRole } = renderCombobox(select);
707
+ const { menuContainerEl } = openMenu();
708
+ expect(inputEl).toHaveFocus();
709
+ userEvent.type(inputEl, '{esc}');
710
+ await waitForElementToBeRemoved(menuContainerEl);
711
+ expect(menuContainerEl).not.toBeInTheDocument();
712
+ userEvent.type(inputEl, '{arrowdown}');
713
+ const reOpenedMenu = await findByRole('listbox');
714
+ expect(reOpenedMenu).toBeInTheDocument();
715
+ });
716
+ });
717
+
718
+ describe('Left arrow key', () => {
719
+ testMultiSelect(
720
+ 'When cursor is at the beginning of input, Left arrow focuses last chip',
721
+ () => {
722
+ const initialValue = ['apple', 'banana', 'carrot'];
723
+ const { queryChipsByIndex, inputEl } = renderCombobox(select, {
724
+ initialValue,
725
+ });
726
+ userEvent.type(inputEl, '{arrowleft}');
727
+ const lastChip = queryChipsByIndex('last');
728
+ expect(lastChip).toContainFocus();
729
+ },
730
+ );
731
+ testSingleSelect(
732
+ 'When cursor is at the beginning of input, Left arrow does nothing',
733
+ () => {
734
+ const { inputEl } = renderCombobox(select);
735
+ userEvent.type(inputEl, '{arrowleft}');
736
+ waitFor(() => expect(inputEl).toHaveFocus());
737
+ },
738
+ );
739
+ test('If cursor is NOT at the beginning of input, Left arrow key moves cursor', () => {
740
+ const { inputEl } = renderCombobox(select);
741
+ userEvent.type(inputEl, 'abc{arrowleft}');
742
+ waitFor(() => expect(inputEl).toHaveFocus());
743
+ });
744
+
745
+ test.skip('When focus is on clear button, Left arrow moves focus to input', async () => {
746
+ const initialValue = select === 'multiple' ? ['apple'] : 'apple';
747
+ const { inputEl } = renderCombobox(select, {
748
+ initialValue,
749
+ });
750
+ userEvent.type(inputEl!, '{arrowright}{arrowleft}');
751
+ expect(inputEl!).toHaveFocus();
752
+ expect(inputEl!.selectionEnd).toEqual(select === 'multiple' ? 0 : 5);
753
+ });
754
+
755
+ testMultiSelect(
756
+ 'When focus is on a chip, Left arrow focuses prev chip',
757
+ () => {
758
+ const initialValue = ['apple', 'banana', 'carrot'];
759
+ const { queryChipsByIndex, inputEl } = renderCombobox(select, {
760
+ initialValue,
761
+ });
762
+ const secondChip = queryChipsByIndex(1);
763
+ userEvent.type(inputEl, '{arrowleft}{arrowleft}');
764
+ expect(secondChip).toContainFocus();
765
+ },
766
+ );
767
+ testMultiSelect(
768
+ 'When focus is on the first chip, Left arrrow does nothing',
769
+ () => {
770
+ const initialValue = ['apple', 'banana', 'carrot'];
771
+ const { queryAllChips, inputEl } = renderCombobox(select, {
772
+ initialValue,
773
+ });
774
+ const [firstChip] = queryAllChips();
775
+ userEvent.type(
776
+ inputEl,
777
+ '{arrowleft}{arrowleft}{arrowleft}{arrowleft}',
778
+ );
779
+ expect(firstChip).toContainFocus();
780
+ },
781
+ );
782
+ });
783
+
784
+ describe('Right arrow key', () => {
785
+ test('Does nothing when focus is on clear button', () => {
786
+ const initialValue =
787
+ select === 'multiple' ? ['apple', 'banana', 'carrot'] : 'apple';
788
+ const { inputEl, clearButtonEl } = renderCombobox(select, {
789
+ initialValue,
790
+ });
791
+ userEvent.type(inputEl, '{arrowright}{arrowright}');
792
+ expect(clearButtonEl).toHaveFocus();
793
+ });
794
+
795
+ test('Focuses clear button when cursor is at the end of input', () => {
796
+ const initialValue =
797
+ select === 'multiple' ? ['apple', 'banana', 'carrot'] : 'apple';
798
+ const { inputEl, clearButtonEl } = renderCombobox(select, {
799
+ initialValue,
800
+ });
801
+ userEvent.type(inputEl, '{arrowright}');
802
+ expect(clearButtonEl).toHaveFocus();
803
+ });
804
+
805
+ test('Moves cursor when cursor is NOT at the end of input', () => {
806
+ const initialValue =
807
+ select === 'multiple' ? ['apple', 'banana', 'carrot'] : 'apple';
808
+ const { inputEl } = renderCombobox(select, {
809
+ initialValue,
810
+ });
811
+ userEvent.type(inputEl, 'abc{arrowleft}{arrowright}');
812
+ expect(inputEl).toHaveFocus();
813
+ });
814
+
815
+ testMultiSelect('Focuses input when focus is on last chip', () => {
816
+ const initialValue = ['apple', 'banana', 'carrot'];
817
+ const { inputEl } = renderCombobox(select, {
818
+ initialValue,
819
+ });
820
+ userEvent.type(
821
+ inputEl!,
822
+ 'abc{arrowleft}{arrowleft}{arrowleft}{arrowleft}{arrowright}',
823
+ );
824
+ expect(inputEl!).toHaveFocus();
825
+ // This behavior passes in the browser, but not in jest
826
+ // expect(inputEl!.selectionStart).toEqual(0);
827
+ });
828
+
829
+ testMultiSelect('Focuses next chip when focus is on a chip', () => {
830
+ const initialValue = ['apple', 'banana', 'carrot'];
831
+ const { inputEl, queryChipsByIndex } = renderCombobox(select, {
832
+ initialValue,
833
+ });
834
+ userEvent.type(inputEl!, '{arrowleft}{arrowleft}{arrowright}');
835
+ const lastChip = queryChipsByIndex('last');
836
+ expect(lastChip!).toContainFocus();
837
+ });
838
+ });
839
+
840
+ describe('Remove chips with keyboard', () => {
841
+ let comboboxEl: HTMLElement,
842
+ queryAllChips: () => Array<HTMLElement>,
843
+ chipButton: HTMLElement;
844
+
845
+ beforeEach(() => {
846
+ const initialValue = ['apple', 'banana', 'carrot'];
847
+ const combobox = renderCombobox(select, {
848
+ initialValue,
849
+ });
850
+ comboboxEl = combobox.comboboxEl;
851
+ userEvent.type(comboboxEl, '{arrowleft}');
852
+ queryAllChips = combobox.queryAllChips;
853
+ const chip = combobox.queryChipsByName('Carrot');
854
+ if (!chip) throw new Error('Carrot Chip not found');
855
+ chipButton = chip.querySelector('button') as HTMLElement;
856
+ });
857
+
858
+ testMultiSelect('Enter key', () => {
859
+ userEvent.type(chipButton, '{enter}');
860
+ waitFor(() => expect(queryAllChips()).toHaveLength(2));
861
+ });
862
+ testMultiSelect('Backspace key', () => {
863
+ userEvent.type(chipButton, '{backspace}');
864
+ waitFor(() => expect(queryAllChips()).toHaveLength(2));
865
+ });
866
+ testMultiSelect('Space key', () => {
867
+ userEvent.type(chipButton, '{space}');
868
+ waitFor(() => expect(queryAllChips()).toHaveLength(2));
869
+ });
870
+ });
871
+
872
+ describe('Any other key', () => {
873
+ test('Updates the value of the input', () => {
874
+ const { inputEl } = renderCombobox(select);
875
+ userEvent.type(inputEl, 'a');
876
+ expect(inputEl).toHaveValue('a');
877
+ });
878
+
879
+ test("Opens the menu if it's closed", async () => {
880
+ const { inputEl, openMenu, getMenuElements } = renderCombobox(select);
881
+ const { menuContainerEl } = openMenu();
882
+ userEvent.type(inputEl, '{esc}');
883
+ await waitForElementToBeRemoved(menuContainerEl);
884
+ expect(menuContainerEl).not.toBeInTheDocument();
885
+ userEvent.type(inputEl, 'a');
886
+ await waitFor(() => {
887
+ const { menuContainerEl: newMenuContainerEl } = getMenuElements();
888
+ expect(newMenuContainerEl).toBeInTheDocument();
889
+ });
890
+ });
891
+
892
+ testSingleSelect(
893
+ 'Opens the menu after making a selection',
894
+ async () => {
895
+ const { inputEl, openMenu, getMenuElements } = renderCombobox(
896
+ select,
897
+ );
898
+ const { optionElements, menuContainerEl } = openMenu();
899
+ const firstOption = optionElements![0];
900
+ userEvent.click(firstOption);
901
+ await waitForElementToBeRemoved(menuContainerEl);
902
+ userEvent.type(inputEl, 'a');
903
+ await waitFor(() => {
904
+ const { menuContainerEl: newMenuContainerEl } = getMenuElements();
905
+ expect(newMenuContainerEl).toBeInTheDocument();
906
+ });
907
+ },
908
+ );
909
+ });
910
+ });
911
+
912
+ /**
913
+ * Filtered options
914
+ */
915
+ test('Providing filteredOptions limits the rendered options', () => {
916
+ const { openMenu } = renderCombobox(select, {
917
+ filteredOptions: ['apple'],
918
+ });
919
+ const { optionElements } = openMenu();
920
+ expect(optionElements!.length).toEqual(1);
921
+ });
922
+
923
+ /**
924
+ * onClear
925
+ */
926
+ test('Clear button calls onClear callback', () => {
927
+ const initialValue = select === 'multiple' ? ['apple'] : 'apple';
928
+ const onClear = jest.fn();
929
+ const { clearButtonEl } = renderCombobox(select, {
930
+ initialValue,
931
+ onClear,
932
+ });
933
+ userEvent.click(clearButtonEl!);
934
+ expect(onClear).toHaveBeenCalled();
935
+ });
936
+
937
+ /**
938
+ * onChange
939
+ */
940
+ describe('onChange', () => {
941
+ test('Selecting an option calls onChange callback', () => {
942
+ const onChange = jest.fn();
943
+ const { openMenu } = renderCombobox(select, { onChange });
944
+ const { optionElements } = openMenu();
945
+ userEvent.click(optionElements![0]);
946
+ waitFor(() => {
947
+ expect(onChange).toHaveBeenCalled();
948
+ });
949
+ });
950
+
951
+ test('Clearing selection calls onChange callback', () => {
952
+ const onChange = jest.fn();
953
+ const initialValue = select === 'multiple' ? ['apple'] : 'apple';
954
+ const { clearButtonEl } = renderCombobox(select, {
955
+ onChange,
956
+ initialValue,
957
+ });
958
+ userEvent.click(clearButtonEl!);
959
+ expect(onChange).toHaveBeenCalled();
960
+ });
961
+
962
+ test('Typing does not call onChange callback', () => {
963
+ const onChange = jest.fn();
964
+ const { inputEl } = renderCombobox(select, { onChange });
965
+ userEvent.type(inputEl, 'a');
966
+ expect(onChange).not.toHaveBeenCalled();
967
+ });
968
+
969
+ test('Closing the menu without making a selection does not call onChange callback', async () => {
970
+ const onChange = jest.fn();
971
+ const { containerEl, openMenu } = renderCombobox(select, { onChange });
972
+ const { menuContainerEl } = openMenu();
973
+ userEvent.click(containerEl.parentElement!);
974
+ await waitForElementToBeRemoved(menuContainerEl);
975
+ expect(onChange).not.toHaveBeenCalled();
976
+ });
977
+ });
978
+
979
+ /**
980
+ * onFilter
981
+ */
982
+ describe('onFilter', () => {
983
+ test('Typing calls onFilter callback on each keystroke', () => {
984
+ const onFilter = jest.fn();
985
+ const { inputEl } = renderCombobox(select, { onFilter });
986
+ userEvent.type(inputEl, 'app');
987
+ expect(onFilter).toHaveBeenCalledTimes(3);
988
+ });
989
+ test('Clearing selection calls onFilter callback once', () => {
990
+ const onFilter = jest.fn();
991
+ const initialValue = select === 'multiple' ? ['apple'] : 'apple';
992
+ const { clearButtonEl } = renderCombobox(select, {
993
+ onFilter,
994
+ initialValue,
995
+ });
996
+ userEvent.click(clearButtonEl!);
997
+ expect(onFilter).toHaveBeenCalledTimes(1);
998
+ });
999
+ test('Selecting an option does not call onFilter callback', () => {
1000
+ const onFilter = jest.fn();
1001
+ const { openMenu } = renderCombobox(select, { onFilter });
1002
+ const { optionElements } = openMenu();
1003
+ userEvent.click((optionElements as HTMLCollectionOf<HTMLLIElement>)[0]);
1004
+ expect(onFilter).not.toHaveBeenCalled();
1005
+ });
1006
+ test('Closing the menu does not call onFilter callback', async () => {
1007
+ const onFilter = jest.fn();
1008
+ const { containerEl, openMenu } = renderCombobox(select, { onFilter });
1009
+ const { menuContainerEl } = openMenu();
1010
+ userEvent.click(containerEl.parentElement!);
1011
+ await waitForElementToBeRemoved(menuContainerEl);
1012
+ expect(onFilter).not.toHaveBeenCalled();
1013
+ });
1014
+ });
1015
+
1016
+ /**
1017
+ * Search State messages & filteredOptions
1018
+ */
1019
+ describe('Search states', () => {
1020
+ test('Menu renders empty state message when there are no options provided', () => {
1021
+ const searchEmptyMessage = 'Empty state message';
1022
+ const { openMenu } = renderCombobox(select, {
1023
+ searchEmptyMessage,
1024
+ options: [],
1025
+ });
1026
+ const { menuContainerEl } = openMenu();
1027
+ const emptyStateTextEl = queryByText(
1028
+ menuContainerEl!,
1029
+ searchEmptyMessage,
1030
+ );
1031
+ expect(emptyStateTextEl).toBeInTheDocument();
1032
+ });
1033
+
1034
+ // Unsure if this is the desired behavior
1035
+ test.skip('Menu renders empty state message when filtered options is empty', () => {
1036
+ const searchEmptyMessage = 'Empty state message';
1037
+ const { openMenu } = renderCombobox(select, {
1038
+ searchEmptyMessage,
1039
+ filteredOptions: [],
1040
+ });
1041
+ const { menuContainerEl } = openMenu();
1042
+ const emptyStateTextEl = queryByText(
1043
+ menuContainerEl!,
1044
+ searchEmptyMessage,
1045
+ );
1046
+ expect(emptyStateTextEl).toBeInTheDocument();
1047
+ });
1048
+
1049
+ test('Menu renders loading state message `searchState` == `loading`', () => {
1050
+ const searchLoadingMessage = 'Loading state message';
1051
+ const { openMenu } = renderCombobox(select, {
1052
+ searchLoadingMessage,
1053
+ searchState: 'loading',
1054
+ });
1055
+ const { menuContainerEl } = openMenu();
1056
+ const loadingStateTextEl = queryByText(
1057
+ menuContainerEl!,
1058
+ searchLoadingMessage,
1059
+ );
1060
+ expect(loadingStateTextEl).toBeInTheDocument();
1061
+ });
1062
+
1063
+ test('Menu renders error state message `searchState` == `error`', () => {
1064
+ const searchErrorMessage = 'Error state message';
1065
+ const { openMenu } = renderCombobox(select, {
1066
+ searchErrorMessage,
1067
+ searchState: 'error',
1068
+ });
1069
+ const { menuContainerEl } = openMenu();
1070
+ const errorStateTextEl = queryByText(
1071
+ menuContainerEl!,
1072
+ searchErrorMessage,
1073
+ );
1074
+ expect(errorStateTextEl).toBeInTheDocument();
1075
+ });
1076
+ });
1077
+
1078
+ // Filtering
1079
+ test('Menu options list narrows when text is entered', async () => {
1080
+ const { inputEl, openMenu, findAllByRole } = renderCombobox(select);
1081
+ openMenu();
1082
+ userEvent.type(inputEl, 'c');
1083
+ const optionElements = await findAllByRole('option');
1084
+ expect(optionElements.length).toEqual(1);
1085
+ });
1086
+ });
1087
+
1088
+ describe('Chips', () => {
1089
+ const ellipsis = '…';
1090
+ const options = [
1091
+ 'loremipsumdolor',
1092
+ 'sitametconsectetur',
1093
+ 'anotherlongoption',
1094
+ ];
1095
+
1096
+ test('Chips truncate at the beginning', () => {
1097
+ const { queryAllChips } = renderCombobox('multiple', {
1098
+ options,
1099
+ initialValue: ['loremipsumdolor'],
1100
+ chipTruncationLocation: 'start',
1101
+ });
1102
+ const firstChipEl = queryAllChips()[0];
1103
+ expect(firstChipEl).toHaveTextContent(ellipsis + 'psumdolor');
1104
+ });
1105
+
1106
+ test('Chips truncate in the middle', () => {
1107
+ const { queryAllChips } = renderCombobox('multiple', {
1108
+ options,
1109
+ initialValue: ['loremipsumdolor'],
1110
+ chipTruncationLocation: 'middle',
1111
+ });
1112
+ const [firstChipEl] = queryAllChips();
1113
+ expect(firstChipEl).toHaveTextContent('lore' + ellipsis + 'dolor');
1114
+ });
1115
+ test('Chips truncate at the end', () => {
1116
+ const { queryAllChips } = renderCombobox('multiple', {
1117
+ options,
1118
+ initialValue: ['loremipsumdolor'],
1119
+ chipTruncationLocation: 'end',
1120
+ });
1121
+ const [firstChipEl] = queryAllChips();
1122
+ expect(firstChipEl).toHaveTextContent('loremipsu' + ellipsis);
1123
+ });
1124
+
1125
+ test('Chips truncate to the provided length', () => {
1126
+ const { queryAllChips } = renderCombobox('multiple', {
1127
+ options,
1128
+ initialValue: ['loremipsumdolor'],
1129
+ chipTruncationLocation: 'start',
1130
+ chipCharacterLimit: 8,
1131
+ });
1132
+ const [firstChipEl] = queryAllChips();
1133
+ expect(firstChipEl).toHaveTextContent(ellipsis + 'dolor');
1134
+ });
1135
+ });
1136
+ });