@leafygreen-ui/combobox 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -0
- package/dist/Chip.d.ts +4 -0
- package/dist/Chip.d.ts.map +1 -0
- package/dist/Combobox.d.ts +7 -0
- package/dist/Combobox.d.ts.map +1 -0
- package/dist/Combobox.styles.d.ts +44 -0
- package/dist/Combobox.styles.d.ts.map +1 -0
- package/dist/Combobox.types.d.ts +230 -0
- package/dist/Combobox.types.d.ts.map +1 -0
- package/dist/ComboboxContext.d.ts +15 -0
- package/dist/ComboboxContext.d.ts.map +1 -0
- package/dist/ComboboxGroup.d.ts +9 -0
- package/dist/ComboboxGroup.d.ts.map +1 -0
- package/dist/ComboboxOption.d.ts +13 -0
- package/dist/ComboboxOption.d.ts.map +1 -0
- package/dist/ComboboxTestUtils.d.ts +126 -0
- package/dist/ComboboxTestUtils.d.ts.map +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/util.d.ts +53 -0
- package/dist/util.d.ts.map +1 -0
- package/package.json +32 -0
- package/src/Chip.tsx +223 -0
- package/src/Combobox.spec.tsx +1136 -0
- package/src/Combobox.story.tsx +248 -0
- package/src/Combobox.styles.ts +354 -0
- package/src/Combobox.tsx +1180 -0
- package/src/Combobox.types.ts +293 -0
- package/src/ComboboxContext.tsx +21 -0
- package/src/ComboboxGroup.tsx +61 -0
- package/src/ComboboxOption.tsx +200 -0
- package/src/ComboboxTestUtils.tsx +287 -0
- package/src/index.ts +3 -0
- package/src/util.tsx +117 -0
- package/tsconfig.json +47 -0
- package/tsconfig.tsbuildinfo +3889 -0
package/src/Combobox.tsx
ADDED
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { clone, isArray, isNull, isString, isUndefined } from 'lodash';
|
|
9
|
+
import { Description, Label } from '@leafygreen-ui/typography';
|
|
10
|
+
import Popover from '@leafygreen-ui/popover';
|
|
11
|
+
import {
|
|
12
|
+
useDynamicRefs,
|
|
13
|
+
useEventListener,
|
|
14
|
+
useIdAllocator,
|
|
15
|
+
usePrevious,
|
|
16
|
+
useViewportSize,
|
|
17
|
+
} from '@leafygreen-ui/hooks';
|
|
18
|
+
import InteractionRing from '@leafygreen-ui/interaction-ring';
|
|
19
|
+
import Icon from '@leafygreen-ui/icon';
|
|
20
|
+
import IconButton from '@leafygreen-ui/icon-button';
|
|
21
|
+
import { cx } from '@leafygreen-ui/emotion';
|
|
22
|
+
import { uiColors } from '@leafygreen-ui/palette';
|
|
23
|
+
import { isComponentType } from '@leafygreen-ui/lib';
|
|
24
|
+
import {
|
|
25
|
+
ComboboxProps,
|
|
26
|
+
getNullSelection,
|
|
27
|
+
onChangeType,
|
|
28
|
+
SelectValueType,
|
|
29
|
+
} from './Combobox.types';
|
|
30
|
+
import { ComboboxContext } from './ComboboxContext';
|
|
31
|
+
import { InternalComboboxOption } from './ComboboxOption';
|
|
32
|
+
import { Chip } from './Chip';
|
|
33
|
+
import {
|
|
34
|
+
clearButton,
|
|
35
|
+
comboboxParentStyle,
|
|
36
|
+
comboboxStyle,
|
|
37
|
+
endIcon,
|
|
38
|
+
errorMessageStyle,
|
|
39
|
+
inputElementStyle,
|
|
40
|
+
inputWrapperStyle,
|
|
41
|
+
interactionRingColor,
|
|
42
|
+
interactionRingStyle,
|
|
43
|
+
loadingIconStyle,
|
|
44
|
+
menuList,
|
|
45
|
+
menuMessage,
|
|
46
|
+
menuStyle,
|
|
47
|
+
menuWrapperStyle,
|
|
48
|
+
} from './Combobox.styles';
|
|
49
|
+
import { InternalComboboxGroup } from './ComboboxGroup';
|
|
50
|
+
import { flattenChildren, getNameAndValue, OptionObject, keyMap } from './util';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Component
|
|
54
|
+
*/
|
|
55
|
+
export default function Combobox<M extends boolean>({
|
|
56
|
+
children,
|
|
57
|
+
label,
|
|
58
|
+
description,
|
|
59
|
+
placeholder = 'Select',
|
|
60
|
+
'aria-label': ariaLabel,
|
|
61
|
+
disabled = false,
|
|
62
|
+
size = 'default',
|
|
63
|
+
darkMode = false,
|
|
64
|
+
state = 'none',
|
|
65
|
+
errorMessage,
|
|
66
|
+
searchState = 'unset',
|
|
67
|
+
searchEmptyMessage = 'No results found',
|
|
68
|
+
searchErrorMessage = 'Could not get results!',
|
|
69
|
+
searchLoadingMessage = 'Loading results...',
|
|
70
|
+
filteredOptions,
|
|
71
|
+
onFilter,
|
|
72
|
+
clearable = true,
|
|
73
|
+
onClear,
|
|
74
|
+
overflow = 'expand-y',
|
|
75
|
+
multiselect = false as M,
|
|
76
|
+
initialValue,
|
|
77
|
+
onChange,
|
|
78
|
+
value,
|
|
79
|
+
chipTruncationLocation,
|
|
80
|
+
chipCharacterLimit = 12,
|
|
81
|
+
className,
|
|
82
|
+
...rest
|
|
83
|
+
}: ComboboxProps<M>) {
|
|
84
|
+
const getOptionRef = useDynamicRefs<HTMLLIElement>({ prefix: 'option' });
|
|
85
|
+
const getChipRef = useDynamicRefs<HTMLSpanElement>({ prefix: 'chip' });
|
|
86
|
+
|
|
87
|
+
const inputId = useIdAllocator({ prefix: 'combobox-input' });
|
|
88
|
+
const labelId = useIdAllocator({ prefix: 'combobox-label' });
|
|
89
|
+
const menuId = useIdAllocator({ prefix: 'combobox-menu' });
|
|
90
|
+
|
|
91
|
+
const comboboxRef = useRef<HTMLDivElement>(null);
|
|
92
|
+
const clearButtonRef = useRef<HTMLButtonElement>(null);
|
|
93
|
+
const inputWrapperRef = useRef<HTMLDivElement>(null);
|
|
94
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
95
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
96
|
+
|
|
97
|
+
const [isOpen, setOpen] = useState(false);
|
|
98
|
+
const prevOpenState = usePrevious(isOpen);
|
|
99
|
+
const [focusedOption, setFocusedOption] = useState<string | null>(null);
|
|
100
|
+
const [selection, setSelection] = useState<SelectValueType<M> | null>(null);
|
|
101
|
+
const prevSelection = usePrevious(selection);
|
|
102
|
+
const [inputValue, setInputValue] = useState<string>('');
|
|
103
|
+
const prevValue = usePrevious(inputValue);
|
|
104
|
+
const [focusedChip, setFocusedChip] = useState<string | null>(null);
|
|
105
|
+
|
|
106
|
+
const doesSelectionExist =
|
|
107
|
+
!isNull(selection) &&
|
|
108
|
+
((isArray(selection) && selection.length > 0) || isString(selection));
|
|
109
|
+
|
|
110
|
+
// Tells typescript that selection is multiselect
|
|
111
|
+
const isMultiselect = useCallback(
|
|
112
|
+
<T extends number | string>(test: Array<T> | T | null): test is Array<T> =>
|
|
113
|
+
multiselect && isArray(test),
|
|
114
|
+
[multiselect],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Force focus of input box
|
|
118
|
+
const setInputFocus = useCallback(
|
|
119
|
+
(cursorPos?: number) => {
|
|
120
|
+
if (!disabled && inputRef && inputRef.current) {
|
|
121
|
+
inputRef.current.focus();
|
|
122
|
+
if (!isUndefined(cursorPos)) {
|
|
123
|
+
inputRef.current.setSelectionRange(cursorPos, cursorPos);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
[disabled],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Update selection differently in mulit & single select
|
|
131
|
+
const updateSelection = useCallback(
|
|
132
|
+
(value: string | null) => {
|
|
133
|
+
if (isMultiselect(selection)) {
|
|
134
|
+
const newSelection: SelectValueType<M> = clone(selection);
|
|
135
|
+
|
|
136
|
+
if (isNull(value)) {
|
|
137
|
+
newSelection.length = 0;
|
|
138
|
+
} else {
|
|
139
|
+
if (selection.includes(value)) {
|
|
140
|
+
// remove from array
|
|
141
|
+
newSelection.splice(newSelection.indexOf(value), 1);
|
|
142
|
+
} else {
|
|
143
|
+
// add to array
|
|
144
|
+
newSelection.push(value);
|
|
145
|
+
// clear text
|
|
146
|
+
setInputValue('');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
setSelection(newSelection);
|
|
150
|
+
(onChange as onChangeType<true>)?.(
|
|
151
|
+
newSelection as SelectValueType<true>,
|
|
152
|
+
);
|
|
153
|
+
} else {
|
|
154
|
+
const newSelection: SelectValueType<M> = value as SelectValueType<M>;
|
|
155
|
+
setSelection(newSelection);
|
|
156
|
+
(onChange as onChangeType<false>)?.(
|
|
157
|
+
newSelection as SelectValueType<false>,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
[isMultiselect, onChange, selection],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Scrolls the combobox to the far right
|
|
165
|
+
// Used when `overflow == 'scroll-x'`
|
|
166
|
+
const scrollToEnd = () => {
|
|
167
|
+
if (inputWrapperRef && inputWrapperRef.current) {
|
|
168
|
+
// TODO - consider converting to .scrollTo(). This is not yet wuppoted in IE or jsdom
|
|
169
|
+
inputWrapperRef.current.scrollLeft = inputWrapperRef.current.scrollWidth;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const placeholderValue =
|
|
174
|
+
multiselect && isArray(selection) && selection.length > 0
|
|
175
|
+
? undefined
|
|
176
|
+
: placeholder;
|
|
177
|
+
|
|
178
|
+
const allOptions = useMemo(() => flattenChildren(children), [children]);
|
|
179
|
+
|
|
180
|
+
const getDisplayNameForValue = useCallback(
|
|
181
|
+
(value: string | null): string => {
|
|
182
|
+
return value
|
|
183
|
+
? allOptions.find(opt => opt.value === value)?.displayName ?? value
|
|
184
|
+
: '';
|
|
185
|
+
},
|
|
186
|
+
[allOptions],
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// Computes whether the option is visible based on the current input
|
|
190
|
+
const isOptionVisible = useCallback(
|
|
191
|
+
(option: string | OptionObject): boolean => {
|
|
192
|
+
const value = typeof option === 'string' ? option : option.value;
|
|
193
|
+
|
|
194
|
+
// If filtered options are provided
|
|
195
|
+
if (filteredOptions && filteredOptions.length > 0) {
|
|
196
|
+
return filteredOptions.includes(value);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// otherwise, we do our own filtering
|
|
200
|
+
const displayName =
|
|
201
|
+
typeof option === 'string'
|
|
202
|
+
? getDisplayNameForValue(value)
|
|
203
|
+
: option.displayName;
|
|
204
|
+
return displayName.toLowerCase().includes(inputValue.toLowerCase());
|
|
205
|
+
},
|
|
206
|
+
[filteredOptions, getDisplayNameForValue, inputValue],
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const visibleOptions = useMemo(() => allOptions.filter(isOptionVisible), [
|
|
210
|
+
allOptions,
|
|
211
|
+
isOptionVisible,
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
const isValueValid = useCallback(
|
|
215
|
+
(value: string | null): boolean => {
|
|
216
|
+
return value ? !!allOptions.find(opt => opt.value === value) : false;
|
|
217
|
+
},
|
|
218
|
+
[allOptions],
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const getIndexOfValue = useCallback(
|
|
222
|
+
(value: string | null): number => {
|
|
223
|
+
return visibleOptions
|
|
224
|
+
? visibleOptions.findIndex(option => option.value === value)
|
|
225
|
+
: -1;
|
|
226
|
+
},
|
|
227
|
+
[visibleOptions],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const getValueAtIndex = useCallback(
|
|
231
|
+
(index: number): string | undefined => {
|
|
232
|
+
if (visibleOptions && visibleOptions.length >= index) {
|
|
233
|
+
const option = visibleOptions[index];
|
|
234
|
+
return option ? option.value : undefined;
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
[visibleOptions],
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const getActiveChipIndex = useCallback(
|
|
241
|
+
() =>
|
|
242
|
+
isMultiselect(selection)
|
|
243
|
+
? selection.findIndex(value =>
|
|
244
|
+
getChipRef(value)?.current?.contains(document.activeElement),
|
|
245
|
+
)
|
|
246
|
+
: -1,
|
|
247
|
+
[getChipRef, isMultiselect, selection],
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
*
|
|
252
|
+
* Focus Management
|
|
253
|
+
*
|
|
254
|
+
*/
|
|
255
|
+
|
|
256
|
+
const getFocusedElementName = useCallback(() => {
|
|
257
|
+
const isFocusOn = {
|
|
258
|
+
Input: inputRef.current?.contains(document.activeElement),
|
|
259
|
+
ClearButton: clearButtonRef.current?.contains(document.activeElement),
|
|
260
|
+
Chip:
|
|
261
|
+
isMultiselect(selection) &&
|
|
262
|
+
selection.some(value =>
|
|
263
|
+
getChipRef(value)?.current?.contains(document.activeElement),
|
|
264
|
+
),
|
|
265
|
+
};
|
|
266
|
+
const getActiveChipIndex = () =>
|
|
267
|
+
isMultiselect(selection)
|
|
268
|
+
? selection.findIndex(value =>
|
|
269
|
+
getChipRef(value)?.current?.contains(document.activeElement),
|
|
270
|
+
)
|
|
271
|
+
: -1;
|
|
272
|
+
|
|
273
|
+
if (isMultiselect(selection) && isFocusOn.Chip) {
|
|
274
|
+
if (getActiveChipIndex() === 0) {
|
|
275
|
+
return 'FirstChip';
|
|
276
|
+
} else if (getActiveChipIndex() === selection.length - 1) {
|
|
277
|
+
return 'LastChip';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return 'MiddleChip';
|
|
281
|
+
} else if (isFocusOn.Input) {
|
|
282
|
+
return 'Input';
|
|
283
|
+
} else if (isFocusOn.ClearButton) {
|
|
284
|
+
return 'ClearButton';
|
|
285
|
+
} else if (comboboxRef.current?.contains(document.activeElement)) {
|
|
286
|
+
return 'Combobox';
|
|
287
|
+
}
|
|
288
|
+
}, [getChipRef, isMultiselect, selection]);
|
|
289
|
+
|
|
290
|
+
type Direction = 'next' | 'prev' | 'first' | 'last';
|
|
291
|
+
const updateFocusedOption = useCallback(
|
|
292
|
+
(direction: Direction) => {
|
|
293
|
+
const optionsCount = visibleOptions?.length ?? 0;
|
|
294
|
+
const lastIndex = optionsCount - 1 > 0 ? optionsCount - 1 : 0;
|
|
295
|
+
const indexOfFocus = getIndexOfValue(focusedOption);
|
|
296
|
+
|
|
297
|
+
// Remove focus from chip
|
|
298
|
+
if (direction && isOpen) {
|
|
299
|
+
setFocusedChip(null);
|
|
300
|
+
setInputFocus();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
switch (direction) {
|
|
304
|
+
case 'next': {
|
|
305
|
+
const newValue =
|
|
306
|
+
indexOfFocus + 1 < optionsCount
|
|
307
|
+
? getValueAtIndex(indexOfFocus + 1)
|
|
308
|
+
: getValueAtIndex(0);
|
|
309
|
+
|
|
310
|
+
setFocusedOption(newValue ?? null);
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
case 'prev': {
|
|
315
|
+
const newValue =
|
|
316
|
+
indexOfFocus - 1 >= 0
|
|
317
|
+
? getValueAtIndex(indexOfFocus - 1)
|
|
318
|
+
: getValueAtIndex(lastIndex);
|
|
319
|
+
|
|
320
|
+
setFocusedOption(newValue ?? null);
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
case 'last': {
|
|
325
|
+
const newValue = getValueAtIndex(lastIndex);
|
|
326
|
+
setFocusedOption(newValue ?? null);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
case 'first':
|
|
331
|
+
default: {
|
|
332
|
+
const newValue = getValueAtIndex(0);
|
|
333
|
+
setFocusedOption(newValue ?? null);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
[
|
|
338
|
+
focusedOption,
|
|
339
|
+
getIndexOfValue,
|
|
340
|
+
getValueAtIndex,
|
|
341
|
+
isOpen,
|
|
342
|
+
setInputFocus,
|
|
343
|
+
visibleOptions?.length,
|
|
344
|
+
],
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
const updateFocusedChip = useCallback(
|
|
348
|
+
(direction: Direction | null, relativeToIndex?: number) => {
|
|
349
|
+
if (isMultiselect(selection)) {
|
|
350
|
+
switch (direction) {
|
|
351
|
+
case 'next': {
|
|
352
|
+
const referenceChipIndex = relativeToIndex ?? getActiveChipIndex();
|
|
353
|
+
const nextChipIndex =
|
|
354
|
+
referenceChipIndex + 1 < selection.length
|
|
355
|
+
? referenceChipIndex + 1
|
|
356
|
+
: selection.length - 1;
|
|
357
|
+
const nextChipValue = selection[nextChipIndex];
|
|
358
|
+
setFocusedChip(nextChipValue);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
case 'prev': {
|
|
363
|
+
const referenceChipIndex = relativeToIndex ?? getActiveChipIndex();
|
|
364
|
+
const prevChipIndex =
|
|
365
|
+
referenceChipIndex > 0
|
|
366
|
+
? referenceChipIndex - 1
|
|
367
|
+
: referenceChipIndex < 0
|
|
368
|
+
? selection.length - 1
|
|
369
|
+
: 0;
|
|
370
|
+
const prevChipValue = selection[prevChipIndex];
|
|
371
|
+
setFocusedChip(prevChipValue);
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
case 'first': {
|
|
376
|
+
const firstChipValue = selection[0];
|
|
377
|
+
setFocusedChip(firstChipValue);
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
case 'last': {
|
|
382
|
+
const lastChipValue = selection[selection.length - 1];
|
|
383
|
+
setFocusedChip(lastChipValue);
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
default:
|
|
388
|
+
setFocusedChip(null);
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
[getActiveChipIndex, isMultiselect, selection],
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const handleArrowKey = useCallback(
|
|
397
|
+
(direction: 'left' | 'right', event: React.KeyboardEvent<Element>) => {
|
|
398
|
+
// Remove focus from menu
|
|
399
|
+
if (direction) setFocusedOption(null);
|
|
400
|
+
|
|
401
|
+
const focusedElementName = getFocusedElementName();
|
|
402
|
+
|
|
403
|
+
switch (direction) {
|
|
404
|
+
case 'right':
|
|
405
|
+
switch (focusedElementName) {
|
|
406
|
+
case 'Input': {
|
|
407
|
+
// If cursor is at the end of the input
|
|
408
|
+
if (
|
|
409
|
+
inputRef.current?.selectionEnd ===
|
|
410
|
+
inputRef.current?.value.length
|
|
411
|
+
) {
|
|
412
|
+
clearButtonRef.current?.focus();
|
|
413
|
+
}
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
case 'LastChip': {
|
|
418
|
+
// if focus is on last chip, go to input
|
|
419
|
+
event.preventDefault();
|
|
420
|
+
setInputFocus(0);
|
|
421
|
+
updateFocusedChip(null);
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
case 'FirstChip':
|
|
426
|
+
case 'MiddleChip': {
|
|
427
|
+
updateFocusedChip('next');
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
case 'ClearButton':
|
|
432
|
+
default:
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
break;
|
|
436
|
+
|
|
437
|
+
case 'left':
|
|
438
|
+
switch (focusedElementName) {
|
|
439
|
+
case 'ClearButton': {
|
|
440
|
+
event.preventDefault();
|
|
441
|
+
setInputFocus(inputRef?.current?.value.length);
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
case 'Input':
|
|
446
|
+
case 'MiddleChip':
|
|
447
|
+
case 'LastChip': {
|
|
448
|
+
if (isMultiselect(selection)) {
|
|
449
|
+
// Break if cursor is not at the start of the input
|
|
450
|
+
if (
|
|
451
|
+
focusedElementName === 'Input' &&
|
|
452
|
+
inputRef.current?.selectionStart !== 0
|
|
453
|
+
) {
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
updateFocusedChip('prev');
|
|
458
|
+
}
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
case 'FirstChip':
|
|
463
|
+
default:
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
break;
|
|
467
|
+
default:
|
|
468
|
+
updateFocusedChip(null);
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
[
|
|
473
|
+
getFocusedElementName,
|
|
474
|
+
isMultiselect,
|
|
475
|
+
selection,
|
|
476
|
+
setInputFocus,
|
|
477
|
+
updateFocusedChip,
|
|
478
|
+
],
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
// Update the focused option when the inputValue changes
|
|
482
|
+
useEffect(() => {
|
|
483
|
+
if (inputValue !== prevValue) {
|
|
484
|
+
updateFocusedOption('first');
|
|
485
|
+
}
|
|
486
|
+
}, [inputValue, isOpen, prevValue, updateFocusedOption]);
|
|
487
|
+
|
|
488
|
+
// When the focused option chenges, update the menu scroll if necessary
|
|
489
|
+
useEffect(() => {
|
|
490
|
+
if (focusedOption) {
|
|
491
|
+
const focusedElementRef = getOptionRef(focusedOption);
|
|
492
|
+
|
|
493
|
+
if (focusedElementRef && focusedElementRef.current && menuRef.current) {
|
|
494
|
+
const { offsetTop: optionTop } = focusedElementRef.current;
|
|
495
|
+
const {
|
|
496
|
+
scrollTop: menuScroll,
|
|
497
|
+
offsetHeight: menuHeight,
|
|
498
|
+
} = menuRef.current;
|
|
499
|
+
|
|
500
|
+
if (optionTop > menuHeight || optionTop < menuScroll) {
|
|
501
|
+
menuRef.current.scrollTop = optionTop;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}, [focusedOption, getOptionRef]);
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
*
|
|
509
|
+
* Rendering
|
|
510
|
+
*
|
|
511
|
+
*/
|
|
512
|
+
const renderInternalOptions = useCallback(
|
|
513
|
+
(_children: React.ReactNode) => {
|
|
514
|
+
return React.Children.map(_children, child => {
|
|
515
|
+
if (isComponentType(child, 'ComboboxOption')) {
|
|
516
|
+
const { value, displayName } = getNameAndValue(child.props);
|
|
517
|
+
|
|
518
|
+
if (isOptionVisible(value)) {
|
|
519
|
+
const { className, glyph } = child.props;
|
|
520
|
+
const index = allOptions.findIndex(opt => opt.value === value);
|
|
521
|
+
|
|
522
|
+
const isFocused = focusedOption === value;
|
|
523
|
+
const isSelected = isMultiselect(selection)
|
|
524
|
+
? selection.includes(value)
|
|
525
|
+
: selection === value;
|
|
526
|
+
|
|
527
|
+
const setSelected = () => {
|
|
528
|
+
setFocusedOption(value);
|
|
529
|
+
updateSelection(value);
|
|
530
|
+
setInputFocus();
|
|
531
|
+
|
|
532
|
+
if (value === selection) {
|
|
533
|
+
closeMenu();
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const optionRef = getOptionRef(value);
|
|
538
|
+
|
|
539
|
+
return (
|
|
540
|
+
<InternalComboboxOption
|
|
541
|
+
value={value}
|
|
542
|
+
displayName={displayName}
|
|
543
|
+
isFocused={isFocused}
|
|
544
|
+
isSelected={isSelected}
|
|
545
|
+
setSelected={setSelected}
|
|
546
|
+
glyph={glyph}
|
|
547
|
+
className={className}
|
|
548
|
+
index={index}
|
|
549
|
+
ref={optionRef}
|
|
550
|
+
/>
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
} else if (isComponentType(child, 'ComboboxGroup')) {
|
|
554
|
+
const nestedChildren = renderInternalOptions(child.props.children);
|
|
555
|
+
|
|
556
|
+
if (nestedChildren && nestedChildren?.length > 0) {
|
|
557
|
+
return (
|
|
558
|
+
<InternalComboboxGroup
|
|
559
|
+
label={child.props.label}
|
|
560
|
+
className={child.props.className}
|
|
561
|
+
>
|
|
562
|
+
{renderInternalOptions(nestedChildren)}
|
|
563
|
+
</InternalComboboxGroup>
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
},
|
|
569
|
+
[
|
|
570
|
+
allOptions,
|
|
571
|
+
focusedOption,
|
|
572
|
+
getOptionRef,
|
|
573
|
+
isMultiselect,
|
|
574
|
+
isOptionVisible,
|
|
575
|
+
selection,
|
|
576
|
+
setInputFocus,
|
|
577
|
+
updateSelection,
|
|
578
|
+
],
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
const renderedOptions = useMemo(() => renderInternalOptions(children), [
|
|
582
|
+
children,
|
|
583
|
+
renderInternalOptions,
|
|
584
|
+
]);
|
|
585
|
+
|
|
586
|
+
const renderedChips = useMemo(() => {
|
|
587
|
+
if (isMultiselect(selection)) {
|
|
588
|
+
return selection.filter(isValueValid).map((value, index) => {
|
|
589
|
+
const displayName = getDisplayNameForValue(value);
|
|
590
|
+
|
|
591
|
+
const onRemove = () => {
|
|
592
|
+
updateFocusedChip('next', index);
|
|
593
|
+
updateSelection(value);
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const isFocused = focusedChip === value;
|
|
597
|
+
const chipRef = getChipRef(value);
|
|
598
|
+
|
|
599
|
+
const onFocus = () => {
|
|
600
|
+
setFocusedChip(value);
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
return (
|
|
604
|
+
<Chip
|
|
605
|
+
key={value}
|
|
606
|
+
displayName={displayName}
|
|
607
|
+
isFocused={isFocused}
|
|
608
|
+
onRemove={onRemove}
|
|
609
|
+
onFocus={onFocus}
|
|
610
|
+
ref={chipRef}
|
|
611
|
+
/>
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}, [
|
|
616
|
+
isMultiselect,
|
|
617
|
+
selection,
|
|
618
|
+
isValueValid,
|
|
619
|
+
getDisplayNameForValue,
|
|
620
|
+
focusedChip,
|
|
621
|
+
getChipRef,
|
|
622
|
+
updateFocusedChip,
|
|
623
|
+
updateSelection,
|
|
624
|
+
]);
|
|
625
|
+
|
|
626
|
+
const renderedInputIcons = useMemo(() => {
|
|
627
|
+
const handleClearButtonClick = (
|
|
628
|
+
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
|
629
|
+
) => {
|
|
630
|
+
if (!disabled) {
|
|
631
|
+
updateSelection(null);
|
|
632
|
+
onClear?.(e);
|
|
633
|
+
onFilter?.('');
|
|
634
|
+
if (!isOpen) {
|
|
635
|
+
openMenu();
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
return (
|
|
641
|
+
<>
|
|
642
|
+
{clearable && doesSelectionExist && (
|
|
643
|
+
<IconButton
|
|
644
|
+
aria-label="Clear selection"
|
|
645
|
+
aria-disabled={disabled}
|
|
646
|
+
disabled={disabled}
|
|
647
|
+
ref={clearButtonRef}
|
|
648
|
+
onClick={handleClearButtonClick}
|
|
649
|
+
onFocus={handleClearButtonFocus}
|
|
650
|
+
className={clearButton}
|
|
651
|
+
>
|
|
652
|
+
<Icon glyph="XWithCircle" />
|
|
653
|
+
</IconButton>
|
|
654
|
+
)}
|
|
655
|
+
{state === 'error' ? (
|
|
656
|
+
<Icon glyph="Warning" color={uiColors.red.base} className={endIcon} />
|
|
657
|
+
) : (
|
|
658
|
+
<Icon glyph="CaretDown" className={endIcon} />
|
|
659
|
+
)}
|
|
660
|
+
</>
|
|
661
|
+
);
|
|
662
|
+
}, [
|
|
663
|
+
clearable,
|
|
664
|
+
doesSelectionExist,
|
|
665
|
+
disabled,
|
|
666
|
+
state,
|
|
667
|
+
updateSelection,
|
|
668
|
+
onClear,
|
|
669
|
+
onFilter,
|
|
670
|
+
isOpen,
|
|
671
|
+
]);
|
|
672
|
+
|
|
673
|
+
// Do any of the options have an icon?
|
|
674
|
+
const withIcons = useMemo(() => allOptions.some(opt => opt.hasGlyph), [
|
|
675
|
+
allOptions,
|
|
676
|
+
]);
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
*
|
|
680
|
+
* Selection Management
|
|
681
|
+
*
|
|
682
|
+
*/
|
|
683
|
+
|
|
684
|
+
const onCloseMenu = useCallback(() => {
|
|
685
|
+
if (!isMultiselect(selection) && selection === prevSelection) {
|
|
686
|
+
const exactMatchedOption = visibleOptions.find(
|
|
687
|
+
option =>
|
|
688
|
+
option.displayName === inputValue || option.value === inputValue,
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
// check if inputValue is matches a valid option
|
|
692
|
+
// Set the selection to that value if the component is not controlled
|
|
693
|
+
if (exactMatchedOption && !value) {
|
|
694
|
+
setSelection(exactMatchedOption.value as SelectValueType<M>);
|
|
695
|
+
} else {
|
|
696
|
+
// Revert the value to the previous selection
|
|
697
|
+
const displayName =
|
|
698
|
+
getDisplayNameForValue(selection as SelectValueType<false>) ?? '';
|
|
699
|
+
setInputValue(displayName);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}, [
|
|
703
|
+
getDisplayNameForValue,
|
|
704
|
+
inputValue,
|
|
705
|
+
isMultiselect,
|
|
706
|
+
prevSelection,
|
|
707
|
+
selection,
|
|
708
|
+
value,
|
|
709
|
+
visibleOptions,
|
|
710
|
+
]);
|
|
711
|
+
|
|
712
|
+
const onSelect = useCallback(() => {
|
|
713
|
+
if (doesSelectionExist) {
|
|
714
|
+
if (isMultiselect(selection)) {
|
|
715
|
+
// Scroll the wrapper to the end. No effect if not `overflow="scroll-x"`
|
|
716
|
+
scrollToEnd();
|
|
717
|
+
} else if (!isMultiselect(selection)) {
|
|
718
|
+
// Update the text input
|
|
719
|
+
const displayName =
|
|
720
|
+
getDisplayNameForValue(selection as SelectValueType<false>) ?? '';
|
|
721
|
+
setInputValue(displayName);
|
|
722
|
+
closeMenu();
|
|
723
|
+
}
|
|
724
|
+
} else {
|
|
725
|
+
setInputValue('');
|
|
726
|
+
}
|
|
727
|
+
}, [doesSelectionExist, getDisplayNameForValue, isMultiselect, selection]);
|
|
728
|
+
|
|
729
|
+
// Set initialValue
|
|
730
|
+
useEffect(() => {
|
|
731
|
+
if (initialValue) {
|
|
732
|
+
if (isArray(initialValue)) {
|
|
733
|
+
// Ensure the values we set are real options
|
|
734
|
+
const filteredValue =
|
|
735
|
+
initialValue.filter(value => isValueValid(value)) ?? [];
|
|
736
|
+
setSelection(filteredValue as SelectValueType<M>);
|
|
737
|
+
} else {
|
|
738
|
+
if (isValueValid(initialValue as string)) {
|
|
739
|
+
setSelection(initialValue);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
} else {
|
|
743
|
+
setSelection(getNullSelection(multiselect));
|
|
744
|
+
}
|
|
745
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
746
|
+
}, []);
|
|
747
|
+
|
|
748
|
+
// When controlled value changes, update the selection
|
|
749
|
+
useEffect(() => {
|
|
750
|
+
if (!isUndefined(value) && value !== prevValue) {
|
|
751
|
+
if (isNull(value)) {
|
|
752
|
+
setSelection(null);
|
|
753
|
+
} else if (isMultiselect(value)) {
|
|
754
|
+
// Ensure the value(s) passed in are valid options
|
|
755
|
+
const newSelection = value.filter(isValueValid) as SelectValueType<M>;
|
|
756
|
+
setSelection(newSelection);
|
|
757
|
+
} else {
|
|
758
|
+
setSelection(
|
|
759
|
+
isValueValid(value as SelectValueType<false>) ? value : null,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}, [isMultiselect, isValueValid, prevValue, value]);
|
|
764
|
+
|
|
765
|
+
// onSelect
|
|
766
|
+
// Side effects to run when the selection changes
|
|
767
|
+
useEffect(() => {
|
|
768
|
+
if (selection !== prevSelection) {
|
|
769
|
+
onSelect();
|
|
770
|
+
}
|
|
771
|
+
}, [onSelect, prevSelection, selection]);
|
|
772
|
+
|
|
773
|
+
// when the menu closes, update the value if needed
|
|
774
|
+
useEffect(() => {
|
|
775
|
+
if (!isOpen && prevOpenState !== isOpen) {
|
|
776
|
+
onCloseMenu();
|
|
777
|
+
}
|
|
778
|
+
}, [isOpen, prevOpenState, onCloseMenu]);
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
*
|
|
782
|
+
* Menu management
|
|
783
|
+
*
|
|
784
|
+
*/
|
|
785
|
+
const closeMenu = () => setOpen(false);
|
|
786
|
+
const openMenu = () => setOpen(true);
|
|
787
|
+
|
|
788
|
+
const [menuWidth, setMenuWidth] = useState(0);
|
|
789
|
+
useEffect(() => {
|
|
790
|
+
setMenuWidth(comboboxRef.current?.clientWidth ?? 0);
|
|
791
|
+
}, [comboboxRef, isOpen, focusedOption, selection]);
|
|
792
|
+
const handleTransitionEnd = () => {
|
|
793
|
+
setMenuWidth(comboboxRef.current?.clientWidth ?? 0);
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
const renderedMenuContents = useMemo((): JSX.Element => {
|
|
797
|
+
switch (searchState) {
|
|
798
|
+
case 'loading': {
|
|
799
|
+
return (
|
|
800
|
+
<span className={menuMessage}>
|
|
801
|
+
<Icon
|
|
802
|
+
glyph="Refresh"
|
|
803
|
+
color={uiColors.blue.base}
|
|
804
|
+
className={loadingIconStyle}
|
|
805
|
+
/>
|
|
806
|
+
{searchLoadingMessage}
|
|
807
|
+
</span>
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
case 'error': {
|
|
812
|
+
return (
|
|
813
|
+
<span className={menuMessage}>
|
|
814
|
+
<Icon glyph="Warning" color={uiColors.red.base} />
|
|
815
|
+
{searchErrorMessage}
|
|
816
|
+
</span>
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
case 'unset':
|
|
821
|
+
default: {
|
|
822
|
+
if (renderedOptions && renderedOptions.length > 0) {
|
|
823
|
+
return <ul className={menuList}>{renderedOptions}</ul>;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return <span className={menuMessage}>{searchEmptyMessage}</span>;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}, [
|
|
830
|
+
renderedOptions,
|
|
831
|
+
searchEmptyMessage,
|
|
832
|
+
searchErrorMessage,
|
|
833
|
+
searchLoadingMessage,
|
|
834
|
+
searchState,
|
|
835
|
+
]);
|
|
836
|
+
|
|
837
|
+
const viewportSize = useViewportSize();
|
|
838
|
+
|
|
839
|
+
// Set the max height of the menu
|
|
840
|
+
const maxHeight = useMemo(() => {
|
|
841
|
+
// TODO - consolidate this hook with Select/ListMenu
|
|
842
|
+
const maxMenuHeight = 274;
|
|
843
|
+
const menuMargin = 8;
|
|
844
|
+
|
|
845
|
+
if (viewportSize && comboboxRef.current && menuRef.current) {
|
|
846
|
+
const {
|
|
847
|
+
top: triggerTop,
|
|
848
|
+
bottom: triggerBottom,
|
|
849
|
+
} = comboboxRef.current.getBoundingClientRect();
|
|
850
|
+
|
|
851
|
+
// Find out how much space is available above or below the trigger
|
|
852
|
+
const safeSpace = Math.max(
|
|
853
|
+
viewportSize.height - triggerBottom,
|
|
854
|
+
triggerTop,
|
|
855
|
+
);
|
|
856
|
+
|
|
857
|
+
// if there's more than enough space, set to maxMenuHeight
|
|
858
|
+
// otherwise fill the space available
|
|
859
|
+
return Math.min(maxMenuHeight, safeSpace - menuMargin);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return maxMenuHeight;
|
|
863
|
+
}, [viewportSize, comboboxRef, menuRef]);
|
|
864
|
+
|
|
865
|
+
// Scroll the menu when the focus changes
|
|
866
|
+
useEffect(() => {
|
|
867
|
+
// get the focused option
|
|
868
|
+
}, [focusedOption]);
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
*
|
|
872
|
+
* Event Handlers
|
|
873
|
+
*
|
|
874
|
+
*/
|
|
875
|
+
|
|
876
|
+
// Prevent combobox from gaining focus by default
|
|
877
|
+
const handleInputWrapperMousedown = (e: React.MouseEvent) => {
|
|
878
|
+
if (e.target !== inputRef.current) {
|
|
879
|
+
e.preventDefault();
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
// Set focus to the input element on click
|
|
884
|
+
const handleInputWrapperClick = (e: React.MouseEvent) => {
|
|
885
|
+
if (e.target !== inputRef.current) {
|
|
886
|
+
let cursorPos = 0;
|
|
887
|
+
|
|
888
|
+
if (inputRef.current) {
|
|
889
|
+
const mouseX = e.nativeEvent.offsetX;
|
|
890
|
+
const inputRight =
|
|
891
|
+
inputRef.current.offsetLeft + inputRef.current.clientWidth;
|
|
892
|
+
cursorPos = mouseX > inputRight ? inputValue.length : 0;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
setInputFocus(cursorPos);
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
// Fired when the wrapper gains focus
|
|
900
|
+
const handleInputWrapperFocus = () => {
|
|
901
|
+
scrollToEnd();
|
|
902
|
+
openMenu();
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
// Fired onChange
|
|
906
|
+
const handleInputChange = ({
|
|
907
|
+
target: { value },
|
|
908
|
+
}: React.ChangeEvent<HTMLInputElement>) => {
|
|
909
|
+
setInputValue(value);
|
|
910
|
+
// fire any filter function passed in
|
|
911
|
+
onFilter?.(value);
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const handleClearButtonFocus = () => {
|
|
915
|
+
setFocusedOption(null);
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
919
|
+
const isFocusInMenu = menuRef.current?.contains(document.activeElement);
|
|
920
|
+
const isFocusOnCombobox = comboboxRef.current?.contains(
|
|
921
|
+
document.activeElement,
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
const isFocusInComponent = isFocusOnCombobox || isFocusInMenu;
|
|
925
|
+
|
|
926
|
+
if (isFocusInComponent) {
|
|
927
|
+
// No support for modifiers yet
|
|
928
|
+
// TODO - Handle support for multiple chip selection
|
|
929
|
+
if (event.ctrlKey || event.shiftKey || event.altKey) {
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const focusedElement = getFocusedElementName();
|
|
934
|
+
|
|
935
|
+
switch (event.keyCode) {
|
|
936
|
+
case keyMap.Tab: {
|
|
937
|
+
switch (focusedElement) {
|
|
938
|
+
case 'Input': {
|
|
939
|
+
if (!doesSelectionExist) {
|
|
940
|
+
closeMenu();
|
|
941
|
+
updateFocusedOption('first');
|
|
942
|
+
updateFocusedChip(null);
|
|
943
|
+
}
|
|
944
|
+
// else use default behavior
|
|
945
|
+
break;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
case 'LastChip': {
|
|
949
|
+
// use default behavior
|
|
950
|
+
updateFocusedChip(null);
|
|
951
|
+
break;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
case 'FirstChip':
|
|
955
|
+
case 'MiddleChip': {
|
|
956
|
+
// use default behavior
|
|
957
|
+
break;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
case 'ClearButton':
|
|
961
|
+
default:
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
break;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
case keyMap.Escape: {
|
|
969
|
+
closeMenu();
|
|
970
|
+
updateFocusedOption('first');
|
|
971
|
+
break;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
case keyMap.Enter:
|
|
975
|
+
case keyMap.Space: {
|
|
976
|
+
if (isOpen) {
|
|
977
|
+
// prevent typing the space character
|
|
978
|
+
event.preventDefault();
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (
|
|
982
|
+
// Focused on input element
|
|
983
|
+
document.activeElement === inputRef.current &&
|
|
984
|
+
isOpen &&
|
|
985
|
+
!isNull(focusedOption)
|
|
986
|
+
) {
|
|
987
|
+
updateSelection(focusedOption);
|
|
988
|
+
} else if (
|
|
989
|
+
// Focused on clear button
|
|
990
|
+
document.activeElement === clearButtonRef.current
|
|
991
|
+
) {
|
|
992
|
+
updateSelection(null);
|
|
993
|
+
setInputFocus();
|
|
994
|
+
}
|
|
995
|
+
break;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
case keyMap.Backspace: {
|
|
999
|
+
// Backspace key focuses last chip
|
|
1000
|
+
// Delete key does not
|
|
1001
|
+
if (
|
|
1002
|
+
isMultiselect(selection) &&
|
|
1003
|
+
inputRef.current?.selectionStart === 0
|
|
1004
|
+
) {
|
|
1005
|
+
updateFocusedChip('last');
|
|
1006
|
+
} else {
|
|
1007
|
+
openMenu();
|
|
1008
|
+
}
|
|
1009
|
+
break;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
case keyMap.ArrowDown: {
|
|
1013
|
+
if (isOpen) {
|
|
1014
|
+
// Prevent the page from scrolling
|
|
1015
|
+
event.preventDefault();
|
|
1016
|
+
}
|
|
1017
|
+
openMenu();
|
|
1018
|
+
updateFocusedOption('next');
|
|
1019
|
+
break;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
case keyMap.ArrowUp: {
|
|
1023
|
+
if (isOpen) {
|
|
1024
|
+
// Prevent the page from scrolling
|
|
1025
|
+
event.preventDefault();
|
|
1026
|
+
}
|
|
1027
|
+
updateFocusedOption('prev');
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
case keyMap.ArrowRight: {
|
|
1032
|
+
handleArrowKey('right', event);
|
|
1033
|
+
break;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
case keyMap.ArrowLeft: {
|
|
1037
|
+
handleArrowKey('left', event);
|
|
1038
|
+
break;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
default: {
|
|
1042
|
+
if (!isOpen) {
|
|
1043
|
+
openMenu();
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
*
|
|
1052
|
+
* Global Event Handler
|
|
1053
|
+
*
|
|
1054
|
+
*/
|
|
1055
|
+
// Global backdrop click handler
|
|
1056
|
+
const handleBackdropClick = ({ target }: MouseEvent) => {
|
|
1057
|
+
const isChildFocused =
|
|
1058
|
+
menuRef.current?.contains(target as Node) ||
|
|
1059
|
+
comboboxRef.current?.contains(target as Node) ||
|
|
1060
|
+
false;
|
|
1061
|
+
|
|
1062
|
+
if (!isChildFocused) {
|
|
1063
|
+
setOpen(false);
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
useEventListener('mousedown', handleBackdropClick);
|
|
1067
|
+
|
|
1068
|
+
return (
|
|
1069
|
+
<ComboboxContext.Provider
|
|
1070
|
+
value={{
|
|
1071
|
+
multiselect,
|
|
1072
|
+
darkMode,
|
|
1073
|
+
size,
|
|
1074
|
+
withIcons,
|
|
1075
|
+
disabled,
|
|
1076
|
+
chipTruncationLocation,
|
|
1077
|
+
chipCharacterLimit,
|
|
1078
|
+
inputValue,
|
|
1079
|
+
}}
|
|
1080
|
+
>
|
|
1081
|
+
<div
|
|
1082
|
+
className={cx(
|
|
1083
|
+
comboboxParentStyle({ darkMode, size, overflow }),
|
|
1084
|
+
className,
|
|
1085
|
+
)}
|
|
1086
|
+
{...rest}
|
|
1087
|
+
>
|
|
1088
|
+
<div>
|
|
1089
|
+
{label && (
|
|
1090
|
+
<Label id={labelId} htmlFor={inputId}>
|
|
1091
|
+
{label}
|
|
1092
|
+
</Label>
|
|
1093
|
+
)}
|
|
1094
|
+
{description && <Description>{description}</Description>}
|
|
1095
|
+
</div>
|
|
1096
|
+
|
|
1097
|
+
<InteractionRing
|
|
1098
|
+
className={interactionRingStyle}
|
|
1099
|
+
disabled={disabled}
|
|
1100
|
+
color={interactionRingColor({ state, darkMode })}
|
|
1101
|
+
>
|
|
1102
|
+
{/* Disable eslint: onClick sets focus. Key events would already have focus */}
|
|
1103
|
+
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
|
|
1104
|
+
<div
|
|
1105
|
+
ref={comboboxRef}
|
|
1106
|
+
role="combobox"
|
|
1107
|
+
aria-expanded={isOpen}
|
|
1108
|
+
aria-controls={menuId}
|
|
1109
|
+
aria-owns={menuId}
|
|
1110
|
+
tabIndex={-1}
|
|
1111
|
+
className={comboboxStyle}
|
|
1112
|
+
onMouseDown={handleInputWrapperMousedown}
|
|
1113
|
+
onClick={handleInputWrapperClick}
|
|
1114
|
+
onFocus={handleInputWrapperFocus}
|
|
1115
|
+
onKeyDown={handleKeyDown}
|
|
1116
|
+
onTransitionEnd={handleTransitionEnd}
|
|
1117
|
+
data-disabled={disabled}
|
|
1118
|
+
data-state={state}
|
|
1119
|
+
>
|
|
1120
|
+
<div
|
|
1121
|
+
ref={inputWrapperRef}
|
|
1122
|
+
className={inputWrapperStyle({
|
|
1123
|
+
overflow,
|
|
1124
|
+
isOpen,
|
|
1125
|
+
selection,
|
|
1126
|
+
value: inputValue,
|
|
1127
|
+
})}
|
|
1128
|
+
>
|
|
1129
|
+
{renderedChips}
|
|
1130
|
+
<input
|
|
1131
|
+
aria-label={ariaLabel ?? label}
|
|
1132
|
+
aria-autocomplete="list"
|
|
1133
|
+
aria-controls={menuId}
|
|
1134
|
+
aria-labelledby={labelId}
|
|
1135
|
+
ref={inputRef}
|
|
1136
|
+
id={inputId}
|
|
1137
|
+
className={inputElementStyle}
|
|
1138
|
+
placeholder={placeholderValue}
|
|
1139
|
+
disabled={disabled ?? undefined}
|
|
1140
|
+
onChange={handleInputChange}
|
|
1141
|
+
value={inputValue}
|
|
1142
|
+
autoComplete="off"
|
|
1143
|
+
/>
|
|
1144
|
+
</div>
|
|
1145
|
+
{renderedInputIcons}
|
|
1146
|
+
</div>
|
|
1147
|
+
</InteractionRing>
|
|
1148
|
+
|
|
1149
|
+
{state === 'error' && errorMessage && (
|
|
1150
|
+
<div className={errorMessageStyle}>{errorMessage}</div>
|
|
1151
|
+
)}
|
|
1152
|
+
|
|
1153
|
+
{/******* /
|
|
1154
|
+
* Menu *
|
|
1155
|
+
/ *******/}
|
|
1156
|
+
<Popover
|
|
1157
|
+
active={isOpen && !disabled}
|
|
1158
|
+
spacing={4}
|
|
1159
|
+
align="bottom"
|
|
1160
|
+
justify="middle"
|
|
1161
|
+
refEl={comboboxRef}
|
|
1162
|
+
adjustOnMutation={true}
|
|
1163
|
+
className={menuWrapperStyle({ darkMode, size, width: menuWidth })}
|
|
1164
|
+
>
|
|
1165
|
+
<div
|
|
1166
|
+
id={menuId}
|
|
1167
|
+
role="listbox"
|
|
1168
|
+
aria-labelledby={labelId}
|
|
1169
|
+
aria-expanded={isOpen}
|
|
1170
|
+
ref={menuRef}
|
|
1171
|
+
className={menuStyle({ maxHeight })}
|
|
1172
|
+
onMouseDownCapture={e => e.preventDefault()}
|
|
1173
|
+
>
|
|
1174
|
+
{renderedMenuContents}
|
|
1175
|
+
</div>
|
|
1176
|
+
</Popover>
|
|
1177
|
+
</div>
|
|
1178
|
+
</ComboboxContext.Provider>
|
|
1179
|
+
);
|
|
1180
|
+
}
|