uikit-react-public 0.49.6 → 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.
@@ -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 />