From e81173697a7e11c7f5e3e8d8e9678eae72a89736 Mon Sep 17 00:00:00 2001 From: Gururaj J <89023023+Gururajj77@users.noreply.github.com> Date: Wed, 9 Oct 2024 19:17:17 +0530 Subject: [PATCH] feat: combobox's new feature, autocomplete with typeahead (#17268) * feat: autocomplete is fked * feat: autoComplete typeahead feature fixed * feat: added tests for autocomplete * feat: adds functionality test cases * feat: matcsh case exactly with option list when Tab is pressed * feat: adds tests, refines functions * feat: updated prop name to component and tests * refactor: fixed conflict * test: covered allowcustomValue prop --------- Co-authored-by: Nikhil Tomar <63502271+2nikhiltom@users.noreply.github.com> Co-authored-by: Nikhil Tomar --- .../__snapshots__/PublicAPI-test.js.snap | 3 + .../src/components/ComboBox/ComboBox-test.js | 147 +++++++++++++++++- .../components/ComboBox/ComboBox.stories.js | 20 +++ .../src/components/ComboBox/ComboBox.tsx | 125 +++++++++++++-- 4 files changed, 282 insertions(+), 13 deletions(-) diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index b7f997ddba64..746d4afdb6e1 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -1392,6 +1392,9 @@ Map { "translateWithId": Object { "type": "func", }, + "typeahead": Object { + "type": "bool", + }, "warn": Object { "type": "bool", }, diff --git a/packages/react/src/components/ComboBox/ComboBox-test.js b/packages/react/src/components/ComboBox/ComboBox-test.js index 2a91b5ae1df4..c4f65503c804 100644 --- a/packages/react/src/components/ComboBox/ComboBox-test.js +++ b/packages/react/src/components/ComboBox/ComboBox-test.js @@ -22,7 +22,7 @@ import { AILabel } from '../AILabel'; const findInputNode = () => screen.getByRole('combobox'); const openMenu = async () => { - await userEvent.click(screen.getByTitle('Open')); + await userEvent.click(screen.getByRole('combobox')); }; const prefix = 'cds'; @@ -456,7 +456,7 @@ describe('ComboBox', () => { render(); await userEvent.type(findInputNode(), '1'); expect(screen.getAllByRole('option')[1]).toHaveClass( - 'cds--list-box__menu-item--highlighted' + 'cds--list-box__menu-item' ); }); @@ -497,4 +497,147 @@ describe('ComboBox', () => { ); }); }); + + describe('ComboBox autocomplete', () => { + const items = [ + { id: 'option-1', text: 'Option 1' }, + { id: 'option-2', text: 'Option 2' }, + { id: 'option-3', text: 'Option 3' }, + { id: 'apple', text: 'Apple' }, + { id: 'banana', text: 'Banana' }, + { id: 'orange', text: 'Orange' }, + { id: 'orangeish', text: 'Orangeish' }, + ]; + + const mockProps = { + id: 'test-combobox', + items, + itemToString: (item) => (item ? item.text : ''), + onChange: jest.fn(), + }; + + it('should respect autocomplete prop', async () => { + render(); + await waitForPosition(); + const inputNode = findInputNode(); + expect(inputNode).toHaveAttribute('autocomplete'); + }); + it('should use autocompleteCustomFilter when autocomplete prop is true', async () => { + const user = userEvent.setup(); + render(); + + // Open the dropdown + const input = screen.getByRole('combobox'); + user.click(input); + + // Type 'op' which should match all options + await user.type(input, 'op'); + expect(screen.getAllByRole('option')).toHaveLength(3); + + // Type 'opt' which should still match all options + await user.type(input, 't'); + expect(screen.getAllByRole('option')).toHaveLength(3); + + // Type 'opti' which should match only 'Option 1' + await user.type(input, 'i'); + expect(screen.getAllByRole('option')).toHaveLength(3); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + }); + + it('should use default filter when autocomplete prop is false', async () => { + const user = userEvent.setup(); + render(); + + // Open the dropdown + const input = screen.getByRole('combobox'); + user.click(input); + + // Type 'op' which should match all options + await user.type(input, 'op'); + expect(screen.getAllByRole('option')).toHaveLength(7); + + // Type 'opt' which should still match all options + await user.type(input, 't'); + expect(screen.getAllByRole('option')).toHaveLength(7); + + // Type 'opti' which should still match all options + await user.type(input, 'i'); + expect(screen.getAllByRole('option')).toHaveLength(7); + + // Type 'option' which should still match all options + await user.type(input, 'on'); + expect(screen.getAllByRole('option')).toHaveLength(7); + }); + + it('should not autocomplete when no match is found', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + user.click(input); + + await user.type(input, 'xyz'); + await user.keyboard('[Tab]'); + + expect(document.activeElement).not.toBe(input); + }); + it('should suggest best matching typeahread suggestion and complete it in Tab key press', async () => { + const user = userEvent.setup(); + render(); + + // Open the dropdown + const input = screen.getByRole('combobox'); + user.click(input); + + // Type 'op' which should match all options + await user.type(input, 'Ap'); + + await user.keyboard('[Tab]'); + + expect(findInputNode()).toHaveDisplayValue('Apple'); + }); + it('should not autocomplete on Tab after backspace', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + user.click(input); + + await user.type(input, 'App'); + await user.keyboard('[Backspace]'); + + await user.keyboard('[Tab]'); + + expect(document.activeElement).not.toBe(input); + }); + it('should autocomplete with the first matching suggestion when multiple matches exist', async () => { + const multipleMatchProps = { + ...mockProps, + options: ['Apple', 'Application', 'Apricot'], + }; + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + user.click(input); + + await user.type(input, 'App'); + await user.keyboard('[Tab]'); + + expect(input).toHaveDisplayValue('Apple'); + }); + + it('should match case exactly with option list when Tab is pressed', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + user.click(input); + + await user.type(input, 'APpl'); + await user.keyboard('[Tab]'); + + expect(input).toHaveDisplayValue('Apple'); + }); + }); }); diff --git a/packages/react/src/components/ComboBox/ComboBox.stories.js b/packages/react/src/components/ComboBox/ComboBox.stories.js index 45c6588c4da4..e317db579d74 100644 --- a/packages/react/src/components/ComboBox/ComboBox.stories.js +++ b/packages/react/src/components/ComboBox/ComboBox.stories.js @@ -132,6 +132,22 @@ export const AllowCustomValue = (args) => { ); }; + +export const AutocompleteWithTypeahead = (args) => { + return ( +
+ +
+ ); +}; export const ExperimentalAutoAlign = () => (
@@ -273,6 +289,10 @@ export const Playground = (args) => (
); +AutocompleteWithTypeahead.argTypes = { + onChange: { action: 'onChange' }, +}; + Playground.argTypes = { ['aria-label']: { table: { diff --git a/packages/react/src/components/ComboBox/ComboBox.tsx b/packages/react/src/components/ComboBox/ComboBox.tsx index 989023be546a..876a15b7dc99 100644 --- a/packages/react/src/components/ComboBox/ComboBox.tsx +++ b/packages/react/src/components/ComboBox/ComboBox.tsx @@ -15,6 +15,7 @@ import React, { useRef, useMemo, forwardRef, + useCallback, type ReactNode, type ComponentType, type ForwardedRef, @@ -80,6 +81,23 @@ const defaultItemToString = (item: ItemType | null) => { const defaultShouldFilterItem = () => true; +const autocompleteCustomFilter = ({ + item, + inputValue, +}: { + item: string; + inputValue: string | null; +}): boolean => { + if (inputValue === null || inputValue === '') { + return true; // Show all items if there's no input + } + + const lowercaseItem = item.toLowerCase(); + const lowercaseInput = inputValue.toLowerCase(); + + return lowercaseItem.startsWith(lowercaseInput); +}; + const getInputValue = ({ initialSelectedItem, inputValue, @@ -316,6 +334,7 @@ export interface ComboBoxProps * Specify your own filtering logic by passing in a `shouldFilterItem` * function that takes in the current input and an item and passes back * whether or not the item should be filtered. + * this prop will be ignored if `typeahead` prop is enabled */ shouldFilterItem?: (input: { item: ItemType; @@ -339,6 +358,11 @@ export interface ComboBoxProps */ titleText?: ReactNode; + /** + * **Experimental**: will enable autcomplete and typeahead for the input field + */ + typeahead?: boolean; + /** * Specify whether the control is currently in warning state */ @@ -355,6 +379,10 @@ const ComboBox = forwardRef( props: ComboBoxProps, ref: ForwardedRef ) => { + const [cursorPosition, setCursorPosition] = useState(0); + const prevInputLengthRef = useRef(0); + const inputRef = useRef(null); + const { ['aria-label']: ariaLabel = 'Choose an item', ariaLabel: deprecatedAriaLabel, @@ -383,6 +411,7 @@ const ComboBox = forwardRef( size, titleText, translateWithId, + typeahead = false, warn, warnText, allowCustomValue = false, @@ -423,10 +452,7 @@ const ComboBox = forwardRef( } } }, [enableFloatingStyles, floatingStyles, refs.floating, parentWidth]); - const prefix = usePrefix(); - const { isFluid } = useContext(FormContext); - const textInput = useRef(null); - const comboBoxInstanceId = useId(); + const [inputValue, setInputValue] = useState( getInputValue({ initialSelectedItem, @@ -435,6 +461,39 @@ const ComboBox = forwardRef( selectedItem: selectedItemProp, }) ); + + const [typeaheadText, setTypeaheadText] = useState(''); + + useEffect(() => { + if (typeahead) { + if (inputValue.length >= prevInputLengthRef.current) { + if (inputValue) { + const filteredItems = items.filter((item) => + autocompleteCustomFilter({ + item: itemToString(item), + inputValue: inputValue, + }) + ); + if (filteredItems.length > 0) { + const suggestion = itemToString(filteredItems[0]); + setTypeaheadText(suggestion.slice(inputValue.length)); + } else { + setTypeaheadText(''); + } + } else { + setTypeaheadText(''); + } + } else { + setTypeaheadText(''); + } + prevInputLengthRef.current = inputValue.length; + } + }, [typeahead, inputValue, items, itemToString, autocompleteCustomFilter]); + + const prefix = usePrefix(); + const { isFluid } = useContext(FormContext); + const textInput = useRef(null); + const comboBoxInstanceId = useId(); const [isFocused, setIsFocused] = useState(false); const savedOnInputChange = useRef(onInputChange); const prevSelectedItemProp = useRef( @@ -466,7 +525,9 @@ const ComboBox = forwardRef( inputValue: string | null ) => items.filter((item) => - shouldFilterItem + typeahead + ? autocompleteCustomFilter({ item: itemToString(item), inputValue }) + : shouldFilterItem ? shouldFilterItem({ item, itemToString, @@ -503,7 +564,7 @@ const ComboBox = forwardRef( inputValue ); - const stateReducer = React.useCallback( + const stateReducer = useCallback( (state, actionAndChanges) => { const { type, changes } = actionAndChanges; const { highlightedIndex } = changes; @@ -677,13 +738,14 @@ const ComboBox = forwardRef( return itemToString(item); }, onInputValueChange({ inputValue }) { - const newInputValue = inputValue || ''; - setInputValue(newInputValue); + const normalizedInput = inputValue || ''; + setInputValue(normalizedInput); if (selectedItemProp && !inputValue) { // ensure onChange is called when selectedItem is cleared - onChange({ selectedItem, inputValue: newInputValue }); + onChange({ selectedItem, inputValue: normalizedInput }); } - setHighlightedIndex(indexToHighlight(inputValue)); + setHighlightedIndex(indexToHighlight(normalizedInput)); + setCursorPosition(inputValue === null ? 0 : normalizedInput.length); }, onSelectedItemChange({ selectedItem }) { onChange({ selectedItem }); @@ -735,6 +797,11 @@ const ComboBox = forwardRef( downshiftSetInputValue, toggleMenu, ]); + useEffect(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(cursorPosition, cursorPosition); + } + }, [inputValue, cursorPosition]); const buttonProps = getToggleButtonProps({ disabled: disabled || readOnly, @@ -801,6 +868,17 @@ const ComboBox = forwardRef( ] ); + useEffect(() => { + if (textInput.current) { + if (inputRef.current && typeaheadText) { + const selectionStart = inputValue.length; + const selectionEnd = selectionStart + typeaheadText.length; + + inputRef.current.value = inputValue + typeaheadText; + inputRef.current.setSelectionRange(selectionStart, selectionEnd); + } + } + }, [inputValue, typeaheadText]); return (
{titleText && ( @@ -834,7 +912,12 @@ const ComboBox = forwardRef( {...getInputProps({ 'aria-controls': isOpen ? undefined : menuProps.id, placeholder, - ref: mergeRefs(textInput, ref), + value: inputValue, + onChange: (e) => { + const newValue = e.target.value; + downshiftSetInputValue(newValue); + }, + ref: mergeRefs(textInput, ref, inputRef), onKeyDown: ( event: KeyboardEvent & { preventDownshiftDefault: boolean; @@ -903,6 +986,20 @@ const ComboBox = forwardRef( toggleMenu(); } } + if (typeahead && event.key === 'Tab') { + // event.preventDefault(); + const matchingItem = items.find((item) => + itemToString(item) + .toLowerCase() + .startsWith(inputValue.toLowerCase()) + ); + if (matchingItem) { + const newValue = itemToString(matchingItem); + downshiftSetInputValue(newValue); + setCursorPosition(newValue.length); + selectItem(matchingItem); + } + } }, })} {...rest} @@ -1172,6 +1269,7 @@ ComboBox.propTypes = { * Specify your own filtering logic by passing in a `shouldFilterItem` * function that takes in the current input and an item and passes back * whether or not the item should be filtered. + * this prop will be ignored if `typeahead` prop is enabled */ shouldFilterItem: PropTypes.func, @@ -1197,6 +1295,11 @@ ComboBox.propTypes = { */ translateWithId: PropTypes.func, + /** + * **Experimental**: will enable autcomplete and typeahead for the input field + */ + typeahead: PropTypes.bool, + /** * Specify whether the control is currently in warning state */