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,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';
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
size as fuiSize,
|
|
20
20
|
autoUpdate,
|
|
21
21
|
Placement,
|
|
22
|
+
type Derivable,
|
|
22
23
|
type FlipOptions,
|
|
23
24
|
type ShiftOptions,
|
|
24
25
|
type Strategy,
|
|
@@ -43,11 +44,12 @@ export type OverlaySize = {
|
|
|
43
44
|
|
|
44
45
|
export interface OverlayProps extends HTMLAttributes<HTMLDivElement> {
|
|
45
46
|
reference: RefObject<HTMLElement | null>;
|
|
47
|
+
positioning?: 'reference' | 'viewport';
|
|
46
48
|
placement?: Placement;
|
|
47
49
|
strategy?: Strategy;
|
|
48
50
|
blanket?: boolean;
|
|
49
51
|
flip?: boolean | FlipOptions;
|
|
50
|
-
shift?: boolean | ShiftOptions
|
|
52
|
+
shift?: boolean | ShiftOptions | Derivable<ShiftOptions>;
|
|
51
53
|
offset?: number;
|
|
52
54
|
size?: boolean | OverlaySizeOptions;
|
|
53
55
|
arrow?: ReactElement;
|
|
@@ -62,6 +64,7 @@ const Overlay = forwardRef<Ref, OverlayProps>(
|
|
|
62
64
|
(
|
|
63
65
|
{
|
|
64
66
|
reference,
|
|
67
|
+
positioning = 'reference',
|
|
65
68
|
placement = 'bottom',
|
|
66
69
|
strategy = 'absolute',
|
|
67
70
|
blanket = false,
|
|
@@ -99,8 +102,7 @@ const Overlay = forwardRef<Ref, OverlayProps>(
|
|
|
99
102
|
...(typeof flip === 'object' ? flip : {}),
|
|
100
103
|
})
|
|
101
104
|
);
|
|
102
|
-
if (shift)
|
|
103
|
-
fuiMiddleware.push(fuiShift(typeof shift === 'object' ? shift : {}));
|
|
105
|
+
if (shift) fuiMiddleware.push(fuiShift(shift === true ? {} : shift));
|
|
104
106
|
if (size) {
|
|
105
107
|
const sizeOptions = typeof size === 'object' ? size : {};
|
|
106
108
|
const { padding = 0, matchReferenceWidth = false } = sizeOptions;
|
|
@@ -111,6 +113,12 @@ const Overlay = forwardRef<Ref, OverlayProps>(
|
|
|
111
113
|
apply({ availableWidth, availableHeight, elements, rects }) {
|
|
112
114
|
const { style } = elements.floating;
|
|
113
115
|
|
|
116
|
+
// CSS var applies before remeasurement; React props update too late.
|
|
117
|
+
style.setProperty(
|
|
118
|
+
'--ucl-overlay-available-height',
|
|
119
|
+
`${Math.max(0, availableHeight)}px`
|
|
120
|
+
);
|
|
121
|
+
|
|
114
122
|
onSizeChange?.({
|
|
115
123
|
referenceWidth: rects.reference.width,
|
|
116
124
|
availableWidth: Math.max(0, availableWidth),
|
|
@@ -140,6 +148,7 @@ const Overlay = forwardRef<Ref, OverlayProps>(
|
|
|
140
148
|
|
|
141
149
|
const overlayPlacement = currentPlacement.split('-')[0] as
|
|
142
150
|
'left' | 'right' | 'top' | 'bottom';
|
|
151
|
+
const isReferencePositioned = positioning === 'reference';
|
|
143
152
|
|
|
144
153
|
useLayoutEffect(() => {
|
|
145
154
|
refs.setReference(reference.current);
|
|
@@ -231,11 +240,13 @@ const Overlay = forwardRef<Ref, OverlayProps>(
|
|
|
231
240
|
className={style}
|
|
232
241
|
style={{
|
|
233
242
|
...(inlineStyle as CSSProperties),
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
243
|
+
...(isReferencePositioned && {
|
|
244
|
+
position: strategy,
|
|
245
|
+
left: x ?? 0,
|
|
246
|
+
top: y ?? 0,
|
|
247
|
+
}),
|
|
237
248
|
visibility:
|
|
238
|
-
x === null || y === null
|
|
249
|
+
isReferencePositioned && (x === null || y === null)
|
|
239
250
|
? 'hidden'
|
|
240
251
|
: (inlineStyle as CSSProperties | undefined)?.visibility,
|
|
241
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
|
});
|
|
@@ -172,6 +172,7 @@ For very long labels, set `lineBreak` to allow breaking the text inside options.
|
|
|
172
172
|
- In `selectionBehaviour="commit"`, arrow keys move highlight without changing value until Enter/click.
|
|
173
173
|
- Tabbing away from an open custom dropdown closes it and lets focus move normally to the next or previous control.
|
|
174
174
|
- Native variant leverages built-in browser semantics.
|
|
175
|
+
- Tabbing to a filterable custom Select focuses the closed combobox. Typing starts filtering immediately; Enter, Space, an arrow key, or a click also opens the panel. Focus moves to the filter input when the panel opens.
|
|
175
176
|
- The filter input (when `filterable` is true) is focusable when the panel opens; Escape and arrow keys bubble to the parent so the dropdown still closes/navigates.
|
|
176
177
|
- In the custom variant, `clearable` emits `null` as the cleared value.
|
|
177
178
|
- `filterInputProps` lets you forward ARIA hooks like `aria-describedby` to the filter input.
|
|
@@ -116,11 +116,70 @@ describe('Select', () => {
|
|
|
116
116
|
expect(overlay).toHaveClass('ucl-overlay');
|
|
117
117
|
await waitFor(() => {
|
|
118
118
|
expect(overlay).toHaveStyle({
|
|
119
|
-
position: '
|
|
119
|
+
position: 'fixed',
|
|
120
120
|
});
|
|
121
121
|
});
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
test.each([
|
|
125
|
+
{ fieldTop: 200, menuHeight: 120, menuTop: 248 },
|
|
126
|
+
{ fieldTop: 500, menuHeight: 120, menuTop: 380 },
|
|
127
|
+
{ fieldTop: 280, menuHeight: 400, menuTop: 328 },
|
|
128
|
+
])(
|
|
129
|
+
'anchors the menu without covering the input at $fieldTop with height $menuHeight',
|
|
130
|
+
async ({ fieldTop, menuHeight, menuTop }) => {
|
|
131
|
+
vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(
|
|
132
|
+
800
|
|
133
|
+
);
|
|
134
|
+
vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(
|
|
135
|
+
600
|
|
136
|
+
);
|
|
137
|
+
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(
|
|
138
|
+
240
|
|
139
|
+
);
|
|
140
|
+
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(
|
|
141
|
+
menuHeight
|
|
142
|
+
);
|
|
143
|
+
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(
|
|
144
|
+
function (this: Element) {
|
|
145
|
+
return this.getAttribute('role') === 'combobox'
|
|
146
|
+
? new DOMRect(80, fieldTop, 240, 48)
|
|
147
|
+
: new DOMRect(0, 0, 240, menuHeight);
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const user = userEvent.setup();
|
|
153
|
+
const result = render(
|
|
154
|
+
<ThemeContextProvider>
|
|
155
|
+
<Select
|
|
156
|
+
filterable
|
|
157
|
+
options={defaultOptions}
|
|
158
|
+
value='2'
|
|
159
|
+
onValueChange={() => {}}
|
|
160
|
+
/>
|
|
161
|
+
</ThemeContextProvider>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
await user.click(result.getByRole('combobox'));
|
|
165
|
+
|
|
166
|
+
const expectAnchoredMenu = () => {
|
|
167
|
+
expect(result.getByRole('listbox').parentElement).toHaveStyle({
|
|
168
|
+
position: 'fixed',
|
|
169
|
+
left: '80px',
|
|
170
|
+
top: `${menuTop}px`,
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
await waitFor(expectAnchoredMenu);
|
|
174
|
+
|
|
175
|
+
await user.type(result.getByRole('searchbox'), '2');
|
|
176
|
+
await waitFor(expectAnchoredMenu);
|
|
177
|
+
} finally {
|
|
178
|
+
vi.restoreAllMocks();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
);
|
|
182
|
+
|
|
124
183
|
test('dropdownWidth defaults to content width', async () => {
|
|
125
184
|
const user = userEvent.setup();
|
|
126
185
|
const renderResult = render(
|
|
@@ -743,7 +802,7 @@ describe('Select', () => {
|
|
|
743
802
|
expect(filterInput).toHaveAttribute('aria-describedby', 'filter-hint');
|
|
744
803
|
});
|
|
745
804
|
|
|
746
|
-
test('
|
|
805
|
+
test('keeps a filterable select closed on keyboard focus', async () => {
|
|
747
806
|
const user = userEvent.setup();
|
|
748
807
|
const result = render(
|
|
749
808
|
<ThemeContextProvider>
|
|
@@ -758,11 +817,79 @@ describe('Select', () => {
|
|
|
758
817
|
|
|
759
818
|
await user.tab();
|
|
760
819
|
|
|
761
|
-
const
|
|
762
|
-
expect(
|
|
820
|
+
const combobox = result.getByRole('combobox');
|
|
821
|
+
expect(combobox).toHaveFocus();
|
|
822
|
+
expect(combobox).toHaveAttribute('aria-expanded', 'false');
|
|
823
|
+
expect(result.queryByRole('searchbox')).not.toBeInTheDocument();
|
|
824
|
+
expect(result.queryByRole('listbox')).not.toBeInTheDocument();
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test('does not duplicate the selected option text while closed', async () => {
|
|
828
|
+
const user = userEvent.setup();
|
|
829
|
+
const result = render(
|
|
830
|
+
<ThemeContextProvider>
|
|
831
|
+
<Select
|
|
832
|
+
filterable
|
|
833
|
+
options={defaultOptions}
|
|
834
|
+
value={defaultOptions[1].value}
|
|
835
|
+
onValueChange={() => {}}
|
|
836
|
+
/>
|
|
837
|
+
</ThemeContextProvider>
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
await user.tab();
|
|
841
|
+
|
|
842
|
+
const combobox = result.getByRole('combobox');
|
|
843
|
+
expect(combobox).toHaveTextContent(defaultOptions[1].label);
|
|
844
|
+
expect(
|
|
845
|
+
result.queryByText(`${defaultOptions[1].label} selected`)
|
|
846
|
+
).not.toBeInTheDocument();
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test('opens a focused filterable select with ArrowDown and focuses its filter', async () => {
|
|
850
|
+
const user = userEvent.setup();
|
|
851
|
+
const result = render(
|
|
852
|
+
<ThemeContextProvider>
|
|
853
|
+
<Select
|
|
854
|
+
filterable
|
|
855
|
+
options={defaultOptions}
|
|
856
|
+
value=''
|
|
857
|
+
onValueChange={() => {}}
|
|
858
|
+
/>
|
|
859
|
+
</ThemeContextProvider>
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
await user.tab();
|
|
863
|
+
await user.keyboard('{ArrowDown}');
|
|
864
|
+
|
|
865
|
+
expect(result.getByRole('searchbox')).toHaveFocus();
|
|
763
866
|
expect(result.getByRole('listbox')).toBeInTheDocument();
|
|
764
867
|
});
|
|
765
868
|
|
|
869
|
+
test('opens and filters when typing on a focused closed filterable select', async () => {
|
|
870
|
+
const user = userEvent.setup();
|
|
871
|
+
const result = render(
|
|
872
|
+
<ThemeContextProvider>
|
|
873
|
+
<Select
|
|
874
|
+
filterable
|
|
875
|
+
options={defaultOptions}
|
|
876
|
+
value=''
|
|
877
|
+
onValueChange={() => {}}
|
|
878
|
+
/>
|
|
879
|
+
</ThemeContextProvider>
|
|
880
|
+
);
|
|
881
|
+
|
|
882
|
+
await user.tab();
|
|
883
|
+
await user.keyboard('2');
|
|
884
|
+
|
|
885
|
+
expect(result.getByRole('searchbox')).toHaveFocus();
|
|
886
|
+
expect(result.getByRole('searchbox')).toHaveValue('2');
|
|
887
|
+
expect(result.getAllByRole('option')).toHaveLength(1);
|
|
888
|
+
expect(
|
|
889
|
+
result.getByRole('option', { name: 'Option 2' })
|
|
890
|
+
).toBeInTheDocument();
|
|
891
|
+
});
|
|
892
|
+
|
|
766
893
|
test('pressing Escape closes dropdown and clears filter', async () => {
|
|
767
894
|
const user = userEvent.setup();
|
|
768
895
|
const result = render(
|
|
@@ -939,6 +1066,9 @@ describe('Select', () => {
|
|
|
939
1066
|
|
|
940
1067
|
await user.tab();
|
|
941
1068
|
await user.tab();
|
|
1069
|
+
expect(result.getByRole('combobox')).toHaveFocus();
|
|
1070
|
+
|
|
1071
|
+
await user.keyboard('{Enter}');
|
|
942
1072
|
expect(result.getByRole('searchbox')).toHaveFocus();
|
|
943
1073
|
expect(result.getByRole('listbox')).toBeInTheDocument();
|
|
944
1074
|
|
|
@@ -985,6 +1115,9 @@ describe('Select', () => {
|
|
|
985
1115
|
);
|
|
986
1116
|
|
|
987
1117
|
await user.tab();
|
|
1118
|
+
expect(result.getByRole('combobox')).toHaveFocus();
|
|
1119
|
+
|
|
1120
|
+
await user.keyboard('{Enter}');
|
|
988
1121
|
const searchbox = result.getByRole('searchbox');
|
|
989
1122
|
expect(searchbox).toHaveFocus();
|
|
990
1123
|
expect(result.getByRole('listbox')).toBeInTheDocument();
|