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,22 +1,30 @@
1
- import { useState, useRef, useEffect } from 'react';
1
+ import { useState, useRef, useEffect, useMemo, useId } from 'react';
2
2
  import { css, cx } from '@emotion/css';
3
- import { VisibleField, Panel, CustomOption } from '.';
3
+ import { VisibleField, Panel, CustomOption, FilterInput } from '.';
4
4
  import { useTheme } from '../../../theme';
5
- import type { CustomSelectProps } from '../Select.types';
5
+ import type { SelectProps } from '../Select.types';
6
6
 
7
7
  const NAME = 'ucl-uikit-select';
8
8
 
9
+ type CustomSelectProps<T> = Omit<
10
+ SelectProps<T>,
11
+ 'native' | 'nativeHtmlAttributes'
12
+ >;
13
+
9
14
  const CustomSelect = <T extends string | number>({
15
+ selectionBehaviour = 'focus',
10
16
  value,
11
17
  options = [],
12
18
  onValueChange,
13
19
  disabled,
14
20
  placeholder,
15
21
  lineBreak = false,
22
+ filterInputProps,
16
23
  width,
17
24
  testId = NAME,
18
25
  className,
19
26
  panelClassName,
27
+ filterable = false,
20
28
  ref,
21
29
  ...props
22
30
  }: CustomSelectProps<T>) => {
@@ -35,11 +43,107 @@ const CustomSelect = <T extends string | number>({
35
43
  console.warn('Select option icon prop is deprecated; it has no effect.');
36
44
  }
37
45
 
46
+ const duplicateOptionValues = useMemo(() => {
47
+ const seen = new Set<T>();
48
+ const duplicates = new Set<T>();
49
+
50
+ for (const option of options) {
51
+ if (seen.has(option.value)) duplicates.add(option.value);
52
+ else seen.add(option.value);
53
+ }
54
+
55
+ return Array.from(duplicates);
56
+ }, [options]);
57
+
58
+ useEffect(() => {
59
+ if (duplicateOptionValues.length > 0) {
60
+ console.warn(
61
+ `Select options contain non-unique values: ${duplicateOptionValues
62
+ .map(String)
63
+ .join(', ')}`
64
+ );
65
+ }
66
+ }, [duplicateOptionValues]);
67
+
38
68
  const internalRef = useRef<HTMLDivElement>(null);
39
- const effectiveRef = ref || internalRef;
69
+ const effectiveRef = internalRef;
70
+ const openedViaFocusRef = useRef(false);
71
+ const skipOpenOnFocusRef = useRef(false);
72
+
73
+ const setRefs = (node: HTMLDivElement | null) => {
74
+ internalRef.current = node;
75
+ if (typeof ref === 'function') {
76
+ ref(node);
77
+ } else if (
78
+ ref &&
79
+ 'current' in
80
+ (ref as React.RefObject<HTMLDivElement | HTMLSelectElement | null>)
81
+ ) {
82
+ (
83
+ ref as React.RefObject<HTMLDivElement | HTMLSelectElement | null>
84
+ ).current = node;
85
+ }
86
+ };
40
87
 
41
88
  const [theme] = useTheme();
42
89
  const [isOpen, setIsOpen] = useState(false);
90
+ const [filterText, setFilterText] = useState('');
91
+ const [activeOptionIndex, setActiveOptionIndex] = useState<number | null>(
92
+ null
93
+ );
94
+ const [selectedOptionIndex, setSelectedOptionIndex] = useState<number | null>(
95
+ null
96
+ );
97
+ const filterInputRef = useRef<HTMLInputElement | null>(null);
98
+ const reactId = useId();
99
+ const idBase = props.id ?? `${testId}-${reactId.replace(/[:]/g, '')}`;
100
+ const listboxId = `${idBase}-listbox`;
101
+
102
+ // Returns a list of indexes of options that are currently visible based on the filter text
103
+ const visibleOptionIndexes = useMemo(() => {
104
+ const normalizedFilterText = filterText.toLowerCase();
105
+ return filterable
106
+ ? options.reduce<number[]>((visibleIndexes, option, index) => {
107
+ if (option.label.toLowerCase().includes(normalizedFilterText)) {
108
+ visibleIndexes.push(index);
109
+ }
110
+ return visibleIndexes;
111
+ }, [])
112
+ : options.map((_, index) => index);
113
+ }, [filterable, options, filterText]);
114
+ const visibleOptions = useMemo(
115
+ () => visibleOptionIndexes.map((index) => options[index]),
116
+ [visibleOptionIndexes, options]
117
+ );
118
+
119
+ useEffect(() => {
120
+ if (!isOpen && filterText) setFilterText('');
121
+ }, [isOpen, filterText]);
122
+
123
+ useEffect(() => {
124
+ const matchingIndexes = options.reduce<number[]>(
125
+ (matches, option, index) => {
126
+ if (option.value === value) matches.push(index);
127
+ return matches;
128
+ },
129
+ []
130
+ );
131
+
132
+ if (matchingIndexes.length === 0) {
133
+ if (selectedOptionIndex !== null) setSelectedOptionIndex(null);
134
+ return;
135
+ }
136
+
137
+ // If the currently selected option is among the matches, keep it selected
138
+ if (
139
+ selectedOptionIndex !== null &&
140
+ matchingIndexes.includes(selectedOptionIndex)
141
+ ) {
142
+ return;
143
+ }
144
+ // Otherwise, select the first matching option
145
+ setSelectedOptionIndex(matchingIndexes[0]);
146
+ }, [options, selectedOptionIndex, value]);
43
147
 
44
148
  useEffect(() => {
45
149
  const handleClickOutside = (event: MouseEvent) => {
@@ -50,6 +154,7 @@ const CustomSelect = <T extends string | number>({
50
154
  setIsOpen(false);
51
155
  }
52
156
  };
157
+
53
158
  document.addEventListener('mousedown', handleClickOutside);
54
159
  return () => {
55
160
  document.removeEventListener('mousedown', handleClickOutside);
@@ -61,6 +166,12 @@ const CustomSelect = <T extends string | number>({
61
166
  if (disabled && isOpen) setIsOpen(false);
62
167
  }, [disabled, isOpen]);
63
168
 
169
+ useEffect(() => {
170
+ if (filterable && isOpen && filterInputRef.current) {
171
+ filterInputRef.current.focus();
172
+ }
173
+ }, [filterable, isOpen]);
174
+
64
175
  const togglePanel = () => {
65
176
  if (!disabled) setIsOpen((prev) => !prev);
66
177
  };
@@ -70,18 +181,82 @@ const CustomSelect = <T extends string | number>({
70
181
  };
71
182
 
72
183
  const closePanel = () => {
73
- if (!disabled) setIsOpen(false);
184
+ if (!disabled) {
185
+ setIsOpen(false);
186
+ setActiveOptionIndex(null);
187
+ }
188
+ };
189
+
190
+ const handleClick = (event: React.MouseEvent) => {
191
+ if (disabled) return;
192
+ if (openedViaFocusRef.current) {
193
+ openedViaFocusRef.current = false;
194
+ return;
195
+ }
196
+ if (!isOpen) {
197
+ openPanel();
198
+ if (filterable && filterInputRef.current) {
199
+ filterInputRef.current.focus();
200
+ }
201
+ return;
202
+ }
203
+ // If filter is enabled and the click was on the input, keep it open
204
+ if (
205
+ filterable &&
206
+ filterInputRef.current &&
207
+ filterInputRef.current.contains(event.target as Node)
208
+ ) {
209
+ return;
210
+ }
211
+ closePanel();
74
212
  };
75
213
 
76
214
  // Used by <CustomOption> and passed as prop
77
- const handleSelect = (event: React.MouseEvent, optionValue: T) => {
78
- if (onValueChange) onValueChange(optionValue, event);
215
+ const handleSelect = (
216
+ event: React.MouseEvent,
217
+ optionValue: T,
218
+ optionIndex?: number
219
+ ) => {
220
+ if (typeof optionIndex === 'number') {
221
+ setSelectedOptionIndex(optionIndex);
222
+ }
223
+ onValueChange?.(optionValue, event);
224
+ setFilterText('');
79
225
  closePanel();
80
226
  };
81
227
 
82
- const selectedOption = options.find((option) => option.value === value);
228
+ // Get the currently selected option object from its index among the visible options
229
+ const selectedOption =
230
+ selectedOptionIndex !== null ? options[selectedOptionIndex] : undefined;
231
+ // Get the index of the selected option among the visible options, or -1 if it's not visible
232
+ const selectedVisibleIndex =
233
+ selectedOptionIndex !== null
234
+ ? visibleOptionIndexes.indexOf(selectedOptionIndex)
235
+ : -1;
236
+ // Ensure the active option index is within bounds of the visible options
237
+ const effectiveActiveOptionIndex =
238
+ activeOptionIndex !== null &&
239
+ activeOptionIndex >= 0 &&
240
+ activeOptionIndex < visibleOptions.length
241
+ ? activeOptionIndex
242
+ : null;
243
+ // Get the index of the currently highlighted option among the visible options, or null if none is highlighted
244
+ const highlightedVisibleIndex =
245
+ effectiveActiveOptionIndex !== null
246
+ ? effectiveActiveOptionIndex
247
+ : selectedVisibleIndex >= 0
248
+ ? selectedVisibleIndex
249
+ : null;
250
+ const highlightedOptionSourceIndex =
251
+ highlightedVisibleIndex !== null
252
+ ? visibleOptionIndexes[highlightedVisibleIndex]
253
+ : null;
254
+ const activeDescendantId =
255
+ isOpen && highlightedOptionSourceIndex !== null
256
+ ? `${idBase}-option-${highlightedOptionSourceIndex}`
257
+ : undefined;
83
258
 
84
- const handleKeyDown = (event: React.KeyboardEvent) => {
259
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
85
260
  // Prevent scrolling the page when the select is open
86
261
  if (
87
262
  event.key === 'ArrowUp' ||
@@ -91,9 +266,48 @@ const CustomSelect = <T extends string | number>({
91
266
  )
92
267
  event.preventDefault();
93
268
 
269
+ // For current option index, activeOptionIndex > selectedVisibleIndex > value match > null
270
+ const getCurrentOptionIndex = () => {
271
+ if (
272
+ activeOptionIndex !== null &&
273
+ activeOptionIndex >= 0 &&
274
+ activeOptionIndex < visibleOptions.length
275
+ ) {
276
+ return activeOptionIndex;
277
+ }
278
+ if (selectedOptionIndex !== null) {
279
+ const selectedVisibleIndex =
280
+ visibleOptionIndexes.indexOf(selectedOptionIndex);
281
+ if (selectedVisibleIndex >= 0) {
282
+ return selectedVisibleIndex;
283
+ }
284
+ }
285
+ const selectedIndex = visibleOptions.findIndex(
286
+ (option) => option.value === value
287
+ );
288
+ return selectedIndex >= 0 ? selectedIndex : null;
289
+ };
290
+
94
291
  if (disabled) return;
95
292
 
96
293
  if (event.key === 'Enter') {
294
+ if (!isOpen) {
295
+ openPanel();
296
+ return;
297
+ }
298
+
299
+ if (selectionBehaviour === 'commit' && visibleOptions.length > 0) {
300
+ const currentOptionIndex = getCurrentOptionIndex();
301
+ if (currentOptionIndex !== null) {
302
+ const currentSourceIndex = visibleOptionIndexes[currentOptionIndex];
303
+ setSelectedOptionIndex(currentSourceIndex);
304
+ onValueChange?.(visibleOptions[currentOptionIndex].value, event);
305
+ setFilterText('');
306
+ }
307
+ closePanel();
308
+ return;
309
+ }
310
+
97
311
  togglePanel();
98
312
  return;
99
313
  }
@@ -103,6 +317,8 @@ const CustomSelect = <T extends string | number>({
103
317
  }
104
318
  if (isOpen && event.key === 'Escape') {
105
319
  closePanel();
320
+ skipOpenOnFocusRef.current = true;
321
+ event.currentTarget.focus();
106
322
  return;
107
323
  }
108
324
  // Select the previous option
@@ -111,17 +327,21 @@ const CustomSelect = <T extends string | number>({
111
327
  openPanel();
112
328
  return;
113
329
  }
114
- if (!value) {
115
- // Initialise at the last option if no value provided
116
- onValueChange?.(options[options.length - 1].value, event);
330
+ if (visibleOptions.length === 0) {
117
331
  return;
118
332
  }
119
- const currentOptionIndex = options.findIndex(
120
- (option) => option.value === value
121
- );
333
+ const currentOptionIndex = getCurrentOptionIndex();
122
334
  const previousOptionIndex =
123
- (currentOptionIndex - 1 + options.length) % options.length;
124
- onValueChange?.(options[previousOptionIndex].value, event);
335
+ currentOptionIndex === null
336
+ ? visibleOptions.length - 1
337
+ : (currentOptionIndex - 1 + visibleOptions.length) %
338
+ visibleOptions.length;
339
+ const previousSourceIndex = visibleOptionIndexes[previousOptionIndex];
340
+ setActiveOptionIndex(previousOptionIndex);
341
+ if (selectionBehaviour === 'focus') {
342
+ setSelectedOptionIndex(previousSourceIndex);
343
+ onValueChange?.(visibleOptions[previousOptionIndex].value, event);
344
+ }
125
345
  return;
126
346
  }
127
347
  // Select the next option
@@ -130,20 +350,38 @@ const CustomSelect = <T extends string | number>({
130
350
  openPanel();
131
351
  return;
132
352
  }
133
- if (!value) {
134
- // Initialise at the first option if no value provided
135
- onValueChange?.(options[0].value, event);
353
+ if (visibleOptions.length === 0) {
136
354
  return;
137
355
  }
138
- const currentOptionIndex = options.findIndex(
139
- (option) => option.value === value
140
- );
141
- const nextOptionIndex = (currentOptionIndex + 1) % options.length;
142
- onValueChange?.(options[nextOptionIndex].value, event);
356
+ const currentOptionIndex = getCurrentOptionIndex();
357
+ const nextOptionIndex =
358
+ currentOptionIndex === null
359
+ ? 0
360
+ : (currentOptionIndex + 1) % visibleOptions.length;
361
+ const nextSourceIndex = visibleOptionIndexes[nextOptionIndex];
362
+ setActiveOptionIndex(nextOptionIndex);
363
+ if (selectionBehaviour === 'focus') {
364
+ setSelectedOptionIndex(nextSourceIndex);
365
+ onValueChange?.(visibleOptions[nextOptionIndex].value, event);
366
+ }
143
367
  return;
144
368
  }
145
369
  };
146
370
 
371
+ const handleFocus = (event: React.FocusEvent<HTMLDivElement>) => {
372
+ if (disabled) return;
373
+ if (skipOpenOnFocusRef.current) {
374
+ skipOpenOnFocusRef.current = false;
375
+ return;
376
+ }
377
+ const isKeyboardFocus = event.currentTarget.matches(':focus-visible');
378
+ if (filterable && isKeyboardFocus) {
379
+ openedViaFocusRef.current = true;
380
+ openPanel();
381
+ if (filterInputRef.current) filterInputRef.current.focus();
382
+ }
383
+ };
384
+
147
385
  const baseStyle = css`
148
386
  display: inline-flex;
149
387
  align-items: center;
@@ -167,7 +405,8 @@ const CustomSelect = <T extends string | number>({
167
405
  ${!isOpen && `background-color: ${theme.color.neutral.grey5};`}
168
406
  }
169
407
 
170
- &:focus-visible {
408
+ &:focus-visible,
409
+ &:focus-within {
171
410
  outline: none;
172
411
  box-shadow: ${theme.boxShadow.focus};
173
412
  }
@@ -183,19 +422,27 @@ const CustomSelect = <T extends string | number>({
183
422
  }
184
423
  `;
185
424
 
425
+ const noOptionsStyle = css`
426
+ padding: ${theme.padding.p8} ${theme.padding.p16};
427
+ color: ${theme.color.text.secondary};
428
+ `;
429
+
186
430
  const style = cx(NAME, baseStyle, disabled && disabledStyle, className);
187
431
 
188
432
  return (
189
433
  <div
190
- onClick={togglePanel}
434
+ onClick={handleClick}
191
435
  onKeyDown={handleKeyDown}
436
+ onFocus={handleFocus}
192
437
  tabIndex={disabled ? -1 : 0}
193
438
  className={style}
194
439
  data-testid={testId}
195
- ref={effectiveRef}
440
+ ref={setRefs}
196
441
  role='combobox'
197
442
  aria-haspopup='listbox'
198
443
  aria-expanded={isOpen}
444
+ aria-controls={isOpen ? listboxId : undefined}
445
+ aria-activedescendant={activeDescendantId}
199
446
  {...props}
200
447
  >
201
448
  <VisibleField
@@ -203,26 +450,49 @@ const CustomSelect = <T extends string | number>({
203
450
  selectedOption={selectedOption}
204
451
  placeholder={placeholder}
205
452
  disabled={disabled}
206
- />
453
+ filterable={filterable}
454
+ >
455
+ {filterable && (
456
+ <FilterInput
457
+ value={filterText}
458
+ onChange={setFilterText}
459
+ placeholder={placeholder}
460
+ disabled={disabled}
461
+ inputRef={filterInputRef}
462
+ ariaControls={listboxId}
463
+ ariaExpanded={isOpen}
464
+ ariaActiveDescendant={activeDescendantId}
465
+ {...filterInputProps}
466
+ />
467
+ )}
468
+ </VisibleField>
207
469
  {isOpen && (
208
470
  <Panel
209
471
  className={panelClassName}
472
+ id={listboxId}
210
473
  role='listbox'
211
474
  >
212
- {options.map((option) => (
475
+ {visibleOptions.map((option, index) => (
213
476
  <CustomOption<T>
214
- key={option.value}
477
+ key={`${String(option.value)}-${visibleOptionIndexes[index]}`}
478
+ id={`${idBase}-option-${visibleOptionIndexes[index]}`}
215
479
  value={option.value}
216
- isSelected={value === option.value}
480
+ optionIndex={visibleOptionIndexes[index]}
481
+ isSelected={highlightedVisibleIndex === index}
217
482
  onSelect={handleSelect}
218
483
  lineBreak={lineBreak}
219
484
  role='option'
220
- aria-selected={value === option.value}
485
+ aria-selected={highlightedVisibleIndex === index}
486
+ aria-posinset={index + 1}
487
+ aria-setsize={visibleOptions.length}
221
488
  {...option.optionProps}
222
489
  >
223
490
  {option.label}
224
491
  </CustomOption>
225
492
  ))}
493
+ {visibleOptions.length === 0 && (
494
+ <div className={noOptionsStyle}>No options</div>
495
+ )}
226
496
  </Panel>
227
497
  )}
228
498
  </div>
@@ -0,0 +1,80 @@
1
+ import { css, cx } from '@emotion/css';
2
+ import { useTheme } from '../../../theme';
3
+ import type { FilterInputProps } from '../Select.types';
4
+
5
+ type FilterInputComponentProps = {
6
+ value: string;
7
+ onChange: (value: string) => void;
8
+ placeholder?: string;
9
+ disabled?: boolean;
10
+ inputRef?: React.RefObject<HTMLInputElement | null>;
11
+ className?: string;
12
+ ariaControls?: string;
13
+ ariaExpanded?: boolean;
14
+ ariaActiveDescendant?: string;
15
+ } & FilterInputProps;
16
+
17
+ const FilterInput = ({
18
+ value,
19
+ onChange,
20
+ placeholder,
21
+ disabled,
22
+ inputRef,
23
+ className,
24
+ ariaControls,
25
+ ariaExpanded,
26
+ ariaActiveDescendant,
27
+ ...rest
28
+ }: FilterInputComponentProps) => {
29
+ const [theme] = useTheme();
30
+
31
+ const handleOnKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
32
+ // Let parent handle key navigation
33
+ if (
34
+ e.key === 'Escape' ||
35
+ e.key === 'Enter' ||
36
+ e.key === 'ArrowUp' ||
37
+ e.key === 'ArrowDown'
38
+ )
39
+ return;
40
+ e.stopPropagation();
41
+ };
42
+
43
+ const style = css`
44
+ flex: 1;
45
+ border: none;
46
+ outline: none;
47
+ background: transparent;
48
+ font: inherit;
49
+ color: ${theme.color.text.primary};
50
+ &::placeholder {
51
+ color: ${theme.color.text.secondary};
52
+ }
53
+ &:disabled {
54
+ color: ${theme.color.text.disabled};
55
+ cursor: not-allowed;
56
+ }
57
+ `;
58
+ return (
59
+ <input
60
+ ref={inputRef}
61
+ className={cx(style, className)}
62
+ value={value}
63
+ placeholder={placeholder}
64
+ disabled={disabled}
65
+ onChange={(e) => onChange(e.target.value)}
66
+ onClick={(e) => e.stopPropagation()}
67
+ onKeyDown={(e) => handleOnKeyDown(e)}
68
+ aria-label='Filter options'
69
+ aria-autocomplete='list'
70
+ role='searchbox'
71
+ aria-haspopup='listbox'
72
+ aria-controls={ariaControls}
73
+ aria-expanded={ariaExpanded}
74
+ aria-activedescendant={ariaActiveDescendant}
75
+ {...rest}
76
+ />
77
+ );
78
+ };
79
+
80
+ export default FilterInput;
@@ -1,7 +1,19 @@
1
1
  import { css, cx } from '@emotion/css';
2
2
  import { useTheme } from '../../../theme';
3
3
  import { dataUri as chevronDownSvgDataUri } from '../../Icon/svgs/ChevronDownSvg';
4
- import { NativeSelectProps } from '../Select.types';
4
+ import type { SelectProps } from '../Select.types';
5
+
6
+ type NativeSelectProps = Omit<
7
+ React.SelectHTMLAttributes<HTMLSelectElement>,
8
+ 'value' | 'defaultValue'
9
+ > &
10
+ Omit<
11
+ SelectProps,
12
+ 'native' | 'filterable' | 'nativeHtmlAttributes' | 'onValueChange' | 'ref'
13
+ > & {
14
+ value?: string | number;
15
+ ref?: React.Ref<HTMLSelectElement>;
16
+ };
5
17
 
6
18
  const NAME = 'ucl-uikit-select--native';
7
19
 
@@ -10,6 +10,8 @@ interface VisibleFieldProps<T> {
10
10
  disabled?: boolean;
11
11
  selectedOption: OptionData<T> | null | undefined;
12
12
  placeholder?: string;
13
+ filterable?: boolean;
14
+ children?: React.ReactNode;
13
15
  }
14
16
 
15
17
  const VisibleField = <T extends string | number>({
@@ -17,6 +19,8 @@ const VisibleField = <T extends string | number>({
17
19
  isOpen,
18
20
  placeholder,
19
21
  disabled,
22
+ filterable,
23
+ children,
20
24
  }: VisibleFieldProps<T>) => {
21
25
  const [theme] = useTheme();
22
26
 
@@ -62,9 +66,13 @@ const VisibleField = <T extends string | number>({
62
66
  className={style}
63
67
  data-testid={NAME}
64
68
  >
65
- <span className={innerStyle}>
66
- {selectedOption ? selectedOption.label : placeholder || ''}
67
- </span>
69
+ {filterable && isOpen ? (
70
+ <div className={innerStyle}>{children}</div>
71
+ ) : (
72
+ <span className={innerStyle}>
73
+ {selectedOption ? selectedOption.label : placeholder || ''}
74
+ </span>
75
+ )}
68
76
  <Icon.ChevronDown className={chevronIconStyle} />
69
77
  </div>
70
78
  );
@@ -3,3 +3,4 @@ export { default as NativeSelect } from './NativeSelect';
3
3
  export { default as CustomOption } from './CustomOption';
4
4
  export { default as Panel } from './Panel';
5
5
  export { default as VisibleField } from './VisibleField';
6
+ export { default as FilterInput } from './FilterInput';
@@ -3,7 +3,7 @@ import { css } from '@emotion/css';
3
3
  import Snackbar from './Snackbar';
4
4
 
5
5
  const meta = {
6
- title: 'Components/Ready to use/Snackbar',
6
+ title: 'Components/Snackbar',
7
7
  component: Snackbar,
8
8
  args: {
9
9
  children: 'Default snackbar text',
@@ -4,7 +4,7 @@ import Spinner from './Spinner';
4
4
  import { theme } from '../../theme';
5
5
 
6
6
  const meta = {
7
- title: 'Components/Ready to use/Spinner',
7
+ title: 'Components/Spinner',
8
8
  component: Spinner,
9
9
  args: {
10
10
  size: 24,
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react';
3
3
  import Textarea from './Textarea';
4
4
 
5
5
  const meta = {
6
- title: 'Components/Ready to use/Textarea',
6
+ title: 'Components/Textarea',
7
7
  component: Textarea,
8
8
  parameters: {
9
9
  layout: 'padded',
@@ -33,6 +33,10 @@ const Timepicker = ({
33
33
  border-color: ${theme.color.text.primary};
34
34
  font-family: ${theme.font.family.primary};
35
35
  padding: ${theme.padding.p4};
36
+
37
+ &:focus-within {
38
+ box-shadow: ${theme.boxShadow.focus};
39
+ }
36
40
  `;
37
41
 
38
42
  const style = cx(NAME, baseStyle, className);
@@ -2,7 +2,7 @@
2
2
 
3
3
  exports[`Timepicker > snapshot 1`] = `
4
4
  <input
5
- class="ucl-uikit-timepicker css-ihuem"
5
+ class="ucl-uikit-timepicker css-4iq7dt"
6
6
  data-testid="ucl-uikit-timepicker"
7
7
  type="time"
8
8
  value=""
@@ -11,7 +11,7 @@ exports[`Timepicker > snapshot 1`] = `
11
11
 
12
12
  exports[`Timepicker > snapshot: testID prop 1`] = `
13
13
  <input
14
- class="ucl-uikit-timepicker css-ihuem"
14
+ class="ucl-uikit-timepicker css-4iq7dt"
15
15
  data-testid="testId"
16
16
  type="time"
17
17
  value=""
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react';
3
3
  import Toggle from './Toggle';
4
4
 
5
5
  const meta = {
6
- title: 'Components/Ready to use/Toggle',
6
+ title: 'Components/Toggle',
7
7
  component: Toggle,
8
8
  parameters: {
9
9
  layout: 'centered',