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.
- package/README.md +4 -2
- package/dist/components/Accordion/Accordion.Heading.d.ts +4 -4
- package/dist/components/Accordion/Accordion.Panel.d.ts +2 -2
- package/dist/components/Accordion/Accordion.d.ts +1 -1
- package/dist/components/Accordion/Accordion.stories.d.ts +57 -0
- package/dist/components/Accordion/index.d.ts +2 -0
- package/dist/components/Avatar/Avatar.stories.d.ts +107 -1
- package/dist/components/Button/Button.d.ts +1 -0
- package/dist/components/Calendar/index.d.ts +1 -1
- package/dist/components/Datepicker/Datepicker.d.ts +1 -1
- package/dist/components/Datepicker/Datepicker.stories.d.ts +4 -3
- package/dist/components/Datepicker/Datepicker.types.d.ts +4 -5
- package/dist/components/Datepicker/subcomponents/CustomDatepicker.d.ts +4 -1
- package/dist/components/Datepicker/subcomponents/DatepickerInput.d.ts +15 -2
- package/dist/components/Datepicker/subcomponents/Panel.d.ts +1 -1
- package/dist/components/Datepicker/subcomponents/VisibleField.d.ts +6 -1
- package/dist/components/Datepicker/subcomponents/index.d.ts +0 -1
- package/dist/components/Datepicker/utils/index.d.ts +0 -1
- package/dist/components/Dialog/BaseDialog.d.ts +2 -1
- package/dist/components/Dialog/Dialog.d.ts +2 -0
- package/dist/components/Header/Header.d.ts +4 -1
- package/dist/components/Header/Header.stories.d.ts +40 -0
- package/dist/components/Main/Main.d.ts +21 -0
- package/dist/components/Main/Main.stories.d.ts +15 -0
- package/dist/components/Main/index.d.ts +2 -0
- package/dist/components/NativeDatepicker/NativeDatepicker.d.ts +3 -0
- package/dist/components/NativeDatepicker/NativeDatepicker.stories.d.ts +36 -0
- package/dist/components/NativeDatepicker/NativeDatepicker.types.d.ts +10 -0
- package/dist/components/NativeDatepicker/index.d.ts +2 -0
- package/dist/components/{Datepicker → NativeDatepicker}/utils/dateToLocaleISOString/dateToLocaleISOString.d.ts +1 -1
- package/dist/components/NativeDatepicker/utils/dateToLocaleISOString/dateToLocaleISOString.test.d.ts +1 -0
- package/dist/components/NativeDatepicker/utils/index.d.ts +1 -0
- package/dist/components/Select/Select.stories.d.ts +154 -2
- package/dist/components/Select/Select.types.d.ts +51 -22
- package/dist/components/Select/subcomponents/CustomOption.d.ts +1 -1
- package/dist/components/Select/subcomponents/CustomSelect.d.ts +3 -2
- package/dist/components/Select/subcomponents/FilterInput.d.ts +14 -0
- package/dist/components/Select/subcomponents/NativeSelect.d.ts +5 -1
- package/dist/components/Select/subcomponents/VisibleField.d.ts +3 -1
- package/dist/components/Select/subcomponents/index.d.ts +1 -0
- package/dist/components/WeekPicker/WeekPicker.d.ts +2 -2
- package/dist/components/WeekPicker/WeekPicker.stories.d.ts +41 -0
- package/dist/components/WeekPicker/WeekPicker.types.d.ts +16 -0
- package/dist/components/WeekPicker/index.d.ts +1 -0
- package/dist/components/WeekPicker/subcomponents/CustomDatepicker.d.ts +1 -1
- package/dist/components/index.d.ts +8 -0
- package/dist/hooks/useFocusTrap.d.ts +2 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4366 -3768
- package/dist/utils/__tests__/announce.test.d.ts +1 -0
- package/dist/utils/announce.d.ts +6 -0
- package/dist/utils/index.d.ts +1 -0
- package/lib/components/Accordion/Accordion.Heading.tsx +27 -8
- package/lib/components/Accordion/Accordion.Panel.tsx +11 -3
- package/lib/components/Accordion/Accordion.stories.tsx +139 -0
- package/lib/components/Accordion/Accordion.tsx +10 -8
- package/lib/components/Accordion/__tests__/__snapshots__/Accordion.test.tsx.snap +7 -7
- package/lib/components/Accordion/index.ts +2 -0
- package/lib/components/Alert/Alert.stories.tsx +1 -1
- package/lib/components/Avatar/Avatar.mdx +117 -0
- package/lib/components/Avatar/Avatar.stories.tsx +110 -2
- package/lib/components/Blanket/Blanket.stories.tsx +1 -1
- package/lib/components/Button/Button.stories.tsx +1 -1
- package/lib/components/Button/Button.tsx +1 -0
- package/lib/components/Calendar/Calendar.stories.tsx +12 -32
- package/lib/components/Calendar/__tests__/Calendar.test.tsx +23 -15
- package/lib/components/Calendar/index.ts +1 -5
- package/lib/components/Calendar/subcomponents/AcademicWeeks.tsx +2 -1
- package/lib/components/Calendar/subcomponents/ColumnHeading.tsx +5 -1
- package/lib/components/Calendar/subcomponents/EventDot.tsx +2 -1
- package/lib/components/Calendar/subcomponents/index.ts +1 -1
- package/lib/components/Calendar/utils/getDatesForCalendarGrid/getDatesForCalendarGrid.ts +43 -11
- package/lib/components/Calendar/utils/normaliseMonth/normaliseMonth.test.ts +5 -5
- package/lib/components/Datepicker/Datepicker.lld.md +108 -0
- package/lib/components/Datepicker/Datepicker.stories.tsx +44 -5
- package/lib/components/Datepicker/Datepicker.tsx +14 -36
- package/lib/components/Datepicker/Datepicker.types.ts +5 -14
- package/lib/components/Datepicker/__tests__/Datepicker.test.tsx +150 -8
- package/lib/components/Datepicker/__tests__/__snapshots__/Datepicker.test.tsx.snap +10 -4
- package/lib/components/Datepicker/subcomponents/CustomDatepicker.tsx +39 -5
- package/lib/components/Datepicker/subcomponents/DatepickerInput.tsx +30 -17
- package/lib/components/Datepicker/subcomponents/Panel.tsx +6 -2
- package/lib/components/Datepicker/subcomponents/VisibleField.tsx +40 -3
- package/lib/components/Datepicker/subcomponents/index.ts +0 -1
- package/lib/components/Datepicker/utils/index.ts +0 -1
- package/lib/components/Dialog/BaseDialog.tsx +11 -0
- package/lib/components/Dialog/Dialog.tsx +8 -1
- package/lib/components/Dialog/DialogBody.tsx +5 -1
- package/lib/components/Dialog/DialogHeader.tsx +2 -1
- package/lib/components/Divider/Divider.stories.tsx +1 -1
- package/lib/components/Field/ErrorText.tsx +1 -0
- package/lib/components/Field/Field.stories.tsx +1 -1
- package/lib/components/Field/__tests__/Field.test.tsx +13 -0
- package/lib/components/FileInput/FileInput.stories.tsx +1 -1
- package/lib/components/Footer/Footer.stories.tsx +1 -1
- package/lib/components/Footer/__tests__/__snapshots__/Footer.test.tsx.snap +3 -3
- package/lib/components/Header/Header.mdx +52 -0
- package/lib/components/Header/Header.stories.tsx +98 -0
- package/lib/components/Header/Header.tsx +51 -6
- package/lib/components/Header/__tests__/Header.test.tsx +17 -1
- package/lib/components/Heading/Heading.stories.tsx +1 -1
- package/lib/components/Icon/Icon.stories.tsx +1 -1
- package/lib/components/IconButton/IconButton.stories.tsx +1 -1
- package/lib/components/Input/Input.stories.tsx +1 -1
- package/lib/components/Label/Label.stories.tsx +1 -1
- package/lib/components/Main/Main.stories.tsx +36 -0
- package/lib/components/Main/Main.tsx +46 -0
- package/lib/components/Main/__tests__/Main.test.tsx +80 -0
- package/lib/components/Main/__tests__/__snapshots__/Main.test.tsx.snap +33 -0
- package/lib/components/Main/index.ts +2 -0
- package/lib/components/NativeDatepicker/NativeDatepicker.stories.tsx +100 -0
- package/lib/components/{Datepicker/subcomponents → NativeDatepicker}/NativeDatepicker.tsx +14 -15
- package/lib/components/NativeDatepicker/NativeDatepicker.types.ts +19 -0
- package/lib/components/NativeDatepicker/index.ts +2 -0
- package/lib/components/{Datepicker → NativeDatepicker}/utils/dateToLocaleISOString/dateToLocaleISOString.ts +1 -1
- package/lib/components/NativeDatepicker/utils/index.ts +1 -0
- package/lib/components/Pagination/PaginationControls.tsx +55 -12
- package/lib/components/Pagination/PaginationInfo.tsx +5 -1
- package/lib/components/Paragraph/Paragraph.stories.tsx +1 -1
- package/lib/components/Search/Search.stories.tsx +1 -1
- package/lib/components/Search/Search.tsx +4 -1
- package/lib/components/Search/__tests__/Search.test.tsx +19 -1
- package/lib/components/Select/Select.mdx +169 -0
- package/lib/components/Select/Select.stories.tsx +191 -43
- package/lib/components/Select/Select.tsx +36 -12
- package/lib/components/Select/Select.types.ts +66 -48
- package/lib/components/Select/__tests__/Select.test.tsx +448 -7
- package/lib/components/Select/__tests__/__snapshots__/Select.test.tsx.snap +1 -1
- package/lib/components/Select/subcomponents/CustomOption.tsx +2 -1
- package/lib/components/Select/subcomponents/CustomSelect.tsx +303 -33
- package/lib/components/Select/subcomponents/FilterInput.tsx +80 -0
- package/lib/components/Select/subcomponents/NativeSelect.tsx +13 -1
- package/lib/components/Select/subcomponents/VisibleField.tsx +11 -3
- package/lib/components/Select/subcomponents/index.tsx +1 -0
- package/lib/components/Snackbar/Snackbar.stories.tsx +1 -1
- package/lib/components/Spinner/Spinner.stories.tsx +1 -1
- package/lib/components/Textarea/Textarea.stories.tsx +1 -1
- package/lib/components/Timepicker/Timepicker.tsx +4 -0
- package/lib/components/Timepicker/__tests__/__snapshots__/Timepicker.test.tsx.snap +2 -2
- package/lib/components/Toggle/Toggle.stories.tsx +1 -1
- package/lib/components/Tooltip/Tooltip.stories.tsx +1 -1
- package/lib/components/WeekPicker/WeekPicker.stories.tsx +147 -0
- package/lib/components/WeekPicker/WeekPicker.tsx +2 -2
- package/lib/components/WeekPicker/WeekPicker.types.ts +21 -0
- package/lib/components/WeekPicker/index.ts +1 -0
- package/lib/components/WeekPicker/subcomponents/CustomDatepicker.tsx +1 -1
- package/lib/components/common/Common.mdx +1 -1
- package/lib/components/index.ts +11 -2
- package/lib/hooks/useFocusTrap.ts +40 -4
- package/lib/index.ts +1 -0
- package/lib/utils/__tests__/announce.test.ts +121 -0
- package/lib/utils/announce.ts +134 -0
- package/lib/utils/index.ts +1 -0
- package/package.json +3 -6
- package/dist/components/Datepicker/subcomponents/NativeDatepicker.d.ts +0 -6
- package/lib/components/Accordion/Accordion.stories.tsx.NOT_READY +0 -93
- /package/dist/components/{Datepicker/utils/dateToLocaleISOString/dateToLocaleISOString.test.d.ts → Main/__tests__/Main.test.d.ts} +0 -0
- /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 {
|
|
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 =
|
|
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)
|
|
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 = (
|
|
78
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
120
|
-
(option) => option.value === value
|
|
121
|
-
);
|
|
333
|
+
const currentOptionIndex = getCurrentOptionIndex();
|
|
122
334
|
const previousOptionIndex =
|
|
123
|
-
|
|
124
|
-
|
|
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 (
|
|
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 =
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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={
|
|
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={
|
|
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
|
-
{
|
|
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
|
-
|
|
480
|
+
optionIndex={visibleOptionIndexes[index]}
|
|
481
|
+
isSelected={highlightedVisibleIndex === index}
|
|
217
482
|
onSelect={handleSelect}
|
|
218
483
|
lineBreak={lineBreak}
|
|
219
484
|
role='option'
|
|
220
|
-
aria-selected={
|
|
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 {
|
|
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
|
-
|
|
66
|
-
{
|
|
67
|
-
|
|
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';
|
|
@@ -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-
|
|
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-
|
|
14
|
+
class="ucl-uikit-timepicker css-4iq7dt"
|
|
15
15
|
data-testid="testId"
|
|
16
16
|
type="time"
|
|
17
17
|
value=""
|