uikit-react-public 0.49.5 → 0.50.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/dist/components/Filters/Filter.d.ts +20 -0
- package/dist/components/Filters/FilterPanel.d.ts +16 -0
- package/dist/components/Filters/Filters.context.d.ts +8 -0
- package/dist/components/Filters/Filters.d.ts +30 -0
- package/dist/components/Filters/Filters.stories.d.ts +17 -0
- package/dist/components/Filters/QuickFilter.d.ts +8 -0
- package/dist/components/Filters/__tests__/Filters.test.d.ts +1 -0
- package/dist/components/Filters/index.d.ts +6 -0
- package/dist/components/Overlay/Overlay.d.ts +3 -2
- package/dist/components/Overlay/Overlay.stories.d.ts +14 -1
- package/dist/components/Select/subcomponents/Panel.d.ts +1 -2
- package/dist/components/index.d.ts +2 -0
- package/dist/index.js +6833 -6236
- package/lib/components/Filters/Filter.tsx +327 -0
- package/lib/components/Filters/FilterPanel.tsx +152 -0
- package/lib/components/Filters/Filters.context.ts +12 -0
- package/lib/components/Filters/Filters.mdx +75 -0
- package/lib/components/Filters/Filters.stories.tsx +354 -0
- package/lib/components/Filters/Filters.tsx +316 -0
- package/lib/components/Filters/QuickFilter.tsx +89 -0
- package/lib/components/Filters/__tests__/Filters.test.tsx +230 -0
- package/lib/components/Filters/index.ts +6 -0
- package/lib/components/Overlay/Overlay.tsx +18 -7
- package/lib/components/Overlay/__tests__/Overlay.test.tsx +39 -0
- package/lib/components/Select/Select.mdx +1 -0
- package/lib/components/Select/__tests__/Select.test.tsx +137 -4
- package/lib/components/Select/subcomponents/CustomSelect.tsx +42 -43
- package/lib/components/Select/subcomponents/Panel.tsx +2 -8
- package/lib/components/index.ts +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { css } from '@emotion/css';
|
|
4
|
+
import { useTheme } from '../../theme';
|
|
5
|
+
import Datepicker from '../Datepicker';
|
|
6
|
+
import Field from '../Field';
|
|
7
|
+
import Heading from '../Heading';
|
|
8
|
+
import Label from '../Label';
|
|
9
|
+
import { LabelledCheckbox } from '../Checkbox';
|
|
10
|
+
import Select from '../Select';
|
|
11
|
+
import Table from '../Table';
|
|
12
|
+
import Filters from './Filters';
|
|
13
|
+
import Filter from './Filter';
|
|
14
|
+
import QuickFilter from './QuickFilter';
|
|
15
|
+
|
|
16
|
+
const meta = {
|
|
17
|
+
title: 'Components/Filters',
|
|
18
|
+
component: Filters,
|
|
19
|
+
parameters: {
|
|
20
|
+
layout: 'padded',
|
|
21
|
+
},
|
|
22
|
+
tags: ['autodocs'],
|
|
23
|
+
} satisfies Meta<typeof Filters>;
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
type Story = StoryObj<typeof meta>;
|
|
27
|
+
|
|
28
|
+
const records = [
|
|
29
|
+
{
|
|
30
|
+
name: 'Annual report',
|
|
31
|
+
type: 'PDF',
|
|
32
|
+
status: 'Endangered',
|
|
33
|
+
retained: '18 September 2026',
|
|
34
|
+
retainedDate: new Date(2026, 8, 18),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'Research data',
|
|
38
|
+
type: 'Spreadsheet',
|
|
39
|
+
status: 'Stable',
|
|
40
|
+
retained: '2 August 2026',
|
|
41
|
+
retainedDate: new Date(2026, 7, 2),
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'Project archive',
|
|
45
|
+
type: 'ZIP',
|
|
46
|
+
status: 'Endangered',
|
|
47
|
+
retained: '14 May 2025',
|
|
48
|
+
retainedDate: new Date(2025, 4, 14),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'Meeting notes',
|
|
52
|
+
type: 'Document',
|
|
53
|
+
status: 'Stable',
|
|
54
|
+
retained: '23 January 2025',
|
|
55
|
+
retainedDate: new Date(2025, 0, 23),
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const DEFAULT_STORY_SOURCE = `const RecordsFilters = () => {
|
|
60
|
+
const [appliedFileTypes, setAppliedFileTypes] = useState<string[]>([]);
|
|
61
|
+
const [draftFileTypes, setDraftFileTypes] = useState<string[]>([]);
|
|
62
|
+
|
|
63
|
+
const resetFileTypes = () => {
|
|
64
|
+
setAppliedFileTypes([]);
|
|
65
|
+
setDraftFileTypes([]);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Filters
|
|
70
|
+
appliedCount={appliedFileTypes.length}
|
|
71
|
+
onOpenChange={(open) => {
|
|
72
|
+
if (open) setDraftFileTypes(appliedFileTypes);
|
|
73
|
+
}}
|
|
74
|
+
onApply={() => setAppliedFileTypes(draftFileTypes)}
|
|
75
|
+
onReset={resetFileTypes}
|
|
76
|
+
>
|
|
77
|
+
<Filter
|
|
78
|
+
label='File type'
|
|
79
|
+
appliedCount={appliedFileTypes.length}
|
|
80
|
+
onOpenChange={(open) => {
|
|
81
|
+
if (open) setDraftFileTypes(appliedFileTypes);
|
|
82
|
+
}}
|
|
83
|
+
onClear={() => setDraftFileTypes([])}
|
|
84
|
+
onApply={() => setAppliedFileTypes(draftFileTypes)}
|
|
85
|
+
>
|
|
86
|
+
{['PDF', 'Spreadsheet', 'ZIP', 'Document'].map((fileType) => (
|
|
87
|
+
<LabelledCheckbox
|
|
88
|
+
key={fileType}
|
|
89
|
+
checked={draftFileTypes.includes(fileType)}
|
|
90
|
+
onChange={(event) => {
|
|
91
|
+
setDraftFileTypes((current) =>
|
|
92
|
+
event.target.checked
|
|
93
|
+
? [...current, fileType]
|
|
94
|
+
: current.filter((value) => value !== fileType)
|
|
95
|
+
);
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
{fileType}
|
|
99
|
+
</LabelledCheckbox>
|
|
100
|
+
))}
|
|
101
|
+
</Filter>
|
|
102
|
+
</Filters>
|
|
103
|
+
);
|
|
104
|
+
};`;
|
|
105
|
+
|
|
106
|
+
const datepickerStyle = css`
|
|
107
|
+
width: 100%;
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
const FiltersExample = () => {
|
|
111
|
+
const [theme] = useTheme();
|
|
112
|
+
const [appliedStartDate, setAppliedStartDate] = useState<Date | null>(null);
|
|
113
|
+
const [appliedEndDate, setAppliedEndDate] = useState<Date | null>(null);
|
|
114
|
+
const [appliedFileTypes, setAppliedFileTypes] = useState<string[]>([]);
|
|
115
|
+
const [appliedStatus, setAppliedStatus] = useState<string | null>(null);
|
|
116
|
+
const [draftStartDate, setDraftStartDate] = useState<Date | null>(null);
|
|
117
|
+
const [draftEndDate, setDraftEndDate] = useState<Date | null>(null);
|
|
118
|
+
const [draftFileTypes, setDraftFileTypes] = useState<string[]>([]);
|
|
119
|
+
const [draftStatus, setDraftStatus] = useState<string | null>(null);
|
|
120
|
+
|
|
121
|
+
const retentionCount = Number(Boolean(appliedStartDate || appliedEndDate));
|
|
122
|
+
const appliedCount =
|
|
123
|
+
retentionCount + appliedFileTypes.length + Number(!!appliedStatus);
|
|
124
|
+
const visibleRecords = records.filter(
|
|
125
|
+
(record) =>
|
|
126
|
+
(!appliedStartDate || record.retainedDate >= appliedStartDate) &&
|
|
127
|
+
(!appliedEndDate || record.retainedDate <= appliedEndDate) &&
|
|
128
|
+
(appliedFileTypes.length === 0 ||
|
|
129
|
+
appliedFileTypes.includes(record.type)) &&
|
|
130
|
+
(!appliedStatus || record.status === appliedStatus)
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const syncDrafts = () => {
|
|
134
|
+
setDraftStartDate(appliedStartDate);
|
|
135
|
+
setDraftEndDate(appliedEndDate);
|
|
136
|
+
setDraftFileTypes(appliedFileTypes);
|
|
137
|
+
setDraftStatus(appliedStatus);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const applyAll = () => {
|
|
141
|
+
setAppliedStartDate(draftStartDate);
|
|
142
|
+
setAppliedEndDate(draftEndDate);
|
|
143
|
+
setAppliedFileTypes(draftFileTypes);
|
|
144
|
+
setAppliedStatus(draftStatus);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const resetAll = () => {
|
|
148
|
+
setAppliedStartDate(null);
|
|
149
|
+
setAppliedEndDate(null);
|
|
150
|
+
setAppliedFileTypes([]);
|
|
151
|
+
setAppliedStatus(null);
|
|
152
|
+
setDraftStartDate(null);
|
|
153
|
+
setDraftEndDate(null);
|
|
154
|
+
setDraftFileTypes([]);
|
|
155
|
+
setDraftStatus(null);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const toggleFileType = (fileType: string, checked: boolean) => {
|
|
159
|
+
setDraftFileTypes((current) =>
|
|
160
|
+
checked
|
|
161
|
+
? [...current, fileType]
|
|
162
|
+
: current.filter((value) => value !== fileType)
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const pageStyle = css`
|
|
167
|
+
display: flex;
|
|
168
|
+
flex-direction: column;
|
|
169
|
+
gap: ${theme.margin.m24};
|
|
170
|
+
min-width: 0;
|
|
171
|
+
`;
|
|
172
|
+
|
|
173
|
+
const filterFieldsStyle = css`
|
|
174
|
+
display: grid;
|
|
175
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
176
|
+
gap: ${theme.margin.m24};
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
const filterFieldStyle = css`
|
|
180
|
+
min-width: 0;
|
|
181
|
+
width: 100%;
|
|
182
|
+
`;
|
|
183
|
+
|
|
184
|
+
const checkboxGroupStyle = css`
|
|
185
|
+
display: grid;
|
|
186
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
187
|
+
gap: ${theme.margin.m8} ${theme.margin.m24};
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div className={pageStyle}>
|
|
192
|
+
<Heading
|
|
193
|
+
level='lg'
|
|
194
|
+
as='h2'
|
|
195
|
+
noMargins
|
|
196
|
+
>
|
|
197
|
+
Records
|
|
198
|
+
</Heading>
|
|
199
|
+
<Filters
|
|
200
|
+
appliedCount={appliedCount}
|
|
201
|
+
onOpenChange={(open) => {
|
|
202
|
+
if (open) syncDrafts();
|
|
203
|
+
}}
|
|
204
|
+
onApply={applyAll}
|
|
205
|
+
onReset={resetAll}
|
|
206
|
+
>
|
|
207
|
+
<Filter
|
|
208
|
+
label='Retention date'
|
|
209
|
+
appliedCount={retentionCount}
|
|
210
|
+
onOpenChange={(open) => {
|
|
211
|
+
if (!open) return;
|
|
212
|
+
setDraftStartDate(appliedStartDate);
|
|
213
|
+
setDraftEndDate(appliedEndDate);
|
|
214
|
+
}}
|
|
215
|
+
onClear={() => {
|
|
216
|
+
setDraftStartDate(null);
|
|
217
|
+
setDraftEndDate(null);
|
|
218
|
+
}}
|
|
219
|
+
onApply={() => {
|
|
220
|
+
setAppliedStartDate(draftStartDate);
|
|
221
|
+
setAppliedEndDate(draftEndDate);
|
|
222
|
+
}}
|
|
223
|
+
quickFilters={
|
|
224
|
+
<>
|
|
225
|
+
<QuickFilter
|
|
226
|
+
onClick={() => {
|
|
227
|
+
setDraftStartDate(new Date(2026, 7, 24));
|
|
228
|
+
setDraftEndDate(new Date(2026, 8, 23));
|
|
229
|
+
}}
|
|
230
|
+
>
|
|
231
|
+
Last 30 days
|
|
232
|
+
</QuickFilter>
|
|
233
|
+
<QuickFilter
|
|
234
|
+
onClick={() => {
|
|
235
|
+
setDraftStartDate(new Date(2026, 5, 25));
|
|
236
|
+
setDraftEndDate(new Date(2026, 8, 23));
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
Last 90 days
|
|
240
|
+
</QuickFilter>
|
|
241
|
+
<QuickFilter
|
|
242
|
+
onClick={() => {
|
|
243
|
+
setDraftStartDate(new Date(2025, 8, 23));
|
|
244
|
+
setDraftEndDate(new Date(2026, 8, 23));
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
Last year
|
|
248
|
+
</QuickFilter>
|
|
249
|
+
</>
|
|
250
|
+
}
|
|
251
|
+
>
|
|
252
|
+
<div className={filterFieldsStyle}>
|
|
253
|
+
<Field className={filterFieldStyle}>
|
|
254
|
+
<Label>Start date</Label>
|
|
255
|
+
<Datepicker
|
|
256
|
+
value={draftStartDate}
|
|
257
|
+
onValueChange={setDraftStartDate}
|
|
258
|
+
clearable
|
|
259
|
+
className={datepickerStyle}
|
|
260
|
+
/>
|
|
261
|
+
</Field>
|
|
262
|
+
<Field className={filterFieldStyle}>
|
|
263
|
+
<Label>End date</Label>
|
|
264
|
+
<Datepicker
|
|
265
|
+
value={draftEndDate}
|
|
266
|
+
onValueChange={setDraftEndDate}
|
|
267
|
+
clearable
|
|
268
|
+
className={datepickerStyle}
|
|
269
|
+
/>
|
|
270
|
+
</Field>
|
|
271
|
+
</div>
|
|
272
|
+
</Filter>
|
|
273
|
+
<Filter
|
|
274
|
+
label='File type'
|
|
275
|
+
appliedCount={appliedFileTypes.length}
|
|
276
|
+
onOpenChange={(open) => {
|
|
277
|
+
if (open) setDraftFileTypes(appliedFileTypes);
|
|
278
|
+
}}
|
|
279
|
+
onClear={() => setDraftFileTypes([])}
|
|
280
|
+
onApply={() => setAppliedFileTypes(draftFileTypes)}
|
|
281
|
+
>
|
|
282
|
+
<div className={checkboxGroupStyle}>
|
|
283
|
+
{['PDF', 'Spreadsheet', 'ZIP', 'Document'].map((fileType) => (
|
|
284
|
+
<LabelledCheckbox
|
|
285
|
+
key={fileType}
|
|
286
|
+
checked={draftFileTypes.includes(fileType)}
|
|
287
|
+
onChange={(event) =>
|
|
288
|
+
toggleFileType(fileType, event.target.checked)
|
|
289
|
+
}
|
|
290
|
+
noMargins
|
|
291
|
+
>
|
|
292
|
+
{fileType}
|
|
293
|
+
</LabelledCheckbox>
|
|
294
|
+
))}
|
|
295
|
+
</div>
|
|
296
|
+
</Filter>
|
|
297
|
+
<Filter
|
|
298
|
+
label='Endangered status'
|
|
299
|
+
appliedCount={Number(!!appliedStatus)}
|
|
300
|
+
onOpenChange={(open) => {
|
|
301
|
+
if (open) setDraftStatus(appliedStatus);
|
|
302
|
+
}}
|
|
303
|
+
onClear={() => setDraftStatus(null)}
|
|
304
|
+
onApply={() => setAppliedStatus(draftStatus)}
|
|
305
|
+
>
|
|
306
|
+
<Field>
|
|
307
|
+
<Label>Status</Label>
|
|
308
|
+
<Select<string>
|
|
309
|
+
options={[
|
|
310
|
+
{ label: 'Endangered', value: 'Endangered' },
|
|
311
|
+
{ label: 'Stable', value: 'Stable' },
|
|
312
|
+
]}
|
|
313
|
+
value={draftStatus}
|
|
314
|
+
clearable
|
|
315
|
+
onValueChange={setDraftStatus}
|
|
316
|
+
placeholder='All statuses'
|
|
317
|
+
dropdownWidth='match-select'
|
|
318
|
+
/>
|
|
319
|
+
</Field>
|
|
320
|
+
</Filter>
|
|
321
|
+
</Filters>
|
|
322
|
+
<Table ariaLabel='Filtered records'>
|
|
323
|
+
<Table.Head>
|
|
324
|
+
<Table.HeadCell showSortIcon={false}>Name</Table.HeadCell>
|
|
325
|
+
<Table.HeadCell showSortIcon={false}>File type</Table.HeadCell>
|
|
326
|
+
<Table.HeadCell showSortIcon={false}>Status</Table.HeadCell>
|
|
327
|
+
<Table.HeadCell showSortIcon={false}>Retention date</Table.HeadCell>
|
|
328
|
+
</Table.Head>
|
|
329
|
+
<Table.Body>
|
|
330
|
+
{visibleRecords.map((record) => (
|
|
331
|
+
<Table.Row key={record.name}>
|
|
332
|
+
<Table.Cell>{record.name}</Table.Cell>
|
|
333
|
+
<Table.Cell>{record.type}</Table.Cell>
|
|
334
|
+
<Table.Cell>{record.status}</Table.Cell>
|
|
335
|
+
<Table.Cell>{record.retained}</Table.Cell>
|
|
336
|
+
</Table.Row>
|
|
337
|
+
))}
|
|
338
|
+
</Table.Body>
|
|
339
|
+
</Table>
|
|
340
|
+
</div>
|
|
341
|
+
);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
export const Default: Story = {
|
|
345
|
+
render: () => <FiltersExample />,
|
|
346
|
+
parameters: {
|
|
347
|
+
docs: {
|
|
348
|
+
source: {
|
|
349
|
+
code: DEFAULT_STORY_SOURCE,
|
|
350
|
+
language: 'tsx',
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
};
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HTMLAttributes,
|
|
3
|
+
memo,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { css, cx } from '@emotion/css';
|
|
12
|
+
import { useTheme } from '../../theme';
|
|
13
|
+
import useMediaQuery from '../../hooks/useMediaQuery';
|
|
14
|
+
import Button from '../Button';
|
|
15
|
+
import Heading from '../Heading';
|
|
16
|
+
import Icon from '../Icon';
|
|
17
|
+
import Overlay from '../Overlay';
|
|
18
|
+
import Filter from './Filter';
|
|
19
|
+
import QuickFilter from './QuickFilter';
|
|
20
|
+
import { FiltersContext } from './Filters.context';
|
|
21
|
+
|
|
22
|
+
export const NAME = 'ucl-uikit-filters';
|
|
23
|
+
|
|
24
|
+
export interface FiltersProps extends Omit<
|
|
25
|
+
HTMLAttributes<HTMLDivElement>,
|
|
26
|
+
'onChange' | 'onReset'
|
|
27
|
+
> {
|
|
28
|
+
label?: string;
|
|
29
|
+
/** Accessible label for the icon-only mobile and tablet trigger. */
|
|
30
|
+
triggerLabel?: string;
|
|
31
|
+
appliedCount?: number;
|
|
32
|
+
open?: boolean;
|
|
33
|
+
defaultOpen?: boolean;
|
|
34
|
+
applyDisabled?: boolean;
|
|
35
|
+
applyLabel?: string;
|
|
36
|
+
resetLabel?: string;
|
|
37
|
+
onOpenChange?: (open: boolean) => void;
|
|
38
|
+
onApply?: React.MouseEventHandler<HTMLButtonElement>;
|
|
39
|
+
onReset?: React.MouseEventHandler<HTMLButtonElement>;
|
|
40
|
+
testId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const FOCUSABLE_SELECTOR =
|
|
44
|
+
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
45
|
+
|
|
46
|
+
const Filters = ({
|
|
47
|
+
label = 'Filters',
|
|
48
|
+
triggerLabel = 'Filters',
|
|
49
|
+
appliedCount = 0,
|
|
50
|
+
open,
|
|
51
|
+
defaultOpen = false,
|
|
52
|
+
applyDisabled = false,
|
|
53
|
+
applyLabel = 'Apply filters',
|
|
54
|
+
resetLabel = 'Reset all',
|
|
55
|
+
onOpenChange,
|
|
56
|
+
onApply,
|
|
57
|
+
onReset,
|
|
58
|
+
testId = NAME,
|
|
59
|
+
className,
|
|
60
|
+
children,
|
|
61
|
+
...props
|
|
62
|
+
}: FiltersProps) => {
|
|
63
|
+
const [theme] = useTheme();
|
|
64
|
+
const isDesktop = useMediaQuery(
|
|
65
|
+
`(min-width: ${theme.breakpoints.desktop}px)`
|
|
66
|
+
);
|
|
67
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
68
|
+
const [activeFilterId, setActiveFilterId] = useState<string | null>(null);
|
|
69
|
+
const isOpen = open ?? internalOpen;
|
|
70
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
71
|
+
const sheetRef = useRef<HTMLDivElement>(null);
|
|
72
|
+
const sheetId = useId();
|
|
73
|
+
const openRef = useRef(open);
|
|
74
|
+
const onOpenChangeRef = useRef(onOpenChange);
|
|
75
|
+
openRef.current = open;
|
|
76
|
+
onOpenChangeRef.current = onOpenChange;
|
|
77
|
+
|
|
78
|
+
const setOpen = useCallback((nextOpen: boolean) => {
|
|
79
|
+
if (openRef.current === undefined) setInternalOpen(nextOpen);
|
|
80
|
+
onOpenChangeRef.current?.(nextOpen);
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (isDesktop || !isOpen) return;
|
|
85
|
+
|
|
86
|
+
const sheet = sheetRef.current;
|
|
87
|
+
const previousOverflow = document.body.style.overflow;
|
|
88
|
+
const previousActiveElement = document.activeElement as HTMLElement | null;
|
|
89
|
+
document.body.style.overflow = 'hidden';
|
|
90
|
+
|
|
91
|
+
const focusableElements = () =>
|
|
92
|
+
Array.from(
|
|
93
|
+
sheet?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? []
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
focusableElements()[0]?.focus();
|
|
97
|
+
|
|
98
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
99
|
+
if (event.key === 'Escape') {
|
|
100
|
+
setOpen(false);
|
|
101
|
+
triggerRef.current?.focus();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (event.key !== 'Tab') return;
|
|
106
|
+
const elements = focusableElements();
|
|
107
|
+
if (elements.length === 0) {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
sheet?.focus();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const first = elements[0];
|
|
114
|
+
const last = elements[elements.length - 1];
|
|
115
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
last.focus();
|
|
118
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
first.focus();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
125
|
+
|
|
126
|
+
return () => {
|
|
127
|
+
document.body.style.overflow = previousOverflow;
|
|
128
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
129
|
+
if (document.contains(previousActiveElement)) {
|
|
130
|
+
previousActiveElement?.focus();
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}, [isDesktop, isOpen, setOpen]);
|
|
134
|
+
|
|
135
|
+
const contextValue = useMemo(
|
|
136
|
+
() => ({
|
|
137
|
+
isDesktop,
|
|
138
|
+
closeFilters: () => setOpen(false),
|
|
139
|
+
activeFilterId,
|
|
140
|
+
setActiveFilterId,
|
|
141
|
+
}),
|
|
142
|
+
[activeFilterId, isDesktop, setOpen]
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const handleApply: React.MouseEventHandler<HTMLButtonElement> = (event) => {
|
|
146
|
+
onApply?.(event);
|
|
147
|
+
if (!event.defaultPrevented) setOpen(false);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const baseStyle = css`
|
|
151
|
+
display: flex;
|
|
152
|
+
align-items: center;
|
|
153
|
+
gap: ${theme.margin.m16};
|
|
154
|
+
color: ${theme.colour.text.default};
|
|
155
|
+
`;
|
|
156
|
+
|
|
157
|
+
const desktopStyle = css`
|
|
158
|
+
flex-wrap: wrap;
|
|
159
|
+
`;
|
|
160
|
+
|
|
161
|
+
const style = cx(NAME, baseStyle, isDesktop && desktopStyle, className);
|
|
162
|
+
|
|
163
|
+
const mobileTriggerLabel =
|
|
164
|
+
appliedCount > 0
|
|
165
|
+
? `${triggerLabel}, ${appliedCount} applied`
|
|
166
|
+
: triggerLabel;
|
|
167
|
+
|
|
168
|
+
const mobileOverlayStyle = css`
|
|
169
|
+
position: fixed;
|
|
170
|
+
inset: auto 0 0;
|
|
171
|
+
width: 100vw;
|
|
172
|
+
max-height: calc(100dvh - ${theme.margin.m32});
|
|
173
|
+
overflow-y: auto;
|
|
174
|
+
border-radius: ${theme.radius.r4} ${theme.radius.r4} 0 0;
|
|
175
|
+
box-sizing: border-box;
|
|
176
|
+
background-color: ${theme.colour.surface.default};
|
|
177
|
+
color: ${theme.colour.text.default};
|
|
178
|
+
`;
|
|
179
|
+
|
|
180
|
+
const sheetStyle = css`
|
|
181
|
+
display: flex;
|
|
182
|
+
flex-direction: column;
|
|
183
|
+
gap: ${theme.margin.m16};
|
|
184
|
+
width: 100%;
|
|
185
|
+
padding: ${theme.padding.p24} ${theme.padding.p16};
|
|
186
|
+
box-sizing: border-box;
|
|
187
|
+
`;
|
|
188
|
+
|
|
189
|
+
const handleStyle = css`
|
|
190
|
+
width: 32px;
|
|
191
|
+
height: 4px;
|
|
192
|
+
margin: 0 auto;
|
|
193
|
+
border-radius: ${theme.radius.r4};
|
|
194
|
+
background-color: ${theme.colour.border.subtle};
|
|
195
|
+
`;
|
|
196
|
+
|
|
197
|
+
const sheetHeaderStyle = css`
|
|
198
|
+
display: flex;
|
|
199
|
+
align-items: center;
|
|
200
|
+
justify-content: space-between;
|
|
201
|
+
gap: ${theme.margin.m16};
|
|
202
|
+
`;
|
|
203
|
+
|
|
204
|
+
const filtersListStyle = css`
|
|
205
|
+
display: flex;
|
|
206
|
+
flex-direction: column;
|
|
207
|
+
`;
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<FiltersContext.Provider value={contextValue}>
|
|
211
|
+
<div
|
|
212
|
+
className={style}
|
|
213
|
+
data-testid={testId}
|
|
214
|
+
{...props}
|
|
215
|
+
>
|
|
216
|
+
{isDesktop ? (
|
|
217
|
+
<>
|
|
218
|
+
{children}
|
|
219
|
+
{onReset && (
|
|
220
|
+
<Button
|
|
221
|
+
variant='tertiary-no-padding'
|
|
222
|
+
size='small'
|
|
223
|
+
onClick={onReset}
|
|
224
|
+
>
|
|
225
|
+
{resetLabel}
|
|
226
|
+
</Button>
|
|
227
|
+
)}
|
|
228
|
+
</>
|
|
229
|
+
) : (
|
|
230
|
+
<>
|
|
231
|
+
<Button
|
|
232
|
+
ref={triggerRef}
|
|
233
|
+
variant='secondary'
|
|
234
|
+
size='medium'
|
|
235
|
+
selected={isOpen || appliedCount > 0}
|
|
236
|
+
icon={<Icon.Filter size={20} />}
|
|
237
|
+
aria-label={mobileTriggerLabel}
|
|
238
|
+
aria-haspopup='dialog'
|
|
239
|
+
aria-expanded={isOpen}
|
|
240
|
+
aria-controls={sheetId}
|
|
241
|
+
onClick={() => setOpen(!isOpen)}
|
|
242
|
+
/>
|
|
243
|
+
{isOpen && (
|
|
244
|
+
<Overlay
|
|
245
|
+
reference={triggerRef}
|
|
246
|
+
positioning='viewport'
|
|
247
|
+
placement='bottom'
|
|
248
|
+
blanket
|
|
249
|
+
flip={false}
|
|
250
|
+
shift={false}
|
|
251
|
+
className={mobileOverlayStyle}
|
|
252
|
+
onBlanketClick={() => setOpen(false)}
|
|
253
|
+
>
|
|
254
|
+
<div
|
|
255
|
+
ref={sheetRef}
|
|
256
|
+
id={sheetId}
|
|
257
|
+
className={sheetStyle}
|
|
258
|
+
role='dialog'
|
|
259
|
+
aria-modal='true'
|
|
260
|
+
aria-label={label}
|
|
261
|
+
tabIndex={-1}
|
|
262
|
+
>
|
|
263
|
+
<div
|
|
264
|
+
className={handleStyle}
|
|
265
|
+
aria-hidden='true'
|
|
266
|
+
/>
|
|
267
|
+
<div className={sheetHeaderStyle}>
|
|
268
|
+
<Heading
|
|
269
|
+
as='h2'
|
|
270
|
+
level='md'
|
|
271
|
+
noMargins
|
|
272
|
+
>
|
|
273
|
+
{label}
|
|
274
|
+
</Heading>
|
|
275
|
+
{onReset && (
|
|
276
|
+
<Button
|
|
277
|
+
variant='tertiary-no-padding'
|
|
278
|
+
size='small'
|
|
279
|
+
onClick={onReset}
|
|
280
|
+
>
|
|
281
|
+
{resetLabel}
|
|
282
|
+
</Button>
|
|
283
|
+
)}
|
|
284
|
+
</div>
|
|
285
|
+
<div className={filtersListStyle}>{children}</div>
|
|
286
|
+
{onApply && (
|
|
287
|
+
<Button
|
|
288
|
+
size='small'
|
|
289
|
+
fullWidth
|
|
290
|
+
disabled={applyDisabled}
|
|
291
|
+
onClick={handleApply}
|
|
292
|
+
>
|
|
293
|
+
{applyLabel}
|
|
294
|
+
</Button>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
</Overlay>
|
|
298
|
+
)}
|
|
299
|
+
</>
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
</FiltersContext.Provider>
|
|
303
|
+
);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
export interface FiltersSubComponents {
|
|
307
|
+
Filter: typeof Filter;
|
|
308
|
+
QuickFilter: typeof QuickFilter;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const FiltersWithSubComponents = Object.assign(memo(Filters), {
|
|
312
|
+
Filter,
|
|
313
|
+
QuickFilter,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
export default FiltersWithSubComponents;
|