@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,248 @@
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, vi } from 'vitest';
6
+ import { FilterBar } from './FilterBar.js';
7
+
8
+ type FilterBarNamespaceCheck = [
9
+ FilterBar.Props,
10
+ FilterBar.ToolbarProps,
11
+ FilterBar.ButtonProps,
12
+ FilterBar.ActiveListProps,
13
+ FilterBar.TagProps,
14
+ FilterBar.TagItem,
15
+ ];
16
+ const filterBarNamespaceCheck: FilterBarNamespaceCheck | undefined = undefined;
17
+ void filterBarNamespaceCheck;
18
+
19
+ describe('FilterBar', () => {
20
+ test('renders toolbar buttons and empty state', () => {
21
+ const { container } = render(
22
+ <FilterBar aria-label="Attendance controls">
23
+ <FilterBar.Toolbar aria-label="Filter, sort, and group controls">
24
+ <FilterBar.Button type="filter" />
25
+ <FilterBar.Button type="sort" />
26
+ <FilterBar.Button type="group" />
27
+ </FilterBar.Toolbar>
28
+ <FilterBar.ActiveList emptyState="No filters applied" />
29
+ </FilterBar>,
30
+ );
31
+
32
+ expect(screen.getByRole('toolbar', { name: 'Filter, sort, and group controls' })).toBeInTheDocument();
33
+ expect(screen.getByRole('button', { name: 'Open filter' })).toBeInTheDocument();
34
+ expect(screen.getByRole('button', { name: 'Open sort' })).toBeInTheDocument();
35
+ expect(screen.getByRole('button', { name: 'Open group' })).toBeInTheDocument();
36
+ expect(screen.getByText('No filters applied')).toBeInTheDocument();
37
+ expect(container.querySelector('.ds-filter-bar')).toBeInTheDocument();
38
+ });
39
+
40
+ test('renders capped counts and preserves the full accessible label', async () => {
41
+ const onClick = vi.fn();
42
+
43
+ render(
44
+ <FilterBar>
45
+ <FilterBar.Toolbar aria-label="Filter, sort, and group controls">
46
+ <FilterBar.Button type="filter" count={142} onClick={onClick} />
47
+ </FilterBar.Toolbar>
48
+ </FilterBar>,
49
+ );
50
+
51
+ const button = screen.getByRole('button', { name: 'Open filter. 142 filter items applied' });
52
+ await userEvent.click(button);
53
+
54
+ expect(onClick).toHaveBeenCalledOnce();
55
+ expect(screen.getByText('99+')).toBeInTheDocument();
56
+ expect(screen.getByLabelText('142 applied filter items')).toBeInTheDocument();
57
+ });
58
+
59
+ test('forwards active item clicks and renders label plus value', async () => {
60
+ const user = userEvent.setup();
61
+ const onItemClick = vi.fn();
62
+
63
+ render(
64
+ <FilterBar>
65
+ <FilterBar.ActiveList
66
+ items={[
67
+ {
68
+ id: 'event-type',
69
+ type: 'filter',
70
+ fieldId: 'filter-event-type',
71
+ label: 'Event Type',
72
+ value: 'Interventions',
73
+ onClick: onItemClick,
74
+ },
75
+ ]}
76
+ />
77
+ </FilterBar>,
78
+ );
79
+
80
+ await user.click(screen.getByRole('button', { name: 'Open filter Event Type: Interventions' }));
81
+
82
+ expect(screen.getAllByText('Event Type:')[0]).toBeInTheDocument();
83
+ expect(screen.getAllByText('Interventions')[0]).toBeInTheDocument();
84
+ expect(onItemClick).toHaveBeenCalledWith(
85
+ expect.objectContaining({
86
+ id: 'event-type',
87
+ type: 'filter',
88
+ fieldId: 'filter-event-type',
89
+ label: 'Event Type',
90
+ value: 'Interventions',
91
+ }),
92
+ expect.any(Object),
93
+ );
94
+ });
95
+
96
+ test('forwards aria-controls and aria-expanded from active items to the tag button', () => {
97
+ render(
98
+ <FilterBar>
99
+ <FilterBar.ActiveList
100
+ items={[
101
+ {
102
+ id: 'event-type',
103
+ type: 'filter',
104
+ label: 'Event Type',
105
+ value: 'Interventions',
106
+ onClick: vi.fn(),
107
+ ariaControls: 'filter-modal',
108
+ ariaExpanded: true,
109
+ },
110
+ ]}
111
+ />
112
+ </FilterBar>,
113
+ );
114
+
115
+ expect(screen.getByRole('button', { name: 'Open filter Event Type: Interventions' })).toHaveAttribute('aria-controls', 'filter-modal');
116
+ expect(screen.getByRole('button', { name: 'Open filter Event Type: Interventions' })).toHaveAttribute('aria-expanded', 'true');
117
+ });
118
+
119
+ test('uses the active list overflow action for the focusable +N more summary', async () => {
120
+ const user = userEvent.setup();
121
+ const onOverflowClick = vi.fn();
122
+ const rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function mockRect(this: HTMLElement) {
123
+ let width = 0;
124
+
125
+ if (this.classList.contains('ds-tag-list__viewport')) width = 220;
126
+ else if (this.classList.contains('ds-tag-list__item')) width = 84;
127
+ else if (this.classList.contains('ds-tag-list__overflow')) width = 76;
128
+
129
+ return {
130
+ width,
131
+ height: 0,
132
+ top: 0,
133
+ left: 0,
134
+ right: width,
135
+ bottom: 0,
136
+ x: 0,
137
+ y: 0,
138
+ toJSON() {
139
+ return {};
140
+ },
141
+ } as DOMRect;
142
+ });
143
+
144
+ try {
145
+ render(
146
+ <FilterBar>
147
+ <FilterBar.ActiveList
148
+ onOverflowClick={onOverflowClick}
149
+ overflowActionLabel="Show more applied items"
150
+ items={[
151
+ { id: 'date', type: 'filter', label: 'Date', value: 'Wed 26 Apr 2026' },
152
+ { id: 'event', type: 'filter', label: 'Event Type', value: 'Interventions' },
153
+ { id: 'course', type: 'filter', label: 'Course', value: 'English' },
154
+ { id: 'student', type: 'sort', label: 'Student', value: 'A to Z' },
155
+ ]}
156
+ />
157
+ </FilterBar>,
158
+ );
159
+
160
+ await user.click(screen.getByRole('button', { name: 'Show more applied items' }));
161
+ expect(onOverflowClick).toHaveBeenCalledOnce();
162
+ }
163
+ finally {
164
+ rectSpy.mockRestore();
165
+ }
166
+ });
167
+
168
+ test('returns focus to the first toolbar button after sequentially removing all tags from their remove buttons', async () => {
169
+ const user = userEvent.setup();
170
+
171
+ const Example = () => {
172
+ const [itemIds, setItemIds] = useState(['year-group', 'attendance-band', 'date-range']);
173
+ const firstToolbarButtonRef = useRef<HTMLButtonElement>(null);
174
+
175
+ const items: FilterBar.TagItem[] = itemIds.map((itemId) => {
176
+ const baseItem = {
177
+ type: 'filter' as const,
178
+ onClick: vi.fn(),
179
+ onRemove: () => setItemIds(current => current.filter(id => id !== itemId)),
180
+ };
181
+
182
+ switch (itemId) {
183
+ case 'year-group':
184
+ return {
185
+ ...baseItem,
186
+ id: itemId,
187
+ label: 'Year group',
188
+ value: 'Year 10',
189
+ removeLabel: 'Remove year group filter',
190
+ };
191
+ case 'attendance-band':
192
+ return {
193
+ ...baseItem,
194
+ id: itemId,
195
+ label: 'Attendance band',
196
+ value: 'Below 90%',
197
+ removeLabel: 'Remove attendance band filter',
198
+ };
199
+ default:
200
+ return {
201
+ ...baseItem,
202
+ id: itemId,
203
+ label: 'Date range',
204
+ value: 'Spring term',
205
+ removeLabel: 'Remove date range filter',
206
+ };
207
+ }
208
+ });
209
+
210
+ return (
211
+ <FilterBar>
212
+ <FilterBar.Toolbar aria-label="Filter, sort, and group controls">
213
+ <FilterBar.Button ref={firstToolbarButtonRef} type="filter" />
214
+ <FilterBar.Button type="sort" />
215
+ <FilterBar.Button type="group" />
216
+ </FilterBar.Toolbar>
217
+ <FilterBar.ActiveList
218
+ items={items}
219
+ returnFocusRef={firstToolbarButtonRef}
220
+ ariaLabel="Applied attendance filters"
221
+ />
222
+ </FilterBar>
223
+ );
224
+ };
225
+
226
+ render(<Example />);
227
+
228
+ await user.tab();
229
+ expect(screen.getByRole('button', { name: 'Open filter' })).toHaveFocus();
230
+
231
+ await user.tab();
232
+ await user.tab();
233
+ await user.tab();
234
+ expect(screen.getByRole('button', { name: 'Open filter Year group: Year 10' })).toHaveFocus();
235
+
236
+ await user.keyboard('{ArrowRight}');
237
+ expect(screen.getByRole('button', { name: 'Remove year group filter' })).toHaveFocus();
238
+
239
+ await user.click(screen.getByRole('button', { name: 'Remove year group filter' }));
240
+ expect(screen.getByRole('button', { name: 'Remove attendance band filter' })).toHaveFocus();
241
+
242
+ await user.click(screen.getByRole('button', { name: 'Remove attendance band filter' }));
243
+ expect(screen.getByRole('button', { name: 'Remove date range filter' })).toHaveFocus();
244
+
245
+ await user.click(screen.getByRole('button', { name: 'Remove date range filter' }));
246
+ expect(screen.getByRole('button', { name: 'Open filter' })).toHaveFocus();
247
+ });
248
+ });
@@ -0,0 +1,298 @@
1
+ import { Badge } from 'Components/badge/Badge';
2
+ import { Button } from 'Components/button/Button';
3
+ import { Icon } from 'Components/icon/Icon';
4
+ import type { IconName } from 'Components/icon/allowedIcons';
5
+ import { Tag } from 'Components/tag/Tag';
6
+ import { TagList } from 'Components/tagList/TagList';
7
+ import classNames from 'classnames';
8
+ import { Children, forwardRef, type ButtonHTMLAttributes, type HTMLAttributes, type MouseEventHandler, type RefObject } from 'react';
9
+
10
+ export type FilterBarType = 'filter' | 'sort' | 'group';
11
+
12
+ export type FilterBarTagItem = {
13
+ id: string;
14
+ type: FilterBarType;
15
+ label: string;
16
+ value: React.ReactNode;
17
+ fieldId?: string;
18
+ icon?: IconName;
19
+ ariaControls?: string;
20
+ ariaExpanded?: boolean;
21
+ actionLabel?: string;
22
+ removeLabel?: string;
23
+ disabled?: boolean;
24
+ onClick?: (item: FilterBarTagItem, event: React.MouseEvent<HTMLButtonElement>) => void;
25
+ onRemove?: (item: FilterBarTagItem) => void;
26
+ };
27
+
28
+ export type FilterBarProps = HTMLAttributes<HTMLDivElement>;
29
+
30
+ export type FilterBarToolbarProps = HTMLAttributes<HTMLDivElement>;
31
+
32
+ export type FilterBarButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'type'> & {
33
+ type: FilterBarType;
34
+ label?: string;
35
+ count?: number | null;
36
+ icon?: IconName;
37
+ htmlType?: ButtonHTMLAttributes<HTMLButtonElement>['type'];
38
+ };
39
+
40
+ export type FilterBarActiveListProps = {
41
+ items?: readonly FilterBarTagItem[];
42
+ children?: React.ReactNode;
43
+ className?: string;
44
+ ariaLabel?: string;
45
+ emptyState?: React.ReactNode;
46
+ returnFocusRef?: RefObject<HTMLElement | null>;
47
+ wrap?: boolean;
48
+ collapseOverflow?: boolean;
49
+ overflowActionLabel?: string;
50
+ onOverflowClick?: MouseEventHandler<HTMLButtonElement>;
51
+ overflowAriaControls?: string;
52
+ overflowAriaExpanded?: boolean;
53
+ };
54
+
55
+ export type FilterBarTagProps = Omit<FilterBarTagItem, 'id' | 'onClick' | 'onRemove'> & {
56
+ id?: string;
57
+ className?: string;
58
+ children?: React.ReactNode;
59
+ onClick?: (item: FilterBarTagItem, event: React.MouseEvent<HTMLButtonElement>) => void;
60
+ onRemove?: (item: FilterBarTagItem) => void;
61
+ };
62
+
63
+ const FILTER_BAR_TYPE_LABELS: Record<FilterBarType, string> = {
64
+ filter: 'Filter',
65
+ sort: 'Sort',
66
+ group: 'Group',
67
+ };
68
+
69
+ const FILTER_BAR_TYPE_ICONS: Record<FilterBarType, IconName> = {
70
+ filter: 'sliders-horizontal',
71
+ sort: 'sorting',
72
+ group: 'list-tree',
73
+ };
74
+
75
+ const getVisibleCount = (count?: number | null): string | null => {
76
+ if (typeof count !== 'number' || count <= 0) return null;
77
+ return count > 99 ? '99+' : `${count}`;
78
+ };
79
+
80
+ const getButtonA11yLabel = (type: FilterBarType, label: string, count?: number | null): string => {
81
+ if (typeof count !== 'number' || count <= 0) {
82
+ return `Open ${label.toLowerCase()}`;
83
+ }
84
+
85
+ return `Open ${label.toLowerCase()}. ${count} ${type} item${count === 1 ? '' : 's'} applied`;
86
+ };
87
+
88
+ const getTagActionLabel = (item: FilterBarTagItem): string => `Open ${item.type} ${item.label}: ${item.value}`;
89
+
90
+ const FilterBarTagContent = ({ label, value }: Pick<FilterBarTagProps, 'label' | 'value'>) => (
91
+ <span className="ds-filter-bar__tag-content">
92
+ <span className="ds-filter-bar__tag-label">
93
+ {label}
94
+ :
95
+ </span>
96
+ <span className="ds-filter-bar__tag-value">{value}</span>
97
+ </span>
98
+ );
99
+
100
+ export const FilterBar = ({
101
+ children,
102
+ className,
103
+ ...rest
104
+ }: FilterBarProps): React.JSX.Element => (
105
+ <div className={classNames('ds-filter-bar', className)} {...rest}>
106
+ {children}
107
+ </div>
108
+ );
109
+
110
+ const FilterBarToolbar = ({
111
+ children,
112
+ className,
113
+ ...rest
114
+ }: FilterBarToolbarProps): React.JSX.Element => (
115
+ <div
116
+ className={classNames('ds-filter-bar__toolbar', className)}
117
+ role="toolbar"
118
+ {...rest}
119
+ >
120
+ {children}
121
+ </div>
122
+ );
123
+
124
+ const FilterBarButton = forwardRef<HTMLButtonElement, FilterBarButtonProps>(({
125
+ type,
126
+ label,
127
+ count,
128
+ icon,
129
+ htmlType = 'button',
130
+ className,
131
+ 'aria-label': ariaLabel,
132
+ 'aria-expanded': ariaExpanded,
133
+ ...rest
134
+ }, ref): React.JSX.Element => {
135
+ const resolvedLabel = label ?? FILTER_BAR_TYPE_LABELS[type];
136
+ const resolvedIcon = icon ?? FILTER_BAR_TYPE_ICONS[type];
137
+ const visibleCount = getVisibleCount(count);
138
+ const hasAppliedItems = Boolean(visibleCount);
139
+ const isExpanded = ariaExpanded === true || ariaExpanded === 'true';
140
+
141
+ return (
142
+ <Button
143
+ ref={ref}
144
+ type={htmlType}
145
+ variant="tertiary"
146
+ size="M"
147
+ className={classNames('ds-filter-bar__button', {
148
+ 'ds-filter-bar__button--has-applied': hasAppliedItems,
149
+ 'ds-filter-bar__button--expanded': isExpanded,
150
+ }, className)}
151
+ aria-label={ariaLabel ?? getButtonA11yLabel(type, resolvedLabel, count)}
152
+ aria-expanded={ariaExpanded}
153
+ {...rest}
154
+ >
155
+ <span className="ds-filter-bar__button-content">
156
+ <span className="ds-filter-bar__button-icon" aria-hidden="true">
157
+ <Icon name={resolvedIcon} size={16} />
158
+ </span>
159
+ <span className="ds-filter-bar__button-label">{resolvedLabel}</span>
160
+ {visibleCount && (
161
+ <Badge
162
+ colour="green"
163
+ className="ds-filter-bar__button-badge"
164
+ a11yLabel={`${count} applied ${resolvedLabel.toLowerCase()} item${count === 1 ? '' : 's'}`}
165
+ >
166
+ {visibleCount}
167
+ </Badge>
168
+ )}
169
+ </span>
170
+ </Button>
171
+ );
172
+ });
173
+
174
+ FilterBarButton.displayName = 'FilterBar.Button';
175
+
176
+ const FilterBarTag = ({
177
+ id,
178
+ type,
179
+ label,
180
+ value,
181
+ fieldId,
182
+ icon,
183
+ ariaControls,
184
+ ariaExpanded,
185
+ actionLabel,
186
+ removeLabel,
187
+ disabled,
188
+ className,
189
+ children,
190
+ onClick,
191
+ onRemove,
192
+ }: FilterBarTagProps): React.JSX.Element => {
193
+ const item: FilterBarTagItem = {
194
+ id: id ?? fieldId ?? `${type}-${label}`,
195
+ type,
196
+ label,
197
+ value,
198
+ fieldId,
199
+ icon,
200
+ ariaControls,
201
+ ariaExpanded,
202
+ actionLabel,
203
+ removeLabel,
204
+ disabled,
205
+ };
206
+ const resolvedIcon = icon ?? FILTER_BAR_TYPE_ICONS[type];
207
+
208
+ return (
209
+ <Tag
210
+ slotStart={<Icon name={resolvedIcon} size={16} />}
211
+ onClick={onClick ? event => onClick(item, event) : undefined}
212
+ onRemove={onRemove ? () => onRemove(item) : undefined}
213
+ actionLabel={actionLabel ?? getTagActionLabel(item)}
214
+ removeLabel={removeLabel}
215
+ disabled={disabled}
216
+ ariaControls={ariaControls}
217
+ ariaExpanded={ariaExpanded}
218
+ >
219
+ <span className={classNames('ds-filter-bar__tag', className)}>
220
+ {children ?? <FilterBarTagContent label={label} value={value} />}
221
+ </span>
222
+ </Tag>
223
+ );
224
+ };
225
+
226
+ const FilterBarActiveList = ({
227
+ items,
228
+ children,
229
+ className,
230
+ ariaLabel = 'Active filters, sorting, and grouping',
231
+ emptyState,
232
+ returnFocusRef,
233
+ wrap = false,
234
+ collapseOverflow = true,
235
+ overflowActionLabel = 'Show more applied items',
236
+ onOverflowClick,
237
+ overflowAriaControls,
238
+ overflowAriaExpanded,
239
+ }: FilterBarActiveListProps): React.JSX.Element | null => {
240
+ if (items) {
241
+ return (
242
+ <TagList
243
+ items={items.map(item => ({
244
+ id: item.id,
245
+ slotStart: <Icon name={item.icon ?? FILTER_BAR_TYPE_ICONS[item.type]} size={16} />,
246
+ children: <FilterBarTagContent label={item.label} value={item.value} />,
247
+ onClick: item.onClick ? event => item.onClick?.(item, event) : undefined,
248
+ onRemove: item.onRemove ? () => item.onRemove?.(item) : undefined,
249
+ ariaControls: item.ariaControls,
250
+ ariaExpanded: item.ariaExpanded,
251
+ actionLabel: item.actionLabel ?? getTagActionLabel(item),
252
+ removeLabel: item.removeLabel,
253
+ disabled: item.disabled,
254
+ }))}
255
+ className={classNames('ds-filter-bar__active-list', className)}
256
+ ariaLabel={ariaLabel}
257
+ emptyState={emptyState}
258
+ returnFocusRef={returnFocusRef}
259
+ wrap={wrap}
260
+ collapseOverflow={collapseOverflow}
261
+ overflowOnClick={onOverflowClick}
262
+ overflowActionLabel={overflowActionLabel}
263
+ overflowAriaControls={overflowAriaControls}
264
+ overflowAriaExpanded={overflowAriaExpanded}
265
+ />
266
+ );
267
+ }
268
+
269
+ if (!children) {
270
+ if (!emptyState) return null;
271
+ return <div className={classNames('ds-filter-bar__empty-state', className)}>{emptyState}</div>;
272
+ }
273
+
274
+ return (
275
+ <ul className={classNames('ds-filter-bar__manual-list', className)} aria-label={ariaLabel}>
276
+ {Children.toArray(children).map((child, index) => (
277
+ <li key={index} className="ds-filter-bar__manual-list-item">
278
+ {child}
279
+ </li>
280
+ ))}
281
+ </ul>
282
+ );
283
+ };
284
+
285
+ FilterBar.Toolbar = FilterBarToolbar;
286
+ FilterBar.Button = FilterBarButton;
287
+ FilterBar.ActiveList = FilterBarActiveList;
288
+ FilterBar.Tag = FilterBarTag;
289
+
290
+ export namespace FilterBar {
291
+ export type Type = FilterBarType;
292
+ export type Props = FilterBarProps;
293
+ export type ToolbarProps = FilterBarToolbarProps;
294
+ export type ButtonProps = FilterBarButtonProps;
295
+ export type ActiveListProps = FilterBarActiveListProps;
296
+ export type TagProps = FilterBarTagProps;
297
+ export type TagItem = FilterBarTagItem;
298
+ }
@@ -0,0 +1,143 @@
1
+ .ds-filter-bar {
2
+ display: flex;
3
+ flex-wrap: nowrap;
4
+ align-items: center;
5
+ gap: var(--spacing-small);
6
+ width: 100%;
7
+ max-width: 100%;
8
+ padding: var(--spacing-small);
9
+ box-sizing: border-box;
10
+ border: var(--border-weight) solid var(--color-grey-100);
11
+ border-radius: var(--section-container-radius);
12
+ background-color: var(--section-container-color-background);
13
+ }
14
+
15
+ .ds-filter-bar__toolbar {
16
+ display: flex;
17
+ flex-wrap: nowrap;
18
+ align-items: center;
19
+ gap: var(--spacing-small);
20
+ flex-shrink: 0;
21
+ }
22
+
23
+ .ds-filter-bar__button {
24
+ flex-shrink: 0;
25
+ border-radius: var(--button-toolbar-radius);
26
+
27
+ &.ds-button--tertiary {
28
+ height: var(--size-medium);
29
+ padding: 0 var(--button-medium-spacing-horizontal);
30
+ border: var(--border-weight) solid transparent;
31
+ background-color: transparent;
32
+ color: var(--color-grey-900);
33
+
34
+ &:hover {
35
+ background-color: var(--color-grey-050);
36
+ border-color: transparent;
37
+ }
38
+
39
+ &:active,
40
+ &:focus {
41
+ background-color: var(--color-grey-100);
42
+ border-color: transparent;
43
+ }
44
+ }
45
+
46
+ &.ds-filter-bar__button--has-applied.ds-button--tertiary {
47
+ background-color: var(--color-brand-050);
48
+
49
+ &:hover {
50
+ background-color: var(--color-brand-100);
51
+ }
52
+
53
+ &:active,
54
+ &:focus {
55
+ background-color: var(--color-brand-100);
56
+ }
57
+ }
58
+
59
+ &.ds-filter-bar__button--expanded.ds-button--tertiary {
60
+ background-color: var(--color-grey-100);
61
+ }
62
+
63
+ &.ds-filter-bar__button--expanded.ds-filter-bar__button--has-applied.ds-button--tertiary {
64
+ background-color: var(--color-brand-100);
65
+ }
66
+ }
67
+
68
+ .ds-filter-bar__button-content {
69
+ display: inline-flex;
70
+ align-items: center;
71
+ gap: var(--spacing-small);
72
+ }
73
+
74
+ .ds-filter-bar__button-icon {
75
+ display: inline-flex;
76
+ align-items: center;
77
+ line-height: 0;
78
+ }
79
+
80
+ .ds-filter-bar__button-label {
81
+ line-height: 1.5;
82
+ }
83
+
84
+ .ds-filter-bar__button-badge {
85
+ flex-shrink: 0;
86
+ }
87
+
88
+ .ds-filter-bar__active-list {
89
+ min-width: 0;
90
+ flex: 1 1 auto;
91
+ }
92
+
93
+ .ds-filter-bar__manual-list {
94
+ display: flex;
95
+ flex: 1 1 auto;
96
+ flex-wrap: nowrap;
97
+ align-items: center;
98
+ gap: var(--spacing-xsmall);
99
+ min-width: 0;
100
+ margin: 0;
101
+ padding: 0;
102
+ list-style: none;
103
+ overflow: hidden;
104
+ }
105
+
106
+ .ds-filter-bar__manual-list-item {
107
+ min-width: 0;
108
+ }
109
+
110
+ .ds-filter-bar__empty-state {
111
+ color: var(--color-grey-800);
112
+ font-size: var(--type-body-p-size);
113
+ line-height: 1.5;
114
+ }
115
+
116
+ .ds-filter-bar__tag {
117
+ display: inline-flex;
118
+ min-width: 0;
119
+ }
120
+
121
+ .ds-filter-bar__tag-content {
122
+ display: inline-flex;
123
+ align-items: baseline;
124
+ gap: 4px;
125
+ min-width: 0;
126
+ }
127
+
128
+ .ds-filter-bar__tag-label {
129
+ font-weight: 600;
130
+ white-space: nowrap;
131
+ }
132
+
133
+ .ds-filter-bar__tag-value {
134
+ min-width: 0;
135
+ overflow: hidden;
136
+ text-overflow: ellipsis;
137
+ }
138
+
139
+ @media (width <= 40rem) {
140
+ .ds-filter-bar__button-label {
141
+ display: none;
142
+ }
143
+ }