From 51eb9f419c6f3f8ef85be39bfc228a57d3fc983c Mon Sep 17 00:00:00 2001 From: Maxim Cheremisin Date: Wed, 30 Aug 2023 13:13:48 +0200 Subject: [PATCH] feat(Autocomplete): add controllable isOpen, inputValue props; extend text behaviour on item select (#2567) * feat(Autocomplete): add controllable inputValue prop; extend text behaviour on item select * refactor(Autocomplete): test * feat(Autocomplete): return clearAfterSelect and deprecate it * fix: useCombobox internally changes input value on blur * feat: extend ControlledFromOutside with new props --- .changeset/soft-queens-eat.md | 5 + .../AutocompleteMultiSelectionExample.tsx | 4 +- .../autocomplete/src/Autocomplete.test.tsx | 249 ++++++++---------- .../autocomplete/src/Autocomplete.tsx | 106 +++++++- .../stories/Autocomplete.stories.tsx | 27 +- 5 files changed, 223 insertions(+), 168 deletions(-) create mode 100644 .changeset/soft-queens-eat.md diff --git a/.changeset/soft-queens-eat.md b/.changeset/soft-queens-eat.md new file mode 100644 index 0000000000..aa7fd336d8 --- /dev/null +++ b/.changeset/soft-queens-eat.md @@ -0,0 +1,5 @@ +--- +'@contentful/f36-autocomplete': minor +--- + +Add new props: `textOnAfterSelect?: 'clear' | 'preserve' | 'replace'`, `isOpen?: boolean; onOpen?: () => void; onClose?: () => void`, `inputValue?: string` diff --git a/packages/components/autocomplete/examples/AutocompleteMultiSelectionExample.tsx b/packages/components/autocomplete/examples/AutocompleteMultiSelectionExample.tsx index 5fe2337076..2cae3c7a1d 100644 --- a/packages/components/autocomplete/examples/AutocompleteMultiSelectionExample.tsx +++ b/packages/components/autocomplete/examples/AutocompleteMultiSelectionExample.tsx @@ -35,8 +35,8 @@ export default function AutocompleteMultiSelectionExample() { onSelectItem={handleSelectItem} itemToString={(item) => item.name} renderItem={(item) => item.name} - // When this prop is `true`, it will clean the TextInput after an option is selected - clearAfterSelect + // When `textOnAfterSelect` is `"clear"`, it will clean the TextInput after an option is selected + textOnAfterSelect="clear" closeAfterSelect={false} /> diff --git a/packages/components/autocomplete/src/Autocomplete.test.tsx b/packages/components/autocomplete/src/Autocomplete.test.tsx index 3c69844ba1..007789a67d 100644 --- a/packages/components/autocomplete/src/Autocomplete.test.tsx +++ b/packages/components/autocomplete/src/Autocomplete.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { getStringMatch } from '@contentful/f36-utils'; import { Autocomplete, AutocompleteProps } from './Autocomplete'; @@ -61,164 +62,160 @@ const mockOnSelectItem = jest.fn(); describe('Autocomplete', () => { describe('items is an array of strings', () => { it('calls the callback on input value change and selects the first item', async () => { + const user = userEvent.setup(); renderComponent({}); - const input = screen.getByTestId('cf-autocomplete-input'); - const list = screen.getByTestId('cf-autocomplete-list'); - const listFirstItem = screen.getByTestId('cf-autocomplete-list-item-0'); + const list = screen.queryByRole('list', { hidden: true }); // list is initially closed expect(list).not.toBeVisible(); expect(list.childElementCount).toBe(12); // Type one letter in the input to open the list - fireEvent.input(input, { - target: { - value: 'a', - }, - }); + await user.type(screen.getByRole('textbox'), 'a'); // checks that onInputValueChange was called with the value we typed expect(mockOnInputValueChange).toHaveBeenCalledWith('a'); // checks if the list is visible and it only shows the filtered options - await waitFor(() => { - expect(list).toBeVisible(); - }); + expect(screen.getByRole('listbox')).toBeVisible(); // go to the list first item - fireEvent.keyDown(input, { - key: 'ArrowDown', - }); + await user.keyboard('[ArrowDown]'); // checks if the first item of the list gets selected + const listFirstItem = screen.getAllByRole('option')[0]; expect(listFirstItem.getAttribute('aria-selected')).toBe('true'); expect(listFirstItem.getAttribute('class')).toContain('highlighted'); // press Enter to select the item - fireEvent.keyDown(input, { - key: 'Enter', - }); + await user.keyboard('[Enter]'); // checks if the list got closed and the value of the input is the one we selected expect(list).not.toBeVisible(); - expect(input.getAttribute('value')).toBe('Apple 🍎'); + expect(screen.getByRole('textbox')).toHaveValue('Apple 🍎'); expect(mockOnSelectItem).toHaveBeenCalledWith('Apple 🍎'); }); it('clears the input after item is selected when "clearAfterSelect" is true', async () => { + const user = userEvent.setup(); + renderComponent({ clearAfterSelect: true }); - const input = screen.getByTestId('cf-autocomplete-input'); - const container = screen.getByTestId('cf-autocomplete-container'); + // Type one letter in the input to open the list + await user.type(screen.getByRole('textbox'), 'a'); + + // checks if the list is visible + expect(screen.getByRole('listbox')).toBeVisible(); + + // go to the list first item + await user.keyboard('[ArrowDown]'); + + // press Enter to select the item + await user.keyboard('[Enter]'); + + // checks if the list got closed and the value of the input is an empty string + expect(screen.getByRole('listbox', { hidden: true })).not.toBeVisible(); + expect(screen.getByRole('textbox')).toHaveValue(''); + expect(mockOnSelectItem).toHaveBeenCalledWith('Apple 🍎'); + }); + + it('clears the input after item is selected when "textOnAfterSelect" is "clear"', async () => { + const user = userEvent.setup(); + + renderComponent({ textOnAfterSelect: 'clear' }); // Type one letter in the input to open the list - fireEvent.input(input, { - target: { - value: 'a', - }, - }); + await user.type(screen.getByRole('textbox'), 'a'); // checks if the list is visible - await waitFor(() => { - expect(container).toBeVisible(); - }); + expect(screen.getByRole('listbox')).toBeVisible(); // go to the list first item - fireEvent.keyDown(input, { - key: 'ArrowDown', - }); + await user.keyboard('[ArrowDown]'); // press Enter to select the item - fireEvent.keyDown(input, { - key: 'Enter', - }); + await user.keyboard('[Enter]'); // checks if the list got closed and the value of the input is an empty string - expect(container).not.toBeVisible(); - expect(input.getAttribute('value')).toBe(''); + expect(screen.getByRole('listbox', { hidden: true })).not.toBeVisible(); + expect(screen.getByRole('textbox')).toHaveValue(''); + expect(mockOnSelectItem).toHaveBeenCalledWith('Apple 🍎'); + }); + + it('preserves the same input value after item is selected when "textOnAfterSelect" is "preserve"', async () => { + const user = userEvent.setup(); + + renderComponent({ textOnAfterSelect: 'preserve' }); + + // Type one letter in the input to open the list + await user.type(screen.getByRole('textbox'), 'a'); + + // checks if the list is visible + expect(screen.getByRole('listbox')).toBeVisible(); + + // go to the list first item + await user.keyboard('[ArrowDown]'); + + // press Enter to select the item + await user.keyboard('[Enter]'); + + // checks if the list got closed and the value of the input is an empty string + expect(screen.getByRole('listbox', { hidden: true })).not.toBeVisible(); + expect(screen.getByRole('textbox')).toHaveValue('a'); expect(mockOnSelectItem).toHaveBeenCalledWith('Apple 🍎'); }); it('shows the value of the "noMatchesMessage" when the list has 0 items', async () => { const noMatchesMessage = 'There is no Broccoli in the list'; + const user = userEvent.setup(); renderComponent({ noMatchesMessage, items: [] }); - const input = screen.getByTestId('cf-autocomplete-input'); - // type anything to open the list - fireEvent.input(input, { - target: { - value: 'tesst', - }, - }); - - const list = screen.getByTestId('cf-autocomplete-container'); + await user.type(screen.getByRole('textbox'), 'tesst'); // checks if the list is visible and it only shows the "No matches" message - await waitFor(() => { - expect(list).toBeVisible(); - expect(screen.getByText(noMatchesMessage)).toBeVisible(); - }); + expect(screen.getByRole('listbox')).toBeVisible(); + expect(screen.getByText(noMatchesMessage)).toBeVisible(); }); it('should show the empty list if showEmptyList is true', async () => { const noMatchesMessage = 'No matches found'; renderComponent({ items: [], showEmptyList: true, noMatchesMessage }); - const input = screen.getByTestId('cf-autocomplete-input'); // Container should exist but not visible - expect(screen.getByTestId('cf-autocomplete-container')).not.toBeVisible(); + expect(screen.getByRole('listbox', { hidden: true })).not.toBeVisible(); // focus on input to open the list - fireEvent.focus(input); + screen.getByRole('textbox').focus(); // Should be visible after clicking on the input - await waitFor(() => { - expect(screen.getByTestId('cf-autocomplete-container')).toBeVisible(); - expect(screen.getByText(noMatchesMessage)).toBeVisible(); - }); + expect(screen.getByRole('listbox')).toBeVisible(); + expect(screen.getByText(noMatchesMessage)).toBeVisible(); }); it('is not showing the container when the list has 0 items and there is no input value', async () => { + const user = userEvent.setup(); renderComponent({ items: [] }); - const input = screen.getByTestId('cf-autocomplete-input'); - - fireEvent.click(input); - - // type anything to open the list - fireEvent.input(input, { - target: { - value: '', - }, - }); - - const list = screen.queryByTestId('cf-autocomplete-list'); + await user.click(screen.getByRole('textbox')); // checks if the list is not visible - expect(list).toBeNull(); + expect(screen.getByRole('textbox')).toHaveFocus(); + expect(screen.getByRole('listbox', { hidden: true })).not.toBeVisible(); }); it('shows loading state when "isLoading" is true', async () => { + const user = userEvent.setup(); renderComponent({ isLoading: true }); - const input = screen.getByTestId('cf-autocomplete-input'); - const container = screen.getByTestId('cf-autocomplete-container'); - // type anything to open the list - fireEvent.input(input, { - target: { - value: 'broccoli', - }, - }); + await user.type(screen.getByRole('textbox'), 'broccoli'); // checks if the list is visible and it shows the loading state - await waitFor(() => { - expect(container).toBeVisible(); - expect(screen.queryAllByTestId('cf-ui-skeleton-form')).toHaveLength(3); - }); + expect(screen.getByRole('listbox')).toBeVisible(); + expect(screen.getAllByLabelText('Loading component...')).toHaveLength(3); }); }); @@ -226,49 +223,39 @@ describe('Autocomplete', () => { const getItemName = (item: Fruit) => item.name; it('selects the first item', async () => { + const user = userEvent.setup(); renderComponent({ items: fruits, itemToString: getItemName, renderItem: getItemName, }); - const input = screen.getByTestId('cf-autocomplete-input'); - const list = screen.getByTestId('cf-autocomplete-list'); - const listFirstItem = screen.getByTestId('cf-autocomplete-list-item-0'); + const list = screen.getByRole('list', { hidden: true }); + const listFirstItem = screen.getAllByRole('option', { hidden: true })[0]; // list is initially closed expect(list).not.toBeVisible(); expect(list.childElementCount).toBe(12); // Type one letter in the input to open the list - fireEvent.input(input, { - target: { - value: 'a', - }, - }); + await user.type(screen.getByRole('textbox'), 'a'); // checks if the list is visible - await waitFor(() => { - expect(list).toBeVisible(); - }); + expect(list).toBeVisible(); // press the ArrowDown key - fireEvent.keyDown(input, { - key: 'ArrowDown', - }); + await user.keyboard('[ArrowDown]'); // checks if the first item of the list gets selected expect(listFirstItem.getAttribute('aria-selected')).toBe('true'); expect(listFirstItem.getAttribute('class')).toContain('highlighted'); // press Enter to select the item - fireEvent.keyDown(input, { - key: 'Enter', - }); + await user.keyboard('[Enter]'); // checks if the list got closed and the value of the input is the one we selected expect(list).not.toBeVisible(); - expect(input.getAttribute('value')).toBe('Apple 🍎'); + expect(screen.getByRole('textbox')).toHaveValue('Apple 🍎'); expect(mockOnSelectItem).toHaveBeenCalledWith({ id: 1, name: 'Apple 🍎', @@ -276,6 +263,7 @@ describe('Autocomplete', () => { }); it('when used with `getStringMatch`, it will render each item with the matched text wrapped in tag', async () => { + const user = userEvent.setup(); renderComponent({ items: fruits, itemToString: (item: Fruit) => item.name, @@ -295,25 +283,14 @@ describe('Autocomplete', () => { }, }); - const input = screen.getByTestId('cf-autocomplete-input'); - const list = screen.getByTestId('cf-autocomplete-list'); - // Type a text to be matched and open the list of suggestions - fireEvent.input(input, { - target: { - value: 'ana', - }, - }); + await user.type(screen.getByRole('textbox'), 'ana'); // checks if the list is visible and it only shows the filtered options - await waitFor(() => { - expect(list).toBeVisible(); - }); + expect(screen.getByRole('list')).toBeVisible(); // go to the list first item - fireEvent.keyDown(input, { - key: 'ArrowDown', - }); + await user.keyboard('[ArrowDown]'); // checks if there are two highlighted children expect(screen.queryAllByText(/ana/i)).toHaveLength(2); @@ -322,23 +299,19 @@ describe('Autocomplete', () => { describe('items is a nested object with groups', () => { const openDropdown = async () => { - const input = screen.getByTestId('cf-autocomplete-input'); - const container = screen.getByTestId('cf-autocomplete-container'); + const user = userEvent.setup(); + + const input = screen.getByRole('textbox'); + const container = screen.getByRole('listbox', { hidden: true }); // list is initially closed expect(container).not.toBeVisible(); // Type one letter in the input to open the list - fireEvent.input(input, { - target: { - value: 'a', - }, - }); + await user.type(input, 'a'); // checks if the list is visible - await waitFor(() => { - expect(container).toBeVisible(); - }); - return { input, container }; + expect(container).toBeVisible(); + return { input, container, user }; }; it('renders the group titles', async () => { @@ -352,9 +325,7 @@ describe('Autocomplete', () => { const { container } = await openDropdown(); expect(container.childElementCount).toBe(2); - expect( - screen.queryAllByTestId('cf-autocomplete-grouptitle'), - ).toHaveLength(2); + expect(screen.getAllByRole('heading')).toHaveLength(2); }); it("doesn't render an empty group", async () => { renderComponent({ @@ -367,7 +338,7 @@ describe('Autocomplete', () => { await openDropdown(); const renderedGroupTitles = screen - .getAllByTestId('cf-autocomplete-grouptitle') + .getAllByRole('heading') .map((node) => node.textContent); expect(renderedGroupTitles).toContain('Fruit'); @@ -382,26 +353,22 @@ describe('Autocomplete', () => { renderItem: (item: Fruit) => item.name, }); - const firstItem = screen.getByTestId('cf-autocomplete-list-item-0'); - - const { container, input } = await openDropdown(); + const { container, input, user } = await openDropdown(); expect(container.childElementCount).toBe(2); + const firstItem = screen.getAllByRole('option')[0]; + // press the ArrowDown key - fireEvent.keyDown(input, { - key: 'ArrowDown', - }); + await user.keyboard('[ArrowDown]'); expect(firstItem.getAttribute('aria-selected')).toBe('true'); // press Enter to select the item - fireEvent.keyDown(input, { - key: 'Enter', - }); + await user.keyboard('[Enter]'); // checks if the list got closed and the value of the input is the one we selected expect(container).not.toBeVisible(); - expect(input.getAttribute('value')).toBe('Apple 🍎'); + expect(input).toHaveValue('Apple 🍎'); expect(mockOnSelectItem).toHaveBeenCalledWith({ id: 1, name: 'Apple 🍎', @@ -423,9 +390,7 @@ describe('Autocomplete', () => { await openDropdown(); // checks if the list is visible and it only shows the "No matches" message - await waitFor(() => { - expect(screen.getByText(noMatchesMessage)).toBeVisible(); - }); + expect(screen.getByText(noMatchesMessage)).toBeVisible(); }); }); }); diff --git a/packages/components/autocomplete/src/Autocomplete.tsx b/packages/components/autocomplete/src/Autocomplete.tsx index 47aae8c441..73bf6913a4 100644 --- a/packages/components/autocomplete/src/Autocomplete.tsx +++ b/packages/components/autocomplete/src/Autocomplete.tsx @@ -40,6 +40,21 @@ export interface AutocompleteProps */ items: ItemType[] | GenericGroupType[]; + /** + * Boolean to control whether the Autocomplete menu is open + */ + isOpen?: boolean; + + /** + * Callback fired when the Autocomplete menu opens + */ + onOpen?: () => void; + + /** + * Callback fired when the Autocomplete menu closes + */ + onClose?: () => void; + /** * Set a custom icon for the text input */ @@ -50,6 +65,10 @@ export interface AutocompleteProps */ isGrouped?: boolean; + /** + * Set the value of the text input + */ + inputValue?: string; /** * Function called whenever the input value changes */ @@ -77,9 +96,15 @@ export interface AutocompleteProps * from those objetcs to be used as inputValue */ itemToString?: (item: ItemType) => string; + /** + * Text input behaviour after an item is selected + * @default "replace" + */ + textOnAfterSelect?: 'clear' | 'preserve' | 'replace'; /** * If this is set to `true` the text input will be cleared after an item is selected * @default false + * @deprecated Use textOnAfterSelect="clear" instead */ clearAfterSelect?: boolean; /** @@ -156,13 +181,18 @@ function _Autocomplete( ref: React.Ref, ) { const { + isOpen: isOpenProp, + onClose, + onOpen, id, className, clearAfterSelect = false, + textOnAfterSelect = clearAfterSelect ? 'clear' : 'replace', closeAfterSelect = true, defaultValue = '', selectedItem, items, + inputValue: inputValueProp, onInputValueChange, onSelectItem, onFocus, @@ -192,7 +222,9 @@ function _Autocomplete( const styles = getAutocompleteStyles(listMaxHeight); - const [inputValue, setInputValue] = useState(defaultValue); + const [_inputValue, setInputValue] = useState(defaultValue); + const inputValue = + typeof inputValueProp === 'undefined' ? _inputValue : inputValueProp; const handleInputValueChange = useCallback( (value: string) => { @@ -231,16 +263,69 @@ function _Autocomplete( getToggleButtonProps, highlightedIndex, isOpen, + openMenu, toggleMenu, } = useCombobox({ + isOpen: isOpenProp, + onIsOpenChange: ({ isOpen }) => { + if (isOpen) { + onOpen?.(); + } else { + onClose?.(); + } + }, + stateReducer: (state, { type, changes }) => { + switch (type) { + case useCombobox.stateChangeTypes.InputBlur: { + // don't change input value on blur + return { ...changes, inputValue: state.inputValue }; + } + + // item is selected by click or keydown + case useCombobox.stateChangeTypes.InputKeyDownEnter: + case useCombobox.stateChangeTypes.ItemClick: { + // prevent the menu from being closed when the user selects an item with a keyboard or mouse + if (!closeAfterSelect) { + return { + ...changes, + isOpen: state.isOpen, + }; + } + + return changes; + } + default: + return changes; + } + }, items: flattenItems, selectedItem, inputValue, itemToString, onInputValueChange: ({ type, inputValue }) => { - if (type !== '__input_change__') { - handleInputValueChange(inputValue); + switch (type) { + // value is set directly from the TextInput onChange handler + case useCombobox.stateChangeTypes.InputChange: { + return; + } + + // item is selected by click or keydown + case useCombobox.stateChangeTypes.ItemClick: + case useCombobox.stateChangeTypes.InputKeyDownEnter: { + // clear the TextInput value + if (textOnAfterSelect === 'clear') { + handleInputValueChange(''); + return; + } + + // keep the current TextInput value + if (textOnAfterSelect === 'preserve') { + return; + } + } } + + handleInputValueChange(inputValue); }, onStateChange: ({ type, selectedItem }) => { switch (type) { @@ -249,12 +334,6 @@ function _Autocomplete( if (selectedItem) { onSelectItem(selectedItem); } - if (clearAfterSelect) { - handleInputValueChange(''); - } - if (!closeAfterSelect) { - toggleMenu(); - } break; default: break; @@ -296,11 +375,12 @@ function _Autocomplete( {...inputProps} onFocus={(e) => { onFocus?.(e as React.FocusEvent); - if (!isOpen) { - toggleMenu(); - } + openMenu(); + }} + onBlur={(e) => { + onBlur?.(e as React.FocusEvent); + inputProps.onBlur(e); }} - onBlur={onBlur} id={id} isInvalid={isInvalid} isDisabled={isDisabled} diff --git a/packages/components/autocomplete/stories/Autocomplete.stories.tsx b/packages/components/autocomplete/stories/Autocomplete.stories.tsx index 817ccb2949..f76836e558 100644 --- a/packages/components/autocomplete/stories/Autocomplete.stories.tsx +++ b/packages/components/autocomplete/stories/Autocomplete.stories.tsx @@ -159,14 +159,12 @@ export const ControlledFromOutside = (args: AutocompleteProps) => { id: 9, name: 'Pear 🍐', }); - const [filteredItems, setFilteredItems] = useState(fruits); + const [inputValue, setInputValue] = useState(selectedFruit.name); + const [isOpen, setIsOpen] = useState(false); - const handleInputValueChange = (value: string) => { - const newFilteredItems = fruits.filter((item) => - item.name.toLowerCase().includes(value.toLowerCase()), - ); - setFilteredItems(newFilteredItems); - }; + const filteredItems = fruits.filter((item) => + item.name.toLowerCase().includes(inputValue.toLowerCase()), + ); const handleSelectItem = (item: Produce) => { setSelectedFruit(item); @@ -181,8 +179,13 @@ export const ControlledFromOutside = (args: AutocompleteProps) => { > {...args} + listMaxHeight={120} + textOnAfterSelect="preserve" items={filteredItems} - onInputValueChange={handleInputValueChange} + isOpen={isOpen} + onOpen={() => setIsOpen(true)} + inputValue={inputValue} + onInputValueChange={setInputValue} onSelectItem={handleSelectItem} itemToString={(item) => item.name} renderItem={(item) => item.name} @@ -190,15 +193,17 @@ export const ControlledFromOutside = (args: AutocompleteProps) => { onBlur={(e) => action('onBlur')(e)} selectedItem={selectedFruit} /> - Selected fruit: {selectedFruit?.name} + Input value: {inputValue} + + ); }; -UsingObjectsAsItems.args = { +ControlledFromOutside.args = { placeholder: 'Search your favorite fruit', }; @@ -284,7 +289,7 @@ export const MultipleSelection = (args: AutocompleteProps) => { onBlur={(e) => action('onBlur')(e)} itemToString={(item) => item.name} renderItem={(item) => item.name} - clearAfterSelect + textOnAfterSelect="clear" closeAfterSelect={false} />