@arbor-education/design-system.components 0.21.1 → 0.23.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 (189) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/component-library.md +77 -14
  3. package/dist/components/articleCard/ArticleCard.d.ts +2 -2
  4. package/dist/components/articleCard/ArticleCard.d.ts.map +1 -1
  5. package/dist/components/articleCard/ArticleCard.js +3 -3
  6. package/dist/components/articleCard/ArticleCard.js.map +1 -1
  7. package/dist/components/articleCard/ArticleCard.stories.d.ts +11 -3
  8. package/dist/components/articleCard/ArticleCard.stories.d.ts.map +1 -1
  9. package/dist/components/articleCard/ArticleCard.stories.js +16 -11
  10. package/dist/components/articleCard/ArticleCard.stories.js.map +1 -1
  11. package/dist/components/combobox/Combobox.js +1 -1
  12. package/dist/components/combobox/Combobox.js.map +1 -1
  13. package/dist/components/combobox/Combobox.stories.d.ts +4 -0
  14. package/dist/components/combobox/Combobox.stories.d.ts.map +1 -1
  15. package/dist/components/combobox/Combobox.stories.js +144 -12
  16. package/dist/components/combobox/Combobox.stories.js.map +1 -1
  17. package/dist/components/combobox/Combobox.test.js +22 -0
  18. package/dist/components/combobox/Combobox.test.js.map +1 -1
  19. package/dist/components/combobox/ComboboxButtonTrigger.d.ts +4 -4
  20. package/dist/components/combobox/ComboboxButtonTrigger.d.ts.map +1 -1
  21. package/dist/components/combobox/ComboboxButtonTrigger.js +35 -40
  22. package/dist/components/combobox/ComboboxButtonTrigger.js.map +1 -1
  23. package/dist/components/combobox/ComboboxTrigger.d.ts.map +1 -1
  24. package/dist/components/combobox/ComboboxTrigger.js +11 -4
  25. package/dist/components/combobox/ComboboxTrigger.js.map +1 -1
  26. package/dist/components/combobox/useVisibleTriggerTags.d.ts +21 -0
  27. package/dist/components/combobox/useVisibleTriggerTags.d.ts.map +1 -0
  28. package/dist/components/combobox/useVisibleTriggerTags.js +46 -0
  29. package/dist/components/combobox/useVisibleTriggerTags.js.map +1 -0
  30. package/dist/components/combobox/useVisibleTriggerTags.test.d.ts +2 -0
  31. package/dist/components/combobox/useVisibleTriggerTags.test.d.ts.map +1 -0
  32. package/dist/components/combobox/useVisibleTriggerTags.test.js +81 -0
  33. package/dist/components/combobox/useVisibleTriggerTags.test.js.map +1 -0
  34. package/dist/components/filterBar/FilterBar.d.ts +71 -0
  35. package/dist/components/filterBar/FilterBar.d.ts.map +1 -0
  36. package/dist/components/filterBar/FilterBar.js +89 -0
  37. package/dist/components/filterBar/FilterBar.js.map +1 -0
  38. package/dist/components/filterBar/FilterBar.stories.d.ts +170 -0
  39. package/dist/components/filterBar/FilterBar.stories.d.ts.map +1 -0
  40. package/dist/components/filterBar/FilterBar.stories.js +894 -0
  41. package/dist/components/filterBar/FilterBar.stories.js.map +1 -0
  42. package/dist/components/filterBar/FilterBar.test.d.ts +2 -0
  43. package/dist/components/filterBar/FilterBar.test.d.ts.map +1 -0
  44. package/dist/components/filterBar/FilterBar.test.js +164 -0
  45. package/dist/components/filterBar/FilterBar.test.js.map +1 -0
  46. package/dist/components/icon/allowedIcons.d.ts +1 -0
  47. package/dist/components/icon/allowedIcons.d.ts.map +1 -1
  48. package/dist/components/icon/allowedIcons.js +2 -1
  49. package/dist/components/icon/allowedIcons.js.map +1 -1
  50. package/dist/components/iconText/IconText.d.ts +43 -0
  51. package/dist/components/iconText/IconText.d.ts.map +1 -0
  52. package/dist/components/iconText/IconText.js +29 -0
  53. package/dist/components/iconText/IconText.js.map +1 -0
  54. package/dist/components/{icoText/IcoText.stories.d.ts → iconText/IconText.stories.d.ts} +8 -9
  55. package/dist/components/iconText/IconText.stories.d.ts.map +1 -0
  56. package/dist/components/{icoText/IcoText.stories.js → iconText/IconText.stories.js} +81 -81
  57. package/dist/components/iconText/IconText.stories.js.map +1 -0
  58. package/dist/components/iconText/IconText.test.d.ts +2 -0
  59. package/dist/components/iconText/IconText.test.d.ts.map +1 -0
  60. package/dist/components/{icoText/IcoText.test.js → iconText/IconText.test.js} +6 -6
  61. package/dist/components/iconText/IconText.test.js.map +1 -0
  62. package/dist/components/modal/Modal.d.ts +1 -0
  63. package/dist/components/modal/Modal.d.ts.map +1 -1
  64. package/dist/components/modal/Modal.js +2 -2
  65. package/dist/components/modal/Modal.js.map +1 -1
  66. package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.d.ts.map +1 -1
  67. package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.js +13 -2
  68. package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.js.map +1 -1
  69. package/dist/components/tag/Tag.d.ts +14 -1
  70. package/dist/components/tag/Tag.d.ts.map +1 -1
  71. package/dist/components/tag/Tag.js +9 -3
  72. package/dist/components/tag/Tag.js.map +1 -1
  73. package/dist/components/tag/Tag.stories.d.ts +1 -1
  74. package/dist/components/tag/Tag.stories.d.ts.map +1 -1
  75. package/dist/components/tag/Tag.stories.js +3 -3
  76. package/dist/components/tag/Tag.stories.js.map +1 -1
  77. package/dist/components/tag/Tag.test.js +36 -5
  78. package/dist/components/tag/Tag.test.js.map +1 -1
  79. package/dist/components/tagList/TagList.d.ts +49 -0
  80. package/dist/components/tagList/TagList.d.ts.map +1 -0
  81. package/dist/components/tagList/TagList.js +114 -0
  82. package/dist/components/tagList/TagList.js.map +1 -0
  83. package/dist/components/tagList/TagList.stories.d.ts +130 -0
  84. package/dist/components/tagList/TagList.stories.d.ts.map +1 -0
  85. package/dist/components/tagList/TagList.stories.js +443 -0
  86. package/dist/components/tagList/TagList.stories.js.map +1 -0
  87. package/dist/components/{icoText/IcoText.test.d.ts → tagList/TagList.test.d.ts} +1 -1
  88. package/dist/components/tagList/TagList.test.d.ts.map +1 -0
  89. package/dist/components/tagList/TagList.test.js +246 -0
  90. package/dist/components/tagList/TagList.test.js.map +1 -0
  91. package/dist/components/tagList/useTagListCollapsedLayout.d.ts +19 -0
  92. package/dist/components/tagList/useTagListCollapsedLayout.d.ts.map +1 -0
  93. package/dist/components/tagList/useTagListCollapsedLayout.js +48 -0
  94. package/dist/components/tagList/useTagListCollapsedLayout.js.map +1 -0
  95. package/dist/components/tagList/useVisibleTags.d.ts +18 -0
  96. package/dist/components/tagList/useVisibleTags.d.ts.map +1 -0
  97. package/dist/components/tagList/useVisibleTags.js +41 -0
  98. package/dist/components/tagList/useVisibleTags.js.map +1 -0
  99. package/dist/index.css +272 -13
  100. package/dist/index.css.map +1 -1
  101. package/dist/index.d.ts +4 -1
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/index.js +3 -1
  104. package/dist/index.js.map +1 -1
  105. package/dist/utils/hooks/useElementWidth.d.ts.map +1 -0
  106. package/dist/{components/combobox → utils/hooks}/useElementWidth.js +0 -1
  107. package/dist/utils/hooks/useElementWidth.js.map +1 -0
  108. package/dist/utils/hooks/useMeasuredChildWidths.d.ts +8 -0
  109. package/dist/utils/hooks/useMeasuredChildWidths.d.ts.map +1 -0
  110. package/dist/utils/hooks/useMeasuredChildWidths.js +26 -0
  111. package/dist/utils/hooks/useMeasuredChildWidths.js.map +1 -0
  112. package/dist/utils/hooks/useRovingFocus.d.ts +18 -0
  113. package/dist/utils/hooks/useRovingFocus.d.ts.map +1 -0
  114. package/dist/utils/hooks/useRovingFocus.js +130 -0
  115. package/dist/utils/hooks/useRovingFocus.js.map +1 -0
  116. package/dist/utils/hooks/useRovingFocus.test.d.ts +2 -0
  117. package/dist/utils/hooks/useRovingFocus.test.d.ts.map +1 -0
  118. package/dist/utils/hooks/useRovingFocus.test.js +59 -0
  119. package/dist/utils/hooks/useRovingFocus.test.js.map +1 -0
  120. package/dist/utils/spacedWidths.d.ts +3 -0
  121. package/dist/utils/spacedWidths.d.ts.map +1 -0
  122. package/dist/utils/spacedWidths.js +28 -0
  123. package/dist/utils/spacedWidths.js.map +1 -0
  124. package/dist/utils/spacedWidths.test.d.ts +2 -0
  125. package/dist/utils/spacedWidths.test.d.ts.map +1 -0
  126. package/dist/utils/spacedWidths.test.js +17 -0
  127. package/dist/utils/spacedWidths.test.js.map +1 -0
  128. package/package.json +1 -1
  129. package/src/components/articleCard/ArticleCard.stories.tsx +17 -12
  130. package/src/components/articleCard/ArticleCard.tsx +9 -9
  131. package/src/components/combobox/Combobox.stories.tsx +186 -12
  132. package/src/components/combobox/Combobox.test.tsx +53 -0
  133. package/src/components/combobox/Combobox.tsx +3 -3
  134. package/src/components/combobox/ComboboxButtonTrigger.tsx +52 -56
  135. package/src/components/combobox/ComboboxTrigger.tsx +19 -16
  136. package/src/components/combobox/combobox.scss +8 -3
  137. package/src/components/combobox/useVisibleTriggerTags.test.tsx +91 -0
  138. package/src/components/combobox/useVisibleTriggerTags.ts +83 -0
  139. package/src/components/filterBar/FilterBar.stories.tsx +1199 -0
  140. package/src/components/filterBar/FilterBar.test.tsx +248 -0
  141. package/src/components/filterBar/FilterBar.tsx +298 -0
  142. package/src/components/filterBar/filterBar.scss +143 -0
  143. package/src/components/icon/allowedIcons.tsx +3 -1
  144. package/src/components/{icoText/IcoText.stories.tsx → iconText/IconText.stories.tsx} +112 -112
  145. package/src/components/{icoText/IcoText.test.tsx → iconText/IconText.test.tsx} +10 -10
  146. package/src/components/{icoText/IcoText.tsx → iconText/IconText.tsx} +27 -20
  147. package/src/components/modal/Modal.tsx +5 -1
  148. package/src/components/table/cellRenderers/ComboboxCellRenderer.test.tsx +20 -3
  149. package/src/components/tag/Tag.stories.tsx +4 -4
  150. package/src/components/tag/Tag.test.tsx +62 -5
  151. package/src/components/tag/Tag.tsx +61 -3
  152. package/src/components/tag/tag.scss +80 -9
  153. package/src/components/tagList/TagList.stories.tsx +564 -0
  154. package/src/components/tagList/TagList.test.tsx +342 -0
  155. package/src/components/tagList/TagList.tsx +296 -0
  156. package/src/components/tagList/tagList.scss +56 -0
  157. package/src/components/tagList/useTagListCollapsedLayout.ts +83 -0
  158. package/src/components/tagList/useVisibleTags.ts +74 -0
  159. package/src/index.scss +3 -1
  160. package/src/index.ts +13 -1
  161. package/src/tokens.scss +3 -1
  162. package/src/{components/combobox → utils/hooks}/useElementWidth.ts +0 -1
  163. package/src/utils/hooks/useMeasuredChildWidths.ts +39 -0
  164. package/src/utils/hooks/useRovingFocus.test.tsx +105 -0
  165. package/src/utils/hooks/useRovingFocus.ts +163 -0
  166. package/src/utils/spacedWidths.test.ts +20 -0
  167. package/src/utils/spacedWidths.ts +37 -0
  168. package/dist/components/combobox/useElementWidth.d.ts.map +0 -1
  169. package/dist/components/combobox/useElementWidth.js.map +0 -1
  170. package/dist/components/combobox/useVisibleChips.d.ts +0 -21
  171. package/dist/components/combobox/useVisibleChips.d.ts.map +0 -1
  172. package/dist/components/combobox/useVisibleChips.js +0 -59
  173. package/dist/components/combobox/useVisibleChips.js.map +0 -1
  174. package/dist/components/combobox/useVisibleChips.test.d.ts +0 -2
  175. package/dist/components/combobox/useVisibleChips.test.d.ts.map +0 -1
  176. package/dist/components/combobox/useVisibleChips.test.js +0 -81
  177. package/dist/components/combobox/useVisibleChips.test.js.map +0 -1
  178. package/dist/components/icoText/IcoText.d.ts +0 -37
  179. package/dist/components/icoText/IcoText.d.ts.map +0 -1
  180. package/dist/components/icoText/IcoText.js +0 -29
  181. package/dist/components/icoText/IcoText.js.map +0 -1
  182. package/dist/components/icoText/IcoText.stories.d.ts.map +0 -1
  183. package/dist/components/icoText/IcoText.stories.js.map +0 -1
  184. package/dist/components/icoText/IcoText.test.d.ts.map +0 -1
  185. package/dist/components/icoText/IcoText.test.js.map +0 -1
  186. package/src/components/combobox/useVisibleChips.test.tsx +0 -91
  187. package/src/components/combobox/useVisibleChips.ts +0 -100
  188. /package/dist/{components/combobox → utils/hooks}/useElementWidth.d.ts +0 -0
  189. /package/src/components/{icoText/icoText.scss → iconText/iconText.scss} +0 -0
@@ -114,6 +114,17 @@ The component does NOT debounce; add your own debounce inside \`onSearch\`.
114
114
 
115
115
  ---
116
116
 
117
+ ### Tag focus recovery
118
+
119
+ When \`selectedValueDisplay="tags"\`, selected chips are rendered through the shared
120
+ \`TagList\` interaction model. Removing the final chip returns focus to the natural
121
+ home control for that trigger:
122
+
123
+ - inline trigger: back to the combobox input
124
+ - button trigger: back to the trigger button
125
+
126
+ ---
127
+
117
128
  ### Grouped options
118
129
 
119
130
  Options with the same \`group\` string are rendered under a shared group heading.
@@ -394,7 +405,7 @@ export const SingleSelect = withDescription(
394
405
 
395
406
  // 4. MultiSelect
396
407
  export const MultiSelect = withDescription(
397
- 'Set `multiple` to allow multiple selections — each selected value is shown as a removable tag chip. Press Backspace to remove the last tag.',
408
+ 'Set `multiple` to allow multiple selections — each selected value is shown as a removable tag chip. Press Backspace to remove the last tag. When the final chip is removed, focus returns to the inline combobox input.',
398
409
  {
399
410
  args: {
400
411
  options: comboboxPeopleOptions,
@@ -591,7 +602,7 @@ export const ButtonTriggerSingleSelect = withDescription(
591
602
 
592
603
  // 13. ButtonTriggerMultiSelect
593
604
  export const ButtonTriggerMultiSelect = withDescription(
594
- 'Button trigger with `multiple` enabled. Use `showSelectionCountBadge` to show a badge with the number of selected items — useful when the trigger label should not grow in size.',
605
+ 'Button trigger with `multiple` enabled. Use `showSelectionCountBadge` to show a badge with the number of selected items — useful when the trigger label should not grow in size. When the trigger is showing tag chips, removing the final chip returns focus to the button trigger.',
595
606
  {
596
607
  args: {
597
608
  options: comboboxPeopleOptions,
@@ -610,7 +621,59 @@ export const ButtonTriggerMultiSelect = withDescription(
610
621
  },
611
622
  );
612
623
 
613
- // 14. ButtonTriggerPlainTextValue
624
+ // 14. FocusRecovery
625
+ export const FocusRecovery = withDescription(
626
+ 'A focused verification story for chip removal. Each example starts with one selected value so you can remove the final chip and confirm focus returns to the natural home control: the input for the inline trigger and the button for the button trigger.',
627
+ {
628
+ parameters: {
629
+ controls: { disable: true },
630
+ docs: {
631
+ source: {
632
+ code: `
633
+ <div style={{ display: 'grid', gap: 16 }}>
634
+ <Combobox
635
+ options={teacherOptions}
636
+ multiple
637
+ defaultValue={['alice-johnson']}
638
+ placeholder="Select teachers..."
639
+ />
640
+
641
+ <Combobox
642
+ options={teacherOptions}
643
+ triggerVariant="button"
644
+ multiple
645
+ defaultValue={['alice-johnson']}
646
+ placeholder="Filter by teacher"
647
+ showSelectionCountBadge
648
+ />
649
+ </div>
650
+ `.trim(),
651
+ },
652
+ },
653
+ },
654
+ render: () => (
655
+ <div style={{ display: 'grid', gap: 16 }}>
656
+ <Combobox
657
+ options={comboboxPeopleOptions}
658
+ multiple
659
+ defaultValue={['alice-johnson']}
660
+ placeholder="Select teachers..."
661
+ />
662
+
663
+ <Combobox
664
+ options={comboboxPeopleOptions}
665
+ triggerVariant="button"
666
+ multiple
667
+ defaultValue={['alice-johnson']}
668
+ placeholder="Filter by teacher"
669
+ showSelectionCountBadge
670
+ />
671
+ </div>
672
+ ),
673
+ },
674
+ );
675
+
676
+ // 15. ButtonTriggerPlainTextValue
614
677
  export const ButtonTriggerPlainTextValue = withDescription(
615
678
  '`selectedValueDisplay="text"` shows the selected value(s) as a comma-separated text string instead of tag chips — useful when space is limited inside a button trigger.',
616
679
  {
@@ -631,7 +694,118 @@ export const ButtonTriggerPlainTextValue = withDescription(
631
694
  },
632
695
  );
633
696
 
634
- // 15. HiddenTrigger
697
+ // 16. ButtonTriggerOverflow
698
+ export const ButtonTriggerOverflow = withDescription(
699
+ 'A dedicated single-line tag overflow example for the button trigger. The trigger width is constrained and multiple default selections are provided so the compact tag display stays on one line, shows the built-in ellipsis, and keeps the selected-count badge visible.',
700
+ {
701
+ args: {
702
+ options: comboboxPeopleOptions,
703
+ triggerVariant: 'button',
704
+ multiple: true,
705
+ defaultValue: ['alice-johnson', 'bob-smith', 'carol-white', 'daniel-clark'],
706
+ placeholder: 'Filter by teacher',
707
+ showSelectionCountBadge: true,
708
+ },
709
+ decorators: [
710
+ Story => (
711
+ <div style={{ width: 260 }}>
712
+ <Story />
713
+ </div>
714
+ ),
715
+ ],
716
+ parameters: {
717
+ docs: {
718
+ source: {
719
+ code: `
720
+ <div style={{ width: 260 }}>
721
+ <Combobox
722
+ options={teacherOptions}
723
+ triggerVariant="button"
724
+ multiple
725
+ defaultValue={['alice-johnson', 'bob-smith', 'carol-white', 'daniel-clark']}
726
+ placeholder="Filter by teacher"
727
+ showSelectionCountBadge
728
+ />
729
+ </div>
730
+ `.trim(),
731
+ },
732
+ },
733
+ },
734
+ },
735
+ );
736
+
737
+ // 17. ButtonTriggerSingleLineTagsWithCountBadge
738
+ export const ButtonTriggerSingleLineTagsWithCountBadge = withDescription(
739
+ 'A focused badge-in-trigger example for button-trigger multi-select. The trigger width is constrained so selected tags remain on a single line while the selected-count badge stays visible alongside the ellipsis.',
740
+ {
741
+ args: {
742
+ options: comboboxPeopleOptions,
743
+ triggerVariant: 'button',
744
+ multiple: true,
745
+ defaultValue: ['alice-johnson', 'bob-smith', 'carol-white', 'daniel-clark'],
746
+ placeholder: 'Filter by teacher',
747
+ showSelectionCountBadge: true,
748
+ },
749
+ decorators: [
750
+ Story => (
751
+ <div style={{ width: 260 }}>
752
+ <Story />
753
+ </div>
754
+ ),
755
+ ],
756
+ parameters: {
757
+ docs: {
758
+ source: {
759
+ code: `
760
+ <div style={{ width: 260 }}>
761
+ <Combobox
762
+ options={teacherOptions}
763
+ triggerVariant="button"
764
+ multiple
765
+ defaultValue={['alice-johnson', 'bob-smith', 'carol-white', 'daniel-clark']}
766
+ placeholder="Filter by teacher"
767
+ showSelectionCountBadge
768
+ />
769
+ </div>
770
+ `.trim(),
771
+ },
772
+ },
773
+ },
774
+ },
775
+ );
776
+
777
+ // 18. WithClearAll
778
+ export const WithClearAll = withDescription(
779
+ 'Shows the existing clear-all affordance below the combobox. This is useful when users need a single action to reset multiple selected values without removing chips one by one. Removing chips individually still follows the shared `TagList` focus recovery behavior.',
780
+ {
781
+ args: {
782
+ options: comboboxPeopleOptions,
783
+ multiple: true,
784
+ defaultValue: ['alice-johnson', 'bob-smith', 'carol-white'],
785
+ placeholder: 'Select teachers...',
786
+ showClearAll: true,
787
+ clearAllLabel: 'Clear all',
788
+ },
789
+ parameters: {
790
+ docs: {
791
+ source: {
792
+ code: `
793
+ <Combobox
794
+ options={teacherOptions}
795
+ multiple
796
+ defaultValue={['alice-johnson', 'bob-smith', 'carol-white']}
797
+ placeholder="Select teachers..."
798
+ showClearAll
799
+ clearAllLabel="Clear all"
800
+ />
801
+ `.trim(),
802
+ },
803
+ },
804
+ },
805
+ },
806
+ );
807
+
808
+ // 18. HiddenTrigger
635
809
  export const HiddenTrigger = withDescription(
636
810
  'Set `showDropdownTrigger={false}` to hide the chevron button at the end of the input. The dropdown can still be opened by typing or focusing the field.',
637
811
  {
@@ -650,7 +824,7 @@ export const HiddenTrigger = withDescription(
650
824
  },
651
825
  );
652
826
 
653
- // 16. ManualOpenOnFocus
827
+ // 18. ManualOpenOnFocus
654
828
  export const ManualOpenOnFocus = withDescription(
655
829
  'Set `dropdownOnFocus={false}` to prevent the dropdown from opening automatically on focus — the user must start typing or click the chevron to open it.',
656
830
  {
@@ -689,7 +863,7 @@ function AsyncSearchTemplate() {
689
863
  return <Combobox options={options} onSearch={handleSearch} placeholder="Search staff..." />;
690
864
  }
691
865
 
692
- // 17. AsyncSearch
866
+ // 19. AsyncSearch
693
867
  export const AsyncSearch = withDescription(
694
868
  'Pass `onSearch` to take control of filtering. The component disables all client-side matching — you update the `options` array yourself (e.g. via an API call). This example simulates a 300 ms network delay.',
695
869
  {
@@ -747,7 +921,7 @@ function CreateNewTemplate() {
747
921
  );
748
922
  }
749
923
 
750
- // 18. CreateNew
924
+ // 20. CreateNew
751
925
  export const CreateNew = withDescription(
752
926
  'Set `allowCreate` to show a "Create X" option when the typed query does not match any existing option. The `onCreateNew` callback receives the typed string and must return a new `ComboboxOption`.',
753
927
  {
@@ -806,7 +980,7 @@ function SingleSelectCreateTemplate() {
806
980
  );
807
981
  }
808
982
 
809
- // 19. SingleSelectCreate
983
+ // 21. SingleSelectCreate
810
984
  export const SingleSelectCreate = withDescription(
811
985
  '`allowCreate` works in single-select mode too — useful for fields like "Add a subject" where the user can either pick from existing values or type a new one.',
812
986
  {
@@ -843,7 +1017,7 @@ function SingleSelectCreateExample() {
843
1017
  },
844
1018
  );
845
1019
 
846
- // 20. CustomOptionLayout
1020
+ // 22. CustomOptionLayout
847
1021
  export const CustomOptionLayout = withDescription(
848
1022
  'Use `renderOption` to fully customise how each option is rendered inside the dropdown — useful for adding avatars, icons, or additional metadata alongside the label.',
849
1023
  {
@@ -880,7 +1054,7 @@ export const CustomOptionLayout = withDescription(
880
1054
  },
881
1055
  );
882
1056
 
883
- // 21. CustomTagLabel
1057
+ // 23. CustomTagLabel
884
1058
  export const CustomTagLabel = withDescription(
885
1059
  'Use `getTagLabel` to customise the label shown inside a selected tag chip — useful when the full option label is too long to display comfortably in the trigger.',
886
1060
  {
@@ -907,7 +1081,7 @@ export const CustomTagLabel = withDescription(
907
1081
  },
908
1082
  );
909
1083
 
910
- // 22. WithDisabledOptions
1084
+ // 24. WithDisabledOptions
911
1085
  export const WithDisabledOptions = withDescription(
912
1086
  'Individual options can be disabled by setting `disabled: true` on the option object. Disabled options are visible but cannot be selected.',
913
1087
  {
@@ -935,7 +1109,7 @@ const options = [
935
1109
  },
936
1110
  );
937
1111
 
938
- // 23. ScrollableLongList
1112
+ // 25. ScrollableLongList
939
1113
  export const ScrollableLongList = withDescription(
940
1114
  'The dropdown scrolls automatically when there are many options. Here we render 50 generated options to demonstrate the scrollable list and confirm search still works at scale.',
941
1115
  {
@@ -223,6 +223,19 @@ describe('Combobox', () => {
223
223
  expect(badge).toHaveTextContent('2');
224
224
  });
225
225
 
226
+ test('button trigger renders selected chips through TagList', () => {
227
+ const { container } = render(
228
+ <Combobox
229
+ options={people}
230
+ triggerVariant="button"
231
+ multiple
232
+ defaultValue={['alice', 'bob']}
233
+ />,
234
+ );
235
+
236
+ expect(container.querySelector('.ds-combobox__button-tags-viewport .ds-tag-list')).toBeInTheDocument();
237
+ });
238
+
226
239
  test('button trigger does not show ellipsis when all selected chips fit', () => {
227
240
  const rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function mockRect(this: HTMLElement) {
228
241
  let width = 0;
@@ -376,6 +389,14 @@ describe('Combobox', () => {
376
389
  expect(onValueChange).toHaveBeenLastCalledWith(['alice', 'bob']);
377
390
  });
378
391
 
392
+ test('input trigger renders selected chips through TagList', () => {
393
+ const { container } = render(
394
+ <Combobox options={people} multiple defaultValue={['alice', 'bob']} />,
395
+ );
396
+
397
+ expect(container.querySelector('.ds-combobox__chips-and-input .ds-tag-list')).toBeInTheDocument();
398
+ });
399
+
379
400
  test('Ctrl+A selects all chips when input is focused', async () => {
380
401
  const user = userEvent.setup();
381
402
  render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
@@ -470,6 +491,38 @@ describe('Combobox', () => {
470
491
  expect(onValueChange).toHaveBeenCalledWith(['bob']);
471
492
  });
472
493
 
494
+ test('removing the last chip in the input trigger returns focus to the combobox input', async () => {
495
+ render(
496
+ <Combobox options={people} multiple defaultValue={['alice']} placeholder="Search people..." />,
497
+ );
498
+
499
+ const aliceTag = screen.getByText('Alice Johnson').closest('.ds-tag') as HTMLElement;
500
+ const removeBtn = within(aliceTag).getByRole('button', { name: 'Remove Alice Johnson' });
501
+
502
+ await userEvent.click(removeBtn);
503
+
504
+ expect(screen.getByRole('combobox')).toHaveFocus();
505
+ });
506
+
507
+ test('removing the last chip in the button trigger returns focus to the parent trigger', async () => {
508
+ const { container } = render(
509
+ <Combobox
510
+ options={people}
511
+ triggerVariant="button"
512
+ multiple
513
+ defaultValue={['alice']}
514
+ placeholder="Filter by teacher"
515
+ />,
516
+ );
517
+
518
+ const visibleTagViewport = container.querySelector('.ds-combobox__button-tags-viewport') as HTMLElement;
519
+ const removeBtn = within(visibleTagViewport).getByRole('button', { name: 'Remove Alice Johnson' });
520
+
521
+ await userEvent.click(removeBtn);
522
+
523
+ expect(container.querySelector('.ds-combobox__trigger--button')).toHaveFocus();
524
+ });
525
+
473
526
  test('uses resolved tag labels in button trigger remove button labels', () => {
474
527
  render(
475
528
  <Combobox
@@ -346,9 +346,9 @@ const ComboboxRoot = (props: ComboboxProps): React.JSX.Element => {
346
346
  showDropdownTrigger={showDropdownTrigger}
347
347
  selectedValueDisplay={selectedValueDisplay}
348
348
  triggerEndContent={triggerEndContent}
349
- selectedChips={selectedChips}
350
- selectedChipValuesSet={selectedChipValuesSet}
351
- focusedChipIndex={focusedChipIndex}
349
+ selectedTags={selectedChips}
350
+ selectedTagValuesSet={selectedChipValuesSet}
351
+ focusedTagIndex={focusedChipIndex}
352
352
  resolveTagLabel={resolveTagLabel}
353
353
  removeValue={removeValue}
354
354
  handleTriggerClick={handleTriggerClick}
@@ -2,11 +2,13 @@ import classNames from 'classnames';
2
2
  import { Badge } from 'Components/badge/Badge';
3
3
  import { Icon } from 'Components/icon/Icon';
4
4
  import { Tag } from 'Components/tag/Tag';
5
+ import { TagList } from 'Components/tagList/TagList';
6
+ import { useMeasuredChildWidths } from 'Utils/hooks/useMeasuredChildWidths';
7
+ import { useElementWidth } from 'Utils/hooks/useElementWidth';
5
8
  import { Popover } from 'radix-ui';
6
9
  import { useMemo, useRef } from 'react';
7
10
  import type { ComboboxAriaInvalid, ComboboxOption, ComboboxSelectedValueDisplay } from './types.js';
8
- import { useElementWidth } from './useElementWidth.js';
9
- import { useVisibleChips } from './useVisibleChips.js';
11
+ import { useVisibleTriggerTags } from './useVisibleTriggerTags.js';
10
12
 
11
13
  export type ComboboxButtonTriggerProps = {
12
14
  triggerRef: React.RefObject<HTMLDivElement | null>;
@@ -21,9 +23,9 @@ export type ComboboxButtonTriggerProps = {
21
23
  showDropdownTrigger: boolean;
22
24
  selectedValueDisplay: ComboboxSelectedValueDisplay;
23
25
  triggerEndContent?: React.ReactNode;
24
- selectedChips: ComboboxOption[];
25
- selectedChipValuesSet: Set<string>;
26
- focusedChipIndex: number | null;
26
+ selectedTags: ComboboxOption[];
27
+ selectedTagValuesSet: Set<string>;
28
+ focusedTagIndex: number | null;
27
29
  resolveTagLabel: (opt: ComboboxOption) => string;
28
30
  removeValue: (value: string) => void;
29
31
  handleTriggerClick: () => void;
@@ -46,9 +48,9 @@ export const ComboboxButtonTrigger = ({
46
48
  showDropdownTrigger,
47
49
  selectedValueDisplay,
48
50
  triggerEndContent,
49
- selectedChips,
50
- selectedChipValuesSet,
51
- focusedChipIndex,
51
+ selectedTags,
52
+ selectedTagValuesSet,
53
+ focusedTagIndex,
52
54
  resolveTagLabel,
53
55
  removeValue,
54
56
  handleTriggerClick,
@@ -62,65 +64,47 @@ export const ComboboxButtonTrigger = ({
62
64
  const ellipsisProbeRef = useRef<HTMLSpanElement>(null);
63
65
  const badgeProbeRef = useRef<HTMLSpanElement>(null);
64
66
 
65
- const chipWatchKey = useMemo(
67
+ const tagWatchKey = useMemo(
66
68
  () =>
67
- selectedChips
69
+ selectedTags
68
70
  .map(opt => `${opt.value}:${resolveTagLabel(opt)}:${opt.iconName ?? ''}`)
69
71
  .join('|'),
70
- [resolveTagLabel, selectedChips],
72
+ [resolveTagLabel, selectedTags],
71
73
  );
72
74
 
73
75
  const selectedValueText = useMemo(
74
- () => selectedChips.map(resolveTagLabel).join(', '),
75
- [resolveTagLabel, selectedChips],
76
+ () => selectedTags.map(resolveTagLabel).join(', '),
77
+ [resolveTagLabel, selectedTags],
76
78
  );
77
79
  const usesTagDisplay = selectedValueDisplay === 'tags';
78
- const shouldShowBadge = showSelectionCountBadge && selectedChips.length > 0;
80
+ const shouldShowBadge = showSelectionCountBadge && selectedTags.length > 0;
79
81
 
80
- const contentWidth = useElementWidth(contentRef, `${chipWatchKey}-${isOpen}-${shouldShowBadge}`);
81
- const measurementTrackWidth = useElementWidth(measureTrackRef, `${chipWatchKey}-${isOpen}`);
82
- const ellipsisWidth = useElementWidth(ellipsisProbeRef, `${chipWatchKey}-${isOpen}`);
83
- const badgeWidth = useElementWidth(badgeProbeRef, `${chipWatchKey}-${isOpen}-${shouldShowBadge}`);
84
- const measurementTrackElement = measureTrackRef.current;
82
+ const contentWidth = useElementWidth(contentRef, `${tagWatchKey}-${isOpen}-${shouldShowBadge}`);
83
+ const ellipsisWidth = useElementWidth(ellipsisProbeRef, `${tagWatchKey}-${isOpen}`);
84
+ const badgeWidth = useElementWidth(badgeProbeRef, `${tagWatchKey}-${isOpen}-${shouldShowBadge}`);
85
+ const { childGap: tagGap, childWidths: tagWidths } = useMeasuredChildWidths(measureTrackRef, `${tagWatchKey}-${isOpen}`);
85
86
 
86
- const chipGap = useMemo(() => {
87
- const el = measureTrackRef.current;
88
- if (!el) return 0;
89
- const styles = getComputedStyle(el);
90
- const parsed = Number.parseFloat(styles.columnGap || styles.gap || '0');
91
- return Number.isFinite(parsed) ? parsed : 0;
92
- }, [measurementTrackElement, measurementTrackWidth]);
93
-
94
- const chipWidths = useMemo(() => {
95
- const el = measureTrackRef.current;
96
- if (!el) return [];
97
- return Array.from(el.children).map((child) => {
98
- const width = (child as HTMLElement).getBoundingClientRect().width;
99
- return Number.isFinite(width) ? width : 0;
100
- });
101
- }, [chipWatchKey, measurementTrackElement, measurementTrackWidth]);
102
-
103
- const layout = useVisibleChips({
87
+ const layout = useVisibleTriggerTags({
104
88
  containerWidth: contentWidth,
105
- chipWidths,
106
- chipGap,
89
+ tagWidths,
90
+ tagGap,
107
91
  badgeWidth,
108
92
  ellipsisWidth,
109
93
  showBadge: shouldShowBadge,
110
94
  });
111
95
 
112
- const canMeasure = contentWidth > 0 && chipWidths.length === selectedChips.length;
113
- const visibleChips = canMeasure
114
- ? layout.visibleChipIndices.map(index => selectedChips[index]!).filter(Boolean)
115
- : selectedChips;
96
+ const canMeasure = contentWidth > 0 && tagWidths.length === selectedTags.length;
97
+ const visibleTags = canMeasure
98
+ ? layout.visibleTagIndices.map(index => selectedTags[index]!).filter(Boolean)
99
+ : selectedTags;
116
100
  const showEllipsis = usesTagDisplay && (canMeasure ? layout.showEllipsis : false);
117
101
  const showBadge = usesTagDisplay && (canMeasure ? layout.showBadge : shouldShowBadge);
118
102
 
119
- const renderSelectionTag = (opt: ComboboxOption, chipIdx: number, onRemove?: () => void) => (
103
+ const renderSelectionTag = (opt: ComboboxOption, tagIdx: number, onRemove?: () => void) => (
120
104
  <Tag
121
105
  key={opt.value}
122
106
  color="neutral"
123
- selected={selectedChipValuesSet.has(opt.value) || focusedChipIndex === chipIdx}
107
+ selected={selectedTagValuesSet.has(opt.value) || focusedTagIndex === tagIdx}
124
108
  slotStart={opt.iconName ? <Icon name={opt.iconName} size={12} /> : undefined}
125
109
  onRemove={onRemove}
126
110
  removeLabel={`Remove ${resolveTagLabel(opt)}`}
@@ -132,10 +116,20 @@ export const ComboboxButtonTrigger = ({
132
116
 
133
117
  const renderSelectionCountBadge = (withA11yLabel: boolean) => (
134
118
  <Badge colour="salmon" a11yLabel={withA11yLabel ? selectionCountA11yLabel : undefined}>
135
- {selectedChips.length}
119
+ {selectedTags.length}
136
120
  </Badge>
137
121
  );
138
122
 
123
+ const visibleTagItems = visibleTags.map((opt, tagIdx) => ({
124
+ id: opt.value,
125
+ children: resolveTagLabel(opt),
126
+ color: 'neutral' as const,
127
+ selected: selectedTagValuesSet.has(opt.value) || focusedTagIndex === tagIdx,
128
+ slotStart: opt.iconName ? <Icon name={opt.iconName} size={12} /> : undefined,
129
+ onRemove: disabled ? undefined : () => removeValue(opt.value),
130
+ removeLabel: `Remove ${resolveTagLabel(opt)}`,
131
+ }));
132
+
139
133
  return (
140
134
  <Popover.Anchor asChild>
141
135
  <div
@@ -160,13 +154,15 @@ export const ComboboxButtonTrigger = ({
160
154
  {usesTagDisplay
161
155
  ? (
162
156
  <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>
157
+ {selectedTags.length === 0
158
+ ? <span className="ds-combobox__button-placeholder">{placeholder}</span>
159
+ : (
160
+ <TagList
161
+ items={visibleTagItems}
162
+ returnFocusRef={triggerRef}
163
+ className="ds-combobox__tag-list"
164
+ />
165
+ )}
170
166
  {showEllipsis && <span className="ds-combobox__button-ellipsis" aria-hidden="true">…</span>}
171
167
  </div>
172
168
  )
@@ -202,11 +198,11 @@ export const ComboboxButtonTrigger = ({
202
198
 
203
199
  {usesTagDisplay && (
204
200
  <div className="ds-combobox__measure" aria-hidden="true">
205
- {/* Mirror the rendered chips off-screen so width calculations use the real Tag layout. */}
201
+ {/* Mirror the rendered tags off-screen so width calculations use the real Tag layout. */}
206
202
  <div className="ds-combobox__button-tags-track" ref={measureTrackRef}>
207
- {selectedChips.map((opt, chipIdx) => (
203
+ {selectedTags.map((opt, tagIdx) => (
208
204
  <span key={`measure-${opt.value}`} className="ds-combobox__measure-chip">
209
- {renderSelectionTag(opt, chipIdx, disabled ? undefined : () => {})}
205
+ {renderSelectionTag(opt, tagIdx, disabled ? undefined : () => {})}
210
206
  </span>
211
207
  ))}
212
208
  </div>
@@ -1,6 +1,6 @@
1
1
  import classNames from 'classnames';
2
2
  import { Icon } from 'Components/icon/Icon';
3
- import { Tag } from 'Components/tag/Tag';
3
+ import { TagList } from 'Components/tagList/TagList';
4
4
  import { Popover } from 'radix-ui';
5
5
  import type { ComboboxAriaInvalid, ComboboxOption, ComboboxSelectedValueDisplay } from './types.js';
6
6
 
@@ -74,6 +74,15 @@ export const ComboboxTrigger = (props: ComboboxTriggerProps): React.JSX.Element
74
74
  = selectedValueDisplay === 'text'
75
75
  && query.length === 0
76
76
  && selectedValueText.length > 0;
77
+ const selectedTagItems = selectedChips.map(opt => ({
78
+ id: opt.value,
79
+ children: resolveTagLabel(opt),
80
+ color: 'neutral' as const,
81
+ selected: selectedChipValuesSet.has(opt.value),
82
+ slotStart: opt.iconName ? <Icon name={opt.iconName} size={12} /> : undefined,
83
+ onRemove: disabled ? undefined : () => removeValue(opt.value),
84
+ removeLabel: `Remove ${resolveTagLabel(opt)}`,
85
+ }));
77
86
 
78
87
  return (
79
88
  <Popover.Anchor asChild>
@@ -87,21 +96,15 @@ export const ComboboxTrigger = (props: ComboboxTriggerProps): React.JSX.Element
87
96
  onClick={handleTriggerClick}
88
97
  >
89
98
  <div className="ds-combobox__chips-and-input">
90
- {selectedValueDisplay === 'tags'
91
- ? selectedChips.map((opt, chipIdx) => (
92
- <Tag
93
- key={opt.value}
94
- color="neutral"
95
- selected={selectedChipValuesSet.has(opt.value) || focusedChipIndex === chipIdx}
96
- slotStart={opt.iconName ? <Icon name={opt.iconName} size={12} /> : undefined}
97
- onRemove={disabled ? undefined : () => removeValue(opt.value)}
98
- removeLabel={`Remove ${resolveTagLabel(opt)}`}
99
- removeButtonTabIndex={-1}
100
- >
101
- {resolveTagLabel(opt)}
102
- </Tag>
103
- ))
104
- : null}
99
+ {selectedValueDisplay === 'tags' && (
100
+ <TagList
101
+ items={selectedTagItems}
102
+ wrap
103
+ highlightedItemIndex={focusedChipIndex}
104
+ returnFocusRef={inputRef}
105
+ className="ds-combobox__tag-list"
106
+ />
107
+ )}
105
108
  {showSelectedValueText && (
106
109
  <span className="ds-combobox__selected-value">
107
110
  {selectedValueText}
@@ -9,16 +9,14 @@
9
9
  align-items: center;
10
10
  gap: var(--spacing-small);
11
11
  min-height: var(--form-field-text-medium-height);
12
- padding: var(--spacing-xsmall) var(--spacing-small);
12
+ padding: calc(var(--spacing-xsmall) - (var(--border-weight))) var(--spacing-small);
13
13
  border: var(--border-weight) solid var(--form-field-combobox-default-color-border);
14
14
  border-radius: var(--form-field-radius);
15
15
  background-color: var(--form-field-combobox-default-color-background);
16
16
  color: var(--form-field-combobox-default-color-text);
17
17
  cursor: text;
18
18
  transition: border-color 0.2s, box-shadow 0.2s, background-color 0.2s;
19
- box-sizing: border-box;
20
19
  font-style: normal;
21
- line-height: 150%;
22
20
 
23
21
  &:hover:not(.ds-combobox__trigger--disabled) {
24
22
  border-color: var(--form-field-combobox-hover-color-border);
@@ -70,11 +68,18 @@
70
68
  }
71
69
 
72
70
  .ds-combobox__button-tags-viewport {
71
+ display: flex;
72
+ align-items: center;
73
73
  flex: 1;
74
74
  min-width: 0;
75
75
  overflow: hidden;
76
76
  }
77
77
 
78
+ .ds-combobox__tag-list {
79
+ flex: 1;
80
+ min-width: 0;
81
+ }
82
+
78
83
  .ds-combobox__button-tags-track {
79
84
  display: inline-flex;
80
85
  width: auto;