diff --git a/packages/react/src/components/ComboBox/ComboBox-test.js b/packages/react/src/components/ComboBox/ComboBox-test.js index c2d07c34703e..1fb29ed4eead 100644 --- a/packages/react/src/components/ComboBox/ComboBox-test.js +++ b/packages/react/src/components/ComboBox/ComboBox-test.js @@ -423,4 +423,69 @@ 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' }, + ]; + + 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 () => { + render(); + + // Open the dropdown + const input = screen.getByRole('combobox'); + fireEvent.click(input); + + // Type 'op' which should match all options + await userEvent.type(input, 'op'); + expect(screen.getAllByRole('option')).toHaveLength(3); + + // Type 'opt' which should still match all options + await userEvent.type(input, 't'); + expect(screen.getAllByRole('option')).toHaveLength(3); + + // Type 'opti' which should match only 'Option 1' + await userEvent.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 () => { + render(); + + // Open the dropdown + const input = screen.getByRole('combobox'); + fireEvent.click(input); + + // Type 'op' which should match all options + await userEvent.type(input, 'op'); + expect(screen.getAllByRole('option')).toHaveLength(3); + + // Type 'opt' which should still match all options + await userEvent.type(input, 't'); + expect(screen.getAllByRole('option')).toHaveLength(3); + + // Type 'opti' which should still match all options + await userEvent.type(input, 'i'); + expect(screen.getAllByRole('option')).toHaveLength(3); + + // Type 'option' which should still match all options + await userEvent.type(input, 'on'); + expect(screen.getAllByRole('option')).toHaveLength(3); + }); + }); }); diff --git a/packages/react/src/components/ComboBox/ComboBox.stories.js b/packages/react/src/components/ComboBox/ComboBox.stories.js index 916f7777baae..7136b1e9c7b6 100644 --- a/packages/react/src/components/ComboBox/ComboBox.stories.js +++ b/packages/react/src/components/ComboBox/ComboBox.stories.js @@ -11,6 +11,7 @@ import { WithLayer } from '../../../.storybook/templates/WithLayer'; import ComboBox from '../ComboBox'; import mdx from './ComboBox.mdx'; +import { on } from 'process'; const items = [ { @@ -92,6 +93,21 @@ export const AllowCustomValue = (args) => { ); }; +export const AutocompleteWithTypeahead = (args) => { + return ( +
+ +
+ ); +}; export const ExperimentalAutoAlign = () => (
@@ -112,6 +128,10 @@ AllowCustomValue.argTypes = { onChange: { action: 'onChange' }, }; +AutocompleteWithTypeahead.argTypes = { + onChange: { action: 'onChange' }, +}; + export const _WithLayer = () => ( {(layer) => ( diff --git a/packages/react/src/components/ComboBox/ComboBox.tsx b/packages/react/src/components/ComboBox/ComboBox.tsx index 9fdf344dd3e5..10cb9c11df74 100644 --- a/packages/react/src/components/ComboBox/ComboBox.tsx +++ b/packages/react/src/components/ComboBox/ComboBox.tsx @@ -159,6 +159,10 @@ export interface ComboBoxProps */ autoAlign?: boolean; + /** + * **Experimental**: will enable autcomplete and typeahead for the input field + */ + autocomplete?: boolean; /** * An optional className to add to the container node */ @@ -323,6 +327,7 @@ const ComboBox = forwardRef( ['aria-label']: ariaLabel = 'Choose an item', ariaLabel: deprecatedAriaLabel, autoAlign = false, + autocomplete = false, className: containerClassName, direction = 'bottom', disabled = false, @@ -406,14 +411,32 @@ const ComboBox = forwardRef( }) ); } + const autocompleteCustomFilter = (menu) => { + if ( + !menu || + typeof menu.item !== 'string' || + typeof menu.inputValue !== 'string' + ) { + return false; + } + const item = menu.item.toLowerCase(); + const input = menu.inputValue.toLowerCase(); + + if (input.length > item.length) { + return false; + } + return input.split('').every((char, index) => item[index] === char); + }; const filterItems = ( items: ItemType[], itemToString: ItemToStringHandler, inputValue: string | null ) => items.filter((item) => - shouldFilterItem + autocomplete + ? autocompleteCustomFilter({ item: itemToString(item), inputValue }) + : shouldFilterItem ? shouldFilterItem({ item, itemToString, @@ -662,7 +685,13 @@ const ComboBox = forwardRef( 'aria-label': deprecatedAriaLabel || ariaLabel, ref: autoAlign ? refs.setFloating : null, }), - [autoAlign, deprecatedAriaLabel, ariaLabel] + [ + getMenuProps, + deprecatedAriaLabel, + ariaLabel, + autoAlign, + refs.setFloating, + ] ); return ( @@ -884,6 +913,11 @@ ComboBox.propTypes = { */ autoAlign: PropTypes.bool, + /** + * **Experimental**: will enable autcomplete and typeahead for the input field + */ + autocomplete: PropTypes.bool, + /** * An optional className to add to the container node */ diff --git a/packages/react/src/components/ListBox/test-helpers.js b/packages/react/src/components/ListBox/test-helpers.js index 4f83ea7966a6..e0c55700399e 100644 --- a/packages/react/src/components/ListBox/test-helpers.js +++ b/packages/react/src/components/ListBox/test-helpers.js @@ -7,7 +7,7 @@ const prefix = 'cds'; import userEvent from '@testing-library/user-event'; -import { act } from 'react'; +import { act } from '@testing-library/react'; // Finding nodes in a ListBox export const findListBoxNode = () => {