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.
Files changed (30) hide show
  1. package/dist/components/Filters/Filter.d.ts +20 -0
  2. package/dist/components/Filters/FilterPanel.d.ts +16 -0
  3. package/dist/components/Filters/Filters.context.d.ts +8 -0
  4. package/dist/components/Filters/Filters.d.ts +30 -0
  5. package/dist/components/Filters/Filters.stories.d.ts +17 -0
  6. package/dist/components/Filters/QuickFilter.d.ts +8 -0
  7. package/dist/components/Filters/__tests__/Filters.test.d.ts +1 -0
  8. package/dist/components/Filters/index.d.ts +6 -0
  9. package/dist/components/Overlay/Overlay.d.ts +3 -2
  10. package/dist/components/Overlay/Overlay.stories.d.ts +14 -1
  11. package/dist/components/Select/subcomponents/Panel.d.ts +1 -2
  12. package/dist/components/index.d.ts +2 -0
  13. package/dist/index.js +6833 -6236
  14. package/lib/components/Filters/Filter.tsx +327 -0
  15. package/lib/components/Filters/FilterPanel.tsx +152 -0
  16. package/lib/components/Filters/Filters.context.ts +12 -0
  17. package/lib/components/Filters/Filters.mdx +75 -0
  18. package/lib/components/Filters/Filters.stories.tsx +354 -0
  19. package/lib/components/Filters/Filters.tsx +316 -0
  20. package/lib/components/Filters/QuickFilter.tsx +89 -0
  21. package/lib/components/Filters/__tests__/Filters.test.tsx +230 -0
  22. package/lib/components/Filters/index.ts +6 -0
  23. package/lib/components/Overlay/Overlay.tsx +18 -7
  24. package/lib/components/Overlay/__tests__/Overlay.test.tsx +39 -0
  25. package/lib/components/Select/Select.mdx +1 -0
  26. package/lib/components/Select/__tests__/Select.test.tsx +137 -4
  27. package/lib/components/Select/subcomponents/CustomSelect.tsx +42 -43
  28. package/lib/components/Select/subcomponents/Panel.tsx +2 -8
  29. package/lib/components/index.ts +8 -0
  30. 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
- position: strategy,
235
- left: x ?? 0,
236
- top: y ?? 0,
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: 'absolute',
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('opens filter input on keyboard focus', async () => {
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 filterInput = await result.findByRole('searchbox');
762
- expect(filterInput).toHaveFocus();
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();