@arbor-education/design-system.components 0.22.0 → 0.23.1
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 +16 -0
- package/component-library.md +62 -0
- package/dist/components/combobox/Combobox.js +1 -1
- package/dist/components/combobox/Combobox.js.map +1 -1
- package/dist/components/combobox/Combobox.stories.d.ts +4 -0
- package/dist/components/combobox/Combobox.stories.d.ts.map +1 -1
- package/dist/components/combobox/Combobox.stories.js +144 -12
- package/dist/components/combobox/Combobox.stories.js.map +1 -1
- package/dist/components/combobox/Combobox.test.js +22 -0
- package/dist/components/combobox/Combobox.test.js.map +1 -1
- package/dist/components/combobox/ComboboxButtonTrigger.d.ts +4 -4
- package/dist/components/combobox/ComboboxButtonTrigger.d.ts.map +1 -1
- package/dist/components/combobox/ComboboxButtonTrigger.js +35 -40
- package/dist/components/combobox/ComboboxButtonTrigger.js.map +1 -1
- package/dist/components/combobox/ComboboxTrigger.d.ts.map +1 -1
- package/dist/components/combobox/ComboboxTrigger.js +11 -4
- package/dist/components/combobox/ComboboxTrigger.js.map +1 -1
- package/dist/components/combobox/useVisibleTriggerTags.d.ts +21 -0
- package/dist/components/combobox/useVisibleTriggerTags.d.ts.map +1 -0
- package/dist/components/combobox/useVisibleTriggerTags.js +46 -0
- package/dist/components/combobox/useVisibleTriggerTags.js.map +1 -0
- package/dist/components/combobox/useVisibleTriggerTags.test.d.ts +2 -0
- package/dist/components/combobox/useVisibleTriggerTags.test.d.ts.map +1 -0
- package/dist/components/combobox/useVisibleTriggerTags.test.js +81 -0
- package/dist/components/combobox/useVisibleTriggerTags.test.js.map +1 -0
- package/dist/components/filterBar/FilterBar.d.ts +71 -0
- package/dist/components/filterBar/FilterBar.d.ts.map +1 -0
- package/dist/components/filterBar/FilterBar.js +89 -0
- package/dist/components/filterBar/FilterBar.js.map +1 -0
- package/dist/components/filterBar/FilterBar.stories.d.ts +170 -0
- package/dist/components/filterBar/FilterBar.stories.d.ts.map +1 -0
- package/dist/components/filterBar/FilterBar.stories.js +894 -0
- package/dist/components/filterBar/FilterBar.stories.js.map +1 -0
- package/dist/components/filterBar/FilterBar.test.d.ts +2 -0
- package/dist/components/filterBar/FilterBar.test.d.ts.map +1 -0
- package/dist/components/filterBar/FilterBar.test.js +164 -0
- package/dist/components/filterBar/FilterBar.test.js.map +1 -0
- package/dist/components/icon/allowedIcons.d.ts +1 -0
- package/dist/components/icon/allowedIcons.d.ts.map +1 -1
- package/dist/components/icon/allowedIcons.js +2 -1
- package/dist/components/icon/allowedIcons.js.map +1 -1
- package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.d.ts.map +1 -1
- package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.js +13 -2
- package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.js.map +1 -1
- package/dist/index.css +142 -3
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/combobox/Combobox.stories.tsx +186 -12
- package/src/components/combobox/Combobox.test.tsx +53 -0
- package/src/components/combobox/Combobox.tsx +3 -3
- package/src/components/combobox/ComboboxButtonTrigger.tsx +52 -56
- package/src/components/combobox/ComboboxTrigger.tsx +19 -16
- package/src/components/combobox/combobox.scss +8 -3
- package/src/components/combobox/useVisibleTriggerTags.test.tsx +91 -0
- package/src/components/combobox/useVisibleTriggerTags.ts +83 -0
- package/src/components/filterBar/FilterBar.stories.tsx +1199 -0
- package/src/components/filterBar/FilterBar.test.tsx +248 -0
- package/src/components/filterBar/FilterBar.tsx +298 -0
- package/src/components/filterBar/filterBar.scss +143 -0
- package/src/components/icon/allowedIcons.tsx +3 -1
- package/src/components/table/cellRenderers/ComboboxCellRenderer.test.tsx +20 -3
- package/src/index.scss +3 -0
- package/src/index.ts +10 -0
- package/src/tokens.scss +1 -0
- package/stylelint.config.mjs +1 -0
- package/dist/components/combobox/useElementWidth.d.ts +0 -2
- package/dist/components/combobox/useElementWidth.d.ts.map +0 -1
- package/dist/components/combobox/useElementWidth.js +0 -31
- package/dist/components/combobox/useElementWidth.js.map +0 -1
- package/dist/components/combobox/useVisibleChips.d.ts +0 -21
- package/dist/components/combobox/useVisibleChips.d.ts.map +0 -1
- package/dist/components/combobox/useVisibleChips.js +0 -59
- package/dist/components/combobox/useVisibleChips.js.map +0 -1
- package/dist/components/combobox/useVisibleChips.test.d.ts +0 -2
- package/dist/components/combobox/useVisibleChips.test.d.ts.map +0 -1
- package/dist/components/combobox/useVisibleChips.test.js +0 -81
- package/dist/components/combobox/useVisibleChips.test.js.map +0 -1
- package/src/components/combobox/useElementWidth.ts +0 -40
- package/src/components/combobox/useVisibleChips.test.tsx +0 -91
- package/src/components/combobox/useVisibleChips.ts +0 -100
|
@@ -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
|
+
}
|