@arbor-education/design-system.components 0.22.0 → 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 (83) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/component-library.md +62 -0
  3. package/dist/components/combobox/Combobox.js +1 -1
  4. package/dist/components/combobox/Combobox.js.map +1 -1
  5. package/dist/components/combobox/Combobox.stories.d.ts +4 -0
  6. package/dist/components/combobox/Combobox.stories.d.ts.map +1 -1
  7. package/dist/components/combobox/Combobox.stories.js +144 -12
  8. package/dist/components/combobox/Combobox.stories.js.map +1 -1
  9. package/dist/components/combobox/Combobox.test.js +22 -0
  10. package/dist/components/combobox/Combobox.test.js.map +1 -1
  11. package/dist/components/combobox/ComboboxButtonTrigger.d.ts +4 -4
  12. package/dist/components/combobox/ComboboxButtonTrigger.d.ts.map +1 -1
  13. package/dist/components/combobox/ComboboxButtonTrigger.js +35 -40
  14. package/dist/components/combobox/ComboboxButtonTrigger.js.map +1 -1
  15. package/dist/components/combobox/ComboboxTrigger.d.ts.map +1 -1
  16. package/dist/components/combobox/ComboboxTrigger.js +11 -4
  17. package/dist/components/combobox/ComboboxTrigger.js.map +1 -1
  18. package/dist/components/combobox/useVisibleTriggerTags.d.ts +21 -0
  19. package/dist/components/combobox/useVisibleTriggerTags.d.ts.map +1 -0
  20. package/dist/components/combobox/useVisibleTriggerTags.js +46 -0
  21. package/dist/components/combobox/useVisibleTriggerTags.js.map +1 -0
  22. package/dist/components/combobox/useVisibleTriggerTags.test.d.ts +2 -0
  23. package/dist/components/combobox/useVisibleTriggerTags.test.d.ts.map +1 -0
  24. package/dist/components/combobox/useVisibleTriggerTags.test.js +81 -0
  25. package/dist/components/combobox/useVisibleTriggerTags.test.js.map +1 -0
  26. package/dist/components/filterBar/FilterBar.d.ts +71 -0
  27. package/dist/components/filterBar/FilterBar.d.ts.map +1 -0
  28. package/dist/components/filterBar/FilterBar.js +89 -0
  29. package/dist/components/filterBar/FilterBar.js.map +1 -0
  30. package/dist/components/filterBar/FilterBar.stories.d.ts +170 -0
  31. package/dist/components/filterBar/FilterBar.stories.d.ts.map +1 -0
  32. package/dist/components/filterBar/FilterBar.stories.js +894 -0
  33. package/dist/components/filterBar/FilterBar.stories.js.map +1 -0
  34. package/dist/components/filterBar/FilterBar.test.d.ts +2 -0
  35. package/dist/components/filterBar/FilterBar.test.d.ts.map +1 -0
  36. package/dist/components/filterBar/FilterBar.test.js +164 -0
  37. package/dist/components/filterBar/FilterBar.test.js.map +1 -0
  38. package/dist/components/icon/allowedIcons.d.ts +1 -0
  39. package/dist/components/icon/allowedIcons.d.ts.map +1 -1
  40. package/dist/components/icon/allowedIcons.js +2 -1
  41. package/dist/components/icon/allowedIcons.js.map +1 -1
  42. package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.d.ts.map +1 -1
  43. package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.js +13 -2
  44. package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.js.map +1 -1
  45. package/dist/index.css +142 -3
  46. package/dist/index.css.map +1 -1
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +1 -0
  50. package/dist/index.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/components/combobox/Combobox.stories.tsx +186 -12
  53. package/src/components/combobox/Combobox.test.tsx +53 -0
  54. package/src/components/combobox/Combobox.tsx +3 -3
  55. package/src/components/combobox/ComboboxButtonTrigger.tsx +52 -56
  56. package/src/components/combobox/ComboboxTrigger.tsx +19 -16
  57. package/src/components/combobox/combobox.scss +8 -3
  58. package/src/components/combobox/useVisibleTriggerTags.test.tsx +91 -0
  59. package/src/components/combobox/useVisibleTriggerTags.ts +83 -0
  60. package/src/components/filterBar/FilterBar.stories.tsx +1199 -0
  61. package/src/components/filterBar/FilterBar.test.tsx +248 -0
  62. package/src/components/filterBar/FilterBar.tsx +298 -0
  63. package/src/components/filterBar/filterBar.scss +143 -0
  64. package/src/components/icon/allowedIcons.tsx +3 -1
  65. package/src/components/table/cellRenderers/ComboboxCellRenderer.test.tsx +20 -3
  66. package/src/index.scss +1 -0
  67. package/src/index.ts +10 -0
  68. package/src/tokens.scss +1 -0
  69. package/dist/components/combobox/useElementWidth.d.ts +0 -2
  70. package/dist/components/combobox/useElementWidth.d.ts.map +0 -1
  71. package/dist/components/combobox/useElementWidth.js +0 -31
  72. package/dist/components/combobox/useElementWidth.js.map +0 -1
  73. package/dist/components/combobox/useVisibleChips.d.ts +0 -21
  74. package/dist/components/combobox/useVisibleChips.d.ts.map +0 -1
  75. package/dist/components/combobox/useVisibleChips.js +0 -59
  76. package/dist/components/combobox/useVisibleChips.js.map +0 -1
  77. package/dist/components/combobox/useVisibleChips.test.d.ts +0 -2
  78. package/dist/components/combobox/useVisibleChips.test.d.ts.map +0 -1
  79. package/dist/components/combobox/useVisibleChips.test.js +0 -81
  80. package/dist/components/combobox/useVisibleChips.test.js.map +0 -1
  81. package/src/components/combobox/useElementWidth.ts +0 -40
  82. package/src/components/combobox/useVisibleChips.test.tsx +0 -91
  83. package/src/components/combobox/useVisibleChips.ts +0 -100
@@ -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;
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { computeVisibleTriggerTagsLayout } from './useVisibleTriggerTags.js';
3
+
4
+ describe('computeVisibleTriggerTagsLayout', () => {
5
+ test('shows all tags and no ellipsis when there is no overflow', () => {
6
+ const result = computeVisibleTriggerTagsLayout({
7
+ containerWidth: 300,
8
+ tagWidths: [70, 80, 60],
9
+ tagGap: 4,
10
+ badgeWidth: 24,
11
+ ellipsisWidth: 12,
12
+ showBadge: true,
13
+ });
14
+
15
+ expect(result).toEqual({
16
+ visibleTagIndices: [0, 1, 2],
17
+ hiddenTagCount: 0,
18
+ showBadge: true,
19
+ showEllipsis: false,
20
+ hasOverflow: false,
21
+ });
22
+ });
23
+
24
+ test('reserves badge and ellipsis before fitting tags when overflow exists', () => {
25
+ const result = computeVisibleTriggerTagsLayout({
26
+ containerWidth: 160,
27
+ tagWidths: [60, 60, 60],
28
+ tagGap: 4,
29
+ badgeWidth: 24,
30
+ ellipsisWidth: 12,
31
+ showBadge: true,
32
+ });
33
+
34
+ expect(result.visibleTagIndices).toEqual([0]);
35
+ expect(result.hiddenTagCount).toBe(2);
36
+ expect(result.showEllipsis).toBe(true);
37
+ expect(result.hasOverflow).toBe(true);
38
+ });
39
+
40
+ test('allows zero tags when badge and ellipsis take priority in tiny space', () => {
41
+ const result = computeVisibleTriggerTagsLayout({
42
+ containerWidth: 36,
43
+ tagWidths: [80, 80],
44
+ tagGap: 4,
45
+ badgeWidth: 24,
46
+ ellipsisWidth: 12,
47
+ showBadge: true,
48
+ });
49
+
50
+ expect(result.visibleTagIndices).toEqual([]);
51
+ expect(result.hiddenTagCount).toBe(2);
52
+ expect(result.showEllipsis).toBe(true);
53
+ });
54
+
55
+ test('reserving badge space reduces visible count', () => {
56
+ const withBadge = computeVisibleTriggerTagsLayout({
57
+ containerWidth: 180,
58
+ tagWidths: [60, 60, 60],
59
+ tagGap: 4,
60
+ badgeWidth: 24,
61
+ ellipsisWidth: 12,
62
+ showBadge: true,
63
+ });
64
+
65
+ const withoutBadge = computeVisibleTriggerTagsLayout({
66
+ containerWidth: 180,
67
+ tagWidths: [60, 60, 60],
68
+ tagGap: 4,
69
+ badgeWidth: 24,
70
+ ellipsisWidth: 12,
71
+ showBadge: false,
72
+ });
73
+
74
+ expect(withoutBadge.visibleTagIndices.length).toBeGreaterThanOrEqual(withBadge.visibleTagIndices.length);
75
+ });
76
+
77
+ test('uses safety buffer to avoid a clipped final tag on boundary widths', () => {
78
+ const result = computeVisibleTriggerTagsLayout({
79
+ containerWidth: 195.5,
80
+ tagWidths: [73.4, 73.4, 20],
81
+ tagGap: 4,
82
+ badgeWidth: 24.2,
83
+ ellipsisWidth: 11.6,
84
+ showBadge: true,
85
+ safetyBuffer: 1,
86
+ });
87
+
88
+ expect(result.visibleTagIndices).toEqual([0]);
89
+ expect(result.showEllipsis).toBe(true);
90
+ });
91
+ });
@@ -0,0 +1,83 @@
1
+ import { useMemo } from 'react';
2
+ import { fitSpacedWidths } from 'Utils/spacedWidths';
3
+
4
+ type ComputeVisibleTriggerTagsLayoutParams = {
5
+ containerWidth: number;
6
+ tagWidths: number[];
7
+ tagGap: number;
8
+ badgeWidth: number;
9
+ ellipsisWidth: number;
10
+ showBadge: boolean;
11
+ safetyBuffer?: number;
12
+ };
13
+
14
+ export type VisibleTriggerTagsLayout = {
15
+ visibleTagIndices: number[];
16
+ hiddenTagCount: number;
17
+ showBadge: boolean;
18
+ showEllipsis: boolean;
19
+ hasOverflow: boolean;
20
+ };
21
+
22
+ export const computeVisibleTriggerTagsLayout = ({
23
+ containerWidth,
24
+ tagWidths,
25
+ tagGap,
26
+ badgeWidth,
27
+ ellipsisWidth,
28
+ showBadge,
29
+ safetyBuffer = 1,
30
+ }: ComputeVisibleTriggerTagsLayoutParams): VisibleTriggerTagsLayout => {
31
+ const selectedCount = tagWidths.length;
32
+ const shouldShowBadge = showBadge && selectedCount > 0;
33
+
34
+ if (selectedCount === 0 || containerWidth <= 0) {
35
+ return {
36
+ visibleTagIndices: [],
37
+ hiddenTagCount: 0,
38
+ showBadge: shouldShowBadge,
39
+ showEllipsis: false,
40
+ hasOverflow: false,
41
+ };
42
+ }
43
+
44
+ const badgeReserve = shouldShowBadge
45
+ ? badgeWidth + tagGap
46
+ : 0;
47
+
48
+ const availableWithoutEllipsis = Math.max(0, containerWidth - badgeReserve - safetyBuffer);
49
+ const countWithoutEllipsis = fitSpacedWidths(availableWithoutEllipsis, tagWidths, tagGap);
50
+ const hasOverflow = countWithoutEllipsis < selectedCount;
51
+
52
+ const ellipsisReserve = hasOverflow
53
+ ? ellipsisWidth + tagGap
54
+ : 0;
55
+ const availableForTags = Math.max(0, containerWidth - badgeReserve - ellipsisReserve - safetyBuffer);
56
+ const visibleCount = fitSpacedWidths(availableForTags, tagWidths, tagGap);
57
+
58
+ return {
59
+ visibleTagIndices: Array.from({ length: visibleCount }, (_, index) => index),
60
+ hiddenTagCount: Math.max(0, selectedCount - visibleCount),
61
+ showBadge: shouldShowBadge,
62
+ showEllipsis: visibleCount < selectedCount,
63
+ hasOverflow: visibleCount < selectedCount,
64
+ };
65
+ };
66
+
67
+ type UseVisibleTriggerTagsParams = ComputeVisibleTriggerTagsLayoutParams;
68
+
69
+ export const useVisibleTriggerTags = (params: UseVisibleTriggerTagsParams): VisibleTriggerTagsLayout =>
70
+ // Deps list each field instead of `[params]`: callers often pass a new `params` object every
71
+ // render; depending on object identity would rerun the layout every time even when values are unchanged.
72
+ useMemo(
73
+ () => computeVisibleTriggerTagsLayout(params),
74
+ [
75
+ params.badgeWidth,
76
+ params.containerWidth,
77
+ params.ellipsisWidth,
78
+ params.safetyBuffer,
79
+ params.showBadge,
80
+ params.tagGap,
81
+ params.tagWidths,
82
+ ],
83
+ );