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,327 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HTMLAttributes,
|
|
3
|
+
memo,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { css, cx } from '@emotion/css';
|
|
12
|
+
import { useTheme } from '../../theme';
|
|
13
|
+
import Button from '../Button';
|
|
14
|
+
import Icon from '../Icon';
|
|
15
|
+
import Overlay from '../Overlay';
|
|
16
|
+
import Text from '../Text';
|
|
17
|
+
import FilterPanel from './FilterPanel';
|
|
18
|
+
import { useFiltersContext } from './Filters.context';
|
|
19
|
+
|
|
20
|
+
export const NAME = 'ucl-uikit-filter';
|
|
21
|
+
|
|
22
|
+
export interface FilterProps extends Omit<
|
|
23
|
+
HTMLAttributes<HTMLDivElement>,
|
|
24
|
+
'onChange'
|
|
25
|
+
> {
|
|
26
|
+
label: string;
|
|
27
|
+
appliedCount?: number;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
open?: boolean;
|
|
30
|
+
defaultOpen?: boolean;
|
|
31
|
+
quickFilters?: ReactNode;
|
|
32
|
+
clearLabel?: string;
|
|
33
|
+
applyLabel?: string;
|
|
34
|
+
applyDisabled?: boolean;
|
|
35
|
+
onClear?: React.MouseEventHandler<HTMLButtonElement>;
|
|
36
|
+
onApply?: React.MouseEventHandler<HTMLButtonElement>;
|
|
37
|
+
panelClassName?: string;
|
|
38
|
+
onOpenChange?: (open: boolean) => void;
|
|
39
|
+
testId?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const Filter = ({
|
|
43
|
+
label,
|
|
44
|
+
appliedCount = 0,
|
|
45
|
+
disabled = false,
|
|
46
|
+
open,
|
|
47
|
+
defaultOpen = false,
|
|
48
|
+
quickFilters,
|
|
49
|
+
clearLabel,
|
|
50
|
+
applyLabel,
|
|
51
|
+
applyDisabled = false,
|
|
52
|
+
onClear,
|
|
53
|
+
onApply,
|
|
54
|
+
panelClassName,
|
|
55
|
+
onOpenChange,
|
|
56
|
+
testId = NAME,
|
|
57
|
+
className,
|
|
58
|
+
children,
|
|
59
|
+
...props
|
|
60
|
+
}: FilterProps) => {
|
|
61
|
+
const [theme] = useTheme();
|
|
62
|
+
const filtersContext = useFiltersContext();
|
|
63
|
+
const isDesktop = filtersContext?.isDesktop ?? true;
|
|
64
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
65
|
+
const filterId = useId();
|
|
66
|
+
const groupedOpen = filtersContext?.activeFilterId === filterId;
|
|
67
|
+
const isOpen = open ?? (filtersContext ? groupedOpen : internalOpen);
|
|
68
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
69
|
+
const panelContainerRef = useRef<HTMLDivElement>(null);
|
|
70
|
+
const didApplyDefaultOpenRef = useRef(false);
|
|
71
|
+
const panelId = useId();
|
|
72
|
+
|
|
73
|
+
const setOpen = useCallback(
|
|
74
|
+
(nextOpen: boolean) => {
|
|
75
|
+
if (open === undefined) {
|
|
76
|
+
if (filtersContext) {
|
|
77
|
+
filtersContext.setActiveFilterId(nextOpen ? filterId : null);
|
|
78
|
+
} else {
|
|
79
|
+
setInternalOpen(nextOpen);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
onOpenChange?.(nextOpen);
|
|
83
|
+
},
|
|
84
|
+
[filterId, filtersContext, onOpenChange, open]
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (didApplyDefaultOpenRef.current || !filtersContext) return;
|
|
89
|
+
|
|
90
|
+
didApplyDefaultOpenRef.current = true;
|
|
91
|
+
if (defaultOpen && open === undefined) {
|
|
92
|
+
filtersContext.setActiveFilterId(filterId);
|
|
93
|
+
}
|
|
94
|
+
}, [defaultOpen, filterId, filtersContext, open]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!isDesktop || !isOpen) return;
|
|
98
|
+
|
|
99
|
+
const handlePointerDown = (event: MouseEvent) => {
|
|
100
|
+
const target = event.target as Node;
|
|
101
|
+
if (
|
|
102
|
+
triggerRef.current?.contains(target) ||
|
|
103
|
+
panelContainerRef.current?.contains(target)
|
|
104
|
+
) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
setOpen(false);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
111
|
+
if (event.key !== 'Escape') return;
|
|
112
|
+
setOpen(false);
|
|
113
|
+
triggerRef.current?.focus();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
document.addEventListener('mousedown', handlePointerDown);
|
|
117
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
118
|
+
|
|
119
|
+
return () => {
|
|
120
|
+
document.removeEventListener('mousedown', handlePointerDown);
|
|
121
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
122
|
+
};
|
|
123
|
+
}, [isDesktop, isOpen, setOpen]);
|
|
124
|
+
|
|
125
|
+
const baseStyle = css`
|
|
126
|
+
display: flex;
|
|
127
|
+
min-width: 0;
|
|
128
|
+
flex-direction: column;
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
const mobileStyle = css`
|
|
132
|
+
width: 100%;
|
|
133
|
+
padding-top: ${theme.padding.p16};
|
|
134
|
+
|
|
135
|
+
&:first-of-type {
|
|
136
|
+
padding-top: 0;
|
|
137
|
+
}
|
|
138
|
+
`;
|
|
139
|
+
|
|
140
|
+
const style = cx(NAME, baseStyle, !isDesktop && mobileStyle, className);
|
|
141
|
+
|
|
142
|
+
const triggerStyle = css`
|
|
143
|
+
min-width: 0;
|
|
144
|
+
border-width: ${theme.border.b1};
|
|
145
|
+
border-radius: 0;
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
const triggerLabelStyle = css`
|
|
149
|
+
overflow: hidden;
|
|
150
|
+
text-overflow: ellipsis;
|
|
151
|
+
white-space: nowrap;
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
const countStyle = css`
|
|
155
|
+
display: inline-flex;
|
|
156
|
+
min-width: 24px;
|
|
157
|
+
height: 24px;
|
|
158
|
+
padding: ${theme.padding.p4} ${theme.padding.p8};
|
|
159
|
+
align-items: center;
|
|
160
|
+
justify-content: center;
|
|
161
|
+
box-sizing: border-box;
|
|
162
|
+
background-color: ${theme.colour.fill.brandSubtleSelected};
|
|
163
|
+
color: ${theme.colour.text.brandHover};
|
|
164
|
+
`;
|
|
165
|
+
|
|
166
|
+
const chevronStyle = css`
|
|
167
|
+
flex-shrink: 0;
|
|
168
|
+
transform: rotate(${isOpen ? '180deg' : '0deg'});
|
|
169
|
+
transition: transform 0.15s ease-out;
|
|
170
|
+
|
|
171
|
+
@media (prefers-reduced-motion: reduce) {
|
|
172
|
+
transition: none;
|
|
173
|
+
}
|
|
174
|
+
`;
|
|
175
|
+
|
|
176
|
+
const mobileTriggerStyle = css`
|
|
177
|
+
all: unset;
|
|
178
|
+
display: flex;
|
|
179
|
+
width: 100%;
|
|
180
|
+
min-height: 24px;
|
|
181
|
+
align-items: center;
|
|
182
|
+
justify-content: space-between;
|
|
183
|
+
gap: ${theme.margin.m16};
|
|
184
|
+
box-sizing: border-box;
|
|
185
|
+
color: ${theme.colour.text.default};
|
|
186
|
+
cursor: ${disabled ? 'not-allowed' : 'pointer'};
|
|
187
|
+
|
|
188
|
+
&:focus-visible {
|
|
189
|
+
box-shadow: ${theme.boxShadow.focus};
|
|
190
|
+
}
|
|
191
|
+
`;
|
|
192
|
+
|
|
193
|
+
const mobileLabelStyle = css`
|
|
194
|
+
display: flex;
|
|
195
|
+
min-width: 0;
|
|
196
|
+
align-items: center;
|
|
197
|
+
gap: ${theme.margin.m12};
|
|
198
|
+
`;
|
|
199
|
+
|
|
200
|
+
const mobileCountStyle = css`
|
|
201
|
+
min-width: 24px;
|
|
202
|
+
height: 24px;
|
|
203
|
+
padding: ${theme.padding.p4} ${theme.padding.p8};
|
|
204
|
+
`;
|
|
205
|
+
|
|
206
|
+
const mobilePanelStyle = css`
|
|
207
|
+
padding-top: ${theme.padding.p24};
|
|
208
|
+
`;
|
|
209
|
+
|
|
210
|
+
const dividerStyle = css`
|
|
211
|
+
width: 100%;
|
|
212
|
+
height: ${theme.border.b1};
|
|
213
|
+
margin-top: ${theme.margin.m16};
|
|
214
|
+
background-color: ${theme.colour.border.subtle};
|
|
215
|
+
`;
|
|
216
|
+
|
|
217
|
+
const panel = (
|
|
218
|
+
<FilterPanel
|
|
219
|
+
label={label}
|
|
220
|
+
isDesktop={isDesktop}
|
|
221
|
+
closeFilter={() => setOpen(false)}
|
|
222
|
+
quickFilters={quickFilters}
|
|
223
|
+
clearLabel={clearLabel}
|
|
224
|
+
applyLabel={applyLabel}
|
|
225
|
+
applyDisabled={applyDisabled}
|
|
226
|
+
onClear={onClear}
|
|
227
|
+
onApply={onApply}
|
|
228
|
+
className={panelClassName}
|
|
229
|
+
>
|
|
230
|
+
{children}
|
|
231
|
+
</FilterPanel>
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<div
|
|
236
|
+
className={style}
|
|
237
|
+
data-testid={testId}
|
|
238
|
+
{...props}
|
|
239
|
+
>
|
|
240
|
+
{isDesktop ? (
|
|
241
|
+
<>
|
|
242
|
+
<Button
|
|
243
|
+
ref={triggerRef}
|
|
244
|
+
variant='secondary'
|
|
245
|
+
size='small'
|
|
246
|
+
selected={isOpen || appliedCount > 0}
|
|
247
|
+
disabled={disabled}
|
|
248
|
+
className={triggerStyle}
|
|
249
|
+
aria-haspopup='dialog'
|
|
250
|
+
aria-expanded={isOpen}
|
|
251
|
+
aria-controls={panelId}
|
|
252
|
+
onClick={() => setOpen(!isOpen)}
|
|
253
|
+
>
|
|
254
|
+
<span className={triggerLabelStyle}>{label}</span>
|
|
255
|
+
{appliedCount > 0 && (
|
|
256
|
+
<Text
|
|
257
|
+
level='xs-medium'
|
|
258
|
+
className={countStyle}
|
|
259
|
+
>
|
|
260
|
+
{appliedCount}
|
|
261
|
+
</Text>
|
|
262
|
+
)}
|
|
263
|
+
<Icon.ChevronDown
|
|
264
|
+
size={16}
|
|
265
|
+
className={chevronStyle}
|
|
266
|
+
/>
|
|
267
|
+
</Button>
|
|
268
|
+
{isOpen && (
|
|
269
|
+
<Overlay
|
|
270
|
+
reference={triggerRef}
|
|
271
|
+
placement='bottom-start'
|
|
272
|
+
offset={8}
|
|
273
|
+
size={{ padding: 16 }}
|
|
274
|
+
>
|
|
275
|
+
<div
|
|
276
|
+
ref={panelContainerRef}
|
|
277
|
+
id={panelId}
|
|
278
|
+
role='dialog'
|
|
279
|
+
aria-label={`${label} filter`}
|
|
280
|
+
>
|
|
281
|
+
{panel}
|
|
282
|
+
</div>
|
|
283
|
+
</Overlay>
|
|
284
|
+
)}
|
|
285
|
+
</>
|
|
286
|
+
) : (
|
|
287
|
+
<>
|
|
288
|
+
<button
|
|
289
|
+
type='button'
|
|
290
|
+
className={mobileTriggerStyle}
|
|
291
|
+
disabled={disabled}
|
|
292
|
+
aria-expanded={isOpen}
|
|
293
|
+
aria-controls={panelId}
|
|
294
|
+
onClick={() => setOpen(!isOpen)}
|
|
295
|
+
>
|
|
296
|
+
<span className={mobileLabelStyle}>
|
|
297
|
+
<Text level='md-semibold'>{label}</Text>
|
|
298
|
+
{appliedCount > 0 && (
|
|
299
|
+
<Text
|
|
300
|
+
level='xs-medium'
|
|
301
|
+
className={cx(countStyle, mobileCountStyle)}
|
|
302
|
+
>
|
|
303
|
+
{appliedCount}
|
|
304
|
+
</Text>
|
|
305
|
+
)}
|
|
306
|
+
</span>
|
|
307
|
+
<Icon.ChevronDown
|
|
308
|
+
size={24}
|
|
309
|
+
className={chevronStyle}
|
|
310
|
+
/>
|
|
311
|
+
</button>
|
|
312
|
+
{isOpen && (
|
|
313
|
+
<div
|
|
314
|
+
id={panelId}
|
|
315
|
+
className={mobilePanelStyle}
|
|
316
|
+
>
|
|
317
|
+
{panel}
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
<div className={dividerStyle} />
|
|
321
|
+
</>
|
|
322
|
+
)}
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
export default memo(Filter);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { HTMLAttributes, ReactNode, memo } from 'react';
|
|
2
|
+
import { css, cx } from '@emotion/css';
|
|
3
|
+
import { useTheme } from '../../theme';
|
|
4
|
+
import Button from '../Button';
|
|
5
|
+
import Heading from '../Heading';
|
|
6
|
+
|
|
7
|
+
export const NAME = 'ucl-uikit-filter-panel';
|
|
8
|
+
|
|
9
|
+
interface FilterPanelProps extends HTMLAttributes<HTMLDivElement> {
|
|
10
|
+
label: string;
|
|
11
|
+
isDesktop: boolean;
|
|
12
|
+
closeFilter: () => void;
|
|
13
|
+
quickFilters?: ReactNode;
|
|
14
|
+
clearLabel?: string;
|
|
15
|
+
applyLabel?: string;
|
|
16
|
+
applyDisabled?: boolean;
|
|
17
|
+
onClear?: React.MouseEventHandler<HTMLButtonElement>;
|
|
18
|
+
onApply?: React.MouseEventHandler<HTMLButtonElement>;
|
|
19
|
+
testId?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const FilterPanel = ({
|
|
23
|
+
label,
|
|
24
|
+
isDesktop,
|
|
25
|
+
closeFilter,
|
|
26
|
+
quickFilters,
|
|
27
|
+
clearLabel = 'Clear',
|
|
28
|
+
applyLabel = 'Apply',
|
|
29
|
+
applyDisabled = false,
|
|
30
|
+
onClear,
|
|
31
|
+
onApply,
|
|
32
|
+
testId = NAME,
|
|
33
|
+
className,
|
|
34
|
+
children,
|
|
35
|
+
onClick,
|
|
36
|
+
...props
|
|
37
|
+
}: FilterPanelProps) => {
|
|
38
|
+
const [theme] = useTheme();
|
|
39
|
+
|
|
40
|
+
const handleApply: React.MouseEventHandler<HTMLButtonElement> = (event) => {
|
|
41
|
+
onApply?.(event);
|
|
42
|
+
if (!event.defaultPrevented) closeFilter();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const baseStyle = css`
|
|
46
|
+
display: flex;
|
|
47
|
+
flex-direction: column;
|
|
48
|
+
gap: ${theme.margin.m24};
|
|
49
|
+
width: ${isDesktop ? 'min(484px, calc(100vw - 32px))' : '100%'};
|
|
50
|
+
padding: ${isDesktop ? theme.padding.p32 : '0'};
|
|
51
|
+
border: ${
|
|
52
|
+
isDesktop
|
|
53
|
+
? `${theme.border.b1} solid ${theme.colour.border.disabled}`
|
|
54
|
+
: 'none'
|
|
55
|
+
};
|
|
56
|
+
box-sizing: border-box;
|
|
57
|
+
background-color: ${theme.colour.surface.default};
|
|
58
|
+
color: ${theme.colour.text.default};
|
|
59
|
+
box-shadow: ${isDesktop ? theme.boxShadow.y1 : 'none'};
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
const style = cx(NAME, baseStyle, className);
|
|
63
|
+
|
|
64
|
+
const headerStyle = css`
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: flex-start;
|
|
67
|
+
justify-content: space-between;
|
|
68
|
+
gap: ${theme.margin.m16};
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
const headingAndQuickFiltersStyle = css`
|
|
72
|
+
display: flex;
|
|
73
|
+
min-width: 0;
|
|
74
|
+
flex: 1;
|
|
75
|
+
flex-direction: column;
|
|
76
|
+
gap: ${theme.margin.m24};
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
const quickFiltersStyle = css`
|
|
80
|
+
display: flex;
|
|
81
|
+
flex-wrap: wrap;
|
|
82
|
+
align-items: center;
|
|
83
|
+
gap: ${isDesktop ? theme.margin.m16 : theme.margin.m8};
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
const contentStyle = css`
|
|
87
|
+
display: flex;
|
|
88
|
+
flex-direction: column;
|
|
89
|
+
gap: ${theme.margin.m24};
|
|
90
|
+
min-width: 0;
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
const footerStyle = css`
|
|
94
|
+
display: flex;
|
|
95
|
+
justify-content: flex-end;
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
className={style}
|
|
101
|
+
data-testid={testId}
|
|
102
|
+
onClick={(event) => {
|
|
103
|
+
event.stopPropagation();
|
|
104
|
+
onClick?.(event);
|
|
105
|
+
}}
|
|
106
|
+
{...props}
|
|
107
|
+
>
|
|
108
|
+
{isDesktop && (
|
|
109
|
+
<div className={headerStyle}>
|
|
110
|
+
<div className={headingAndQuickFiltersStyle}>
|
|
111
|
+
<Heading
|
|
112
|
+
as='h2'
|
|
113
|
+
level='sm'
|
|
114
|
+
noMargins
|
|
115
|
+
>
|
|
116
|
+
{label}
|
|
117
|
+
</Heading>
|
|
118
|
+
{quickFilters && (
|
|
119
|
+
<div className={quickFiltersStyle}>{quickFilters}</div>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
{onClear && (
|
|
123
|
+
<Button
|
|
124
|
+
variant='tertiary-no-padding'
|
|
125
|
+
size='small'
|
|
126
|
+
onClick={onClear}
|
|
127
|
+
>
|
|
128
|
+
{clearLabel}
|
|
129
|
+
</Button>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
<div className={contentStyle}>{children}</div>
|
|
134
|
+
{!isDesktop && quickFilters && (
|
|
135
|
+
<div className={quickFiltersStyle}>{quickFilters}</div>
|
|
136
|
+
)}
|
|
137
|
+
{isDesktop && onApply && (
|
|
138
|
+
<div className={footerStyle}>
|
|
139
|
+
<Button
|
|
140
|
+
size='small'
|
|
141
|
+
disabled={applyDisabled}
|
|
142
|
+
onClick={handleApply}
|
|
143
|
+
>
|
|
144
|
+
{applyLabel}
|
|
145
|
+
</Button>
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export default memo(FilterPanel);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createContext, use } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface FiltersContextValue {
|
|
4
|
+
isDesktop: boolean;
|
|
5
|
+
closeFilters: () => void;
|
|
6
|
+
activeFilterId: string | null;
|
|
7
|
+
setActiveFilterId: (id: string | null) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const FiltersContext = createContext<FiltersContextValue | null>(null);
|
|
11
|
+
|
|
12
|
+
export const useFiltersContext = () => use(FiltersContext);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as FiltersStories from './Filters.stories';
|
|
2
|
+
import {
|
|
3
|
+
Meta,
|
|
4
|
+
Title,
|
|
5
|
+
Subtitle,
|
|
6
|
+
Canvas,
|
|
7
|
+
Controls,
|
|
8
|
+
} from '@storybook/addon-docs/blocks';
|
|
9
|
+
|
|
10
|
+
<Meta of={FiltersStories} />
|
|
11
|
+
<Title />
|
|
12
|
+
<Subtitle>
|
|
13
|
+
A responsive, composable group of controls for filtering tables and other
|
|
14
|
+
content.
|
|
15
|
+
</Subtitle>
|
|
16
|
+
|
|
17
|
+
Use `Filters` to group one or more `Filter` components. Each `Filter` opens an
|
|
18
|
+
anchored panel on desktop. On mobile and tablet, the group opens in a single
|
|
19
|
+
modal bottom sheet and each filter expands inline.
|
|
20
|
+
|
|
21
|
+
`Filter` accepts arbitrary controls as children, so filters can be composed
|
|
22
|
+
from `Input`, `Datepicker`, `Select`, radios, checkboxes, `Chip`, `Toggle`,
|
|
23
|
+
`Search`, or application-specific controls. `QuickFilter` is a compact toggle
|
|
24
|
+
button for common preset values.
|
|
25
|
+
|
|
26
|
+
<Canvas
|
|
27
|
+
of={FiltersStories.Default}
|
|
28
|
+
sourceState='shown'
|
|
29
|
+
/>
|
|
30
|
+
|
|
31
|
+
## Composition
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
<Filters
|
|
35
|
+
appliedCount={selectedFileTypes.length}
|
|
36
|
+
onReset={resetFilters}
|
|
37
|
+
onApply={applyFilters}
|
|
38
|
+
>
|
|
39
|
+
<Filter
|
|
40
|
+
label='File type'
|
|
41
|
+
appliedCount={selectedFileTypes.length}
|
|
42
|
+
onClear={clearFileTypes}
|
|
43
|
+
onApply={applyFileTypes}
|
|
44
|
+
quickFilters={
|
|
45
|
+
<QuickFilter onClick={selectDocuments}>Documents</QuickFilter>
|
|
46
|
+
}
|
|
47
|
+
>
|
|
48
|
+
<LabelledCheckbox>PDF</LabelledCheckbox>
|
|
49
|
+
<LabelledCheckbox>Spreadsheet</LabelledCheckbox>
|
|
50
|
+
</Filter>
|
|
51
|
+
</Filters>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The same API is also available as compound properties:
|
|
55
|
+
`Filters.Filter` and `Filters.QuickFilter`.
|
|
56
|
+
|
|
57
|
+
## Responsive behaviour
|
|
58
|
+
|
|
59
|
+
- **Desktop:** Filters are arranged horizontally. Each trigger opens its own
|
|
60
|
+
`Overlay`-positioned panel and provides panel-level clear/apply actions.
|
|
61
|
+
- **Mobile and tablet:** A single Filters trigger opens a modal bottom sheet.
|
|
62
|
+
Filter headings behave as expandable sections, Reset all appears in the
|
|
63
|
+
sheet header, and Apply filters spans the sheet width.
|
|
64
|
+
|
|
65
|
+
## State and accessibility
|
|
66
|
+
|
|
67
|
+
Use `appliedCount` on each `Filter` and on the group to communicate active
|
|
68
|
+
criteria. `open` and `onOpenChange` provide controlled opening; `defaultOpen`
|
|
69
|
+
provides uncontrolled opening.
|
|
70
|
+
|
|
71
|
+
Filter triggers expose `aria-expanded` and `aria-controls`. Desktop panels and
|
|
72
|
+
the mobile sheet use dialog semantics, Escape closes an open panel, and focus
|
|
73
|
+
returns to the trigger after the mobile sheet closes.
|
|
74
|
+
|
|
75
|
+
<Controls />
|