diff --git a/.changeset/fuzzy-spiders-hug.md b/.changeset/fuzzy-spiders-hug.md new file mode 100644 index 0000000000..3754ca9128 --- /dev/null +++ b/.changeset/fuzzy-spiders-hug.md @@ -0,0 +1,7 @@ +--- +'@leafygreen-ui/combobox': major +--- + +- Adds optional `inputValue` and `onInputChange` props to Combobox. These props are used to control the value of the inner text input (not the selected combobox value itself). + +- `onChange` callback now fires when the input is blurred and the input contains a valid selection value. diff --git a/packages/combobox/package.json b/packages/combobox/package.json index 3a3a4888f1..781a15fc26 100644 --- a/packages/combobox/package.json +++ b/packages/combobox/package.json @@ -43,7 +43,8 @@ "@leafygreen-ui/leafygreen-provider": "^3.1.10" }, "devDependencies": { - "@leafygreen-ui/button": "^21.0.12" + "@leafygreen-ui/button": "^21.0.12", + "@leafygreen-ui/testing-lib": "^0.4.0" }, "homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/packages/combobox", "repository": { diff --git a/packages/combobox/src/Combobox.story.tsx b/packages/combobox/src/Combobox.story.tsx index beedc55839..f038c072d6 100644 --- a/packages/combobox/src/Combobox.story.tsx +++ b/packages/combobox/src/Combobox.story.tsx @@ -99,6 +99,7 @@ const meta: StoryMetaType = { label: { control: 'text' }, description: { control: 'text' }, placeholder: { control: 'text' }, + inputValue: { control: 'text' }, size: { options: Object.values(ComboboxSize), control: 'select', diff --git a/packages/combobox/src/Combobox/Combobox.spec.tsx b/packages/combobox/src/Combobox/Combobox.spec.tsx index 7c3b7571e4..cd3f66b275 100644 --- a/packages/combobox/src/Combobox/Combobox.spec.tsx +++ b/packages/combobox/src/Combobox/Combobox.spec.tsx @@ -16,6 +16,7 @@ import isUndefined from 'lodash/isUndefined'; import Button from '@leafygreen-ui/button'; import { keyMap } from '@leafygreen-ui/lib'; +import { eventContainingTargetValue } from '@leafygreen-ui/testing-lib'; import { OptionObject } from '../ComboboxOption/ComboboxOption.types'; import { @@ -134,6 +135,13 @@ describe('packages/combobox', () => { }); expect(clearButtonEl).not.toBeInTheDocument(); }); + + test('`inputValue` prop is rendered in the textbox', () => { + const { inputEl } = renderCombobox(select, { + inputValue: 'abc', + }); + expect(inputEl).toHaveValue('abc'); + }); }); /** @@ -420,7 +428,7 @@ describe('packages/combobox', () => { /** * Input element */ - describe('Input interaction', () => { + describe('Typing (Input interaction)', () => { test('Typing any character updates the input', () => { const { inputEl } = renderCombobox(select); userEvent.type(inputEl, 'zy'); @@ -434,70 +442,103 @@ describe('packages/combobox', () => { expect(inputEl).toHaveValue(displayName); expect(inputEl.scrollWidth).toBeGreaterThanOrEqual(inputEl.clientWidth); }); - }); - /** - * Controlled - * (i.e. `value` prop) - */ - describe('When value is controlled', () => { - test('Typing any character updates the input', () => { - const value = select === 'multiple' ? [] : ''; - const { inputEl } = renderCombobox(select, { - value, - }); - expect(inputEl).toHaveValue(''); - userEvent.type(inputEl, 'z'); - expect(inputEl).toHaveValue('z'); + test('Typing does not fire onChange callback', () => { + const onChange = jest.fn(); + const { inputEl } = renderCombobox(select, { onChange }); + userEvent.type(inputEl, 'Apple'); + expect(onChange).not.toHaveBeenCalled(); }); - testSingleSelect('Text input renders with value update', () => { - let value = 'apple'; - const { inputEl, rerenderCombobox } = renderCombobox(select, { - value, - }); - expect(inputEl).toHaveValue('Apple'); - value = 'banana'; - rerenderCombobox({ value }); - expect(inputEl).toHaveValue('Banana'); + test('Typing fires onInputChange callback', () => { + const onInputChange = jest.fn(); + const { inputEl } = renderCombobox(select, { onInputChange }); + userEvent.type(inputEl, 'abc'); + expect(onInputChange).toHaveBeenCalledWith( + eventContainingTargetValue('abc'), + ); }); - testSingleSelect('Invalid option passed as value is not selected', () => { - const value = 'jellybean'; - const { inputEl } = renderCombobox(select, { value }); - expect(inputEl).toHaveValue(''); + test('Blurring the input after typing a valid value fires onChange', async () => { + const onChange = jest.fn(); + const { inputEl, openMenu } = renderCombobox(select, { onChange }); + const { menuContainerEl } = openMenu(); + userEvent.type(inputEl, 'Apple'); + userEvent.tab(); + await waitForElementToBeRemoved(menuContainerEl); + if (select === 'multiple') { + expect(onChange).toHaveBeenCalledWith(['apple'], expect.anything()); + } else { + expect(onChange).toHaveBeenCalledWith('apple'); + } }); - testMultiSelect('Updating `value` updates the chips', () => { - let value = ['apple', 'banana']; - const { queryChipsByName, queryAllChips, rerenderCombobox } = - renderCombobox(select, { + /** + * Controlled + * (i.e. `value` prop is set) + */ + describe('When value is controlled', () => { + test('Typing any character updates the input', () => { + const value = select === 'multiple' ? [] : ''; + const { inputEl } = renderCombobox(select, { value, }); - waitFor(() => { - const allChips = queryChipsByName(['Apple', 'Banana']); - allChips?.forEach(chip => expect(chip).toBeInTheDocument()); - expect(queryAllChips()).toHaveLength(2); - value = ['banana', 'carrot']; + expect(inputEl).toHaveValue(''); + userEvent.type(inputEl, 'z'); + expect(inputEl).toHaveValue('z'); + }); + + testSingleSelect('Text input renders with value update', () => { + let value = 'apple'; + const { inputEl, rerenderCombobox } = renderCombobox(select, { + value, + }); + expect(inputEl).toHaveValue('Apple'); + value = 'banana'; rerenderCombobox({ value }); + expect(inputEl).toHaveValue('Banana'); + }); + + testSingleSelect( + 'Invalid option passed as value is not selected', + () => { + const value = 'jellybean'; + const { inputEl } = renderCombobox(select, { value }); + expect(inputEl).toHaveValue(''); + }, + ); + + testMultiSelect('Updating `value` updates the chips', () => { + let value = ['apple', 'banana']; + const { queryChipsByName, queryAllChips, rerenderCombobox } = + renderCombobox(select, { + value, + }); waitFor(() => { - const allChips = queryChipsByName(['Carrot', 'Banana']); + const allChips = queryChipsByName(['Apple', 'Banana']); allChips?.forEach(chip => expect(chip).toBeInTheDocument()); expect(queryAllChips()).toHaveLength(2); + value = ['banana', 'carrot']; + rerenderCombobox({ value }); + waitFor(() => { + const allChips = queryChipsByName(['Carrot', 'Banana']); + allChips?.forEach(chip => expect(chip).toBeInTheDocument()); + expect(queryAllChips()).toHaveLength(2); + }); }); }); - }); - testMultiSelect('Invalid options are not selected', () => { - const value = ['apple', 'jellybean']; - const { queryChipsByName, queryAllChips } = renderCombobox(select, { - value, - }); - waitFor(() => { - const allChips = queryChipsByName(['Apple']); - allChips?.forEach(chip => expect(chip).toBeInTheDocument()); - expect(queryChipsByName('Jellybean')).not.toBeInTheDocument(); - expect(queryAllChips()).toHaveLength(1); + testMultiSelect('Invalid options are not selected', () => { + const value = ['apple', 'jellybean']; + const { queryChipsByName, queryAllChips } = renderCombobox(select, { + value, + }); + waitFor(() => { + const allChips = queryChipsByName(['Apple']); + allChips?.forEach(chip => expect(chip).toBeInTheDocument()); + expect(queryChipsByName('Jellybean')).not.toBeInTheDocument(); + expect(queryAllChips()).toHaveLength(1); + }); }); }); }); @@ -1538,13 +1579,6 @@ describe('packages/combobox', () => { expect(onChange).toHaveBeenCalled(); }); - test('Typing does not call onChange callback', () => { - const onChange = jest.fn(); - const { inputEl } = renderCombobox(select, { onChange }); - userEvent.type(inputEl, 'a'); - expect(onChange).not.toHaveBeenCalled(); - }); - test('Closing the menu without making a selection does not call onChange callback', async () => { const onChange = jest.fn(); const { containerEl, openMenu } = renderCombobox(select, { onChange }); diff --git a/packages/combobox/src/Combobox/Combobox.tsx b/packages/combobox/src/Combobox/Combobox.tsx index 6fcb973df1..6d82911937 100644 --- a/packages/combobox/src/Combobox/Combobox.tsx +++ b/packages/combobox/src/Combobox/Combobox.tsx @@ -62,6 +62,7 @@ import { getOptionObjectFromValue, getValueForDisplayName, } from '../utils'; +import { doesSelectionExist } from '../utils/doesSelectionExist'; import { isValueCurrentSelection } from './utils/isValueCurrentSelection'; import { @@ -121,6 +122,8 @@ export function Combobox({ overflow = Overflow.expandY, multiselect = false as M, initialValue, + inputValue: inputValueProp, + onInputChange, onChange, value, chipTruncationLocation, @@ -154,16 +157,23 @@ export function Combobox({ ); const [selection, setSelection] = useState | null>(null); const prevSelection = usePrevious(selection); - const [inputValue, setInputValue] = useState(''); + const [inputValue, setInputValue] = useState(inputValueProp ?? ''); + + useEffect(() => { + if (!isUndefined(inputValueProp)) { + setInputValue(inputValueProp); + } + }, [inputValueProp]); + + const updateInputValue = (newInputVal: string) => { + setInputValue(newInputVal); + }; + const prevValue = usePrevious(inputValue); const [focusedChip, setFocusedChip] = useState(null); const [shouldShowOverflowShadow, setShouldShowOverflowShadow] = useState(false); - const doesSelectionExist = - !isNull(selection) && - ((isArray(selection) && selection.length > 0) || isString(selection)); - const placeholderValue = multiselect && isArray(selection) && selection.length > 0 ? undefined @@ -243,7 +253,7 @@ export function Combobox({ newSelection.push(value); diff.diffType = 'insert'; // clear text - setInputValue(''); + updateInputValue(''); } } setSelection(newSelection as SelectValueType); @@ -757,31 +767,30 @@ export function Combobox({ ); /** - * + *` * Selection Management * */ const onCloseMenu = useCallback(() => { - // Single select, and no change to selection - if (!isMultiselect(selection) && selection === prevSelection) { - const exactMatchedOption = visibleOptions.find( - option => - option.displayName === inputValue || option.value === inputValue, - ); - - // check if inputValue is matches a valid option - // Set the selection to that value if the component is not controlled - if (exactMatchedOption && !value) { - setSelection(exactMatchedOption.value as SelectValueType); - } else { + const exactMatchedOption = visibleOptions.find( + option => + option.displayName === inputValue || option.value === inputValue, + ); + + // check if inputValue is matches a valid option + // Set the selection to that value if the component is not controlled + if (!value && exactMatchedOption) { + updateSelection(exactMatchedOption.value); + } else { + if (!isMultiselect(selection)) { // Revert the value to the previous selection const displayName = getDisplayNameForValue( selection as SelectValueType, allOptions, - ) ?? ''; - setInputValue(displayName); + ) ?? prevSelection; + updateInputValue(displayName); } } }, [ @@ -790,12 +799,16 @@ export function Combobox({ isMultiselect, prevSelection, selection, + updateSelection, value, visibleOptions, ]); + /** + * Side effects to run when the selection changes + */ const onSelect = useCallback(() => { - if (doesSelectionExist) { + if (doesSelectionExist(selection)) { if (isMultiselect(selection)) { scrollInputToEnd(overflow); } else if (!isMultiselect(selection)) { @@ -805,13 +818,13 @@ export function Combobox({ selection as SelectValueType, allOptions, ) ?? ''; - setInputValue(displayName); + updateInputValue(displayName); closeMenu(); } } else { - setInputValue(''); + updateInputValue(''); } - }, [doesSelectionExist, allOptions, isMultiselect, selection, overflow]); + }, [allOptions, isMultiselect, selection, overflow]); // Set the initialValue useEffect(() => { @@ -853,7 +866,14 @@ export function Combobox({ // onSelect // Side effects to run when the selection changes useEffect(() => { - if (!isEqual(selection, prevSelection)) { + const hasSelectionChanged = + !isUndefined(prevSelection) && + ((isArray(selection) && !isNull(prevSelection)) || + isString(selection) || + isNull(selection)) && + !isEqual(selection, prevSelection); + + if (hasSelectionChanged) { onSelect(); } }, [onSelect, prevSelection, selection]); @@ -926,12 +946,13 @@ export function Combobox({ }; // Fired onChange - const handleInputChange: ChangeEventHandler = ({ - target: { value }, - }: React.ChangeEvent) => { - setInputValue(value); + const handleInputChange: ChangeEventHandler = ( + e: React.ChangeEvent, + ) => { + updateInputValue(e.target.value); // fire any filter function passed in - onFilter?.(value); + onFilter?.(e.target.value); + onInputChange?.(e); }; const handleClearButtonFocus: FocusEventHandler = () => { @@ -969,7 +990,7 @@ export function Combobox({ case keyMap.Tab: { switch (focusedElementName) { case 'Input': { - if (!doesSelectionExist) { + if (!doesSelectionExist(selection)) { closeMenu(); updateHighlightedOption('first'); updateFocusedChip(null); @@ -1268,7 +1289,7 @@ export function Combobox({ className={endIconStyle} /> )} - {clearable && doesSelectionExist && !disabled && ( + {clearable && doesSelectionExist(selection) && !disabled && ( , 'onChange'> & * Do not remove options from the JSX children, as this will affect the selected options */ filteredOptions?: Array; + + /** + * A callback fired when the input text changes + */ + onInputChange?: React.ChangeEventHandler; + + /** + * Allows for a controlled text-input value + */ + inputValue?: string; }; export type ComboboxProps = Either< diff --git a/packages/combobox/src/utils/doesSelectionExist.ts b/packages/combobox/src/utils/doesSelectionExist.ts new file mode 100644 index 0000000000..2ccde4522c --- /dev/null +++ b/packages/combobox/src/utils/doesSelectionExist.ts @@ -0,0 +1,16 @@ +import isArray from 'lodash/isArray'; +import isNull from 'lodash/isNull'; +import isString from 'lodash/isString'; +import isUndefined from 'lodash/isUndefined'; + +import { SelectValueType } from '../types'; + +export const doesSelectionExist = ( + selection?: SelectValueType | null, +): boolean => { + return ( + !isUndefined(selection) && + !isNull(selection) && + (isString(selection) || (isArray(selection) && selection.length > 0)) + ); +}; diff --git a/packages/combobox/tsconfig.json b/packages/combobox/tsconfig.json index 04d31fa67c..6c6d2356c2 100644 --- a/packages/combobox/tsconfig.json +++ b/packages/combobox/tsconfig.json @@ -52,6 +52,9 @@ { "path": "../popover" }, + { + "path": "../testing-lib" + }, { "path": "../tokens" },