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,89 @@
1
+ import { ButtonHTMLAttributes, memo } from 'react';
2
+ import { css, cx } from '@emotion/css';
3
+ import { useTheme } from '../../theme';
4
+ import { useFiltersContext } from './Filters.context';
5
+
6
+ export const NAME = 'ucl-uikit-quick-filter';
7
+
8
+ export interface QuickFilterProps extends ButtonHTMLAttributes<HTMLButtonElement> {
9
+ selected?: boolean;
10
+ testId?: string;
11
+ }
12
+
13
+ const QuickFilter = ({
14
+ selected = false,
15
+ disabled = false,
16
+ testId = NAME,
17
+ className,
18
+ children,
19
+ type = 'button',
20
+ ...props
21
+ }: QuickFilterProps) => {
22
+ const [theme] = useTheme();
23
+ const filtersContext = useFiltersContext();
24
+ const isDesktop = filtersContext?.isDesktop ?? true;
25
+ const typography = isDesktop
26
+ ? theme.typography.body.sm
27
+ : theme.typography.body.smSemibold;
28
+
29
+ const baseStyle = css`
30
+ min-height: ${isDesktop ? '28px' : '32px'};
31
+ padding: ${theme.padding.p4}
32
+ ${isDesktop ? theme.padding.p8 : theme.padding.p16};
33
+ border: ${theme.border.b1} solid ${theme.colour.border.brand};
34
+ border-radius: ${isDesktop ? theme.radius.r4 : theme.radius.r16};
35
+ box-sizing: border-box;
36
+ background-color: ${theme.colour.fill.default};
37
+ color: ${theme.colour.text.brand};
38
+ font-family: ${typography.fontFamily};
39
+ font-feature-settings: ${typography.fontSettings};
40
+ font-size: ${typography.fontSize}px;
41
+ font-weight: ${typography.fontWeight};
42
+ line-height: ${typography.lineHeight}%;
43
+ cursor: pointer;
44
+
45
+ &:hover {
46
+ background-color: ${theme.colour.fill.brandSubtleHover};
47
+ }
48
+
49
+ &:focus-visible {
50
+ outline: none;
51
+ box-shadow: ${theme.boxShadow.focus};
52
+ }
53
+ `;
54
+
55
+ const selectedStyle = css`
56
+ background-color: ${theme.colour.fill.brandSubtleSelected};
57
+ color: ${theme.colour.text.brandHover};
58
+ `;
59
+
60
+ const disabledStyle = css`
61
+ border-color: ${theme.colour.border.disabled};
62
+ background-color: ${theme.colour.fill.disabled};
63
+ color: ${theme.colour.text.disabled};
64
+ cursor: not-allowed;
65
+ `;
66
+
67
+ const style = cx(
68
+ NAME,
69
+ baseStyle,
70
+ selected && selectedStyle,
71
+ disabled && disabledStyle,
72
+ className
73
+ );
74
+
75
+ return (
76
+ <button
77
+ type={type}
78
+ className={style}
79
+ disabled={disabled}
80
+ aria-pressed={selected}
81
+ data-testid={testId}
82
+ {...props}
83
+ >
84
+ {children}
85
+ </button>
86
+ );
87
+ };
88
+
89
+ export default memo(QuickFilter);
@@ -0,0 +1,230 @@
1
+ import { useState } from 'react';
2
+ import { afterEach, describe, expect, test, vi } from 'vitest';
3
+ import { render } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import { ThemeContextProvider } from '../../../theme';
6
+ import Filters from '../Filters';
7
+ import Filter from '../Filter';
8
+ import QuickFilter from '../QuickFilter';
9
+
10
+ const setDesktopViewport = (matches: boolean) => {
11
+ vi.stubGlobal(
12
+ 'matchMedia',
13
+ vi.fn().mockImplementation(() => ({
14
+ matches,
15
+ media: '',
16
+ onchange: null,
17
+ addEventListener: vi.fn(),
18
+ removeEventListener: vi.fn(),
19
+ addListener: vi.fn(),
20
+ removeListener: vi.fn(),
21
+ dispatchEvent: vi.fn(),
22
+ }))
23
+ );
24
+ };
25
+
26
+ const renderFilters = (component: React.ReactNode) =>
27
+ render(<ThemeContextProvider>{component}</ThemeContextProvider>);
28
+
29
+ const StatefulMobileFilters = () => {
30
+ const [value, setValue] = useState('');
31
+
32
+ return (
33
+ <Filters
34
+ onOpenChange={() => {}}
35
+ onApply={() => {}}
36
+ >
37
+ <Filter
38
+ label='Search'
39
+ defaultOpen
40
+ >
41
+ <input
42
+ aria-label='Search records'
43
+ value={value}
44
+ onChange={(event) => setValue(event.target.value)}
45
+ />
46
+ </Filter>
47
+ </Filters>
48
+ );
49
+ };
50
+
51
+ describe('Filters', () => {
52
+ afterEach(() => {
53
+ vi.unstubAllGlobals();
54
+ vi.restoreAllMocks();
55
+ });
56
+
57
+ test('opens a desktop panel in an Overlay and closes on Apply', async () => {
58
+ setDesktopViewport(true);
59
+ const user = userEvent.setup();
60
+ const onApply = vi.fn();
61
+
62
+ const result = renderFilters(
63
+ <Filters>
64
+ <Filter
65
+ label='File type'
66
+ onApply={onApply}
67
+ >
68
+ Panel controls
69
+ </Filter>
70
+ </Filters>
71
+ );
72
+
73
+ const trigger = result.getByRole('button', { name: 'File type' });
74
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
75
+
76
+ await user.click(trigger);
77
+
78
+ const panel = result.getByRole('dialog', { name: 'File type filter' });
79
+ expect(panel.parentElement).toHaveClass('ucl-overlay');
80
+ expect(panel).toHaveTextContent('Panel controls');
81
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
82
+
83
+ await user.click(result.getByRole('button', { name: 'Apply' }));
84
+
85
+ expect(onApply).toHaveBeenCalledOnce();
86
+ expect(
87
+ result.queryByRole('dialog', { name: 'File type filter' })
88
+ ).not.toBeInTheDocument();
89
+ });
90
+
91
+ test('closes a desktop filter on Escape and restores trigger focus', async () => {
92
+ setDesktopViewport(true);
93
+ const user = userEvent.setup();
94
+
95
+ const result = renderFilters(
96
+ <Filters>
97
+ <Filter label='Status'>Panel controls</Filter>
98
+ </Filters>
99
+ );
100
+
101
+ const trigger = result.getByRole('button', { name: 'Status' });
102
+ await user.click(trigger);
103
+ await user.keyboard('{Escape}');
104
+
105
+ expect(result.queryByText('Panel controls')).not.toBeInTheDocument();
106
+ expect(trigger).toHaveFocus();
107
+ });
108
+
109
+ test('renders mobile filters in a bottom sheet and expands a filter inline', async () => {
110
+ setDesktopViewport(false);
111
+ const user = userEvent.setup();
112
+ const onApply = vi.fn();
113
+ const onReset = vi.fn();
114
+
115
+ const result = renderFilters(
116
+ <Filters
117
+ appliedCount={2}
118
+ onApply={onApply}
119
+ onReset={onReset}
120
+ >
121
+ <Filter
122
+ label='File type'
123
+ appliedCount={2}
124
+ >
125
+ Panel controls
126
+ </Filter>
127
+ </Filters>
128
+ );
129
+
130
+ const groupTrigger = result.getByRole('button', {
131
+ name: 'Filters, 2 applied',
132
+ });
133
+ expect(groupTrigger).toHaveTextContent('');
134
+ await user.click(groupTrigger);
135
+
136
+ expect(result.getByRole('dialog', { name: 'Filters' })).toBeInTheDocument();
137
+ expect(result.getByTestId('ucl-blanket')).toBeInTheDocument();
138
+ expect(result.queryByText('Panel controls')).not.toBeInTheDocument();
139
+
140
+ await user.click(result.getByRole('button', { name: /File type/ }));
141
+ expect(result.getByText('Panel controls')).toBeInTheDocument();
142
+
143
+ await user.click(result.getByRole('button', { name: 'Reset all' }));
144
+ expect(onReset).toHaveBeenCalledOnce();
145
+
146
+ await user.click(result.getByRole('button', { name: 'Apply filters' }));
147
+ expect(onApply).toHaveBeenCalledOnce();
148
+ expect(
149
+ result.queryByRole('dialog', { name: 'Filters' })
150
+ ).not.toBeInTheDocument();
151
+ });
152
+
153
+ test('preserves focus when an inline open callback changes identity', async () => {
154
+ setDesktopViewport(false);
155
+ const user = userEvent.setup();
156
+ const result = renderFilters(<StatefulMobileFilters />);
157
+
158
+ await user.click(result.getByRole('button', { name: 'Filters' }));
159
+
160
+ const input = result.getByRole('textbox', { name: 'Search records' });
161
+ await user.click(input);
162
+ await user.type(input, 'report');
163
+
164
+ expect(input).toHaveFocus();
165
+ expect(input).toHaveValue('report');
166
+ });
167
+
168
+ test('supports controlled open state', async () => {
169
+ setDesktopViewport(true);
170
+ const user = userEvent.setup();
171
+ const onOpenChange = vi.fn();
172
+
173
+ const result = renderFilters(
174
+ <Filters>
175
+ <Filter
176
+ label='Status'
177
+ open={false}
178
+ onOpenChange={onOpenChange}
179
+ >
180
+ Panel controls
181
+ </Filter>
182
+ </Filters>
183
+ );
184
+
185
+ await user.click(result.getByRole('button', { name: 'Status' }));
186
+
187
+ expect(onOpenChange).toHaveBeenCalledWith(true);
188
+ expect(result.queryByText('Panel controls')).not.toBeInTheDocument();
189
+ });
190
+
191
+ test('keeps only one filter open at a time', async () => {
192
+ setDesktopViewport(true);
193
+ const user = userEvent.setup();
194
+
195
+ const result = renderFilters(
196
+ <Filters>
197
+ <Filter label='File type'>File controls</Filter>
198
+ <Filter label='Status'>Status controls</Filter>
199
+ </Filters>
200
+ );
201
+
202
+ await user.click(result.getByRole('button', { name: 'File type' }));
203
+ expect(result.getByText('File controls')).toBeInTheDocument();
204
+
205
+ await user.click(result.getByRole('button', { name: 'Status' }));
206
+ expect(result.queryByText('File controls')).not.toBeInTheDocument();
207
+ expect(result.getByText('Status controls')).toBeInTheDocument();
208
+ });
209
+
210
+ test('QuickFilter exposes its selected state', async () => {
211
+ setDesktopViewport(true);
212
+ const user = userEvent.setup();
213
+ const onClick = vi.fn();
214
+
215
+ const result = renderFilters(
216
+ <QuickFilter
217
+ selected
218
+ onClick={onClick}
219
+ >
220
+ Last 30 days
221
+ </QuickFilter>
222
+ );
223
+
224
+ const quickFilter = result.getByRole('button', { name: 'Last 30 days' });
225
+ expect(quickFilter).toHaveAttribute('aria-pressed', 'true');
226
+
227
+ await user.click(quickFilter);
228
+ expect(onClick).toHaveBeenCalledOnce();
229
+ });
230
+ });
@@ -0,0 +1,6 @@
1
+ export { default } from './Filters';
2
+ export { default as Filter } from './Filter';
3
+ export { default as QuickFilter } from './QuickFilter';
4
+ export type { FiltersProps, FiltersSubComponents } from './Filters';
5
+ export type { FilterProps } from './Filter';
6
+ export type { QuickFilterProps } from './QuickFilter';
@@ -44,6 +44,7 @@ export type OverlaySize = {
44
44
 
45
45
  export interface OverlayProps extends HTMLAttributes<HTMLDivElement> {
46
46
  reference: RefObject<HTMLElement | null>;
47
+ positioning?: 'reference' | 'viewport';
47
48
  placement?: Placement;
48
49
  strategy?: Strategy;
49
50
  blanket?: boolean;
@@ -63,6 +64,7 @@ const Overlay = forwardRef<Ref, OverlayProps>(
63
64
  (
64
65
  {
65
66
  reference,
67
+ positioning = 'reference',
66
68
  placement = 'bottom',
67
69
  strategy = 'absolute',
68
70
  blanket = false,
@@ -146,6 +148,7 @@ const Overlay = forwardRef<Ref, OverlayProps>(
146
148
 
147
149
  const overlayPlacement = currentPlacement.split('-')[0] as
148
150
  'left' | 'right' | 'top' | 'bottom';
151
+ const isReferencePositioned = positioning === 'reference';
149
152
 
150
153
  useLayoutEffect(() => {
151
154
  refs.setReference(reference.current);
@@ -237,11 +240,13 @@ const Overlay = forwardRef<Ref, OverlayProps>(
237
240
  className={style}
238
241
  style={{
239
242
  ...(inlineStyle as CSSProperties),
240
- position: strategy,
241
- left: x ?? 0,
242
- top: y ?? 0,
243
+ ...(isReferencePositioned && {
244
+ position: strategy,
245
+ left: x ?? 0,
246
+ top: y ?? 0,
247
+ }),
243
248
  visibility:
244
- x === null || y === null
249
+ isReferencePositioned && (x === null || y === null)
245
250
  ? 'hidden'
246
251
  : (inlineStyle as CSSProperties | undefined)?.visibility,
247
252
  }}
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, test, vi } from 'vitest';
2
2
  import { useRef } from 'react';
3
3
  import { render, waitFor } from '@testing-library/react';
4
+ import { css } from '@emotion/css';
4
5
  import Overlay from '../Overlay';
5
6
 
6
7
  describe('Overlay', () => {
@@ -78,4 +79,42 @@ describe('Overlay', () => {
78
79
  });
79
80
  });
80
81
  });
82
+
83
+ test('allows viewport positioning to be defined by className', async () => {
84
+ const TestOverlay = () => {
85
+ const reference = useRef<HTMLButtonElement>(null);
86
+ const viewportStyle = css`
87
+ position: fixed;
88
+ inset: auto 0 0;
89
+ `;
90
+
91
+ return (
92
+ <>
93
+ <button
94
+ ref={reference}
95
+ type='button'
96
+ >
97
+ Anchor
98
+ </button>
99
+ <Overlay
100
+ reference={reference}
101
+ positioning='viewport'
102
+ className={viewportStyle}
103
+ >
104
+ Content
105
+ </Overlay>
106
+ </>
107
+ );
108
+ };
109
+
110
+ const result = render(<TestOverlay />);
111
+ const overlay = result.container.querySelector('.ucl-overlay');
112
+
113
+ await waitFor(() => {
114
+ expect(overlay).toHaveStyle({
115
+ position: 'fixed',
116
+ inset: 'auto 0 0',
117
+ });
118
+ });
119
+ });
81
120
  });
@@ -37,6 +37,14 @@ export type { BlanketProps } from './Blanket';
37
37
  export { default as Overlay } from './Overlay';
38
38
  export type { OverlayProps } from './Overlay';
39
39
 
40
+ export { default as Filters, Filter, QuickFilter } from './Filters';
41
+ export type {
42
+ FiltersProps,
43
+ FiltersSubComponents,
44
+ FilterProps,
45
+ QuickFilterProps,
46
+ } from './Filters';
47
+
40
48
  export { default as Snackbar } from './Snackbar';
41
49
  export { Snackbars } from './Snackbar';
42
50
  export type {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "uikit-react-public",
3
3
  "private": false,
4
4
  "license": "UNLICENSED",
5
- "version": "0.49.6",
5
+ "version": "0.50.0",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",