@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
@@ -0,0 +1,83 @@
1
+ import { useMemo, useRef } from 'react';
2
+ import { useMeasuredChildWidths } from 'Utils/hooks/useMeasuredChildWidths';
3
+ import { useElementWidth } from 'Utils/hooks/useElementWidth';
4
+ import type { TagListItem, TagListOverflowRenderArgs } from './TagList.js';
5
+ import { useVisibleTags } from './useVisibleTags.js';
6
+
7
+ type UseTagListCollapsedLayoutParams = {
8
+ items: TagListItem[];
9
+ wrap: boolean;
10
+ collapseOverflow: boolean;
11
+ };
12
+
13
+ type TagListCollapsedLayout = {
14
+ contentRef: React.RefObject<HTMLDivElement | null>;
15
+ measureTrackRef: React.RefObject<HTMLUListElement | null>;
16
+ overflowProbeRef: React.RefObject<HTMLDivElement | null>;
17
+ shouldCollapse: boolean;
18
+ visibleItemIndices: number[];
19
+ visibleItems: TagListItem[];
20
+ hiddenItemCount: number;
21
+ overflowArgs: TagListOverflowRenderArgs;
22
+ };
23
+
24
+ export const useTagListCollapsedLayout = ({
25
+ items,
26
+ wrap,
27
+ collapseOverflow,
28
+ }: UseTagListCollapsedLayoutParams): TagListCollapsedLayout => {
29
+ const contentRef = useRef<HTMLDivElement>(null);
30
+ const measureTrackRef = useRef<HTMLUListElement>(null);
31
+ const overflowProbeRef = useRef<HTMLDivElement>(null);
32
+
33
+ const shouldCollapse = collapseOverflow && !wrap && items.length > 0;
34
+ const itemWatchKey = useMemo(() => items.map(item => item.id).join('|'), [items]);
35
+
36
+ const contentWidth = useElementWidth(contentRef, itemWatchKey);
37
+ const overflowProbeWidth = useElementWidth(overflowProbeRef, `${itemWatchKey}-overflow`);
38
+ const { childGap: itemGap, childWidths: itemWidths } = useMeasuredChildWidths(measureTrackRef, itemWatchKey);
39
+
40
+ const layout = useVisibleTags({
41
+ containerWidth: contentWidth,
42
+ itemWidths,
43
+ itemGap,
44
+ overflowWidth: overflowProbeWidth,
45
+ allowOverflow: shouldCollapse,
46
+ });
47
+
48
+ const canMeasureCollapsedLayout = shouldCollapse
49
+ && contentWidth > 0
50
+ && itemWidths.length === items.length
51
+ && overflowProbeWidth > 0;
52
+
53
+ const visibleItemIndices = useMemo(
54
+ () =>
55
+ canMeasureCollapsedLayout
56
+ ? layout.visibleItemIndices
57
+ : items.map((_, index) => index),
58
+ [canMeasureCollapsedLayout, items, layout.visibleItemIndices],
59
+ );
60
+ const hiddenItemCount = canMeasureCollapsedLayout ? layout.hiddenItemCount : 0;
61
+ const visibleItems = visibleItemIndices
62
+ .map(index => items[index])
63
+ .filter((item): item is TagListItem => item != null);
64
+ const overflowArgs = useMemo(
65
+ (): TagListOverflowRenderArgs => ({
66
+ hiddenItemCount,
67
+ totalItemCount: items.length,
68
+ visibleItemCount: visibleItems.length,
69
+ }),
70
+ [hiddenItemCount, items.length, visibleItems.length],
71
+ );
72
+
73
+ return {
74
+ contentRef,
75
+ measureTrackRef,
76
+ overflowProbeRef,
77
+ shouldCollapse,
78
+ visibleItemIndices,
79
+ visibleItems,
80
+ hiddenItemCount,
81
+ overflowArgs,
82
+ };
83
+ };
@@ -0,0 +1,74 @@
1
+ import { useMemo } from 'react';
2
+ import { sumSpacedWidths } from 'Utils/spacedWidths';
3
+
4
+ type ComputeVisibleTagsLayoutParams = {
5
+ containerWidth: number;
6
+ itemWidths: number[];
7
+ itemGap: number;
8
+ overflowWidth: number;
9
+ allowOverflow: boolean;
10
+ safetyBuffer?: number;
11
+ };
12
+
13
+ export type VisibleTagsLayout = {
14
+ visibleItemIndices: number[];
15
+ hiddenItemCount: number;
16
+ hasOverflow: boolean;
17
+ };
18
+
19
+ export const computeVisibleTagsLayout = ({
20
+ containerWidth,
21
+ itemWidths,
22
+ itemGap,
23
+ overflowWidth,
24
+ allowOverflow,
25
+ safetyBuffer = 1,
26
+ }: ComputeVisibleTagsLayoutParams): VisibleTagsLayout => {
27
+ const totalItemCount = itemWidths.length;
28
+
29
+ if (totalItemCount === 0 || containerWidth <= 0) {
30
+ return {
31
+ visibleItemIndices: [],
32
+ hiddenItemCount: 0,
33
+ hasOverflow: false,
34
+ };
35
+ }
36
+
37
+ for (let visibleItemCount = totalItemCount; visibleItemCount >= 0; visibleItemCount -= 1) {
38
+ const hiddenItemCount = totalItemCount - visibleItemCount;
39
+ const hasOverflow = hiddenItemCount > 0;
40
+ const overflowReserve = hasOverflow && allowOverflow
41
+ ? overflowWidth + (visibleItemCount > 0 ? itemGap : 0)
42
+ : 0;
43
+ const requiredWidth = sumSpacedWidths(itemWidths, itemGap, visibleItemCount) + overflowReserve;
44
+
45
+ if (requiredWidth <= Math.max(0, containerWidth - safetyBuffer)) {
46
+ return {
47
+ visibleItemIndices: Array.from({ length: visibleItemCount }, (_, index) => index),
48
+ hiddenItemCount,
49
+ hasOverflow,
50
+ };
51
+ }
52
+ }
53
+
54
+ return {
55
+ visibleItemIndices: [],
56
+ hiddenItemCount: totalItemCount,
57
+ hasOverflow: allowOverflow,
58
+ };
59
+ };
60
+
61
+ type UseVisibleTagsParams = ComputeVisibleTagsLayoutParams;
62
+
63
+ export const useVisibleTags = (params: UseVisibleTagsParams): VisibleTagsLayout =>
64
+ useMemo(
65
+ () => computeVisibleTagsLayout(params),
66
+ [
67
+ params.allowOverflow,
68
+ params.containerWidth,
69
+ params.itemGap,
70
+ params.itemWidths,
71
+ params.overflowWidth,
72
+ params.safetyBuffer,
73
+ ],
74
+ );
package/src/index.scss CHANGED
@@ -3,7 +3,7 @@
3
3
  @use "./global.scss";
4
4
  @use "components/button/button.scss";
5
5
  @use "components/icon/icon.scss";
6
- @use "components/icoText/icoText.scss";
6
+ @use "components/iconText/iconText.scss";
7
7
  @use "components/heading/heading.scss";
8
8
  @use "components/card/card.scss";
9
9
  @use "components/articleCard/articleCard.scss";
@@ -19,6 +19,8 @@
19
19
  @use "components/formField/inputs/selectDropdown/selectDropdown";
20
20
  @use "components/formField/inputs/colourPickerDropdown/colourPickerDropdown.scss";
21
21
  @use "components/tag/tag.scss";
22
+ @use "components/tagList/tagList.scss";
23
+ @use "components/filterBar/filterBar.scss";
22
24
  @use "components/dot/dot.scss";
23
25
  @use "components/badge/badge.scss";
24
26
  @use "components/pill/pill.scss";
package/src/index.ts CHANGED
@@ -11,6 +11,16 @@ export { DateTimePicker } from 'Components/dateTimePicker/DateTimePicker';
11
11
  export { Dot } from 'Components/dot/Dot';
12
12
  export { Dropdown } from 'Components/dropdown/Dropdown';
13
13
  export { EditableText } from 'Components/editableText/EditableText';
14
+ export {
15
+ FilterBar,
16
+ type FilterBarActiveListProps,
17
+ type FilterBarButtonProps,
18
+ type FilterBarProps,
19
+ type FilterBarTagItem,
20
+ type FilterBarTagProps,
21
+ type FilterBarToolbarProps,
22
+ type FilterBarType,
23
+ } from 'Components/filterBar/FilterBar';
14
24
  export { Fieldset } from 'Components/formField/fieldset/Fieldset';
15
25
  export { FormField } from 'Components/formField/FormField';
16
26
  export { CheckboxGroup } from 'Components/formField/inputs/checkbox/CheckboxGroup';
@@ -25,7 +35,7 @@ export { TextArea } from 'Components/formField/inputs/textArea/TextArea';
25
35
  export { TimeInput } from 'Components/formField/inputs/time/TimeInput';
26
36
  export { Heading } from 'Components/heading/Heading';
27
37
  export { Icon } from 'Components/icon/Icon';
28
- export { IcoText, type IcoTextHeadingProps, type IcoTextIconProps, type IcoTextParagraphProps, type IcoTextProps } from 'Components/icoText/IcoText';
38
+ export { IconText, type IconTextHeadingProps, type IconTextIconProps, type IconTextParagraphProps, type IconTextProps } from 'Components/iconText/IconText';
29
39
  export { KPICard, type KPICardProps } from 'Components/kpiCard/KPICard';
30
40
  export { KVPList, type KVPListDefinitionProps, type KVPListProps, type KVPListRowProps, type KVPListTermProps } from 'Components/kvpList/KVPList';
31
41
  export { Modal } from 'Components/modal/Modal';
@@ -48,6 +58,8 @@ export { CheckboxCellRenderer } from 'Components/table/cellRenderers/CheckboxCel
48
58
  export { Table } from 'Components/table/Table';
49
59
  export { Tabs } from 'Components/tabs/Tabs';
50
60
  export { Tag } from 'Components/tag/Tag';
61
+ export { TagList } from 'Components/tagList/TagList';
62
+ export type { TagListItem, TagListOverflowRenderArgs, TagListOverflowTarget, TagListProps } from 'Components/tagList/TagList';
51
63
  export { Toast } from 'Components/toast/Toast';
52
64
  export { Toggle } from 'Components/toggle/Toggle';
53
65
  export { Tooltip } from 'Components/tooltip/Tooltip';
package/src/tokens.scss CHANGED
@@ -20,6 +20,7 @@
20
20
  --font-weight-medium: 500;
21
21
  --font-weight-regular: 400;
22
22
  --font-weight-semi-bold: 600;
23
+ --size-control-2xs: 1.75rem;
23
24
  --size-control-xsmall: 2rem;
24
25
  --size-control-small: 2.25rem;
25
26
  --size-control-medium: 3rem;
@@ -239,7 +240,7 @@
239
240
  --tag-spacing-gap-horizontal: var(--spacing-small);
240
241
  --tag-selected-color-background: var(--color-grey-200);
241
242
  --tag-selected-color-border: var(--color-grey-200);
242
- --tag-height: var(--size-control-xsmall);
243
+ --tag-height: var(--size-control-2xs);
243
244
  --card-focus-color-icon: var(--color-grey-900);
244
245
  --card-focus-color-text: var(--color-grey-900);
245
246
  --card-focus-color-border: var(--color-grey-100);
@@ -475,6 +476,7 @@
475
476
  --banner-spacing-vertical: var(--spacing-large);
476
477
  --banner-spacing-horizontal: var(--spacing-large);
477
478
  --button-small-radius: var(--border-radius-round);
479
+ --button-toolbar-radius: var(--border-radius-small);
478
480
  --button-small-primary-focus-color-icon: var(--color-mono-white);
479
481
  --button-small-primary-focus-color-text: var(--color-mono-white);
480
482
  --button-small-primary-focus-color-background: var(--color-brand-600);
@@ -21,7 +21,6 @@ export const useElementWidth = (
21
21
  }, [ref]);
22
22
 
23
23
  useLayoutEffect(() => {
24
- // Re-run measurement when callers know the element's contents/layout inputs changed.
25
24
  update();
26
25
  }, [update, watchKey]);
27
26
 
@@ -0,0 +1,39 @@
1
+ import type { RefObject } from 'react';
2
+ import { useElementWidth } from './useElementWidth.js';
3
+
4
+ type MeasuredChildWidths = {
5
+ childGap: number;
6
+ childWidths: number[];
7
+ };
8
+
9
+ const getTrackGap = (trackElement: HTMLElement | null): number => {
10
+ if (!trackElement) return 0;
11
+
12
+ const styles = getComputedStyle(trackElement);
13
+ const parsedGap = Number.parseFloat(styles.columnGap || styles.gap || '0');
14
+ return Number.isFinite(parsedGap) ? parsedGap : 0;
15
+ };
16
+
17
+ const getChildWidths = (trackElement: HTMLElement | null): number[] => {
18
+ if (!trackElement) return [];
19
+
20
+ return Array.from(trackElement.children).map((child) => {
21
+ const width = (child as HTMLElement).getBoundingClientRect().width;
22
+ return Number.isFinite(width) ? width : 0;
23
+ });
24
+ };
25
+
26
+ export const useMeasuredChildWidths = <T extends HTMLElement>(
27
+ trackRef: RefObject<T | null>,
28
+ watchKey: string,
29
+ ): MeasuredChildWidths => {
30
+ useElementWidth(trackRef as RefObject<HTMLElement | null>, watchKey);
31
+
32
+ const childGap = getTrackGap(trackRef.current);
33
+ const childWidths = getChildWidths(trackRef.current);
34
+
35
+ return {
36
+ childGap,
37
+ childWidths,
38
+ };
39
+ };
@@ -0,0 +1,105 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { useRef, useState } from 'react';
5
+ import { describe, expect, test } from 'vitest';
6
+ import { useRovingFocus } from './useRovingFocus.js';
7
+
8
+ type TestTarget = 'one' | 'two' | 'three';
9
+
10
+ const labels: Record<TestTarget, string> = {
11
+ one: 'One',
12
+ two: 'Two',
13
+ three: 'Three',
14
+ };
15
+
16
+ type RovingExampleProps = {
17
+ initialTargets?: TestTarget[];
18
+ returnFocusOnEmpty?: boolean;
19
+ };
20
+
21
+ const RovingExample = ({
22
+ initialTargets = ['one', 'two', 'three'],
23
+ returnFocusOnEmpty = false,
24
+ }: RovingExampleProps) => {
25
+ const [targets, setTargets] = useState<TestTarget[]>(initialTargets);
26
+ const returnFocusRef = useRef<HTMLButtonElement>(null);
27
+ const {
28
+ getTargetTabIndex,
29
+ handleTargetKeyDown,
30
+ registerTarget,
31
+ } = useRovingFocus<TestTarget>({
32
+ targets,
33
+ returnFocusRef,
34
+ onDeleteKey: (target) => {
35
+ setTargets(currentTargets => currentTargets.filter(currentTarget => currentTarget !== target));
36
+ return true;
37
+ },
38
+ });
39
+
40
+ return (
41
+ <>
42
+ {targets.map(target => (
43
+ <button
44
+ key={target}
45
+ ref={node => registerTarget(target, node)}
46
+ tabIndex={getTargetTabIndex(target)}
47
+ type="button"
48
+ onKeyDown={event => handleTargetKeyDown(event, target)}
49
+ >
50
+ {labels[target]}
51
+ </button>
52
+ ))}
53
+ {returnFocusOnEmpty && (
54
+ <button ref={returnFocusRef} type="button">
55
+ Return target
56
+ </button>
57
+ )}
58
+ </>
59
+ );
60
+ };
61
+
62
+ describe('useRovingFocus', () => {
63
+ test('moves focus through targets with arrow keys and Home/End', async () => {
64
+ const user = userEvent.setup();
65
+ render(<RovingExample />);
66
+
67
+ expect(screen.getByRole('button', { name: 'One' })).toHaveAttribute('tabindex', '0');
68
+ expect(screen.getByRole('button', { name: 'Two' })).toHaveAttribute('tabindex', '-1');
69
+
70
+ await user.tab();
71
+ expect(screen.getByRole('button', { name: 'One' })).toHaveFocus();
72
+
73
+ await user.keyboard('{ArrowRight}');
74
+ expect(screen.getByRole('button', { name: 'Two' })).toHaveFocus();
75
+ expect(screen.getByRole('button', { name: 'Two' })).toHaveAttribute('tabindex', '0');
76
+
77
+ await user.keyboard('{End}');
78
+ expect(screen.getByRole('button', { name: 'Three' })).toHaveFocus();
79
+
80
+ await user.keyboard('{Home}');
81
+ expect(screen.getByRole('button', { name: 'One' })).toHaveFocus();
82
+ });
83
+
84
+ test('recovers focus to the next target after deletion', async () => {
85
+ const user = userEvent.setup();
86
+ render(<RovingExample initialTargets={['one', 'two']} />);
87
+
88
+ await user.tab();
89
+ expect(screen.getByRole('button', { name: 'One' })).toHaveFocus();
90
+
91
+ await user.keyboard('{Delete}');
92
+ expect(screen.getByRole('button', { name: 'Two' })).toHaveFocus();
93
+ });
94
+
95
+ test('returns focus to the fallback element when all targets are removed', async () => {
96
+ const user = userEvent.setup();
97
+ render(<RovingExample initialTargets={['one']} returnFocusOnEmpty />);
98
+
99
+ await user.tab();
100
+ expect(screen.getByRole('button', { name: 'One' })).toHaveFocus();
101
+
102
+ await user.keyboard('{Delete}');
103
+ expect(screen.getByRole('button', { name: 'Return target' })).toHaveFocus();
104
+ });
105
+ });
@@ -0,0 +1,163 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react';
2
+
3
+ type PendingFocusRecovery = {
4
+ targetIndex: number;
5
+ };
6
+
7
+ type UseRovingFocusOptions<Target extends string> = {
8
+ targets: readonly Target[];
9
+ returnFocusRef?: RefObject<HTMLElement | null>;
10
+ onActiveTargetChange?: (target: Target | null) => void;
11
+ onDeleteKey?: (target: Target, event: React.KeyboardEvent<HTMLElement>) => boolean | void;
12
+ };
13
+
14
+ export const useRovingFocus = <Target extends string>({
15
+ targets,
16
+ returnFocusRef,
17
+ onActiveTargetChange,
18
+ onDeleteKey,
19
+ }: UseRovingFocusOptions<Target>) => {
20
+ const [activeTarget, setActiveTargetState] = useState<Target | null>(null);
21
+ const targetRefs = useRef(new Map<Target, HTMLElement>());
22
+ const pendingFocusRecoveryRef = useRef<PendingFocusRecovery | null>(null);
23
+ const returnFocusRefRef = useRef(returnFocusRef);
24
+
25
+ returnFocusRefRef.current = returnFocusRef;
26
+
27
+ const setActiveTarget = useCallback((target: Target | null) => {
28
+ setActiveTargetState(target);
29
+ onActiveTargetChange?.(target);
30
+ }, [onActiveTargetChange]);
31
+
32
+ const focusTarget = useCallback((target: Target) => {
33
+ targetRefs.current.get(target)?.focus();
34
+ }, []);
35
+
36
+ const moveFocus = useCallback((target: Target) => {
37
+ focusTarget(target);
38
+ setActiveTarget(target);
39
+ }, [focusTarget, setActiveTarget]);
40
+
41
+ const registerTarget = useCallback((target: Target, node: HTMLElement | null) => {
42
+ if (node) {
43
+ targetRefs.current.set(target, node);
44
+ }
45
+ else {
46
+ targetRefs.current.delete(target);
47
+ }
48
+ }, []);
49
+
50
+ const queueFocusRecovery = useCallback((target: Target) => {
51
+ const targetIndex = targets.indexOf(target);
52
+ if (targetIndex < 0) return;
53
+
54
+ pendingFocusRecoveryRef.current = { targetIndex };
55
+ }, [targets]);
56
+
57
+ useEffect(() => {
58
+ if (targets.length === 0) {
59
+ if (activeTarget !== null) {
60
+ setActiveTarget(null);
61
+ }
62
+ return;
63
+ }
64
+
65
+ if (activeTarget === null || !targets.includes(activeTarget)) {
66
+ setActiveTarget(targets[0] ?? null);
67
+ }
68
+ }, [activeTarget, setActiveTarget, targets]);
69
+
70
+ useLayoutEffect(() => {
71
+ const pendingFocusRecovery = pendingFocusRecoveryRef.current;
72
+ if (!pendingFocusRecovery) return;
73
+
74
+ pendingFocusRecoveryRef.current = null;
75
+
76
+ if (targets.length > 0) {
77
+ const nextTarget = targets[Math.min(pendingFocusRecovery.targetIndex, targets.length - 1)];
78
+
79
+ if (nextTarget !== undefined) {
80
+ moveFocus(nextTarget);
81
+ return;
82
+ }
83
+ }
84
+
85
+ returnFocusRef?.current?.focus();
86
+ setActiveTarget(null);
87
+ }, [moveFocus, returnFocusRef, setActiveTarget, targets]);
88
+
89
+ useLayoutEffect(() => () => {
90
+ if (!pendingFocusRecoveryRef.current) return;
91
+
92
+ pendingFocusRecoveryRef.current = null;
93
+ returnFocusRefRef.current?.current?.focus();
94
+ }, []);
95
+
96
+ const handleTargetKeyDown = useCallback((event: React.KeyboardEvent<HTMLElement>, target: Target) => {
97
+ const targetIndex = targets.indexOf(target);
98
+ if (targetIndex < 0) return;
99
+
100
+ switch (event.key) {
101
+ case 'ArrowLeft':
102
+ case 'ArrowUp': {
103
+ event.preventDefault();
104
+ const previousTarget = targets[Math.max(0, targetIndex - 1)];
105
+ if (previousTarget !== undefined) {
106
+ moveFocus(previousTarget);
107
+ }
108
+ break;
109
+ }
110
+ case 'ArrowRight':
111
+ case 'ArrowDown': {
112
+ event.preventDefault();
113
+ const nextTarget = targets[Math.min(targets.length - 1, targetIndex + 1)];
114
+ if (nextTarget !== undefined) {
115
+ moveFocus(nextTarget);
116
+ }
117
+ break;
118
+ }
119
+ case 'Home':
120
+ event.preventDefault();
121
+ if (targets[0] !== undefined) {
122
+ moveFocus(targets[0]);
123
+ }
124
+ break;
125
+ case 'End': {
126
+ event.preventDefault();
127
+ const lastTarget = targets[targets.length - 1];
128
+ if (lastTarget !== undefined) {
129
+ moveFocus(lastTarget);
130
+ }
131
+ break;
132
+ }
133
+ case 'Backspace':
134
+ case 'Delete': {
135
+ const shouldRecoverFocus = onDeleteKey?.(target, event);
136
+ if (shouldRecoverFocus) {
137
+ queueFocusRecovery(target);
138
+ }
139
+ break;
140
+ }
141
+ default:
142
+ break;
143
+ }
144
+ }, [moveFocus, onDeleteKey, queueFocusRecovery, targets]);
145
+
146
+ const getTargetTabIndex = useCallback((target: Target): 0 | -1 => {
147
+ if (activeTarget === null) {
148
+ return targets[0] === target ? 0 : -1;
149
+ }
150
+
151
+ return activeTarget === target ? 0 : -1;
152
+ }, [activeTarget, targets]);
153
+
154
+ return {
155
+ activeTarget,
156
+ focusTarget,
157
+ getTargetTabIndex,
158
+ handleTargetKeyDown,
159
+ queueFocusRecovery,
160
+ registerTarget,
161
+ setActiveTarget,
162
+ };
163
+ };
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { fitSpacedWidths, sumSpacedWidths } from './spacedWidths.js';
3
+
4
+ describe('spacedWidths', () => {
5
+ test('sums widths with gaps between visible items', () => {
6
+ expect(sumSpacedWidths([40, 50, 60], 8, 2)).toBe(98);
7
+ });
8
+
9
+ test('returns zero summed width when no items are visible', () => {
10
+ expect(sumSpacedWidths([40, 50, 60], 8, 0)).toBe(0);
11
+ });
12
+
13
+ test('fits the number of items that can be shown within the available width', () => {
14
+ expect(fitSpacedWidths(102, [40, 50, 60], 8)).toBe(2);
15
+ });
16
+
17
+ test('returns zero fitted items when no width is available', () => {
18
+ expect(fitSpacedWidths(0, [40, 50], 8)).toBe(0);
19
+ });
20
+ });
@@ -0,0 +1,37 @@
1
+ export const sumSpacedWidths = (
2
+ widths: number[],
3
+ gap: number,
4
+ visibleCount: number = widths.length,
5
+ ): number => {
6
+ const boundedCount = Math.max(0, Math.min(visibleCount, widths.length));
7
+ if (boundedCount === 0) return 0;
8
+
9
+ let usedWidth = 0;
10
+ for (let index = 0; index < boundedCount; index += 1) {
11
+ usedWidth += widths[index] ?? 0;
12
+ if (index > 0) {
13
+ usedWidth += gap;
14
+ }
15
+ }
16
+
17
+ return usedWidth;
18
+ };
19
+
20
+ export const fitSpacedWidths = (
21
+ availableWidth: number,
22
+ widths: number[],
23
+ gap: number,
24
+ ): number => {
25
+ if (availableWidth <= 0 || widths.length === 0) return 0;
26
+
27
+ let usedWidth = 0;
28
+ let visibleCount = 0;
29
+ for (let index = 0; index < widths.length; index += 1) {
30
+ const requiredWidth = (widths[index] ?? 0) + (index > 0 ? gap : 0);
31
+ if (usedWidth + requiredWidth > availableWidth) break;
32
+ usedWidth += requiredWidth;
33
+ visibleCount += 1;
34
+ }
35
+
36
+ return visibleCount;
37
+ };
@@ -1 +0,0 @@
1
- {"version":3,"file":"useElementWidth.d.ts","sourceRoot":"","sources":["../../../src/components/combobox/useElementWidth.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,eAAe,GAC1B,KAAK,KAAK,CAAC,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,EACxC,UAAU,MAAM,KACf,MAkCF,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"useElementWidth.js","sourceRoot":"","sources":["../../../src/components/combobox/useElementWidth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAE/D,MAAM,CAAC,MAAM,eAAe,GAAG,CAC7B,GAAwC,EACxC,QAAgB,EACR,EAAE;IACV,MAAM,CAAC,WAAW,EAAE,cAAc,CAAC,GAAG,QAAQ,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC,CAAC;IACjF,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,CAAC;IAE5B,MAAM,MAAM,GAAG,WAAW,CAAC,GAAG,EAAE;QAC9B,MAAM,cAAc,GAAG,GAAG,CAAC,OAAO,CAAC;QACnC,IAAI,CAAC,cAAc,EAAE,CAAC;YACpB,cAAc,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC;YAChD,OAAO;QACT,CAAC;QAED,cAAc,CAAC;YACb,KAAK,EAAE,cAAc,CAAC,qBAAqB,EAAE,CAAC,KAAK;YACnD,WAAW,EAAE,IAAI;SAClB,CAAC,CAAC;IACL,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAEV,eAAe,CAAC,GAAG,EAAE;QACnB,qFAAqF;QACrF,MAAM,EAAE,CAAC;IACX,CAAC,EAAE,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;IAEvB,eAAe,CAAC,GAAG,EAAE;QACnB,IAAI,CAAC,OAAO;YAAE,OAAO;QAErB,MAAM,QAAQ,GAAG,IAAI,cAAc,CAAC,MAAM,CAAC,CAAC;QAC5C,QAAQ,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAE1B,OAAO,GAAG,EAAE;YACV,QAAQ,CAAC,UAAU,EAAE,CAAC;QACxB,CAAC,CAAC;IACJ,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC;IAEtB,OAAO,WAAW,CAAC,KAAK,CAAC;AAC3B,CAAC,CAAC"}
@@ -1,21 +0,0 @@
1
- type ComputeTriggerLayoutModelParams = {
2
- containerWidth: number;
3
- chipWidths: number[];
4
- chipGap: number;
5
- badgeWidth: number;
6
- ellipsisWidth: number;
7
- showBadge: boolean;
8
- safetyBuffer?: number;
9
- };
10
- export type TriggerLayoutModel = {
11
- visibleChipIndices: number[];
12
- hiddenChipCount: number;
13
- showBadge: boolean;
14
- showEllipsis: boolean;
15
- hasOverflow: boolean;
16
- };
17
- export declare const computeTriggerLayoutModel: ({ containerWidth, chipWidths, chipGap, badgeWidth, ellipsisWidth, showBadge, safetyBuffer, }: ComputeTriggerLayoutModelParams) => TriggerLayoutModel;
18
- type UseVisibleChipsParams = ComputeTriggerLayoutModelParams;
19
- export declare const useVisibleChips: (params: UseVisibleChipsParams) => TriggerLayoutModel;
20
- export {};
21
- //# sourceMappingURL=useVisibleChips.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"useVisibleChips.d.ts","sourceRoot":"","sources":["../../../src/components/combobox/useVisibleChips.ts"],"names":[],"mappings":"AAEA,KAAK,+BAA+B,GAAG;IACrC,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,OAAO,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B,eAAe,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,OAAO,CAAC;IACnB,YAAY,EAAE,OAAO,CAAC;IACtB,WAAW,EAAE,OAAO,CAAC;CACtB,CAAC;AAoBF,eAAO,MAAM,yBAAyB,GAAI,8FAQvC,+BAA+B,KAAG,kBAmCpC,CAAC;AAEF,KAAK,qBAAqB,GAAG,+BAA+B,CAAC;AAE7D,eAAO,MAAM,eAAe,GAAI,QAAQ,qBAAqB,KAAG,kBAc7D,CAAC"}