@arbor-education/design-system.components 0.21.0 → 0.22.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 (127) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/component-library.md +15 -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/iconText/IconText.d.ts +43 -0
  12. package/dist/components/iconText/IconText.d.ts.map +1 -0
  13. package/dist/components/iconText/IconText.js +29 -0
  14. package/dist/components/iconText/IconText.js.map +1 -0
  15. package/dist/components/{icoText/IcoText.stories.d.ts → iconText/IconText.stories.d.ts} +8 -9
  16. package/dist/components/iconText/IconText.stories.d.ts.map +1 -0
  17. package/dist/components/{icoText/IcoText.stories.js → iconText/IconText.stories.js} +81 -81
  18. package/dist/components/iconText/IconText.stories.js.map +1 -0
  19. package/dist/components/iconText/IconText.test.d.ts +2 -0
  20. package/dist/components/iconText/IconText.test.d.ts.map +1 -0
  21. package/dist/components/{icoText/IcoText.test.js → iconText/IconText.test.js} +6 -6
  22. package/dist/components/iconText/IconText.test.js.map +1 -0
  23. package/dist/components/modal/Modal.d.ts +1 -0
  24. package/dist/components/modal/Modal.d.ts.map +1 -1
  25. package/dist/components/modal/Modal.js +2 -2
  26. package/dist/components/modal/Modal.js.map +1 -1
  27. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.js +2 -2
  28. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.js.map +1 -1
  29. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.test.js +6 -0
  30. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.test.js.map +1 -1
  31. package/dist/components/tag/Tag.d.ts +14 -1
  32. package/dist/components/tag/Tag.d.ts.map +1 -1
  33. package/dist/components/tag/Tag.js +9 -3
  34. package/dist/components/tag/Tag.js.map +1 -1
  35. package/dist/components/tag/Tag.stories.d.ts +1 -1
  36. package/dist/components/tag/Tag.stories.d.ts.map +1 -1
  37. package/dist/components/tag/Tag.stories.js +3 -3
  38. package/dist/components/tag/Tag.stories.js.map +1 -1
  39. package/dist/components/tag/Tag.test.js +36 -5
  40. package/dist/components/tag/Tag.test.js.map +1 -1
  41. package/dist/components/tagList/TagList.d.ts +49 -0
  42. package/dist/components/tagList/TagList.d.ts.map +1 -0
  43. package/dist/components/tagList/TagList.js +114 -0
  44. package/dist/components/tagList/TagList.js.map +1 -0
  45. package/dist/components/tagList/TagList.stories.d.ts +130 -0
  46. package/dist/components/tagList/TagList.stories.d.ts.map +1 -0
  47. package/dist/components/tagList/TagList.stories.js +443 -0
  48. package/dist/components/tagList/TagList.stories.js.map +1 -0
  49. package/dist/components/{icoText/IcoText.test.d.ts → tagList/TagList.test.d.ts} +1 -1
  50. package/dist/components/tagList/TagList.test.d.ts.map +1 -0
  51. package/dist/components/tagList/TagList.test.js +246 -0
  52. package/dist/components/tagList/TagList.test.js.map +1 -0
  53. package/dist/components/tagList/useTagListCollapsedLayout.d.ts +19 -0
  54. package/dist/components/tagList/useTagListCollapsedLayout.d.ts.map +1 -0
  55. package/dist/components/tagList/useTagListCollapsedLayout.js +48 -0
  56. package/dist/components/tagList/useTagListCollapsedLayout.js.map +1 -0
  57. package/dist/components/tagList/useVisibleTags.d.ts +18 -0
  58. package/dist/components/tagList/useVisibleTags.d.ts.map +1 -0
  59. package/dist/components/tagList/useVisibleTags.js +41 -0
  60. package/dist/components/tagList/useVisibleTags.js.map +1 -0
  61. package/dist/index.css +130 -10
  62. package/dist/index.css.map +1 -1
  63. package/dist/index.d.ts +3 -1
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +2 -1
  66. package/dist/index.js.map +1 -1
  67. package/dist/utils/hooks/useElementWidth.d.ts +2 -0
  68. package/dist/utils/hooks/useElementWidth.d.ts.map +1 -0
  69. package/dist/utils/hooks/useElementWidth.js +30 -0
  70. package/dist/utils/hooks/useElementWidth.js.map +1 -0
  71. package/dist/utils/hooks/useMeasuredChildWidths.d.ts +8 -0
  72. package/dist/utils/hooks/useMeasuredChildWidths.d.ts.map +1 -0
  73. package/dist/utils/hooks/useMeasuredChildWidths.js +26 -0
  74. package/dist/utils/hooks/useMeasuredChildWidths.js.map +1 -0
  75. package/dist/utils/hooks/useRovingFocus.d.ts +18 -0
  76. package/dist/utils/hooks/useRovingFocus.d.ts.map +1 -0
  77. package/dist/utils/hooks/useRovingFocus.js +130 -0
  78. package/dist/utils/hooks/useRovingFocus.js.map +1 -0
  79. package/dist/utils/hooks/useRovingFocus.test.d.ts +2 -0
  80. package/dist/utils/hooks/useRovingFocus.test.d.ts.map +1 -0
  81. package/dist/utils/hooks/useRovingFocus.test.js +59 -0
  82. package/dist/utils/hooks/useRovingFocus.test.js.map +1 -0
  83. package/dist/utils/spacedWidths.d.ts +3 -0
  84. package/dist/utils/spacedWidths.d.ts.map +1 -0
  85. package/dist/utils/spacedWidths.js +28 -0
  86. package/dist/utils/spacedWidths.js.map +1 -0
  87. package/dist/utils/spacedWidths.test.d.ts +2 -0
  88. package/dist/utils/spacedWidths.test.d.ts.map +1 -0
  89. package/dist/utils/spacedWidths.test.js +17 -0
  90. package/dist/utils/spacedWidths.test.js.map +1 -0
  91. package/package.json +1 -1
  92. package/src/components/articleCard/ArticleCard.stories.tsx +17 -12
  93. package/src/components/articleCard/ArticleCard.tsx +9 -9
  94. package/src/components/{icoText/IcoText.stories.tsx → iconText/IconText.stories.tsx} +112 -112
  95. package/src/components/{icoText/IcoText.test.tsx → iconText/IconText.test.tsx} +10 -10
  96. package/src/components/{icoText/IcoText.tsx → iconText/IconText.tsx} +27 -20
  97. package/src/components/modal/Modal.tsx +5 -1
  98. package/src/components/table/cellRenderers/SelectDropdownCellRenderer.test.tsx +12 -0
  99. package/src/components/table/cellRenderers/SelectDropdownCellRenderer.tsx +2 -2
  100. package/src/components/tag/Tag.stories.tsx +4 -4
  101. package/src/components/tag/Tag.test.tsx +62 -5
  102. package/src/components/tag/Tag.tsx +61 -3
  103. package/src/components/tag/tag.scss +80 -9
  104. package/src/components/tagList/TagList.stories.tsx +564 -0
  105. package/src/components/tagList/TagList.test.tsx +342 -0
  106. package/src/components/tagList/TagList.tsx +296 -0
  107. package/src/components/tagList/tagList.scss +56 -0
  108. package/src/components/tagList/useTagListCollapsedLayout.ts +83 -0
  109. package/src/components/tagList/useVisibleTags.ts +74 -0
  110. package/src/index.scss +2 -1
  111. package/src/index.ts +3 -1
  112. package/src/tokens.scss +2 -1
  113. package/src/utils/hooks/useElementWidth.ts +39 -0
  114. package/src/utils/hooks/useMeasuredChildWidths.ts +39 -0
  115. package/src/utils/hooks/useRovingFocus.test.tsx +105 -0
  116. package/src/utils/hooks/useRovingFocus.ts +163 -0
  117. package/src/utils/spacedWidths.test.ts +20 -0
  118. package/src/utils/spacedWidths.ts +37 -0
  119. package/dist/components/icoText/IcoText.d.ts +0 -37
  120. package/dist/components/icoText/IcoText.d.ts.map +0 -1
  121. package/dist/components/icoText/IcoText.js +0 -29
  122. package/dist/components/icoText/IcoText.js.map +0 -1
  123. package/dist/components/icoText/IcoText.stories.d.ts.map +0 -1
  124. package/dist/components/icoText/IcoText.stories.js.map +0 -1
  125. package/dist/components/icoText/IcoText.test.d.ts.map +0 -1
  126. package/dist/components/icoText/IcoText.test.js.map +0 -1
  127. /package/src/components/{icoText/icoText.scss → iconText/iconText.scss} +0 -0
@@ -0,0 +1,342 @@
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 { Icon } from 'Components/icon/Icon';
5
+ import { useRef, useState } from 'react';
6
+ import { describe, expect, test, vi } from 'vitest';
7
+ import { TagList } from './TagList.js';
8
+
9
+ const mockTagListCollapseRects = () =>
10
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function mockRect(this: HTMLElement) {
11
+ let width = 0;
12
+
13
+ if (this.classList.contains('ds-tag-list__viewport')) width = 220;
14
+ else if (this.classList.contains('ds-tag-list__item')) width = 72;
15
+ else if (this.classList.contains('ds-tag-list__overflow')) width = 76;
16
+
17
+ return {
18
+ width,
19
+ height: 0,
20
+ top: 0,
21
+ left: 0,
22
+ right: width,
23
+ bottom: 0,
24
+ x: 0,
25
+ y: 0,
26
+ toJSON() {
27
+ return {};
28
+ },
29
+ } as DOMRect;
30
+ });
31
+
32
+ describe('TagList', () => {
33
+ test('renders empty state when there are no items', () => {
34
+ render(<TagList items={[]} emptyState={<span>No filters set</span>} />);
35
+ expect(screen.getByText('No filters set')).toBeInTheDocument();
36
+ });
37
+
38
+ test('calls the item action when a tag is clicked', async () => {
39
+ const onClick = vi.fn();
40
+ render(<TagList items={[{ id: 'attendance', children: 'Attendance', onClick }]} />);
41
+
42
+ await userEvent.click(screen.getByRole('button', { name: 'Attendance' }));
43
+ expect(onClick).toHaveBeenCalledOnce();
44
+ });
45
+
46
+ test('forwards item-level aria-controls and aria-expanded to the tag action button', () => {
47
+ render(
48
+ <TagList
49
+ items={[
50
+ {
51
+ id: 'attendance',
52
+ children: 'Attendance',
53
+ onClick: vi.fn(),
54
+ ariaControls: 'filter-modal',
55
+ ariaExpanded: true,
56
+ },
57
+ ]}
58
+ />,
59
+ );
60
+
61
+ expect(screen.getByRole('button', { name: 'Attendance' })).toHaveAttribute('aria-controls', 'filter-modal');
62
+ expect(screen.getByRole('button', { name: 'Attendance' })).toHaveAttribute('aria-expanded', 'true');
63
+ });
64
+
65
+ test('moves roving focus between interactive tags with arrow keys', async () => {
66
+ const user = userEvent.setup();
67
+ render(
68
+ <TagList
69
+ items={[
70
+ { id: 'attendance', children: 'Attendance', onClick: vi.fn() },
71
+ { id: 'registers', children: 'Registers', onClick: vi.fn() },
72
+ { id: 'interventions', children: 'Interventions', onClick: vi.fn() },
73
+ ]}
74
+ />,
75
+ );
76
+
77
+ await user.tab();
78
+ expect(screen.getByRole('button', { name: 'Attendance' })).toHaveFocus();
79
+
80
+ await user.keyboard('{ArrowRight}');
81
+ expect(screen.getByRole('button', { name: 'Registers' })).toHaveFocus();
82
+
83
+ await user.keyboard('{End}');
84
+ expect(screen.getByRole('button', { name: 'Interventions' })).toHaveFocus();
85
+ });
86
+
87
+ test('ArrowRight moves from the tag action to its remove button, then to the next tag', async () => {
88
+ const user = userEvent.setup();
89
+ render(
90
+ <TagList
91
+ items={[
92
+ {
93
+ id: 'attendance',
94
+ children: 'Attendance',
95
+ onClick: vi.fn(),
96
+ onRemove: vi.fn(),
97
+ removeLabel: 'Remove Attendance',
98
+ },
99
+ {
100
+ id: 'registers',
101
+ children: 'Registers',
102
+ onClick: vi.fn(),
103
+ },
104
+ ]}
105
+ />,
106
+ );
107
+
108
+ await user.tab();
109
+ expect(screen.getByRole('button', { name: 'Attendance' })).toHaveFocus();
110
+ expect(screen.getByRole('button', { name: 'Remove Attendance' })).toHaveAttribute('tabindex', '-1');
111
+
112
+ await user.keyboard('{ArrowRight}');
113
+ expect(screen.getByRole('button', { name: 'Remove Attendance' })).toHaveFocus();
114
+ expect(screen.getByRole('button', { name: 'Remove Attendance' })).toHaveAttribute('tabindex', '0');
115
+
116
+ await user.keyboard('{ArrowRight}');
117
+ expect(screen.getByRole('button', { name: 'Registers' })).toHaveFocus();
118
+ });
119
+
120
+ test('ArrowLeft moves from the remove button back to the tag action', async () => {
121
+ const user = userEvent.setup();
122
+ render(
123
+ <TagList
124
+ items={[
125
+ {
126
+ id: 'attendance',
127
+ children: 'Attendance',
128
+ onClick: vi.fn(),
129
+ onRemove: vi.fn(),
130
+ removeLabel: 'Remove Attendance',
131
+ },
132
+ ]}
133
+ />,
134
+ );
135
+
136
+ await user.tab();
137
+ await user.keyboard('{ArrowRight}');
138
+ expect(screen.getByRole('button', { name: 'Remove Attendance' })).toHaveFocus();
139
+
140
+ await user.keyboard('{ArrowLeft}');
141
+ expect(screen.getByRole('button', { name: 'Attendance' })).toHaveFocus();
142
+ });
143
+
144
+ test('Delete triggers the focused tag remove callback', async () => {
145
+ const user = userEvent.setup();
146
+ const onRemove = vi.fn();
147
+ render(
148
+ <TagList
149
+ items={[
150
+ {
151
+ id: 'attendance',
152
+ children: 'Attendance',
153
+ slotStart: <Icon name="funnel" size={12} />,
154
+ onClick: vi.fn(),
155
+ onRemove,
156
+ removeLabel: 'Remove Attendance',
157
+ },
158
+ ]}
159
+ />,
160
+ );
161
+
162
+ await user.tab();
163
+ await user.keyboard('{Delete}');
164
+
165
+ expect(onRemove).toHaveBeenCalledOnce();
166
+ });
167
+
168
+ test('focuses the next available target after a removable tag is deleted', async () => {
169
+ const user = userEvent.setup();
170
+
171
+ const Example = () => {
172
+ const [itemIds, setItemIds] = useState(['attendance', 'registers']);
173
+ const items: TagList.Item[] = itemIds.map(itemId => ({
174
+ id: itemId,
175
+ children: itemId === 'attendance' ? 'Attendance' : 'Registers',
176
+ onRemove: () => setItemIds(current => current.filter(id => id !== itemId)),
177
+ removeLabel: itemId === 'attendance' ? 'Remove Attendance' : 'Remove Registers',
178
+ }));
179
+
180
+ return <TagList items={items} />;
181
+ };
182
+
183
+ render(<Example />);
184
+
185
+ await user.tab();
186
+ expect(screen.getByRole('button', { name: 'Remove Attendance' })).toHaveFocus();
187
+
188
+ await user.click(screen.getByRole('button', { name: 'Remove Attendance' }));
189
+ expect(screen.getByRole('button', { name: 'Remove Registers' })).toHaveFocus();
190
+ });
191
+
192
+ test('returns focus to the provided fallback element when the last removable tag is deleted', async () => {
193
+ const user = userEvent.setup();
194
+
195
+ const Example = () => {
196
+ const [items, setItems] = useState<TagList.Item[]>([
197
+ {
198
+ id: 'attendance',
199
+ children: 'Attendance',
200
+ onRemove: () => setItems([]),
201
+ removeLabel: 'Remove Attendance',
202
+ },
203
+ ]);
204
+ const returnFocusRef = useRef<HTMLButtonElement>(null);
205
+
206
+ return (
207
+ <>
208
+ <TagList items={items} returnFocusRef={returnFocusRef} />
209
+ <button ref={returnFocusRef} type="button">
210
+ Return home
211
+ </button>
212
+ </>
213
+ );
214
+ };
215
+
216
+ render(<Example />);
217
+
218
+ await user.tab();
219
+ expect(screen.getByRole('button', { name: 'Remove Attendance' })).toHaveFocus();
220
+
221
+ await user.click(screen.getByRole('button', { name: 'Remove Attendance' }));
222
+ expect(screen.getByRole('button', { name: 'Return home' })).toHaveFocus();
223
+ });
224
+
225
+ test('returns focus to the fallback element when the last removal unmounts TagList immediately', async () => {
226
+ const user = userEvent.setup();
227
+
228
+ const Example = () => {
229
+ const [items, setItems] = useState<TagList.Item[]>([
230
+ {
231
+ id: 'attendance',
232
+ children: 'Attendance',
233
+ onRemove: () => setItems([]),
234
+ removeLabel: 'Remove Attendance',
235
+ },
236
+ ]);
237
+ const returnFocusRef = useRef<HTMLButtonElement>(null);
238
+
239
+ return (
240
+ <>
241
+ {items.length > 0 && <TagList items={items} returnFocusRef={returnFocusRef} />}
242
+ <button ref={returnFocusRef} type="button">
243
+ Return home
244
+ </button>
245
+ </>
246
+ );
247
+ };
248
+
249
+ render(<Example />);
250
+
251
+ await user.tab();
252
+ expect(screen.getByRole('button', { name: 'Remove Attendance' })).toHaveFocus();
253
+
254
+ await user.click(screen.getByRole('button', { name: 'Remove Attendance' }));
255
+ expect(screen.getByRole('button', { name: 'Return home' })).toHaveFocus();
256
+ });
257
+
258
+ test('deleting a focused remove button in the composite model focuses the next available tag target', async () => {
259
+ const user = userEvent.setup();
260
+
261
+ const Example = () => {
262
+ const [itemIds, setItemIds] = useState(['attendance', 'registers']);
263
+ const items: TagList.Item[] = itemIds.map(itemId => ({
264
+ id: itemId,
265
+ children: itemId === 'attendance' ? 'Attendance' : 'Registers',
266
+ onClick: vi.fn(),
267
+ onRemove: itemId === 'attendance'
268
+ ? () => setItemIds(current => current.filter(id => id !== itemId))
269
+ : undefined,
270
+ removeLabel: itemId === 'attendance' ? 'Remove Attendance' : undefined,
271
+ }));
272
+
273
+ return <TagList items={items} />;
274
+ };
275
+
276
+ render(<Example />);
277
+
278
+ await user.tab();
279
+ expect(screen.getByRole('button', { name: 'Attendance' })).toHaveFocus();
280
+
281
+ await user.keyboard('{ArrowRight}');
282
+ expect(screen.getByRole('button', { name: 'Remove Attendance' })).toHaveFocus();
283
+
284
+ await user.keyboard('{Delete}');
285
+ expect(screen.getByRole('button', { name: 'Registers' })).toHaveFocus();
286
+ });
287
+
288
+ test('collapses overflowing items into a trailing summary', () => {
289
+ const rectSpy = mockTagListCollapseRects();
290
+
291
+ try {
292
+ render(
293
+ <TagList
294
+ collapseOverflow
295
+ items={[
296
+ { id: 'date', children: 'Wed, 05 Feb 2025 AM' },
297
+ { id: 'registers', children: 'Attendance registers' },
298
+ { id: 'interventions', children: 'Interventions' },
299
+ { id: 'exclusions', children: 'Internal exclusions' },
300
+ ]}
301
+ />,
302
+ );
303
+
304
+ expect(screen.getByText('+3 more')).toBeInTheDocument();
305
+ }
306
+ finally {
307
+ rectSpy.mockRestore();
308
+ }
309
+ });
310
+
311
+ test('makes the +N more summary focusable and clickable when an overflow action is provided', async () => {
312
+ const user = userEvent.setup();
313
+ const onOverflowClick = vi.fn();
314
+ const rectSpy = mockTagListCollapseRects();
315
+
316
+ try {
317
+ render(
318
+ <TagList
319
+ collapseOverflow
320
+ overflowOnClick={onOverflowClick}
321
+ overflowActionLabel="Show more filters"
322
+ items={[
323
+ { id: 'date', children: 'Wed, 05 Feb 2025 AM', onClick: vi.fn() },
324
+ { id: 'registers', children: 'Attendance registers', onClick: vi.fn() },
325
+ { id: 'interventions', children: 'Interventions', onClick: vi.fn() },
326
+ { id: 'exclusions', children: 'Internal exclusions', onClick: vi.fn() },
327
+ ]}
328
+ />,
329
+ );
330
+
331
+ await user.tab();
332
+ await user.keyboard('{ArrowRight}');
333
+ expect(screen.getByRole('button', { name: 'Show more filters' })).toHaveFocus();
334
+
335
+ await user.keyboard('{Enter}');
336
+ expect(onOverflowClick).toHaveBeenCalledOnce();
337
+ }
338
+ finally {
339
+ rectSpy.mockRestore();
340
+ }
341
+ });
342
+ });
@@ -0,0 +1,296 @@
1
+ import classNames from 'classnames';
2
+ import { Tag, type TagColor } from 'Components/tag/Tag';
3
+ import { useRovingFocus } from 'Utils/hooks/useRovingFocus';
4
+ import { useCallback, useMemo, type RefObject } from 'react';
5
+ import { useTagListCollapsedLayout } from './useTagListCollapsedLayout.js';
6
+
7
+ export type TagListItem = {
8
+ id: string;
9
+ children: React.ReactNode;
10
+ color?: TagColor;
11
+ selected?: boolean;
12
+ slotStart?: React.ReactNode;
13
+ slotEnd?: React.ReactNode;
14
+ onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
15
+ onRemove?: () => void;
16
+ removeLabel?: string;
17
+ actionLabel?: string;
18
+ disabled?: boolean;
19
+ ariaControls?: string;
20
+ ariaExpanded?: boolean;
21
+ };
22
+
23
+ export type TagListOverflowRenderArgs = {
24
+ hiddenItemCount: number;
25
+ totalItemCount: number;
26
+ visibleItemCount: number;
27
+ };
28
+
29
+ export type TagListOverflowTarget = 'overflow';
30
+ type TagListItemTarget = `action:${string}` | `remove:${string}`;
31
+ type TagListTarget = TagListItemTarget | TagListOverflowTarget;
32
+
33
+ const getActionTarget = (itemId: string): TagListItemTarget => `action:${itemId}`;
34
+ const getRemoveTarget = (itemId: string): TagListItemTarget => `remove:${itemId}`;
35
+ const getTargetItemId = (target: TagListItemTarget): string => target.slice(target.indexOf(':') + 1);
36
+
37
+ export type TagListProps = {
38
+ items: TagListItem[];
39
+ className?: string;
40
+ ariaLabel?: string;
41
+ emptyState?: React.ReactNode;
42
+ returnFocusRef?: RefObject<HTMLElement | null>;
43
+ wrap?: boolean;
44
+ collapseOverflow?: boolean;
45
+ highlightedItemIndex?: number | null;
46
+ onHighlightedItemIndexChange?: (index: number | null) => void;
47
+ overflowRenderer?: (args: TagListOverflowRenderArgs) => React.ReactNode;
48
+ overflowOnClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
49
+ overflowActionLabel?: string;
50
+ overflowDisabled?: boolean;
51
+ overflowColor?: TagColor;
52
+ overflowAriaControls?: string;
53
+ overflowAriaExpanded?: boolean;
54
+ };
55
+
56
+ const defaultOverflowRenderer = ({
57
+ hiddenItemCount,
58
+ }: TagListOverflowRenderArgs): React.ReactNode => `+${hiddenItemCount} more`;
59
+
60
+ export const TagList = ({
61
+ items,
62
+ className,
63
+ ariaLabel,
64
+ emptyState,
65
+ returnFocusRef,
66
+ wrap = false,
67
+ collapseOverflow = false,
68
+ highlightedItemIndex,
69
+ onHighlightedItemIndexChange,
70
+ overflowRenderer = defaultOverflowRenderer,
71
+ overflowOnClick,
72
+ overflowActionLabel,
73
+ overflowDisabled = false,
74
+ overflowColor = 'neutral',
75
+ overflowAriaControls,
76
+ overflowAriaExpanded,
77
+ }: TagListProps): React.JSX.Element | null => {
78
+ const {
79
+ contentRef,
80
+ measureTrackRef,
81
+ overflowProbeRef,
82
+ shouldCollapse,
83
+ visibleItemIndices,
84
+ visibleItems,
85
+ hiddenItemCount,
86
+ overflowArgs,
87
+ } = useTagListCollapsedLayout({
88
+ items,
89
+ wrap,
90
+ collapseOverflow,
91
+ });
92
+ const itemIndexById = useMemo(
93
+ () => new Map(items.map((item, index) => [item.id, index])),
94
+ [items],
95
+ );
96
+ const hasInteractiveOverflow = hiddenItemCount > 0 && Boolean(overflowOnClick) && !overflowDisabled;
97
+ const visibleInteractiveTargets = useMemo<TagListTarget[]>(
98
+ () =>
99
+ [
100
+ ...visibleItemIndices.flatMap((index) => {
101
+ const item = items[index];
102
+ if (!item) return [];
103
+
104
+ const targets: TagListTarget[] = [];
105
+ if (item.onClick) {
106
+ targets.push(getActionTarget(item.id));
107
+ }
108
+ if (item.onRemove) {
109
+ targets.push(getRemoveTarget(item.id));
110
+ }
111
+ return targets;
112
+ }),
113
+ ...(hasInteractiveOverflow ? ['overflow' as const] : []),
114
+ ],
115
+ [hasInteractiveOverflow, items, visibleItemIndices],
116
+ );
117
+ const hasInteractiveItems = visibleInteractiveTargets.length > 0;
118
+
119
+ const getHighlightedIndexForTarget = useCallback((target: TagListTarget | null): number | null => {
120
+ if (!target || target === 'overflow') return null;
121
+
122
+ return itemIndexById.get(getTargetItemId(target)) ?? null;
123
+ }, [itemIndexById]);
124
+
125
+ const handleActiveTargetChange = useCallback((target: TagListTarget | null) => {
126
+ onHighlightedItemIndexChange?.(getHighlightedIndexForTarget(target));
127
+ }, [getHighlightedIndexForTarget, onHighlightedItemIndexChange]);
128
+
129
+ const handleDeleteKey = useCallback((target: TagListTarget, event: React.KeyboardEvent<HTMLElement>): boolean => {
130
+ if (target === 'overflow') return false;
131
+
132
+ const item = items[getHighlightedIndexForTarget(target) ?? -1];
133
+ if (item?.onRemove) {
134
+ event.preventDefault();
135
+ item.onRemove();
136
+ return true;
137
+ }
138
+
139
+ return false;
140
+ }, [getHighlightedIndexForTarget, items]);
141
+
142
+ const {
143
+ getTargetTabIndex,
144
+ handleTargetKeyDown,
145
+ queueFocusRecovery,
146
+ registerTarget,
147
+ setActiveTarget,
148
+ } = useRovingFocus<TagListTarget>({
149
+ targets: hasInteractiveItems ? visibleInteractiveTargets : [],
150
+ returnFocusRef,
151
+ onActiveTargetChange: handleActiveTargetChange,
152
+ onDeleteKey: handleDeleteKey,
153
+ });
154
+
155
+ const renderTagItem = useCallback((item: TagListItem, index: number, includeInteractiveProps = true): React.ReactNode => {
156
+ const actionTarget = getActionTarget(item.id);
157
+ const removeTarget = getRemoveTarget(item.id);
158
+
159
+ return (
160
+ <Tag
161
+ color={item.color}
162
+ selected={item.selected || highlightedItemIndex === index}
163
+ slotStart={item.slotStart}
164
+ slotEnd={item.slotEnd}
165
+ onClick={includeInteractiveProps && item.onClick
166
+ ? (event) => {
167
+ setActiveTarget(actionTarget);
168
+ item.onClick?.(event);
169
+ }
170
+ : undefined}
171
+ onKeyDown={includeInteractiveProps && item.onClick ? event => handleTargetKeyDown(event, actionTarget) : undefined}
172
+ onFocus={includeInteractiveProps && item.onClick ? () => setActiveTarget(actionTarget) : undefined}
173
+ actionLabel={item.actionLabel}
174
+ actionButtonTabIndex={includeInteractiveProps && item.onClick ? getTargetTabIndex(actionTarget) : 0}
175
+ actionRef={includeInteractiveProps && item.onClick ? node => registerTarget(actionTarget, node) : undefined}
176
+ onRemove={includeInteractiveProps && item.onRemove
177
+ ? () => {
178
+ queueFocusRecovery(removeTarget);
179
+ item.onRemove?.();
180
+ }
181
+ : undefined}
182
+ onRemoveKeyDown={includeInteractiveProps && item.onRemove ? event => handleTargetKeyDown(event, removeTarget) : undefined}
183
+ onRemoveFocus={includeInteractiveProps && item.onRemove ? () => setActiveTarget(removeTarget) : undefined}
184
+ removeLabel={item.removeLabel}
185
+ removeButtonTabIndex={includeInteractiveProps && item.onRemove ? getTargetTabIndex(removeTarget) : 0}
186
+ removeButtonRef={includeInteractiveProps && item.onRemove ? node => registerTarget(removeTarget, node) : undefined}
187
+ disabled={item.disabled}
188
+ ariaControls={item.ariaControls}
189
+ ariaExpanded={item.ariaExpanded}
190
+ >
191
+ {item.children}
192
+ </Tag>
193
+ );
194
+ }, [
195
+ getTargetTabIndex,
196
+ handleTargetKeyDown,
197
+ highlightedItemIndex,
198
+ queueFocusRecovery,
199
+ registerTarget,
200
+ setActiveTarget,
201
+ ]);
202
+
203
+ const overflowContent = hiddenItemCount > 0
204
+ ? overflowRenderer(overflowArgs)
205
+ : null;
206
+
207
+ const overflowTag = overflowContent
208
+ ? (
209
+ <Tag
210
+ color={overflowColor}
211
+ onClick={overflowOnClick
212
+ ? (event) => {
213
+ setActiveTarget('overflow');
214
+ overflowOnClick(event);
215
+ }
216
+ : undefined}
217
+ onKeyDown={overflowOnClick ? event => handleTargetKeyDown(event, 'overflow') : undefined}
218
+ onFocus={overflowOnClick ? () => setActiveTarget('overflow') : undefined}
219
+ actionLabel={overflowActionLabel}
220
+ actionButtonTabIndex={overflowOnClick ? getTargetTabIndex('overflow') : 0}
221
+ actionRef={overflowOnClick ? node => registerTarget('overflow', node) : undefined}
222
+ disabled={overflowDisabled}
223
+ ariaControls={overflowAriaControls}
224
+ ariaExpanded={overflowAriaExpanded}
225
+ >
226
+ {overflowContent}
227
+ </Tag>
228
+ )
229
+ : null;
230
+
231
+ if (items.length === 0) {
232
+ if (!emptyState) return null;
233
+ return <div className={classNames('ds-tag-list', 'ds-tag-list--empty', className)}>{emptyState}</div>;
234
+ }
235
+
236
+ return (
237
+ <div className={classNames('ds-tag-list', className, {
238
+ 'ds-tag-list--wrap': wrap,
239
+ })}
240
+ >
241
+ <div
242
+ className={classNames('ds-tag-list__viewport', {
243
+ 'ds-tag-list__viewport--clip': shouldCollapse,
244
+ })}
245
+ ref={contentRef}
246
+ >
247
+ <ul
248
+ className={classNames('ds-tag-list__list', {
249
+ 'ds-tag-list__list--wrap': wrap,
250
+ })}
251
+ aria-label={ariaLabel}
252
+ >
253
+ {visibleItems.map((item, visibleIndex) => {
254
+ const actualIndex = visibleItemIndices[visibleIndex]!;
255
+ return (
256
+ <li key={item.id} className="ds-tag-list__item">
257
+ {renderTagItem(item, actualIndex)}
258
+ </li>
259
+ );
260
+ })}
261
+ </ul>
262
+ {overflowTag && (
263
+ <div className="ds-tag-list__overflow">
264
+ {overflowTag}
265
+ </div>
266
+ )}
267
+ </div>
268
+
269
+ {shouldCollapse && (
270
+ <div className="ds-tag-list__measure" aria-hidden="true">
271
+ <ul className="ds-tag-list__list" ref={measureTrackRef}>
272
+ {items.map((item, index) => (
273
+ <li key={`measure-${item.id}`} className="ds-tag-list__item">
274
+ {renderTagItem(item, index, false)}
275
+ </li>
276
+ ))}
277
+ </ul>
278
+ <div ref={overflowProbeRef} className="ds-tag-list__overflow">
279
+ {overflowRenderer({
280
+ hiddenItemCount: items.length,
281
+ totalItemCount: items.length,
282
+ visibleItemCount: 0,
283
+ })}
284
+ </div>
285
+ </div>
286
+ )}
287
+ </div>
288
+ );
289
+ };
290
+
291
+ export namespace TagList {
292
+ export type Props = TagListProps;
293
+ export type Item = TagListItem;
294
+ export type OverflowRenderArgs = TagListOverflowRenderArgs;
295
+ export type OverflowTarget = TagListOverflowTarget;
296
+ }
@@ -0,0 +1,56 @@
1
+ .ds-tag-list {
2
+ min-width: 0;
3
+ }
4
+
5
+ .ds-tag-list--empty {
6
+ min-width: 0;
7
+ }
8
+
9
+ .ds-tag-list__viewport {
10
+ display: flex;
11
+ align-items: center;
12
+ gap: var(--spacing-xsmall);
13
+ min-width: 0;
14
+ padding-block: var(--focus-border);
15
+ margin-block: calc(var(--focus-border) * -1);
16
+ }
17
+
18
+ .ds-tag-list__viewport--clip {
19
+ overflow-x: hidden;
20
+ }
21
+
22
+ .ds-tag-list__list {
23
+ display: inline-flex;
24
+ align-items: center;
25
+ gap: var(--spacing-xsmall);
26
+ min-width: 0;
27
+ margin: 0;
28
+ padding: 0;
29
+ list-style: none;
30
+ }
31
+
32
+ .ds-tag-list__list--wrap {
33
+ flex-wrap: wrap;
34
+ }
35
+
36
+ .ds-tag-list__item {
37
+ min-width: 0;
38
+ }
39
+
40
+ .ds-tag-list__overflow {
41
+ display: inline-flex;
42
+ align-items: center;
43
+ flex-shrink: 0;
44
+ }
45
+
46
+ .ds-tag-list__measure {
47
+ position: absolute;
48
+ visibility: hidden;
49
+ pointer-events: none;
50
+ left: -9999px;
51
+ top: -9999px;
52
+ white-space: nowrap;
53
+ width: max-content;
54
+ height: 0;
55
+ overflow: hidden;
56
+ }