@arbor-education/design-system.components 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/.github/workflows/release.yml +1 -1
  2. package/CHANGELOG.md +22 -0
  3. package/dist/components/button/Button.d.ts.map +1 -1
  4. package/dist/components/button/Button.js +2 -2
  5. package/dist/components/button/Button.js.map +1 -1
  6. package/dist/components/combobox/Combobox.d.ts.map +1 -1
  7. package/dist/components/combobox/Combobox.js +10 -8
  8. package/dist/components/combobox/Combobox.js.map +1 -1
  9. package/dist/components/combobox/Combobox.stories.d.ts +1 -0
  10. package/dist/components/combobox/Combobox.stories.d.ts.map +1 -1
  11. package/dist/components/combobox/Combobox.stories.js +16 -0
  12. package/dist/components/combobox/Combobox.stories.js.map +1 -1
  13. package/dist/components/combobox/Combobox.test.js +107 -61
  14. package/dist/components/combobox/Combobox.test.js.map +1 -1
  15. package/dist/components/combobox/ComboboxButtonTrigger.d.ts +4 -2
  16. package/dist/components/combobox/ComboboxButtonTrigger.d.ts.map +1 -1
  17. package/dist/components/combobox/ComboboxButtonTrigger.js +11 -4
  18. package/dist/components/combobox/ComboboxButtonTrigger.js.map +1 -1
  19. package/dist/components/combobox/ComboboxTrigger.d.ts +3 -1
  20. package/dist/components/combobox/ComboboxTrigger.d.ts.map +1 -1
  21. package/dist/components/combobox/ComboboxTrigger.js +10 -2
  22. package/dist/components/combobox/ComboboxTrigger.js.map +1 -1
  23. package/dist/components/combobox/types.d.ts +3 -0
  24. package/dist/components/combobox/types.d.ts.map +1 -1
  25. package/dist/components/combobox/useComboboxPopoverBehavior.d.ts +3 -1
  26. package/dist/components/combobox/useComboboxPopoverBehavior.d.ts.map +1 -1
  27. package/dist/components/combobox/useComboboxPopoverBehavior.js +7 -6
  28. package/dist/components/combobox/useComboboxPopoverBehavior.js.map +1 -1
  29. package/dist/components/combobox/useComboboxState.d.ts.map +1 -1
  30. package/dist/components/combobox/useComboboxState.js +4 -1
  31. package/dist/components/combobox/useComboboxState.js.map +1 -1
  32. package/dist/components/datePicker/DatePicker.d.ts +4 -1
  33. package/dist/components/datePicker/DatePicker.d.ts.map +1 -1
  34. package/dist/components/datePicker/DatePicker.js +77 -37
  35. package/dist/components/datePicker/DatePicker.js.map +1 -1
  36. package/dist/components/datePicker/DatePicker.stories.d.ts +28 -3
  37. package/dist/components/datePicker/DatePicker.stories.d.ts.map +1 -1
  38. package/dist/components/datePicker/DatePicker.stories.js +62 -9
  39. package/dist/components/datePicker/DatePicker.stories.js.map +1 -1
  40. package/dist/components/datePicker/DatePicker.test.js +133 -66
  41. package/dist/components/datePicker/DatePicker.test.js.map +1 -1
  42. package/dist/components/datePicker/DatePickerCalendarHeader.d.ts +8 -0
  43. package/dist/components/datePicker/DatePickerCalendarHeader.d.ts.map +1 -0
  44. package/dist/components/datePicker/DatePickerCalendarHeader.js +36 -0
  45. package/dist/components/datePicker/DatePickerCalendarHeader.js.map +1 -0
  46. package/dist/components/datePicker/dateInputUtils.d.ts +25 -0
  47. package/dist/components/datePicker/dateInputUtils.d.ts.map +1 -0
  48. package/dist/components/datePicker/dateInputUtils.js +60 -0
  49. package/dist/components/datePicker/dateInputUtils.js.map +1 -0
  50. package/dist/components/datePicker/datePickerTestUtils.test-helpers.d.ts +2 -0
  51. package/dist/components/datePicker/datePickerTestUtils.test-helpers.d.ts.map +1 -0
  52. package/dist/components/datePicker/datePickerTestUtils.test-helpers.js +4 -0
  53. package/dist/components/datePicker/datePickerTestUtils.test-helpers.js.map +1 -0
  54. package/dist/components/dateTimePicker/DateTimePicker.d.ts +22 -0
  55. package/dist/components/dateTimePicker/DateTimePicker.d.ts.map +1 -0
  56. package/dist/components/dateTimePicker/DateTimePicker.js +132 -0
  57. package/dist/components/dateTimePicker/DateTimePicker.js.map +1 -0
  58. package/dist/components/dateTimePicker/DateTimePicker.stories.d.ts +77 -0
  59. package/dist/components/dateTimePicker/DateTimePicker.stories.d.ts.map +1 -0
  60. package/dist/components/dateTimePicker/DateTimePicker.stories.js +163 -0
  61. package/dist/components/dateTimePicker/DateTimePicker.stories.js.map +1 -0
  62. package/dist/components/dateTimePicker/DateTimePicker.test.d.ts +2 -0
  63. package/dist/components/dateTimePicker/DateTimePicker.test.d.ts.map +1 -0
  64. package/dist/components/dateTimePicker/DateTimePicker.test.js +235 -0
  65. package/dist/components/dateTimePicker/DateTimePicker.test.js.map +1 -0
  66. package/dist/components/formField/FormField.d.ts +4 -0
  67. package/dist/components/formField/FormField.d.ts.map +1 -1
  68. package/dist/components/formField/FormField.js +2 -1
  69. package/dist/components/formField/FormField.js.map +1 -1
  70. package/dist/components/formField/FormField.stories.d.ts.map +1 -1
  71. package/dist/components/formField/FormField.stories.js +4 -1
  72. package/dist/components/formField/FormField.stories.js.map +1 -1
  73. package/dist/components/formField/FormField.test.d.ts.map +1 -1
  74. package/dist/components/formField/FormField.test.js +10 -5
  75. package/dist/components/formField/FormField.test.js.map +1 -1
  76. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.d.ts +1 -0
  77. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.d.ts.map +1 -1
  78. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js +7 -3
  79. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js.map +1 -1
  80. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.test.js +12 -0
  81. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.test.js.map +1 -1
  82. package/dist/components/formField/inputs/text/TextInput.d.ts +4 -1
  83. package/dist/components/formField/inputs/text/TextInput.d.ts.map +1 -1
  84. package/dist/components/formField/inputs/text/TextInput.js +5 -4
  85. package/dist/components/formField/inputs/text/TextInput.js.map +1 -1
  86. package/dist/components/formField/inputs/text/TextInput.stories.d.ts +4 -1
  87. package/dist/components/formField/inputs/text/TextInput.stories.d.ts.map +1 -1
  88. package/dist/components/formField/inputs/time/TimeInput.d.ts +29 -0
  89. package/dist/components/formField/inputs/time/TimeInput.d.ts.map +1 -0
  90. package/dist/components/formField/inputs/time/TimeInput.js +67 -0
  91. package/dist/components/formField/inputs/time/TimeInput.js.map +1 -0
  92. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +60 -0
  93. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts.map +1 -0
  94. package/dist/components/formField/inputs/time/TimeInput.stories.js +132 -0
  95. package/dist/components/formField/inputs/time/TimeInput.stories.js.map +1 -0
  96. package/dist/components/formField/inputs/time/TimeInput.test.d.ts +2 -0
  97. package/dist/components/formField/inputs/time/TimeInput.test.d.ts.map +1 -0
  98. package/dist/components/formField/inputs/time/TimeInput.test.js +58 -0
  99. package/dist/components/formField/inputs/time/TimeInput.test.js.map +1 -0
  100. package/dist/components/table/Table.d.ts.map +1 -1
  101. package/dist/components/table/Table.js +2 -0
  102. package/dist/components/table/Table.js.map +1 -1
  103. package/dist/components/table/Table.stories.d.ts +1 -0
  104. package/dist/components/table/Table.stories.d.ts.map +1 -1
  105. package/dist/components/table/Table.stories.js +37 -0
  106. package/dist/components/table/Table.stories.js.map +1 -1
  107. package/dist/components/table/cellRenderers/BooleanCellRenderer.d.ts +3 -0
  108. package/dist/components/table/cellRenderers/BooleanCellRenderer.d.ts.map +1 -0
  109. package/dist/components/table/cellRenderers/BooleanCellRenderer.js +15 -0
  110. package/dist/components/table/cellRenderers/BooleanCellRenderer.js.map +1 -0
  111. package/dist/components/table/cellRenderers/BooleanCellRenderer.test.d.ts +2 -0
  112. package/dist/components/table/cellRenderers/BooleanCellRenderer.test.d.ts.map +1 -0
  113. package/dist/components/table/cellRenderers/BooleanCellRenderer.test.js +31 -0
  114. package/dist/components/table/cellRenderers/BooleanCellRenderer.test.js.map +1 -0
  115. package/dist/index.css +309 -4
  116. package/dist/index.css.map +1 -1
  117. package/dist/index.d.ts +5 -0
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +3 -0
  120. package/dist/index.js.map +1 -1
  121. package/package.json +1 -1
  122. package/src/components/button/Button.tsx +2 -1
  123. package/src/components/combobox/Combobox.stories.tsx +18 -0
  124. package/src/components/combobox/Combobox.test.tsx +131 -61
  125. package/src/components/combobox/Combobox.tsx +15 -6
  126. package/src/components/combobox/ComboboxButtonTrigger.tsx +54 -25
  127. package/src/components/combobox/ComboboxTrigger.tsx +39 -15
  128. package/src/components/combobox/combobox.scss +18 -0
  129. package/src/components/combobox/types.ts +3 -0
  130. package/src/components/combobox/useComboboxPopoverBehavior.ts +10 -5
  131. package/src/components/combobox/useComboboxState.ts +4 -1
  132. package/src/components/datePicker/DatePicker.stories.tsx +67 -9
  133. package/src/components/datePicker/DatePicker.test.tsx +157 -72
  134. package/src/components/datePicker/DatePicker.tsx +163 -69
  135. package/src/components/datePicker/DatePickerCalendarHeader.tsx +82 -0
  136. package/src/components/datePicker/date-field-hint.scss +152 -0
  137. package/src/components/datePicker/dateInputUtils.ts +117 -0
  138. package/src/components/datePicker/datePicker.scss +53 -29
  139. package/src/components/datePicker/datePickerTestUtils.test-helpers.ts +6 -0
  140. package/src/components/dateTimePicker/DateTimePicker.stories.tsx +202 -0
  141. package/src/components/dateTimePicker/DateTimePicker.test.tsx +295 -0
  142. package/src/components/dateTimePicker/DateTimePicker.tsx +293 -0
  143. package/src/components/dateTimePicker/dateTimePicker.scss +17 -0
  144. package/src/components/formField/FormField.stories.tsx +10 -1
  145. package/src/components/formField/FormField.test.tsx +11 -5
  146. package/src/components/formField/FormField.tsx +5 -0
  147. package/src/components/formField/inputs/selectDropdown/SelectDropdown.test.tsx +28 -0
  148. package/src/components/formField/inputs/selectDropdown/SelectDropdown.tsx +8 -2
  149. package/src/components/formField/inputs/text/TextInput.tsx +6 -3
  150. package/src/components/formField/inputs/time/TimeInput.stories.tsx +170 -0
  151. package/src/components/formField/inputs/time/TimeInput.test.tsx +86 -0
  152. package/src/components/formField/inputs/time/TimeInput.tsx +168 -0
  153. package/src/components/formField/inputs/time/timeInput.scss +33 -0
  154. package/src/components/row/row.scss +2 -2
  155. package/src/components/table/Table.stories.tsx +48 -0
  156. package/src/components/table/Table.tsx +2 -0
  157. package/src/components/table/cellRenderers/BooleanCellRenderer.test.tsx +37 -0
  158. package/src/components/table/cellRenderers/BooleanCellRenderer.tsx +34 -0
  159. package/src/components/table/cellRenderers/booleanCellRenderer.scss +7 -0
  160. package/src/index.scss +3 -0
  161. package/src/index.ts +5 -0
@@ -147,6 +147,30 @@ describe('Combobox', () => {
147
147
  expect(search).toHaveFocus();
148
148
  });
149
149
 
150
+ test('button trigger keeps popover with search input when filter matches nothing', async () => {
151
+ const user = userEvent.setup();
152
+ render(<Combobox options={people} triggerVariant="button" placeholder="Students" />);
153
+ await user.click(screen.getByRole('button', { name: 'Open suggestions' }));
154
+
155
+ const search = screen.getByRole('combobox');
156
+ await user.type(search, 'zzz');
157
+
158
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
159
+ expect(screen.queryAllByRole('option')).toHaveLength(0);
160
+
161
+ await user.clear(search);
162
+ expect(within(screen.getByRole('listbox')).getAllByRole('option')).toHaveLength(people.length);
163
+ });
164
+
165
+ test('button trigger closes popover after single-select option click', async () => {
166
+ const user = userEvent.setup();
167
+ render(<Combobox options={people} triggerVariant="button" placeholder="Students" />);
168
+ await user.click(screen.getByRole('button', { name: 'Open suggestions' }));
169
+ await user.click(screen.getByText('Alice Johnson'));
170
+
171
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
172
+ });
173
+
150
174
  test('button trigger single-select does not show selection count badge', () => {
151
175
  render(
152
176
  <Combobox
@@ -158,6 +182,33 @@ describe('Combobox', () => {
158
182
  expect(screen.queryByLabelText(/selected item/i)).not.toBeInTheDocument();
159
183
  });
160
184
 
185
+ test('button trigger can render plain text instead of tags for a selected value', () => {
186
+ render(
187
+ <Combobox
188
+ options={people}
189
+ triggerVariant="button"
190
+ selectedValueDisplay="text"
191
+ defaultValue={['alice']}
192
+ />,
193
+ );
194
+
195
+ expect(screen.getByRole('button', { name: /alice johnson/i })).toHaveTextContent('Alice Johnson');
196
+ expect(document.querySelector('.ds-tag')).not.toBeInTheDocument();
197
+ });
198
+
199
+ test('renders custom trigger end content alongside the trigger', () => {
200
+ render(
201
+ <Combobox
202
+ options={people}
203
+ triggerVariant="button"
204
+ showDropdownTrigger={false}
205
+ triggerEndContent={<span>clock</span>}
206
+ />,
207
+ );
208
+
209
+ expect(screen.getByText('clock')).toBeInTheDocument();
210
+ });
211
+
161
212
  test('button trigger multi-select shows salmon count badge when multiple chips', () => {
162
213
  render(
163
214
  <Combobox
@@ -326,20 +377,22 @@ describe('Combobox', () => {
326
377
  });
327
378
 
328
379
  test('Ctrl+A selects all chips when input is focused', async () => {
380
+ const user = userEvent.setup();
329
381
  render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
330
382
  const input = screen.getByRole('combobox');
331
- input.focus();
383
+ await user.click(input);
332
384
 
333
- await userEvent.keyboard('{Control>}a{/Control}');
385
+ await user.keyboard('{Control>}a{/Control}');
334
386
 
335
387
  const selectedTags = document.querySelectorAll('.ds-tag--selected');
336
388
  expect(selectedTags).toHaveLength(2);
337
389
  });
338
390
 
339
391
  test('Cmd+A selects all chips in single-select mode', async () => {
392
+ const user = userEvent.setup();
340
393
  render(<Combobox options={people} defaultValue={['alice']} />);
341
394
  const input = screen.getByRole('combobox');
342
- input.focus();
395
+ await user.click(input);
343
396
 
344
397
  fireEvent.keyDown(input, { key: 'a', metaKey: true });
345
398
 
@@ -348,54 +401,58 @@ describe('Combobox', () => {
348
401
  });
349
402
 
350
403
  test('Backspace clears all selected chips after select-all', async () => {
404
+ const user = userEvent.setup();
351
405
  const onValueChange = vi.fn();
352
406
  render(
353
407
  <Combobox options={people} multiple defaultValue={['alice', 'bob']} onValueChange={onValueChange} />,
354
408
  );
355
409
  const input = screen.getByRole('combobox');
356
- input.focus();
410
+ await user.click(input);
357
411
 
358
- await userEvent.keyboard('{Control>}a{/Control}');
359
- await userEvent.keyboard('{Backspace}');
412
+ await user.keyboard('{Control>}a{/Control}');
413
+ await user.keyboard('{Backspace}');
360
414
 
361
415
  expect(onValueChange).toHaveBeenLastCalledWith([]);
362
416
  expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
363
417
  });
364
418
 
365
419
  test('Delete clears all selected chips after select-all', async () => {
420
+ const user = userEvent.setup();
366
421
  const onValueChange = vi.fn();
367
422
  render(
368
423
  <Combobox options={people} multiple defaultValue={['alice', 'bob']} onValueChange={onValueChange} />,
369
424
  );
370
425
  const input = screen.getByRole('combobox');
371
- input.focus();
426
+ await user.click(input);
372
427
 
373
- await userEvent.keyboard('{Control>}a{/Control}');
374
- await userEvent.keyboard('{Delete}');
428
+ await user.keyboard('{Control>}a{/Control}');
429
+ await user.keyboard('{Delete}');
375
430
 
376
431
  expect(onValueChange).toHaveBeenLastCalledWith([]);
377
432
  expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
378
433
  });
379
434
 
380
435
  test('typing after chip select-all clears selected chip styling', async () => {
436
+ const user = userEvent.setup();
381
437
  render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
382
438
  const input = screen.getByRole('combobox');
383
- input.focus();
439
+ await user.click(input);
384
440
 
385
- await userEvent.keyboard('{Control>}a{/Control}');
441
+ await user.keyboard('{Control>}a{/Control}');
386
442
  expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(2);
387
443
 
388
- await userEvent.type(input, 'c');
444
+ await user.type(input, 'c');
389
445
 
390
446
  expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
391
447
  });
392
448
 
393
449
  test('Ctrl+A with no chips keeps native input behavior and does not crash', async () => {
450
+ const user = userEvent.setup();
394
451
  render(<Combobox options={people} />);
395
452
  const input = screen.getByRole('combobox');
396
- input.focus();
453
+ await user.click(input);
397
454
 
398
- await userEvent.keyboard('{Control>}a{/Control}');
455
+ await user.keyboard('{Control>}a{/Control}');
399
456
 
400
457
  expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
401
458
  });
@@ -555,21 +612,23 @@ describe('Combobox', () => {
555
612
  });
556
613
 
557
614
  test('keyboard: ArrowDown opens the listbox', async () => {
615
+ const user = userEvent.setup();
558
616
  render(<Combobox options={people} />);
559
617
  const input = screen.getByRole('combobox');
560
- input.focus();
618
+ await user.click(input);
561
619
 
562
- await userEvent.keyboard('{ArrowDown}');
620
+ await user.keyboard('{ArrowDown}');
563
621
 
564
622
  expect(screen.getByRole('listbox')).toBeInTheDocument();
565
623
  });
566
624
 
567
625
  test('keyboard: Alt+ArrowDown opens the listbox', async () => {
626
+ const user = userEvent.setup();
568
627
  render(<Combobox options={people} />);
569
628
  const input = screen.getByRole('combobox');
570
- input.focus();
629
+ await user.click(input);
571
630
 
572
- await userEvent.keyboard('{Alt>}{ArrowDown}{/Alt}');
631
+ await user.keyboard('{Alt>}{ArrowDown}{/Alt}');
573
632
 
574
633
  expect(screen.getByRole('listbox')).toBeInTheDocument();
575
634
  });
@@ -612,14 +671,15 @@ describe('Combobox', () => {
612
671
  });
613
672
 
614
673
  test('keyboard: Backspace on empty input removes last chip', async () => {
674
+ const user = userEvent.setup();
615
675
  const onValueChange = vi.fn();
616
676
  render(
617
677
  <Combobox options={people} multiple defaultValue={['alice', 'bob']} onValueChange={onValueChange} />,
618
678
  );
619
679
  const input = screen.getByRole('combobox');
620
- input.focus();
680
+ await user.click(input);
621
681
 
622
- await userEvent.keyboard('{Backspace}');
682
+ await user.keyboard('{Backspace}');
623
683
 
624
684
  expect(onValueChange).toHaveBeenCalledWith(['alice']);
625
685
  });
@@ -695,7 +755,7 @@ describe('Combobox', () => {
695
755
 
696
756
  expect(onCreateNew).toHaveBeenCalledWith('Taylor');
697
757
  expect(onValueChange).toHaveBeenCalledWith(['new-taylor']);
698
- expect(screen.getByRole('listbox')).toBeInTheDocument();
758
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
699
759
  });
700
760
 
701
761
  test('allowCreate duplicate prevention selects existing option', async () => {
@@ -1017,131 +1077,141 @@ describe('Combobox', () => {
1017
1077
  });
1018
1078
 
1019
1079
  describe('chip keyboard navigation', () => {
1020
- test('ArrowLeft at caret 0 focuses the last chip', () => {
1080
+ test('ArrowLeft at caret 0 focuses the last chip', async () => {
1081
+ const user = userEvent.setup();
1021
1082
  render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
1022
1083
  const input = screen.getByRole('combobox');
1023
- input.focus();
1084
+ await user.click(input);
1024
1085
 
1025
- fireEvent.keyDown(input, { key: 'ArrowLeft' });
1086
+ await user.keyboard('{ArrowLeft}');
1026
1087
 
1027
1088
  const tags = document.querySelectorAll('.ds-tag');
1028
1089
  expect(tags[1]).toHaveClass('ds-tag--selected');
1029
1090
  expect(tags[0]).not.toHaveClass('ds-tag--selected');
1030
1091
  });
1031
1092
 
1032
- test('ArrowLeft navigates through chips from right to left', () => {
1093
+ test('ArrowLeft navigates through chips from right to left', async () => {
1094
+ const user = userEvent.setup();
1033
1095
  render(<Combobox options={people} multiple defaultValue={['alice', 'bob', 'charlie']} />);
1034
1096
  const input = screen.getByRole('combobox');
1035
- input.focus();
1097
+ await user.click(input);
1036
1098
 
1037
- fireEvent.keyDown(input, { key: 'ArrowLeft' });
1038
- fireEvent.keyDown(input, { key: 'ArrowLeft' });
1099
+ await user.keyboard('{ArrowLeft}');
1100
+ await user.keyboard('{ArrowLeft}');
1039
1101
 
1040
1102
  const tags = document.querySelectorAll('.ds-tag');
1041
1103
  expect(tags[1]).toHaveClass('ds-tag--selected');
1042
1104
  expect(tags[2]).not.toHaveClass('ds-tag--selected');
1043
1105
  });
1044
1106
 
1045
- test('ArrowLeft does not go past the first chip', () => {
1107
+ test('ArrowLeft does not go past the first chip', async () => {
1108
+ const user = userEvent.setup();
1046
1109
  render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
1047
1110
  const input = screen.getByRole('combobox');
1048
- input.focus();
1111
+ await user.click(input);
1049
1112
 
1050
- fireEvent.keyDown(input, { key: 'ArrowLeft' });
1051
- fireEvent.keyDown(input, { key: 'ArrowLeft' });
1052
- fireEvent.keyDown(input, { key: 'ArrowLeft' });
1113
+ await user.keyboard('{ArrowLeft}');
1114
+ await user.keyboard('{ArrowLeft}');
1115
+ await user.keyboard('{ArrowLeft}');
1053
1116
 
1054
1117
  const tags = document.querySelectorAll('.ds-tag');
1055
1118
  expect(tags[0]).toHaveClass('ds-tag--selected');
1056
1119
  });
1057
1120
 
1058
- test('ArrowRight past the last chip returns focus to input (exits chip nav)', () => {
1121
+ test('ArrowRight past the last chip returns focus to input (exits chip nav)', async () => {
1122
+ const user = userEvent.setup();
1059
1123
  render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
1060
1124
  const input = screen.getByRole('combobox');
1061
- input.focus();
1125
+ await user.click(input);
1062
1126
 
1063
- fireEvent.keyDown(input, { key: 'ArrowLeft' });
1127
+ await user.keyboard('{ArrowLeft}');
1064
1128
  expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(1);
1065
1129
 
1066
- fireEvent.keyDown(input, { key: 'ArrowRight' });
1130
+ await user.keyboard('{ArrowRight}');
1067
1131
  expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
1068
1132
  });
1069
1133
 
1070
- test('Backspace on focused chip removes it', () => {
1134
+ test('Backspace on focused chip removes it', async () => {
1135
+ const user = userEvent.setup();
1071
1136
  const onValueChange = vi.fn();
1072
1137
  render(
1073
1138
  <Combobox options={people} multiple defaultValue={['alice', 'bob', 'charlie']} onValueChange={onValueChange} />,
1074
1139
  );
1075
1140
  const input = screen.getByRole('combobox');
1076
- input.focus();
1141
+ await user.click(input);
1077
1142
 
1078
- fireEvent.keyDown(input, { key: 'ArrowLeft' });
1079
- fireEvent.keyDown(input, { key: 'ArrowLeft' });
1080
- fireEvent.keyDown(input, { key: 'Backspace' });
1143
+ await user.keyboard('{ArrowLeft}');
1144
+ await user.keyboard('{ArrowLeft}');
1145
+ await user.keyboard('{Backspace}');
1081
1146
 
1082
1147
  expect(onValueChange).toHaveBeenCalledWith(['alice', 'charlie']);
1083
1148
  });
1084
1149
 
1085
- test('Delete on focused chip removes it', () => {
1150
+ test('Delete on focused chip removes it', async () => {
1151
+ const user = userEvent.setup();
1086
1152
  const onValueChange = vi.fn();
1087
1153
  render(
1088
1154
  <Combobox options={people} multiple defaultValue={['alice', 'bob']} onValueChange={onValueChange} />,
1089
1155
  );
1090
1156
  const input = screen.getByRole('combobox');
1091
- input.focus();
1157
+ await user.click(input);
1092
1158
 
1093
- fireEvent.keyDown(input, { key: 'ArrowLeft' });
1094
- fireEvent.keyDown(input, { key: 'ArrowLeft' });
1095
- fireEvent.keyDown(input, { key: 'Delete' });
1159
+ await user.keyboard('{ArrowLeft}');
1160
+ await user.keyboard('{ArrowLeft}');
1161
+ await user.keyboard('{Delete}');
1096
1162
 
1097
1163
  expect(onValueChange).toHaveBeenCalledWith(['bob']);
1098
1164
  });
1099
1165
 
1100
- test('Backspace on first chip focuses next or exits', () => {
1166
+ test('Backspace on first chip focuses next or exits', async () => {
1167
+ const user = userEvent.setup();
1101
1168
  const onValueChange = vi.fn();
1102
1169
  render(
1103
1170
  <Combobox options={people} multiple defaultValue={['alice']} onValueChange={onValueChange} />,
1104
1171
  );
1105
1172
  const input = screen.getByRole('combobox');
1106
- input.focus();
1173
+ await user.click(input);
1107
1174
 
1108
- fireEvent.keyDown(input, { key: 'ArrowLeft' });
1109
- fireEvent.keyDown(input, { key: 'Backspace' });
1175
+ await user.keyboard('{ArrowLeft}');
1176
+ await user.keyboard('{Backspace}');
1110
1177
 
1111
1178
  expect(onValueChange).toHaveBeenCalledWith([]);
1112
1179
  expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
1113
1180
  });
1114
1181
 
1115
1182
  test('typing exits chip nav mode', async () => {
1183
+ const user = userEvent.setup();
1116
1184
  render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
1117
1185
  const input = screen.getByRole('combobox');
1118
- input.focus();
1186
+ await user.click(input);
1119
1187
 
1120
- fireEvent.keyDown(input, { key: 'ArrowLeft' });
1188
+ await user.keyboard('{ArrowLeft}');
1121
1189
  expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(1);
1122
1190
 
1123
- await userEvent.type(input, 'c');
1191
+ await user.type(input, 'c');
1124
1192
  expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
1125
1193
  });
1126
1194
 
1127
- test('Escape exits chip nav mode', () => {
1195
+ test('Escape exits chip nav mode', async () => {
1196
+ const user = userEvent.setup();
1128
1197
  render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
1129
1198
  const input = screen.getByRole('combobox');
1130
- input.focus();
1199
+ await user.click(input);
1131
1200
 
1132
- fireEvent.keyDown(input, { key: 'ArrowLeft' });
1201
+ await user.keyboard('{ArrowLeft}');
1133
1202
  expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(1);
1134
1203
 
1135
- fireEvent.keyDown(input, { key: 'Escape' });
1204
+ await user.keyboard('{Escape}');
1136
1205
  expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
1137
1206
  });
1138
1207
 
1139
- test('Cmd+A during chip nav enters bulk-select-all mode', () => {
1208
+ test('Cmd+A during chip nav enters bulk-select-all mode', async () => {
1209
+ const user = userEvent.setup();
1140
1210
  render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
1141
1211
  const input = screen.getByRole('combobox');
1142
- input.focus();
1212
+ await user.click(input);
1143
1213
 
1144
- fireEvent.keyDown(input, { key: 'ArrowLeft' });
1214
+ await user.keyboard('{ArrowLeft}');
1145
1215
  expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(1);
1146
1216
 
1147
1217
  fireEvent.keyDown(input, { key: 'a', metaKey: true });
@@ -30,7 +30,9 @@ const ComboboxRoot = (props: ComboboxProps): React.JSX.Element => {
30
30
  disabled = false,
31
31
  dropdownOnFocus = true,
32
32
  triggerVariant = 'input',
33
+ selectedValueDisplay = 'tags',
33
34
  showDropdownTrigger = true,
35
+ triggerEndContent,
34
36
  showSelectionCountBadge = triggerVariant === 'button' && multiple,
35
37
  selectionCountA11yLabel,
36
38
  loading = false,
@@ -94,6 +96,7 @@ const ComboboxRoot = (props: ComboboxProps): React.JSX.Element => {
94
96
  const { listboxRef, getOptionId, scrollHighlightedIntoView } = useComboboxListboxDom({
95
97
  comboboxId,
96
98
  });
99
+ const chipKeyboardEnabled = selectedValueDisplay === 'tags';
97
100
 
98
101
  const {
99
102
  selectedChipValues,
@@ -103,7 +106,9 @@ const ComboboxRoot = (props: ComboboxProps): React.JSX.Element => {
103
106
  focusedChipIndex,
104
107
  setFocusedChipIndex,
105
108
  exitChipNav,
106
- } = useChipSelection(selectedValues);
109
+ } = useChipSelection(chipKeyboardEnabled ? selectedValues : []);
110
+
111
+ const renderSearchInputInListbox = triggerVariant === 'button';
107
112
 
108
113
  const {
109
114
  shouldRenderPopoverContent,
@@ -136,6 +141,7 @@ const ComboboxRoot = (props: ComboboxProps): React.JSX.Element => {
136
141
  clearChipSelection,
137
142
  exitChipNav,
138
143
  triggerContainsInput: triggerVariant === 'input',
144
+ renderSearchInputInListbox,
139
145
  });
140
146
 
141
147
  const optionGroups = useMemo(() => buildOptionGroups(filteredOptions), [filteredOptions]);
@@ -220,7 +226,7 @@ const ComboboxRoot = (props: ComboboxProps): React.JSX.Element => {
220
226
  scrollHighlightedIntoView,
221
227
  selectOption,
222
228
  selectedChipValues,
223
- selectedValues,
229
+ selectedValues: chipKeyboardEnabled ? selectedValues : [],
224
230
  setFocusedChipIndex,
225
231
  setHighlightIndex,
226
232
  setIsOpen,
@@ -247,7 +253,6 @@ const ComboboxRoot = (props: ComboboxProps): React.JSX.Element => {
247
253
  const preventMouseDefault = useCallback((e: React.MouseEvent) => {
248
254
  e.preventDefault();
249
255
  }, []);
250
- const renderSearchInputInListbox = triggerVariant === 'button';
251
256
 
252
257
  const handlePopoverOpenAutoFocus = useCallback(
253
258
  (e: Event) => {
@@ -262,7 +267,7 @@ const ComboboxRoot = (props: ComboboxProps): React.JSX.Element => {
262
267
  );
263
268
 
264
269
  const resolvedSelectionCountA11yLabel = useMemo(() => {
265
- if (!showSelectionCountBadge || selectedValues.length === 0) {
270
+ if (!showSelectionCountBadge || !chipKeyboardEnabled || selectedValues.length === 0) {
266
271
  return undefined;
267
272
  }
268
273
  if (typeof selectionCountA11yLabel === 'function') {
@@ -272,7 +277,7 @@ const ComboboxRoot = (props: ComboboxProps): React.JSX.Element => {
272
277
  return selectionCountA11yLabel;
273
278
  }
274
279
  return `${selectedValues.length} selected item${selectedValues.length === 1 ? '' : 's'}`;
275
- }, [selectedValues.length, selectionCountA11yLabel, showSelectionCountBadge]);
280
+ }, [chipKeyboardEnabled, selectedValues.length, selectionCountA11yLabel, showSelectionCountBadge]);
276
281
 
277
282
  const handleButtonTriggerKeyDown = useCallback(
278
283
  (e: React.KeyboardEvent<HTMLDivElement>) => {
@@ -312,6 +317,8 @@ const ComboboxRoot = (props: ComboboxProps): React.JSX.Element => {
312
317
  aria-invalid={ariaInvalid}
313
318
  aria-label={ariaLabel}
314
319
  showDropdownTrigger={showDropdownTrigger}
320
+ selectedValueDisplay={selectedValueDisplay}
321
+ triggerEndContent={triggerEndContent}
315
322
  selectedChips={selectedChips}
316
323
  selectedChipValuesSet={selectedChipValuesSet}
317
324
  focusedChipIndex={focusedChipIndex}
@@ -337,6 +344,8 @@ const ComboboxRoot = (props: ComboboxProps): React.JSX.Element => {
337
344
  ariaDescribedBy={ariaDescribedBy}
338
345
  ariaInvalid={ariaInvalid}
339
346
  showDropdownTrigger={showDropdownTrigger}
347
+ selectedValueDisplay={selectedValueDisplay}
348
+ triggerEndContent={triggerEndContent}
340
349
  selectedChips={selectedChips}
341
350
  selectedChipValuesSet={selectedChipValuesSet}
342
351
  focusedChipIndex={focusedChipIndex}
@@ -345,7 +354,7 @@ const ComboboxRoot = (props: ComboboxProps): React.JSX.Element => {
345
354
  handleTriggerClick={handleTriggerClick}
346
355
  handleTriggerKeyDown={handleButtonTriggerKeyDown}
347
356
  handleChevronClick={handleChevronClick}
348
- showSelectionCountBadge={showSelectionCountBadge}
357
+ showSelectionCountBadge={showSelectionCountBadge && chipKeyboardEnabled}
349
358
  selectionCountA11yLabel={resolvedSelectionCountA11yLabel}
350
359
  />
351
360
  )}
@@ -4,7 +4,7 @@ import { Icon } from 'Components/icon/Icon';
4
4
  import { Tag } from 'Components/tag/Tag';
5
5
  import { Popover } from 'radix-ui';
6
6
  import { useMemo, useRef } from 'react';
7
- import type { ComboboxAriaInvalid, ComboboxOption } from './types';
7
+ import type { ComboboxAriaInvalid, ComboboxOption, ComboboxSelectedValueDisplay } from './types';
8
8
  import { useElementWidth } from './useElementWidth';
9
9
  import { useVisibleChips } from './useVisibleChips';
10
10
 
@@ -19,6 +19,8 @@ export type ComboboxButtonTriggerProps = {
19
19
  ariaDescribedBy?: string;
20
20
  ariaInvalid?: ComboboxAriaInvalid;
21
21
  showDropdownTrigger: boolean;
22
+ selectedValueDisplay: ComboboxSelectedValueDisplay;
23
+ triggerEndContent?: React.ReactNode;
22
24
  selectedChips: ComboboxOption[];
23
25
  selectedChipValuesSet: Set<string>;
24
26
  focusedChipIndex: number | null;
@@ -42,6 +44,8 @@ export const ComboboxButtonTrigger = ({
42
44
  ariaDescribedBy,
43
45
  ariaInvalid,
44
46
  showDropdownTrigger,
47
+ selectedValueDisplay,
48
+ triggerEndContent,
45
49
  selectedChips,
46
50
  selectedChipValuesSet,
47
51
  focusedChipIndex,
@@ -66,6 +70,11 @@ export const ComboboxButtonTrigger = ({
66
70
  [resolveTagLabel, selectedChips],
67
71
  );
68
72
 
73
+ const selectedValueText = useMemo(
74
+ () => selectedChips.map(resolveTagLabel).join(', '),
75
+ [resolveTagLabel, selectedChips],
76
+ );
77
+ const usesTagDisplay = selectedValueDisplay === 'tags';
69
78
  const shouldShowBadge = showSelectionCountBadge && selectedChips.length > 0;
70
79
 
71
80
  const contentWidth = useElementWidth(contentRef, `${chipWatchKey}-${isOpen}-${shouldShowBadge}`);
@@ -104,8 +113,8 @@ export const ComboboxButtonTrigger = ({
104
113
  const visibleChips = canMeasure
105
114
  ? layout.visibleChipIndices.map(index => selectedChips[index]!).filter(Boolean)
106
115
  : selectedChips;
107
- const showEllipsis = canMeasure ? layout.showEllipsis : false;
108
- const showBadge = canMeasure ? layout.showBadge : shouldShowBadge;
116
+ const showEllipsis = usesTagDisplay && (canMeasure ? layout.showEllipsis : false);
117
+ const showBadge = usesTagDisplay && (canMeasure ? layout.showBadge : shouldShowBadge);
109
118
 
110
119
  const renderSelectionTag = (opt: ComboboxOption, chipIdx: number, onRemove?: () => void) => (
111
120
  <Tag
@@ -148,18 +157,36 @@ export const ComboboxButtonTrigger = ({
148
157
  onKeyDown={handleTriggerKeyDown}
149
158
  >
150
159
  <div className="ds-combobox__button-content" ref={contentRef}>
151
- <div className="ds-combobox__button-tags-viewport">
152
- <div className="ds-combobox__button-tags-track">
153
- {selectedChips.length === 0 && (
154
- <span className="ds-combobox__button-placeholder">{placeholder}</span>
160
+ {usesTagDisplay
161
+ ? (
162
+ <div className="ds-combobox__button-tags-viewport">
163
+ <div className="ds-combobox__button-tags-track">
164
+ {selectedChips.length === 0 && (
165
+ <span className="ds-combobox__button-placeholder">{placeholder}</span>
166
+ )}
167
+ {visibleChips.map((opt, chipIdx) =>
168
+ renderSelectionTag(opt, chipIdx, disabled ? undefined : () => removeValue(opt.value)))}
169
+ </div>
170
+ {showEllipsis && <span className="ds-combobox__button-ellipsis" aria-hidden="true">…</span>}
171
+ </div>
172
+ )
173
+ : (
174
+ <span
175
+ className={classNames({
176
+ 'ds-combobox__button-placeholder': selectedValueText.length === 0,
177
+ 'ds-combobox__selected-value': selectedValueText.length > 0,
178
+ })}
179
+ >
180
+ {selectedValueText || placeholder}
181
+ </span>
155
182
  )}
156
- {visibleChips.map((opt, chipIdx) =>
157
- renderSelectionTag(opt, chipIdx, disabled ? undefined : () => removeValue(opt.value)))}
158
- </div>
159
- {showEllipsis && <span className="ds-combobox__button-ellipsis" aria-hidden="true">…</span>}
160
- </div>
161
183
  {showBadge && renderSelectionCountBadge(true)}
162
184
  </div>
185
+ {triggerEndContent && (
186
+ <span className="ds-combobox__end-content" aria-hidden="true">
187
+ {triggerEndContent}
188
+ </span>
189
+ )}
163
190
  {showDropdownTrigger && (
164
191
  <button
165
192
  type="button"
@@ -173,20 +200,22 @@ export const ComboboxButtonTrigger = ({
173
200
  </button>
174
201
  )}
175
202
 
176
- <div className="ds-combobox__measure" aria-hidden="true">
177
- {/* Mirror the rendered chips off-screen so width calculations use the real Tag layout. */}
178
- <div className="ds-combobox__button-tags-track" ref={measureTrackRef}>
179
- {selectedChips.map((opt, chipIdx) => (
180
- <span key={`measure-${opt.value}`} className="ds-combobox__measure-chip">
181
- {renderSelectionTag(opt, chipIdx, disabled ? undefined : () => {})}
182
- </span>
183
- ))}
203
+ {usesTagDisplay && (
204
+ <div className="ds-combobox__measure" aria-hidden="true">
205
+ {/* Mirror the rendered chips off-screen so width calculations use the real Tag layout. */}
206
+ <div className="ds-combobox__button-tags-track" ref={measureTrackRef}>
207
+ {selectedChips.map((opt, chipIdx) => (
208
+ <span key={`measure-${opt.value}`} className="ds-combobox__measure-chip">
209
+ {renderSelectionTag(opt, chipIdx, disabled ? undefined : () => {})}
210
+ </span>
211
+ ))}
212
+ </div>
213
+ <span ref={ellipsisProbeRef} className="ds-combobox__button-ellipsis">…</span>
214
+ <span ref={badgeProbeRef}>
215
+ {shouldShowBadge ? renderSelectionCountBadge(false) : null}
216
+ </span>
184
217
  </div>
185
- <span ref={ellipsisProbeRef} className="ds-combobox__button-ellipsis">…</span>
186
- <span ref={badgeProbeRef}>
187
- {shouldShowBadge ? renderSelectionCountBadge(false) : null}
188
- </span>
189
- </div>
218
+ )}
190
219
  </div>
191
220
  </Popover.Anchor>
192
221
  );