@arbor-education/design-system.components 0.21.1 → 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.
- package/CHANGELOG.md +12 -0
- package/component-library.md +15 -14
- package/dist/components/articleCard/ArticleCard.d.ts +2 -2
- package/dist/components/articleCard/ArticleCard.d.ts.map +1 -1
- package/dist/components/articleCard/ArticleCard.js +3 -3
- package/dist/components/articleCard/ArticleCard.js.map +1 -1
- package/dist/components/articleCard/ArticleCard.stories.d.ts +11 -3
- package/dist/components/articleCard/ArticleCard.stories.d.ts.map +1 -1
- package/dist/components/articleCard/ArticleCard.stories.js +16 -11
- package/dist/components/articleCard/ArticleCard.stories.js.map +1 -1
- package/dist/components/iconText/IconText.d.ts +43 -0
- package/dist/components/iconText/IconText.d.ts.map +1 -0
- package/dist/components/iconText/IconText.js +29 -0
- package/dist/components/iconText/IconText.js.map +1 -0
- package/dist/components/{icoText/IcoText.stories.d.ts → iconText/IconText.stories.d.ts} +8 -9
- package/dist/components/iconText/IconText.stories.d.ts.map +1 -0
- package/dist/components/{icoText/IcoText.stories.js → iconText/IconText.stories.js} +81 -81
- package/dist/components/iconText/IconText.stories.js.map +1 -0
- package/dist/components/iconText/IconText.test.d.ts +2 -0
- package/dist/components/iconText/IconText.test.d.ts.map +1 -0
- package/dist/components/{icoText/IcoText.test.js → iconText/IconText.test.js} +6 -6
- package/dist/components/iconText/IconText.test.js.map +1 -0
- package/dist/components/modal/Modal.d.ts +1 -0
- package/dist/components/modal/Modal.d.ts.map +1 -1
- package/dist/components/modal/Modal.js +2 -2
- package/dist/components/modal/Modal.js.map +1 -1
- package/dist/components/tag/Tag.d.ts +14 -1
- package/dist/components/tag/Tag.d.ts.map +1 -1
- package/dist/components/tag/Tag.js +9 -3
- package/dist/components/tag/Tag.js.map +1 -1
- package/dist/components/tag/Tag.stories.d.ts +1 -1
- package/dist/components/tag/Tag.stories.d.ts.map +1 -1
- package/dist/components/tag/Tag.stories.js +3 -3
- package/dist/components/tag/Tag.stories.js.map +1 -1
- package/dist/components/tag/Tag.test.js +36 -5
- package/dist/components/tag/Tag.test.js.map +1 -1
- package/dist/components/tagList/TagList.d.ts +49 -0
- package/dist/components/tagList/TagList.d.ts.map +1 -0
- package/dist/components/tagList/TagList.js +114 -0
- package/dist/components/tagList/TagList.js.map +1 -0
- package/dist/components/tagList/TagList.stories.d.ts +130 -0
- package/dist/components/tagList/TagList.stories.d.ts.map +1 -0
- package/dist/components/tagList/TagList.stories.js +443 -0
- package/dist/components/tagList/TagList.stories.js.map +1 -0
- package/dist/components/{icoText/IcoText.test.d.ts → tagList/TagList.test.d.ts} +1 -1
- package/dist/components/tagList/TagList.test.d.ts.map +1 -0
- package/dist/components/tagList/TagList.test.js +246 -0
- package/dist/components/tagList/TagList.test.js.map +1 -0
- package/dist/components/tagList/useTagListCollapsedLayout.d.ts +19 -0
- package/dist/components/tagList/useTagListCollapsedLayout.d.ts.map +1 -0
- package/dist/components/tagList/useTagListCollapsedLayout.js +48 -0
- package/dist/components/tagList/useTagListCollapsedLayout.js.map +1 -0
- package/dist/components/tagList/useVisibleTags.d.ts +18 -0
- package/dist/components/tagList/useVisibleTags.d.ts.map +1 -0
- package/dist/components/tagList/useVisibleTags.js +41 -0
- package/dist/components/tagList/useVisibleTags.js.map +1 -0
- package/dist/index.css +130 -10
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/utils/hooks/useElementWidth.d.ts +2 -0
- package/dist/utils/hooks/useElementWidth.d.ts.map +1 -0
- package/dist/utils/hooks/useElementWidth.js +30 -0
- package/dist/utils/hooks/useElementWidth.js.map +1 -0
- package/dist/utils/hooks/useMeasuredChildWidths.d.ts +8 -0
- package/dist/utils/hooks/useMeasuredChildWidths.d.ts.map +1 -0
- package/dist/utils/hooks/useMeasuredChildWidths.js +26 -0
- package/dist/utils/hooks/useMeasuredChildWidths.js.map +1 -0
- package/dist/utils/hooks/useRovingFocus.d.ts +18 -0
- package/dist/utils/hooks/useRovingFocus.d.ts.map +1 -0
- package/dist/utils/hooks/useRovingFocus.js +130 -0
- package/dist/utils/hooks/useRovingFocus.js.map +1 -0
- package/dist/utils/hooks/useRovingFocus.test.d.ts +2 -0
- package/dist/utils/hooks/useRovingFocus.test.d.ts.map +1 -0
- package/dist/utils/hooks/useRovingFocus.test.js +59 -0
- package/dist/utils/hooks/useRovingFocus.test.js.map +1 -0
- package/dist/utils/spacedWidths.d.ts +3 -0
- package/dist/utils/spacedWidths.d.ts.map +1 -0
- package/dist/utils/spacedWidths.js +28 -0
- package/dist/utils/spacedWidths.js.map +1 -0
- package/dist/utils/spacedWidths.test.d.ts +2 -0
- package/dist/utils/spacedWidths.test.d.ts.map +1 -0
- package/dist/utils/spacedWidths.test.js +17 -0
- package/dist/utils/spacedWidths.test.js.map +1 -0
- package/package.json +1 -1
- package/src/components/articleCard/ArticleCard.stories.tsx +17 -12
- package/src/components/articleCard/ArticleCard.tsx +9 -9
- package/src/components/{icoText/IcoText.stories.tsx → iconText/IconText.stories.tsx} +112 -112
- package/src/components/{icoText/IcoText.test.tsx → iconText/IconText.test.tsx} +10 -10
- package/src/components/{icoText/IcoText.tsx → iconText/IconText.tsx} +27 -20
- package/src/components/modal/Modal.tsx +5 -1
- package/src/components/tag/Tag.stories.tsx +4 -4
- package/src/components/tag/Tag.test.tsx +62 -5
- package/src/components/tag/Tag.tsx +61 -3
- package/src/components/tag/tag.scss +80 -9
- package/src/components/tagList/TagList.stories.tsx +564 -0
- package/src/components/tagList/TagList.test.tsx +342 -0
- package/src/components/tagList/TagList.tsx +296 -0
- package/src/components/tagList/tagList.scss +56 -0
- package/src/components/tagList/useTagListCollapsedLayout.ts +83 -0
- package/src/components/tagList/useVisibleTags.ts +74 -0
- package/src/index.scss +2 -1
- package/src/index.ts +3 -1
- package/src/tokens.scss +2 -1
- package/src/utils/hooks/useElementWidth.ts +39 -0
- package/src/utils/hooks/useMeasuredChildWidths.ts +39 -0
- package/src/utils/hooks/useRovingFocus.test.tsx +105 -0
- package/src/utils/hooks/useRovingFocus.ts +163 -0
- package/src/utils/spacedWidths.test.ts +20 -0
- package/src/utils/spacedWidths.ts +37 -0
- package/dist/components/icoText/IcoText.d.ts +0 -37
- package/dist/components/icoText/IcoText.d.ts.map +0 -1
- package/dist/components/icoText/IcoText.js +0 -29
- package/dist/components/icoText/IcoText.js.map +0 -1
- package/dist/components/icoText/IcoText.stories.d.ts.map +0 -1
- package/dist/components/icoText/IcoText.stories.js.map +0 -1
- package/dist/components/icoText/IcoText.test.d.ts.map +0 -1
- package/dist/components/icoText/IcoText.test.js.map +0 -1
- /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
|
+
}
|