@leafygreen-ui/combobox 7.2.0 → 8.0.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,3 @@
1
+ import { SelectValueType } from '../types';
2
+ export declare const doesSelectionExist: <M extends boolean>(selection?: SelectValueType<M> | null | undefined) => boolean;
3
+ //# sourceMappingURL=doesSelectionExist.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"doesSelectionExist.d.ts","sourceRoot":"","sources":["../../src/utils/doesSelectionExist.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAE3C,eAAO,MAAM,kBAAkB,4EAE5B,OAMF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leafygreen-ui/combobox",
3
- "version": "7.2.0",
3
+ "version": "8.0.0",
4
4
  "description": "leafyGreen UI Kit Combobox",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/esm/index.js",
@@ -22,7 +22,7 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@leafygreen-ui/checkbox": "^12.0.20",
25
+ "@leafygreen-ui/checkbox": "^12.1.0",
26
26
  "@leafygreen-ui/chip": "^1.0.0",
27
27
  "@leafygreen-ui/emotion": "^4.0.7",
28
28
  "@leafygreen-ui/hooks": "^8.1.1",
@@ -34,7 +34,7 @@
34
34
  "@leafygreen-ui/palette": "^4.0.7",
35
35
  "@leafygreen-ui/popover": "^11.1.1",
36
36
  "@leafygreen-ui/tokens": "^2.3.0",
37
- "@leafygreen-ui/typography": "^18.0.0",
37
+ "@leafygreen-ui/typography": "^18.2.0",
38
38
  "chalk": "^4.1.2",
39
39
  "lodash": "^4.17.21",
40
40
  "polished": "^4.2.2"
@@ -43,7 +43,8 @@
43
43
  "@leafygreen-ui/leafygreen-provider": "^3.1.10"
44
44
  },
45
45
  "devDependencies": {
46
- "@leafygreen-ui/button": "^21.0.12"
46
+ "@leafygreen-ui/button": "^21.0.12",
47
+ "@leafygreen-ui/testing-lib": "^0.4.0"
47
48
  },
48
49
  "homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/packages/combobox",
49
50
  "repository": {
@@ -16,6 +16,7 @@ import isUndefined from 'lodash/isUndefined';
16
16
 
17
17
  import Button from '@leafygreen-ui/button';
18
18
  import { keyMap } from '@leafygreen-ui/lib';
19
+ import { eventContainingTargetValue } from '@leafygreen-ui/testing-lib';
19
20
 
20
21
  import { OptionObject } from '../ComboboxOption/ComboboxOption.types';
21
22
  import {
@@ -134,6 +135,13 @@ describe('packages/combobox', () => {
134
135
  });
135
136
  expect(clearButtonEl).not.toBeInTheDocument();
136
137
  });
138
+
139
+ test('`inputValue` prop is rendered in the textbox', () => {
140
+ const { inputEl } = renderCombobox(select, {
141
+ inputValue: 'abc',
142
+ });
143
+ expect(inputEl).toHaveValue('abc');
144
+ });
137
145
  });
138
146
 
139
147
  /**
@@ -420,7 +428,7 @@ describe('packages/combobox', () => {
420
428
  /**
421
429
  * Input element
422
430
  */
423
- describe('Input interaction', () => {
431
+ describe('Typing (Input interaction)', () => {
424
432
  test('Typing any character updates the input', () => {
425
433
  const { inputEl } = renderCombobox(select);
426
434
  userEvent.type(inputEl, 'zy');
@@ -434,70 +442,103 @@ describe('packages/combobox', () => {
434
442
  expect(inputEl).toHaveValue(displayName);
435
443
  expect(inputEl.scrollWidth).toBeGreaterThanOrEqual(inputEl.clientWidth);
436
444
  });
437
- });
438
445
 
439
- /**
440
- * Controlled
441
- * (i.e. `value` prop)
442
- */
443
- describe('When value is controlled', () => {
444
- test('Typing any character updates the input', () => {
445
- const value = select === 'multiple' ? [] : '';
446
- const { inputEl } = renderCombobox(select, {
447
- value,
448
- });
449
- expect(inputEl).toHaveValue('');
450
- userEvent.type(inputEl, 'z');
451
- expect(inputEl).toHaveValue('z');
446
+ test('Typing does not fire onChange callback', () => {
447
+ const onChange = jest.fn();
448
+ const { inputEl } = renderCombobox(select, { onChange });
449
+ userEvent.type(inputEl, 'Apple');
450
+ expect(onChange).not.toHaveBeenCalled();
452
451
  });
453
452
 
454
- testSingleSelect('Text input renders with value update', () => {
455
- let value = 'apple';
456
- const { inputEl, rerenderCombobox } = renderCombobox(select, {
457
- value,
458
- });
459
- expect(inputEl).toHaveValue('Apple');
460
- value = 'banana';
461
- rerenderCombobox({ value });
462
- expect(inputEl).toHaveValue('Banana');
453
+ test('Typing fires onInputChange callback', () => {
454
+ const onInputChange = jest.fn();
455
+ const { inputEl } = renderCombobox(select, { onInputChange });
456
+ userEvent.type(inputEl, 'abc');
457
+ expect(onInputChange).toHaveBeenCalledWith(
458
+ eventContainingTargetValue('abc'),
459
+ );
463
460
  });
464
461
 
465
- testSingleSelect('Invalid option passed as value is not selected', () => {
466
- const value = 'jellybean';
467
- const { inputEl } = renderCombobox(select, { value });
468
- expect(inputEl).toHaveValue('');
462
+ test('Blurring the input after typing a valid value fires onChange', async () => {
463
+ const onChange = jest.fn();
464
+ const { inputEl, openMenu } = renderCombobox(select, { onChange });
465
+ const { menuContainerEl } = openMenu();
466
+ userEvent.type(inputEl, 'Apple');
467
+ userEvent.tab();
468
+ await waitForElementToBeRemoved(menuContainerEl);
469
+ if (select === 'multiple') {
470
+ expect(onChange).toHaveBeenCalledWith(['apple'], expect.anything());
471
+ } else {
472
+ expect(onChange).toHaveBeenCalledWith('apple');
473
+ }
469
474
  });
470
475
 
471
- testMultiSelect('Updating `value` updates the chips', () => {
472
- let value = ['apple', 'banana'];
473
- const { queryChipsByName, queryAllChips, rerenderCombobox } =
474
- renderCombobox(select, {
476
+ /**
477
+ * Controlled
478
+ * (i.e. `value` prop is set)
479
+ */
480
+ describe('When value is controlled', () => {
481
+ test('Typing any character updates the input', () => {
482
+ const value = select === 'multiple' ? [] : '';
483
+ const { inputEl } = renderCombobox(select, {
475
484
  value,
476
485
  });
477
- waitFor(() => {
478
- const allChips = queryChipsByName(['Apple', 'Banana']);
479
- allChips?.forEach(chip => expect(chip).toBeInTheDocument());
480
- expect(queryAllChips()).toHaveLength(2);
481
- value = ['banana', 'carrot'];
486
+ expect(inputEl).toHaveValue('');
487
+ userEvent.type(inputEl, 'z');
488
+ expect(inputEl).toHaveValue('z');
489
+ });
490
+
491
+ testSingleSelect('Text input renders with value update', () => {
492
+ let value = 'apple';
493
+ const { inputEl, rerenderCombobox } = renderCombobox(select, {
494
+ value,
495
+ });
496
+ expect(inputEl).toHaveValue('Apple');
497
+ value = 'banana';
482
498
  rerenderCombobox({ value });
499
+ expect(inputEl).toHaveValue('Banana');
500
+ });
501
+
502
+ testSingleSelect(
503
+ 'Invalid option passed as value is not selected',
504
+ () => {
505
+ const value = 'jellybean';
506
+ const { inputEl } = renderCombobox(select, { value });
507
+ expect(inputEl).toHaveValue('');
508
+ },
509
+ );
510
+
511
+ testMultiSelect('Updating `value` updates the chips', () => {
512
+ let value = ['apple', 'banana'];
513
+ const { queryChipsByName, queryAllChips, rerenderCombobox } =
514
+ renderCombobox(select, {
515
+ value,
516
+ });
483
517
  waitFor(() => {
484
- const allChips = queryChipsByName(['Carrot', 'Banana']);
518
+ const allChips = queryChipsByName(['Apple', 'Banana']);
485
519
  allChips?.forEach(chip => expect(chip).toBeInTheDocument());
486
520
  expect(queryAllChips()).toHaveLength(2);
521
+ value = ['banana', 'carrot'];
522
+ rerenderCombobox({ value });
523
+ waitFor(() => {
524
+ const allChips = queryChipsByName(['Carrot', 'Banana']);
525
+ allChips?.forEach(chip => expect(chip).toBeInTheDocument());
526
+ expect(queryAllChips()).toHaveLength(2);
527
+ });
487
528
  });
488
529
  });
489
- });
490
530
 
491
- testMultiSelect('Invalid options are not selected', () => {
492
- const value = ['apple', 'jellybean'];
493
- const { queryChipsByName, queryAllChips } = renderCombobox(select, {
494
- value,
495
- });
496
- waitFor(() => {
497
- const allChips = queryChipsByName(['Apple']);
498
- allChips?.forEach(chip => expect(chip).toBeInTheDocument());
499
- expect(queryChipsByName('Jellybean')).not.toBeInTheDocument();
500
- expect(queryAllChips()).toHaveLength(1);
531
+ testMultiSelect('Invalid options are not selected', () => {
532
+ const value = ['apple', 'jellybean'];
533
+ const { queryChipsByName, queryAllChips } = renderCombobox(select, {
534
+ value,
535
+ });
536
+ waitFor(() => {
537
+ const allChips = queryChipsByName(['Apple']);
538
+ allChips?.forEach(chip => expect(chip).toBeInTheDocument());
539
+ expect(queryChipsByName('Jellybean')).not.toBeInTheDocument();
540
+ expect(queryAllChips()).toHaveLength(1);
541
+ });
501
542
  });
502
543
  });
503
544
  });
@@ -1538,13 +1579,6 @@ describe('packages/combobox', () => {
1538
1579
  expect(onChange).toHaveBeenCalled();
1539
1580
  });
1540
1581
 
1541
- test('Typing does not call onChange callback', () => {
1542
- const onChange = jest.fn();
1543
- const { inputEl } = renderCombobox(select, { onChange });
1544
- userEvent.type(inputEl, 'a');
1545
- expect(onChange).not.toHaveBeenCalled();
1546
- });
1547
-
1548
1582
  test('Closing the menu without making a selection does not call onChange callback', async () => {
1549
1583
  const onChange = jest.fn();
1550
1584
  const { containerEl, openMenu } = renderCombobox(select, { onChange });
@@ -62,6 +62,7 @@ import {
62
62
  getOptionObjectFromValue,
63
63
  getValueForDisplayName,
64
64
  } from '../utils';
65
+ import { doesSelectionExist } from '../utils/doesSelectionExist';
65
66
 
66
67
  import { isValueCurrentSelection } from './utils/isValueCurrentSelection';
67
68
  import {
@@ -121,6 +122,8 @@ export function Combobox<M extends boolean>({
121
122
  overflow = Overflow.expandY,
122
123
  multiselect = false as M,
123
124
  initialValue,
125
+ inputValue: inputValueProp,
126
+ onInputChange,
124
127
  onChange,
125
128
  value,
126
129
  chipTruncationLocation,
@@ -154,16 +157,23 @@ export function Combobox<M extends boolean>({
154
157
  );
155
158
  const [selection, setSelection] = useState<SelectValueType<M> | null>(null);
156
159
  const prevSelection = usePrevious(selection);
157
- const [inputValue, setInputValue] = useState<string>('');
160
+ const [inputValue, setInputValue] = useState<string>(inputValueProp ?? '');
161
+
162
+ useEffect(() => {
163
+ if (!isUndefined(inputValueProp)) {
164
+ setInputValue(inputValueProp);
165
+ }
166
+ }, [inputValueProp]);
167
+
168
+ const updateInputValue = (newInputVal: string) => {
169
+ setInputValue(newInputVal);
170
+ };
171
+
158
172
  const prevValue = usePrevious(inputValue);
159
173
  const [focusedChip, setFocusedChip] = useState<string | null>(null);
160
174
  const [shouldShowOverflowShadow, setShouldShowOverflowShadow] =
161
175
  useState<boolean>(false);
162
176
 
163
- const doesSelectionExist =
164
- !isNull(selection) &&
165
- ((isArray(selection) && selection.length > 0) || isString(selection));
166
-
167
177
  const placeholderValue =
168
178
  multiselect && isArray(selection) && selection.length > 0
169
179
  ? undefined
@@ -243,7 +253,7 @@ export function Combobox<M extends boolean>({
243
253
  newSelection.push(value);
244
254
  diff.diffType = 'insert';
245
255
  // clear text
246
- setInputValue('');
256
+ updateInputValue('');
247
257
  }
248
258
  }
249
259
  setSelection(newSelection as SelectValueType<M>);
@@ -757,31 +767,30 @@ export function Combobox<M extends boolean>({
757
767
  );
758
768
 
759
769
  /**
760
- *
770
+ *`
761
771
  * Selection Management
762
772
  *
763
773
  */
764
774
 
765
775
  const onCloseMenu = useCallback(() => {
766
- // Single select, and no change to selection
767
- if (!isMultiselect(selection) && selection === prevSelection) {
768
- const exactMatchedOption = visibleOptions.find(
769
- option =>
770
- option.displayName === inputValue || option.value === inputValue,
771
- );
772
-
773
- // check if inputValue is matches a valid option
774
- // Set the selection to that value if the component is not controlled
775
- if (exactMatchedOption && !value) {
776
- setSelection(exactMatchedOption.value as SelectValueType<M>);
777
- } else {
776
+ const exactMatchedOption = visibleOptions.find(
777
+ option =>
778
+ option.displayName === inputValue || option.value === inputValue,
779
+ );
780
+
781
+ // check if inputValue is matches a valid option
782
+ // Set the selection to that value if the component is not controlled
783
+ if (!value && exactMatchedOption) {
784
+ updateSelection(exactMatchedOption.value);
785
+ } else {
786
+ if (!isMultiselect(selection)) {
778
787
  // Revert the value to the previous selection
779
788
  const displayName =
780
789
  getDisplayNameForValue(
781
790
  selection as SelectValueType<false>,
782
791
  allOptions,
783
- ) ?? '';
784
- setInputValue(displayName);
792
+ ) ?? prevSelection;
793
+ updateInputValue(displayName);
785
794
  }
786
795
  }
787
796
  }, [
@@ -790,12 +799,16 @@ export function Combobox<M extends boolean>({
790
799
  isMultiselect,
791
800
  prevSelection,
792
801
  selection,
802
+ updateSelection,
793
803
  value,
794
804
  visibleOptions,
795
805
  ]);
796
806
 
807
+ /**
808
+ * Side effects to run when the selection changes
809
+ */
797
810
  const onSelect = useCallback(() => {
798
- if (doesSelectionExist) {
811
+ if (doesSelectionExist(selection)) {
799
812
  if (isMultiselect(selection)) {
800
813
  scrollInputToEnd(overflow);
801
814
  } else if (!isMultiselect(selection)) {
@@ -805,13 +818,13 @@ export function Combobox<M extends boolean>({
805
818
  selection as SelectValueType<false>,
806
819
  allOptions,
807
820
  ) ?? '';
808
- setInputValue(displayName);
821
+ updateInputValue(displayName);
809
822
  closeMenu();
810
823
  }
811
824
  } else {
812
- setInputValue('');
825
+ updateInputValue('');
813
826
  }
814
- }, [doesSelectionExist, allOptions, isMultiselect, selection, overflow]);
827
+ }, [allOptions, isMultiselect, selection, overflow]);
815
828
 
816
829
  // Set the initialValue
817
830
  useEffect(() => {
@@ -853,7 +866,14 @@ export function Combobox<M extends boolean>({
853
866
  // onSelect
854
867
  // Side effects to run when the selection changes
855
868
  useEffect(() => {
856
- if (!isEqual(selection, prevSelection)) {
869
+ const hasSelectionChanged =
870
+ !isUndefined(prevSelection) &&
871
+ ((isArray(selection) && !isNull(prevSelection)) ||
872
+ isString(selection) ||
873
+ isNull(selection)) &&
874
+ !isEqual(selection, prevSelection);
875
+
876
+ if (hasSelectionChanged) {
857
877
  onSelect();
858
878
  }
859
879
  }, [onSelect, prevSelection, selection]);
@@ -926,12 +946,13 @@ export function Combobox<M extends boolean>({
926
946
  };
927
947
 
928
948
  // Fired onChange
929
- const handleInputChange: ChangeEventHandler<HTMLInputElement> = ({
930
- target: { value },
931
- }: React.ChangeEvent<HTMLInputElement>) => {
932
- setInputValue(value);
949
+ const handleInputChange: ChangeEventHandler<HTMLInputElement> = (
950
+ e: React.ChangeEvent<HTMLInputElement>,
951
+ ) => {
952
+ updateInputValue(e.target.value);
933
953
  // fire any filter function passed in
934
- onFilter?.(value);
954
+ onFilter?.(e.target.value);
955
+ onInputChange?.(e);
935
956
  };
936
957
 
937
958
  const handleClearButtonFocus: FocusEventHandler<HTMLButtonElement> = () => {
@@ -969,7 +990,7 @@ export function Combobox<M extends boolean>({
969
990
  case keyMap.Tab: {
970
991
  switch (focusedElementName) {
971
992
  case 'Input': {
972
- if (!doesSelectionExist) {
993
+ if (!doesSelectionExist(selection)) {
973
994
  closeMenu();
974
995
  updateHighlightedOption('first');
975
996
  updateFocusedChip(null);
@@ -1268,7 +1289,7 @@ export function Combobox<M extends boolean>({
1268
1289
  className={endIconStyle}
1269
1290
  />
1270
1291
  )}
1271
- {clearable && doesSelectionExist && !disabled && (
1292
+ {clearable && doesSelectionExist(selection) && !disabled && (
1272
1293
  <IconButton
1273
1294
  aria-label="Clear selection"
1274
1295
  aria-disabled={disabled}
@@ -155,6 +155,16 @@ export type BaseComboboxProps = Omit<HTMLElementProps<'div'>, 'onChange'> &
155
155
  * Do not remove options from the JSX children, as this will affect the selected options
156
156
  */
157
157
  filteredOptions?: Array<string>;
158
+
159
+ /**
160
+ * A callback fired when the input text changes
161
+ */
162
+ onInputChange?: React.ChangeEventHandler<HTMLInputElement>;
163
+
164
+ /**
165
+ * Allows for a controlled text-input value
166
+ */
167
+ inputValue?: string;
158
168
  };
159
169
 
160
170
  export type ComboboxProps<M extends boolean> = Either<
@@ -99,6 +99,7 @@ const meta: StoryMetaType<typeof Combobox> = {
99
99
  label: { control: 'text' },
100
100
  description: { control: 'text' },
101
101
  placeholder: { control: 'text' },
102
+ inputValue: { control: 'text' },
102
103
  size: {
103
104
  options: Object.values(ComboboxSize),
104
105
  control: 'select',
@@ -0,0 +1,16 @@
1
+ import isArray from 'lodash/isArray';
2
+ import isNull from 'lodash/isNull';
3
+ import isString from 'lodash/isString';
4
+ import isUndefined from 'lodash/isUndefined';
5
+
6
+ import { SelectValueType } from '../types';
7
+
8
+ export const doesSelectionExist = <M extends boolean>(
9
+ selection?: SelectValueType<M> | null,
10
+ ): boolean => {
11
+ return (
12
+ !isUndefined(selection) &&
13
+ !isNull(selection) &&
14
+ (isString(selection) || (isArray(selection) && selection.length > 0))
15
+ );
16
+ };