uikit-react-public 0.14.21 → 0.17.4

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 (158) hide show
  1. package/README.md +4 -2
  2. package/dist/components/Accordion/Accordion.Heading.d.ts +4 -4
  3. package/dist/components/Accordion/Accordion.Panel.d.ts +2 -2
  4. package/dist/components/Accordion/Accordion.d.ts +1 -1
  5. package/dist/components/Accordion/Accordion.stories.d.ts +57 -0
  6. package/dist/components/Accordion/index.d.ts +2 -0
  7. package/dist/components/Avatar/Avatar.stories.d.ts +107 -1
  8. package/dist/components/Button/Button.d.ts +1 -0
  9. package/dist/components/Calendar/index.d.ts +1 -1
  10. package/dist/components/Datepicker/Datepicker.d.ts +1 -1
  11. package/dist/components/Datepicker/Datepicker.stories.d.ts +4 -3
  12. package/dist/components/Datepicker/Datepicker.types.d.ts +4 -5
  13. package/dist/components/Datepicker/subcomponents/CustomDatepicker.d.ts +4 -1
  14. package/dist/components/Datepicker/subcomponents/DatepickerInput.d.ts +15 -2
  15. package/dist/components/Datepicker/subcomponents/Panel.d.ts +1 -1
  16. package/dist/components/Datepicker/subcomponents/VisibleField.d.ts +6 -1
  17. package/dist/components/Datepicker/subcomponents/index.d.ts +0 -1
  18. package/dist/components/Datepicker/utils/index.d.ts +0 -1
  19. package/dist/components/Dialog/BaseDialog.d.ts +2 -1
  20. package/dist/components/Dialog/Dialog.d.ts +2 -0
  21. package/dist/components/Header/Header.d.ts +4 -1
  22. package/dist/components/Header/Header.stories.d.ts +40 -0
  23. package/dist/components/Main/Main.d.ts +21 -0
  24. package/dist/components/Main/Main.stories.d.ts +15 -0
  25. package/dist/components/Main/index.d.ts +2 -0
  26. package/dist/components/NativeDatepicker/NativeDatepicker.d.ts +3 -0
  27. package/dist/components/NativeDatepicker/NativeDatepicker.stories.d.ts +36 -0
  28. package/dist/components/NativeDatepicker/NativeDatepicker.types.d.ts +10 -0
  29. package/dist/components/NativeDatepicker/index.d.ts +2 -0
  30. package/dist/components/{Datepicker → NativeDatepicker}/utils/dateToLocaleISOString/dateToLocaleISOString.d.ts +1 -1
  31. package/dist/components/NativeDatepicker/utils/dateToLocaleISOString/dateToLocaleISOString.test.d.ts +1 -0
  32. package/dist/components/NativeDatepicker/utils/index.d.ts +1 -0
  33. package/dist/components/Select/Select.stories.d.ts +154 -2
  34. package/dist/components/Select/Select.types.d.ts +51 -22
  35. package/dist/components/Select/subcomponents/CustomOption.d.ts +1 -1
  36. package/dist/components/Select/subcomponents/CustomSelect.d.ts +3 -2
  37. package/dist/components/Select/subcomponents/FilterInput.d.ts +14 -0
  38. package/dist/components/Select/subcomponents/NativeSelect.d.ts +5 -1
  39. package/dist/components/Select/subcomponents/VisibleField.d.ts +3 -1
  40. package/dist/components/Select/subcomponents/index.d.ts +1 -0
  41. package/dist/components/WeekPicker/WeekPicker.d.ts +2 -2
  42. package/dist/components/WeekPicker/WeekPicker.stories.d.ts +41 -0
  43. package/dist/components/WeekPicker/WeekPicker.types.d.ts +16 -0
  44. package/dist/components/WeekPicker/index.d.ts +1 -0
  45. package/dist/components/WeekPicker/subcomponents/CustomDatepicker.d.ts +1 -1
  46. package/dist/components/index.d.ts +8 -0
  47. package/dist/hooks/useFocusTrap.d.ts +2 -1
  48. package/dist/index.d.ts +1 -0
  49. package/dist/index.js +4366 -3768
  50. package/dist/utils/__tests__/announce.test.d.ts +1 -0
  51. package/dist/utils/announce.d.ts +6 -0
  52. package/dist/utils/index.d.ts +1 -0
  53. package/lib/components/Accordion/Accordion.Heading.tsx +27 -8
  54. package/lib/components/Accordion/Accordion.Panel.tsx +11 -3
  55. package/lib/components/Accordion/Accordion.stories.tsx +139 -0
  56. package/lib/components/Accordion/Accordion.tsx +10 -8
  57. package/lib/components/Accordion/__tests__/__snapshots__/Accordion.test.tsx.snap +7 -7
  58. package/lib/components/Accordion/index.ts +2 -0
  59. package/lib/components/Alert/Alert.stories.tsx +1 -1
  60. package/lib/components/Avatar/Avatar.mdx +117 -0
  61. package/lib/components/Avatar/Avatar.stories.tsx +110 -2
  62. package/lib/components/Blanket/Blanket.stories.tsx +1 -1
  63. package/lib/components/Button/Button.stories.tsx +1 -1
  64. package/lib/components/Button/Button.tsx +1 -0
  65. package/lib/components/Calendar/Calendar.stories.tsx +12 -32
  66. package/lib/components/Calendar/__tests__/Calendar.test.tsx +23 -15
  67. package/lib/components/Calendar/index.ts +1 -5
  68. package/lib/components/Calendar/subcomponents/AcademicWeeks.tsx +2 -1
  69. package/lib/components/Calendar/subcomponents/ColumnHeading.tsx +5 -1
  70. package/lib/components/Calendar/subcomponents/EventDot.tsx +2 -1
  71. package/lib/components/Calendar/subcomponents/index.ts +1 -1
  72. package/lib/components/Calendar/utils/getDatesForCalendarGrid/getDatesForCalendarGrid.ts +43 -11
  73. package/lib/components/Calendar/utils/normaliseMonth/normaliseMonth.test.ts +5 -5
  74. package/lib/components/Datepicker/Datepicker.lld.md +108 -0
  75. package/lib/components/Datepicker/Datepicker.stories.tsx +44 -5
  76. package/lib/components/Datepicker/Datepicker.tsx +14 -36
  77. package/lib/components/Datepicker/Datepicker.types.ts +5 -14
  78. package/lib/components/Datepicker/__tests__/Datepicker.test.tsx +150 -8
  79. package/lib/components/Datepicker/__tests__/__snapshots__/Datepicker.test.tsx.snap +10 -4
  80. package/lib/components/Datepicker/subcomponents/CustomDatepicker.tsx +39 -5
  81. package/lib/components/Datepicker/subcomponents/DatepickerInput.tsx +30 -17
  82. package/lib/components/Datepicker/subcomponents/Panel.tsx +6 -2
  83. package/lib/components/Datepicker/subcomponents/VisibleField.tsx +40 -3
  84. package/lib/components/Datepicker/subcomponents/index.ts +0 -1
  85. package/lib/components/Datepicker/utils/index.ts +0 -1
  86. package/lib/components/Dialog/BaseDialog.tsx +11 -0
  87. package/lib/components/Dialog/Dialog.tsx +8 -1
  88. package/lib/components/Dialog/DialogBody.tsx +5 -1
  89. package/lib/components/Dialog/DialogHeader.tsx +2 -1
  90. package/lib/components/Divider/Divider.stories.tsx +1 -1
  91. package/lib/components/Field/ErrorText.tsx +1 -0
  92. package/lib/components/Field/Field.stories.tsx +1 -1
  93. package/lib/components/Field/__tests__/Field.test.tsx +13 -0
  94. package/lib/components/FileInput/FileInput.stories.tsx +1 -1
  95. package/lib/components/Footer/Footer.stories.tsx +1 -1
  96. package/lib/components/Footer/__tests__/__snapshots__/Footer.test.tsx.snap +3 -3
  97. package/lib/components/Header/Header.mdx +52 -0
  98. package/lib/components/Header/Header.stories.tsx +98 -0
  99. package/lib/components/Header/Header.tsx +51 -6
  100. package/lib/components/Header/__tests__/Header.test.tsx +17 -1
  101. package/lib/components/Heading/Heading.stories.tsx +1 -1
  102. package/lib/components/Icon/Icon.stories.tsx +1 -1
  103. package/lib/components/IconButton/IconButton.stories.tsx +1 -1
  104. package/lib/components/Input/Input.stories.tsx +1 -1
  105. package/lib/components/Label/Label.stories.tsx +1 -1
  106. package/lib/components/Main/Main.stories.tsx +36 -0
  107. package/lib/components/Main/Main.tsx +46 -0
  108. package/lib/components/Main/__tests__/Main.test.tsx +80 -0
  109. package/lib/components/Main/__tests__/__snapshots__/Main.test.tsx.snap +33 -0
  110. package/lib/components/Main/index.ts +2 -0
  111. package/lib/components/NativeDatepicker/NativeDatepicker.stories.tsx +100 -0
  112. package/lib/components/{Datepicker/subcomponents → NativeDatepicker}/NativeDatepicker.tsx +14 -15
  113. package/lib/components/NativeDatepicker/NativeDatepicker.types.ts +19 -0
  114. package/lib/components/NativeDatepicker/index.ts +2 -0
  115. package/lib/components/{Datepicker → NativeDatepicker}/utils/dateToLocaleISOString/dateToLocaleISOString.ts +1 -1
  116. package/lib/components/NativeDatepicker/utils/index.ts +1 -0
  117. package/lib/components/Pagination/PaginationControls.tsx +55 -12
  118. package/lib/components/Pagination/PaginationInfo.tsx +5 -1
  119. package/lib/components/Paragraph/Paragraph.stories.tsx +1 -1
  120. package/lib/components/Search/Search.stories.tsx +1 -1
  121. package/lib/components/Search/Search.tsx +4 -1
  122. package/lib/components/Search/__tests__/Search.test.tsx +19 -1
  123. package/lib/components/Select/Select.mdx +169 -0
  124. package/lib/components/Select/Select.stories.tsx +191 -43
  125. package/lib/components/Select/Select.tsx +36 -12
  126. package/lib/components/Select/Select.types.ts +66 -48
  127. package/lib/components/Select/__tests__/Select.test.tsx +448 -7
  128. package/lib/components/Select/__tests__/__snapshots__/Select.test.tsx.snap +1 -1
  129. package/lib/components/Select/subcomponents/CustomOption.tsx +2 -1
  130. package/lib/components/Select/subcomponents/CustomSelect.tsx +303 -33
  131. package/lib/components/Select/subcomponents/FilterInput.tsx +80 -0
  132. package/lib/components/Select/subcomponents/NativeSelect.tsx +13 -1
  133. package/lib/components/Select/subcomponents/VisibleField.tsx +11 -3
  134. package/lib/components/Select/subcomponents/index.tsx +1 -0
  135. package/lib/components/Snackbar/Snackbar.stories.tsx +1 -1
  136. package/lib/components/Spinner/Spinner.stories.tsx +1 -1
  137. package/lib/components/Textarea/Textarea.stories.tsx +1 -1
  138. package/lib/components/Timepicker/Timepicker.tsx +4 -0
  139. package/lib/components/Timepicker/__tests__/__snapshots__/Timepicker.test.tsx.snap +2 -2
  140. package/lib/components/Toggle/Toggle.stories.tsx +1 -1
  141. package/lib/components/Tooltip/Tooltip.stories.tsx +1 -1
  142. package/lib/components/WeekPicker/WeekPicker.stories.tsx +147 -0
  143. package/lib/components/WeekPicker/WeekPicker.tsx +2 -2
  144. package/lib/components/WeekPicker/WeekPicker.types.ts +21 -0
  145. package/lib/components/WeekPicker/index.ts +1 -0
  146. package/lib/components/WeekPicker/subcomponents/CustomDatepicker.tsx +1 -1
  147. package/lib/components/common/Common.mdx +1 -1
  148. package/lib/components/index.ts +11 -2
  149. package/lib/hooks/useFocusTrap.ts +40 -4
  150. package/lib/index.ts +1 -0
  151. package/lib/utils/__tests__/announce.test.ts +121 -0
  152. package/lib/utils/announce.ts +134 -0
  153. package/lib/utils/index.ts +1 -0
  154. package/package.json +3 -6
  155. package/dist/components/Datepicker/subcomponents/NativeDatepicker.d.ts +0 -6
  156. package/lib/components/Accordion/Accordion.stories.tsx.NOT_READY +0 -93
  157. /package/dist/components/{Datepicker/utils/dateToLocaleISOString/dateToLocaleISOString.test.d.ts → Main/__tests__/Main.test.d.ts} +0 -0
  158. /package/lib/components/{Datepicker → NativeDatepicker}/utils/dateToLocaleISOString/dateToLocaleISOString.test.ts +0 -0
@@ -1,4 +1,5 @@
1
- import { describe, expect, test } from 'vitest';
1
+ import { describe, expect, test, vi, beforeAll } from 'vitest';
2
+ import { useState } from 'react';
2
3
  import { render } from '@testing-library/react';
3
4
  import userEvent from '@testing-library/user-event';
4
5
  import Select from '../Select';
@@ -11,6 +12,13 @@ const defaultOptions = [
11
12
  ];
12
13
 
13
14
  describe('Select', () => {
15
+ beforeAll(() => {
16
+ Object.defineProperty(Element.prototype, 'scrollIntoView', {
17
+ value: vi.fn(),
18
+ writable: true,
19
+ });
20
+ });
21
+
14
22
  // Snapshot tests
15
23
 
16
24
  test('Snapshot: default', () => {
@@ -19,7 +27,7 @@ describe('Select', () => {
19
27
  <Select
20
28
  options={defaultOptions}
21
29
  value=''
22
- onChange={() => {}}
30
+ onValueChange={() => {}}
23
31
  />
24
32
  </ThemeContextProvider>
25
33
  );
@@ -35,7 +43,7 @@ describe('Select', () => {
35
43
  <Select
36
44
  options={defaultOptions}
37
45
  value=''
38
- onChange={() => {}}
46
+ onValueChange={() => {}}
39
47
  />
40
48
  </ThemeContextProvider>
41
49
  );
@@ -50,7 +58,7 @@ describe('Select', () => {
50
58
  native
51
59
  options={defaultOptions}
52
60
  value=''
53
- onChange={() => {}}
61
+ nativeHtmlAttributes={{ onChange: () => {} }}
54
62
  />
55
63
  </ThemeContextProvider>
56
64
  );
@@ -66,7 +74,7 @@ describe('Select', () => {
66
74
  <Select
67
75
  options={defaultOptions}
68
76
  value=''
69
- onChange={() => {}}
77
+ onValueChange={() => {}}
70
78
  />
71
79
  </ThemeContextProvider>
72
80
  );
@@ -91,7 +99,7 @@ describe('Select', () => {
91
99
  disabled
92
100
  options={defaultOptions}
93
101
  value=''
94
- onChange={() => {}}
102
+ onValueChange={() => {}}
95
103
  />
96
104
  </ThemeContextProvider>
97
105
  );
@@ -109,11 +117,444 @@ describe('Select', () => {
109
117
  native
110
118
  options={defaultOptions}
111
119
  value='1'
112
- onChange={() => {}}
120
+ nativeHtmlAttributes={{ onChange: () => {} }}
113
121
  />
114
122
  </ThemeContextProvider>
115
123
  );
116
124
  const select = renderResult.getByTestId('ucl-uikit-select--native');
117
125
  expect(select).toHaveProperty('disabled');
118
126
  });
127
+
128
+ test('Filters options when filterable is true', async () => {
129
+ const user = userEvent.setup();
130
+ const renderResult = render(
131
+ <ThemeContextProvider>
132
+ <Select
133
+ filterable
134
+ options={defaultOptions}
135
+ value=''
136
+ onValueChange={() => {}}
137
+ />
138
+ </ThemeContextProvider>
139
+ );
140
+ await user.click(renderResult.getByTestId('ucl-uikit-select'));
141
+ const input = renderResult.container.querySelector('input');
142
+ expect(input).not.toBeNull();
143
+ await user.type(input as HTMLInputElement, '2');
144
+
145
+ const options = await renderResult.findAllByRole('option');
146
+ expect(options.length).toBe(1);
147
+ expect(options[0].textContent).toBe('Option 2');
148
+ });
149
+
150
+ test('shows empty state when filter yields no options', async () => {
151
+ const user = userEvent.setup();
152
+ const result = render(
153
+ <ThemeContextProvider>
154
+ <Select
155
+ filterable
156
+ options={defaultOptions}
157
+ value=''
158
+ onValueChange={() => {}}
159
+ />
160
+ </ThemeContextProvider>
161
+ );
162
+
163
+ await user.click(result.getByTestId('ucl-uikit-select'));
164
+ await user.type(result.getByRole('searchbox'), 'zzz');
165
+
166
+ expect(result.getByText('No options')).toBeInTheDocument();
167
+ expect(result.queryAllByTestId('ucl-uikit-select__option')).toHaveLength(0);
168
+ });
169
+
170
+ test('clears filter text after selecting an option', async () => {
171
+ const user = userEvent.setup();
172
+ const ControlledSelect = () => {
173
+ const [value, setValue] = useState('');
174
+ return (
175
+ <Select
176
+ filterable
177
+ options={defaultOptions}
178
+ value={value}
179
+ onValueChange={(next) => setValue(next as string)}
180
+ />
181
+ );
182
+ };
183
+
184
+ const result = render(
185
+ <ThemeContextProvider>
186
+ <ControlledSelect />
187
+ </ThemeContextProvider>
188
+ );
189
+
190
+ await user.click(result.getByTestId('ucl-uikit-select'));
191
+ await user.type(result.getByRole('searchbox'), '2');
192
+ await user.click(result.getByText('Option 2'));
193
+
194
+ await user.click(result.getByTestId('ucl-uikit-select'));
195
+ expect(result.getByRole('searchbox')).toHaveValue('');
196
+ expect(result.getAllByTestId('ucl-uikit-select__option')).toHaveLength(
197
+ defaultOptions.length
198
+ );
199
+ });
200
+
201
+ test('clears filter text when dropdown closes', async () => {
202
+ const user = userEvent.setup();
203
+ const result = render(
204
+ <ThemeContextProvider>
205
+ <Select
206
+ filterable
207
+ options={defaultOptions}
208
+ value=''
209
+ onValueChange={() => {}}
210
+ />
211
+ </ThemeContextProvider>
212
+ );
213
+
214
+ await user.click(result.getByTestId('ucl-uikit-select'));
215
+ await user.type(result.getByRole('searchbox'), '1');
216
+
217
+ await user.click(document.body);
218
+ await user.click(result.getByTestId('ucl-uikit-select'));
219
+
220
+ expect(result.getByRole('searchbox')).toHaveValue('');
221
+ });
222
+
223
+ test('keyboard navigation uses filtered options', async () => {
224
+ const user = userEvent.setup();
225
+ const options = [
226
+ { label: 'Alpha', value: 'alpha' },
227
+ { label: 'Echo', value: 'echo' },
228
+ { label: 'Gamma', value: 'gamma' },
229
+ ];
230
+ const changeSpy = vi.fn();
231
+
232
+ const ControlledSelect = () => {
233
+ const [value, setValue] = useState('');
234
+ const handleChange = vi.fn((next, ev) => {
235
+ setValue(next as string);
236
+ changeSpy(next, ev);
237
+ });
238
+ return (
239
+ <Select
240
+ filterable
241
+ options={options}
242
+ value={value}
243
+ onValueChange={handleChange}
244
+ />
245
+ );
246
+ };
247
+
248
+ const result = render(
249
+ <ThemeContextProvider>
250
+ <ControlledSelect />
251
+ </ThemeContextProvider>
252
+ );
253
+
254
+ await user.click(result.getByTestId('ucl-uikit-select'));
255
+ await user.type(result.getByRole('searchbox'), 'a');
256
+ expect(result.getAllByTestId('ucl-uikit-select__option')).toHaveLength(2);
257
+
258
+ await user.keyboard('{ArrowDown}');
259
+ await user.keyboard('{ArrowDown}');
260
+ await user.keyboard('{ArrowDown}');
261
+
262
+ expect(changeSpy.mock.calls.map(([val]) => val)).toEqual([
263
+ 'alpha',
264
+ 'gamma',
265
+ 'alpha',
266
+ ]);
267
+ });
268
+
269
+ test('selectionBehaviour=commit does not call onValueChange on arrow keys', async () => {
270
+ const user = userEvent.setup();
271
+ const changeSpy = vi.fn();
272
+
273
+ const ControlledSelect = () => {
274
+ const [value, setValue] = useState('');
275
+ return (
276
+ <Select
277
+ options={defaultOptions}
278
+ value={value}
279
+ selectionBehaviour='commit'
280
+ onValueChange={(next, ev) => {
281
+ setValue(next as string);
282
+ changeSpy(next, ev);
283
+ }}
284
+ />
285
+ );
286
+ };
287
+
288
+ const result = render(
289
+ <ThemeContextProvider>
290
+ <ControlledSelect />
291
+ </ThemeContextProvider>
292
+ );
293
+
294
+ await user.click(result.getByTestId('ucl-uikit-select'));
295
+ await user.keyboard('{ArrowDown}');
296
+ await user.keyboard('{ArrowDown}');
297
+ await user.keyboard('{ArrowDown}');
298
+
299
+ expect(changeSpy).not.toHaveBeenCalled();
300
+ });
301
+
302
+ test('selectionBehaviour=commit commits highlighted option on Enter', async () => {
303
+ const user = userEvent.setup();
304
+ const changeSpy = vi.fn();
305
+
306
+ const ControlledSelect = () => {
307
+ const [value, setValue] = useState('');
308
+ return (
309
+ <Select
310
+ options={defaultOptions}
311
+ value={value}
312
+ selectionBehaviour='commit'
313
+ onValueChange={(next, ev) => {
314
+ setValue(next as string);
315
+ changeSpy(next, ev);
316
+ }}
317
+ />
318
+ );
319
+ };
320
+
321
+ const result = render(
322
+ <ThemeContextProvider>
323
+ <ControlledSelect />
324
+ </ThemeContextProvider>
325
+ );
326
+
327
+ await user.click(result.getByTestId('ucl-uikit-select'));
328
+ await user.keyboard('{ArrowDown}');
329
+ await user.keyboard('{ArrowDown}');
330
+ await user.keyboard('{Enter}');
331
+
332
+ expect(changeSpy.mock.calls.map(([val]) => val)).toEqual(['2']);
333
+ expect(
334
+ result.queryByTestId('ucl-uikit-select__panel')
335
+ ).not.toBeInTheDocument();
336
+ expect(result.getByTestId('ucl-uikit-select')).toHaveTextContent(
337
+ 'Option 2'
338
+ );
339
+ });
340
+
341
+ test('keyboard navigation does not get stuck on duplicate values', async () => {
342
+ const user = userEvent.setup();
343
+ const options = [
344
+ { label: 'First duplicate', value: 'dup' },
345
+ { label: 'Second duplicate', value: 'dup' },
346
+ { label: 'Unique', value: 'unique' },
347
+ ];
348
+ const changeSpy = vi.fn();
349
+
350
+ const ControlledSelect = () => {
351
+ const [value, setValue] = useState('');
352
+ const handleChange = vi.fn((next, ev) => {
353
+ setValue(next as string);
354
+ changeSpy(next, ev);
355
+ });
356
+ return (
357
+ <Select
358
+ options={options}
359
+ value={value}
360
+ onValueChange={handleChange}
361
+ />
362
+ );
363
+ };
364
+
365
+ const result = render(
366
+ <ThemeContextProvider>
367
+ <ControlledSelect />
368
+ </ThemeContextProvider>
369
+ );
370
+
371
+ await user.click(result.getByTestId('ucl-uikit-select'));
372
+ await user.keyboard('{ArrowDown}');
373
+ await user.keyboard('{ArrowDown}');
374
+ await user.keyboard('{ArrowDown}');
375
+
376
+ expect(changeSpy.mock.calls.map(([val]) => val)).toEqual([
377
+ 'dup',
378
+ 'dup',
379
+ 'unique',
380
+ ]);
381
+
382
+ const optionElements = result.getAllByRole('option');
383
+ expect(
384
+ optionElements.filter((el) => el.getAttribute('aria-selected') === 'true')
385
+ ).toHaveLength(1);
386
+ expect(optionElements[2]).toHaveAttribute('aria-selected', 'true');
387
+ });
388
+
389
+ test('keeps duplicate label identity when selecting by click', async () => {
390
+ const user = userEvent.setup();
391
+ const options = [
392
+ { label: 'First duplicate', value: 'dup' },
393
+ { label: 'Second duplicate', value: 'dup' },
394
+ ];
395
+
396
+ const ControlledSelect = () => {
397
+ const [value, setValue] = useState('');
398
+ return (
399
+ <Select
400
+ options={options}
401
+ value={value}
402
+ onValueChange={(next) => setValue(next as string)}
403
+ />
404
+ );
405
+ };
406
+
407
+ const result = render(
408
+ <ThemeContextProvider>
409
+ <ControlledSelect />
410
+ </ThemeContextProvider>
411
+ );
412
+
413
+ await user.click(result.getByTestId('ucl-uikit-select'));
414
+ await user.click(result.getAllByRole('option')[1]);
415
+
416
+ expect(result.getByTestId('ucl-uikit-select')).toHaveTextContent(
417
+ 'Second duplicate'
418
+ );
419
+ });
420
+
421
+ test('forwards id and title attributes to the combobox', () => {
422
+ const result = render(
423
+ <ThemeContextProvider>
424
+ <Select
425
+ id='my-select'
426
+ title='Select an option'
427
+ options={defaultOptions}
428
+ value=''
429
+ onValueChange={() => {}}
430
+ />
431
+ </ThemeContextProvider>
432
+ );
433
+
434
+ const combobox = result.getByRole('combobox');
435
+ expect(combobox).toHaveAttribute('id', 'my-select');
436
+ expect(combobox).toHaveAttribute('title', 'Select an option');
437
+ });
438
+
439
+ test('applies filterInputProps to the filter input', async () => {
440
+ const user = userEvent.setup();
441
+ const result = render(
442
+ <ThemeContextProvider>
443
+ <div>
444
+ <span id='filter-hint'>Filter options by text</span>
445
+ <Select
446
+ filterable
447
+ options={defaultOptions}
448
+ value=''
449
+ onValueChange={() => {}}
450
+ filterInputProps={{ 'aria-describedby': 'filter-hint' }}
451
+ />
452
+ </div>
453
+ </ThemeContextProvider>
454
+ );
455
+
456
+ await user.click(result.getByRole('combobox'));
457
+ const filterInput = result.getByRole('searchbox');
458
+ expect(filterInput).toHaveAttribute('aria-describedby', 'filter-hint');
459
+ });
460
+
461
+ test('opens filter input on keyboard focus', async () => {
462
+ const user = userEvent.setup();
463
+ const result = render(
464
+ <ThemeContextProvider>
465
+ <Select
466
+ filterable
467
+ options={defaultOptions}
468
+ value=''
469
+ onValueChange={() => {}}
470
+ />
471
+ </ThemeContextProvider>
472
+ );
473
+
474
+ await user.tab();
475
+
476
+ const filterInput = await result.findByRole('searchbox');
477
+ expect(filterInput).toHaveFocus();
478
+ expect(result.getByRole('listbox')).toBeInTheDocument();
479
+ });
480
+
481
+ test('pressing Escape closes dropdown and clears filter', async () => {
482
+ const user = userEvent.setup();
483
+ const result = render(
484
+ <ThemeContextProvider>
485
+ <Select
486
+ filterable
487
+ options={defaultOptions}
488
+ value=''
489
+ onValueChange={() => {}}
490
+ />
491
+ </ThemeContextProvider>
492
+ );
493
+
494
+ const combobox = result.getByTestId('ucl-uikit-select');
495
+ await user.click(combobox);
496
+ await user.type(result.getByRole('searchbox'), '1');
497
+ await user.keyboard('{Escape}');
498
+
499
+ expect(
500
+ result.queryByTestId('ucl-uikit-select__panel')
501
+ ).not.toBeInTheDocument();
502
+ expect(combobox).toHaveFocus();
503
+
504
+ await user.click(combobox);
505
+ expect(result.getByRole('searchbox')).toHaveValue('');
506
+ });
507
+
508
+ test('announces active option and option position metadata', async () => {
509
+ const user = userEvent.setup();
510
+ const result = render(
511
+ <ThemeContextProvider>
512
+ <Select
513
+ options={defaultOptions}
514
+ value=''
515
+ onValueChange={() => {}}
516
+ />
517
+ </ThemeContextProvider>
518
+ );
519
+
520
+ const combobox = result.getByRole('combobox');
521
+ await user.click(combobox);
522
+
523
+ const options = result.getAllByRole('option');
524
+ expect(options[0]).toHaveAttribute('aria-posinset', '1');
525
+ expect(options[0]).toHaveAttribute('aria-setsize', '3');
526
+ expect(options[1]).toHaveAttribute('aria-posinset', '2');
527
+ expect(options[1]).toHaveAttribute('aria-setsize', '3');
528
+
529
+ await user.keyboard('{ArrowDown}');
530
+ expect(combobox).toHaveAttribute('aria-activedescendant', options[0].id);
531
+
532
+ await user.keyboard('{ArrowDown}');
533
+ expect(combobox).toHaveAttribute('aria-activedescendant', options[1].id);
534
+ });
535
+
536
+ test('filterable input tracks active descendant while navigating options', async () => {
537
+ const user = userEvent.setup();
538
+ const result = render(
539
+ <ThemeContextProvider>
540
+ <Select
541
+ filterable
542
+ options={defaultOptions}
543
+ value=''
544
+ onValueChange={() => {}}
545
+ />
546
+ </ThemeContextProvider>
547
+ );
548
+
549
+ await user.click(result.getByRole('combobox'));
550
+ const filterInput = result.getByRole('searchbox');
551
+ expect(filterInput).toHaveFocus();
552
+
553
+ const options = result.getAllByRole('option');
554
+ await user.keyboard('{ArrowDown}');
555
+ expect(filterInput).toHaveAttribute('aria-activedescendant', options[0].id);
556
+
557
+ await user.keyboard('{ArrowDown}');
558
+ expect(filterInput).toHaveAttribute('aria-activedescendant', options[1].id);
559
+ });
119
560
  });
@@ -4,7 +4,7 @@ exports[`Select > Snapshot: default 1`] = `
4
4
  <div
5
5
  aria-expanded="false"
6
6
  aria-haspopup="listbox"
7
- class="ucl-uikit-select css-1g9yg1n"
7
+ class="ucl-uikit-select css-1kop2c3"
8
8
  data-testid="ucl-uikit-select"
9
9
  role="combobox"
10
10
  tabindex="0"
@@ -7,6 +7,7 @@ const NAME = 'ucl-uikit-select__option';
7
7
 
8
8
  const CustomOption = <T extends string | number>({
9
9
  value,
10
+ optionIndex,
10
11
  isSelected = false,
11
12
  onSelect,
12
13
  lineBreak = false,
@@ -29,7 +30,7 @@ const CustomOption = <T extends string | number>({
29
30
  }, [isSelected]);
30
31
 
31
32
  const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
32
- onSelect(event, value);
33
+ onSelect(event, value, optionIndex);
33
34
  event.stopPropagation(); // Otherwise the panel will open again instantaneously
34
35
  };
35
36