@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.
@@ -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
+ }