From e733f7b3045afc3b0924d5b1c4559f545ff45fb8 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Lafleur Date: Mon, 18 Mar 2024 14:42:51 -0400 Subject: [PATCH] feat(Combobox): restrict custom values --- .../src/components/combobox/combobox.test.tsx | 242 +++++++++++--- .../combobox/combobox.test.tsx.snap | 99 +++++- .../src/components/combobox/combobox.tsx | 295 +++++++++++++----- .../dropdown-list/dropdown-list.test.tsx.snap | 4 + .../components/listbox/listbox.test.tsx.snap | 1 + .../react/src/components/listbox/listbox.tsx | 2 +- packages/react/src/i18n/translations.ts | 10 + .../storybook/stories/combobox.stories.tsx | 88 ++++-- 8 files changed, 591 insertions(+), 150 deletions(-) diff --git a/packages/react/src/components/combobox/combobox.test.tsx b/packages/react/src/components/combobox/combobox.test.tsx index 409ee79c87..e01f20e8fa 100644 --- a/packages/react/src/components/combobox/combobox.test.tsx +++ b/packages/react/src/components/combobox/combobox.test.tsx @@ -33,36 +33,36 @@ describe('Combobox', () => { expect(findByTestId(wrapper, 'listbox').length).toEqual(1); }); - test('opens when the textbox receives the focus and the input has a value', () => { - const wrapper = shallow(); + test('opens when clicking the arrow button', () => { + const wrapper = shallow(); - getByTestId(wrapper, 'textbox').simulate('focus'); + getByTestId(wrapper, 'arrow').simulate('click'); expect(getByTestId(wrapper, 'listbox').length).toEqual(1); }); - test('does not open when the textbox receives the focus and the input has no value', () => { - const wrapper = shallow(); + test('closes when clicking the arrow button', () => { + const wrapper = shallow(); - getByTestId(wrapper, 'textbox').simulate('focus'); + getByTestId(wrapper, 'arrow').simulate('click'); - expect(getByTestId(wrapper, 'listbox').length).toEqual(0); + expect(findByTestId(wrapper, 'listbox').length).toEqual(0); }); - test('opens when clicking the arrow button', () => { + test('opens when clicking the textbox', () => { const wrapper = shallow(); - getByTestId(wrapper, 'arrow').simulate('click'); + getByTestId(wrapper, 'textbox').simulate('click'); expect(getByTestId(wrapper, 'listbox').length).toEqual(1); }); - test('closes when clicking the arrow button', () => { + test('closes when clicking the textbox', () => { const wrapper = shallow(); - getByTestId(wrapper, 'arrow').simulate('click'); + getByTestId(wrapper, 'textbox').simulate('click'); - expect(findByTestId(wrapper, 'listbox').length).toEqual(0); + expect(getByTestId(wrapper, 'listbox').length).toEqual(0); }); test('closes when clicking outside', () => { @@ -92,12 +92,6 @@ describe('Combobox', () => { expect(getByTestId(wrapper, 'textbox').prop('value')).toBe('Quebec'); }); - test('setting the prop to a arbitrary value assigns this value to the input', () => { - const wrapper = shallow(); - - expect(getByTestId(wrapper, 'textbox').prop('value')).toBe('Nowhere'); - }); - test('the corresponding option is selected and focused when expanding the listbox', () => { const wrapper = mountWithTheme(); @@ -106,6 +100,20 @@ describe('Combobox', () => { expect(getByTestId(wrapper, 'listitem-Quebec').prop('selected')).toBe(true); expect(getByTestId(wrapper, 'listitem-Quebec').prop('focused')).toBe(true); }); + + test('setting the prop to an arbitrary value rejects the input', () => { + const wrapper = shallow(); + + expect(getByTestId(wrapper, 'textbox').prop('value')).toBe(''); + }); + + describe('when allowing a custom value', () => { + test('setting the prop to an arbitrary value assigns this value to the input', () => { + const wrapper = shallow(); + + expect(getByTestId(wrapper, 'textbox').prop('value')).toBe('Nowhere'); + }); + }); }); describe('option selection', () => { @@ -121,7 +129,7 @@ describe('Combobox', () => { const wrapper = mountWithTheme(); getByTestId(wrapper, 'listitem-Quebec').simulate('click'); - getByTestId(wrapper, 'textbox').simulate('focus'); + getByTestId(wrapper, 'textbox').simulate('click'); expect(getByTestId(wrapper, 'listitem-Quebec').prop('selected')).toBe(true); expect(getByTestId(wrapper, 'listitem-Quebec').prop('focused')).toBe(true); @@ -145,33 +153,62 @@ describe('Combobox', () => { expect(getByTestId(wrapper, 'textbox').prop('value')).toBe('British Columbia'); }); + + test('clearing the input removes the value from textbox', () => { + const wrapper = shallow(); + + getByTestId(wrapper, 'clear').simulate('click'); + + expect(getByTestId(wrapper, 'textbox').prop('value')).toBe(''); + }); + + test('clearing the input deselects the corresponding option', () => { + const wrapper = mountWithTheme(); + + getByTestId(wrapper, 'clear').simulate('click'); + + expect(getByTestId(wrapper, 'listitem-Quebec').prop('selected')).toBe(false); + expect(getByTestId(wrapper, 'listitem-Quebec').prop('focused')).toBe(false); + }); + + test('typing an exact match selects the corresponding option', () => { + const wrapper = mountWithTheme(); + + getByTestId(wrapper, 'textbox').simulate( + 'change', + { target: { value: 'quebec' } }, + ); + + expect(getByTestId(wrapper, 'listitem-Quebec').prop('selected')).toBe(true); + }); }); - describe('list autocomplete', () => { + describe('list filtering', () => { test('typing a valid letter opens the listbox', () => { - const wrapper = shallow(); + const wrapper = shallow(); getByTestId(wrapper, 'textbox').simulate( 'change', { target: { value: 'q' } }, ); - expect(getByTestId(wrapper, 'listbox').length).toEqual(1); + expect(getByTestId(wrapper, 'listbox').length).toBeGreaterThan(0); }); - test('typing an invalid letter does not open the listbox', () => { - const wrapper = shallow(); + test('typing an invalid letter opens the listbox with the no option placeholder', () => { + const wrapper = shallow(); getByTestId(wrapper, 'textbox').simulate( 'change', { target: { value: 'z' } }, ); - expect(getByTestId(wrapper, 'listbox').length).toEqual(0); + expect(getByTestId(wrapper, 'listbox').length).toEqual(1); + expect(getByTestId(wrapper, 'listbox').prop('options')[0].disabled).toBeTruthy(); }); test('typing a letter filters the list', () => { - const wrapper = shallow(); + const wrapper = shallow(); getByTestId(wrapper, 'textbox').simulate( 'change', @@ -184,9 +221,7 @@ describe('Combobox', () => { }); test('erasing characters updates the list to match the remaining input', () => { - const wrapper = shallow( - , - ); + const wrapper = shallow(); getByTestId(wrapper, 'textbox').simulate( 'change', @@ -203,11 +238,119 @@ describe('Combobox', () => { { value: 'Newfoundland and Labrador' }, ]); }); + + test('when a value is selected the list is not filtered', () => { + const wrapper = shallow(); + + expect(getByTestId(wrapper, 'listbox').prop('options')).toEqual(provinces); + }); + + describe('disabled filtering', () => { + test('typing a letter does not filter the list', () => { + const wrapper = shallow(); + + getByTestId(wrapper, 'textbox').simulate( + 'change', + { target: { value: 'q' } }, + ); + + expect(getByTestId(wrapper, 'listbox').prop('options')).toEqual(provinces); + }); + }); + }); + + describe('empty options list', () => { + test('the listbox contains the empty message', () => { + const emptyListMessage = 'The list is empty'; + const wrapper = shallow(); + + expect(getByTestId(wrapper, 'listbox').prop('options')).toEqual([{ + disabled: true, + label: emptyListMessage, + value: '', + }]); + }); + + test('the empty message is not removed if custom values are not allowed', () => { + const emptyListMessage = 'The list is empty'; + const wrapper = shallow(); + + getByTestId(wrapper, 'textbox').simulate( + 'change', + { target: { value: 'q' } }, + ); + + expect(getByTestId(wrapper, 'listbox').prop('options')).toEqual([{ + disabled: true, + label: emptyListMessage, + value: '', + }]); + }); + + test('the empty message is removed if custom values are allowed', () => { + const emptyListMessage = 'The list is empty'; + const wrapper = shallow( + , + ); + + getByTestId(wrapper, 'textbox').simulate( + 'change', + { target: { value: 'q' } }, + ); + + expect(getByTestId(wrapper, 'listbox').length).toEqual(0); + }); + }); + + describe('loading state', () => { + test('when active the listbox only contains the loading message', () => { + const wrapper = shallow(); + + expect(getByTestId(wrapper, 'listbox').prop('options')).toEqual([{ + disabled: true, + label: 'Loading...', + value: '', + }]); + }); + }); + + describe('value handling', () => { + test('clicking outside reverts to previous valid value', () => { + const wrapper = shallow(); + + getByTestId(wrapper, 'textbox').simulate( + 'change', + { target: { value: 'z' } }, + ); + + getByTestId(wrapper, 'textbox').simulate( + 'blur', + { relatedTarget: document.createElement('div') }, + ); + + expect(getByTestId(wrapper, 'textbox').prop('value')).toBe('Quebec'); + }); + + test('arbitrary value is kept when allowing custom values', () => { + const wrapper = shallow(); + + getByTestId(wrapper, 'textbox').simulate( + 'change', + { target: { value: 'z' } }, + ); + + getByTestId(wrapper, 'textbox').simulate( + 'blur', + { relatedTarget: document.createElement('div') }, + ); + + expect(getByTestId(wrapper, 'textbox').prop('value')).toBe('z'); + }); }); describe('inline autocomplete', () => { test('typing a valid letter opens the listbox', () => { - const wrapper = shallow(); + const wrapper = shallow(); getByTestId(wrapper, 'textbox').simulate( 'change', @@ -218,7 +361,7 @@ describe('Combobox', () => { }); test('typing the first letter of an existing option autocompletes the input', () => { - const wrapper = shallow(); + const wrapper = shallow(); getByTestId(wrapper, 'textbox').simulate( 'change', @@ -229,7 +372,7 @@ describe('Combobox', () => { }); test('the suggested part of the input is highlighted', async () => { - const wrapper = mountWithTheme(); + const wrapper = mountWithTheme(); await actAndWaitForEffects(wrapper, () => { getByTestId(wrapper, 'textbox').simulate( @@ -243,7 +386,7 @@ describe('Combobox', () => { }); test('erasing characters removes the suggestion', async () => { - const wrapper = mountWithTheme(); + const wrapper = mountWithTheme(); await actAndWaitForEffects(wrapper, () => { getByTestId(wrapper, 'textbox').simulate( @@ -262,7 +405,7 @@ describe('Combobox', () => { }); test('focusing an option with ArrowUp fills the input with its value', () => { - const wrapper = mountWithTheme(); + const wrapper = mountWithTheme(); getByTestId(wrapper, 'textbox').simulate( 'keydown', @@ -273,7 +416,7 @@ describe('Combobox', () => { }); test('focusing an option with ArrowDown fills the input with its value', () => { - const wrapper = mountWithTheme(); + const wrapper = mountWithTheme(); getByTestId(wrapper, 'textbox').simulate( 'keydown', @@ -300,7 +443,7 @@ describe('Combobox', () => { }); test('the input value is updated when the value prop changes to an arbitrary value', () => { - const wrapper = shallow(); + const wrapper = shallow(); wrapper.setProps({ value: 'Nowhere' }).update(); @@ -376,7 +519,7 @@ describe('Combobox', () => { test('callback does not receive the suggestion when fired', () => { const callback = jest.fn(); const wrapper = mountWithTheme( - , + , ); getByTestId(wrapper, 'textbox').simulate( @@ -447,7 +590,7 @@ describe('Combobox', () => { }); test('Escape closes the listbox', () => { - const wrapper = shallow(); + const wrapper = shallow(); getByTestId(wrapper, 'textbox').simulate( 'keydown', @@ -455,7 +598,17 @@ describe('Combobox', () => { ); expect(findByTestId(wrapper, 'listbox').length).toEqual(0); - expect(getByTestId(wrapper, 'textbox').prop('value')).toEqual('Test'); + }); + + test('Escape does not clear the value when the listbox is open', () => { + const wrapper = shallow(); + + getByTestId(wrapper, 'textbox').simulate( + 'keydown', + { key: 'Escape', preventDefault: jest.fn() }, + ); + + expect(getByTestId(wrapper, 'textbox').prop('value')).toEqual('Quebec'); }); test('Escape clears the textbox when the listbox is closed', () => { @@ -483,6 +636,17 @@ describe('Combobox', () => { expect(getByTestId(wrapper, 'textbox').prop('value')).toBe('Saskatchewan'); }); + + test('Enter closes the listbox if custom values are allowed', () => { + const wrapper = shallow(); + + getByTestId(wrapper, 'textbox').simulate( + 'keydown', + { key: 'Enter', preventDefault: jest.fn() }, + ); + + expect(getByTestId(wrapper, 'listbox').length).toEqual(0); + }); }); test('input should have controllable data-test-id', () => { diff --git a/packages/react/src/components/combobox/combobox.test.tsx.snap b/packages/react/src/components/combobox/combobox.test.tsx.snap index c8322a5767..101ad31420 100644 --- a/packages/react/src/components/combobox/combobox.test.tsx.snap +++ b/packages/react/src/components/combobox/combobox.test.tsx.snap @@ -218,6 +218,26 @@ input + .c2 { box-shadow: 0 0 0 2px #84C6EA; } +.c5::-webkit-input-placeholder { + color: #B7BBC2; + font-style: italic; +} + +.c5::-moz-placeholder { + color: #B7BBC2; + font-style: italic; +} + +.c5:-ms-input-placeholder { + color: #B7BBC2; + font-style: italic; +} + +.c5::placeholder { + color: #B7BBC2; + font-style: italic; +} + .c8 { -webkit-align-items: center; -webkit-box-align: center; @@ -233,7 +253,7 @@ input + .c2 { height: var(--size-1x); padding: var(--spacing-half); position: absolute; - right: var(--spacing-half); + right: 0; width: var(--size-1x); } @@ -255,13 +275,14 @@ input + .c2 { class="c4" > - validationErrorMessage + You must select an option
{} +export type ComboboxOption = ListboxOption; -function getBorderColor({ $disabled, theme, $valid }: TextboxProps): string { - if ($disabled) { +function getBorderColor({ disabled, theme, $valid }: TextboxProps): string { + if (disabled) { return theme.greys['mid-grey']; } if (!$valid) { @@ -65,17 +64,22 @@ const StyledListbox = styled(Listbox)` `; const Textbox = styled.input` - background-color: ${({ $disabled, theme }) => ($disabled ? theme.greys['light-grey'] : theme.greys.white)}; + background-color: ${({ disabled, theme }) => (disabled ? theme.greys['light-grey'] : theme.greys.white)}; border: 1px solid ${getBorderColor}; border-radius: var(--border-radius); box-sizing: border-box; - ${({ $disabled, theme }) => $disabled && `color: ${theme.greys['mid-grey']}`}; + ${({ disabled, theme }) => disabled && `color: ${theme.greys['mid-grey']}`}; font-size: ${({ $isMobile }) => ($isMobile ? '1rem' : '0.875rem')}; height: ${({ $isMobile }) => ($isMobile ? 'var(--size-2halfx)' : 'var(--size-2x)')}; padding: 0 var(--spacing-1x); width: 100%; ${({ theme }) => focus({ theme }, true)}; + + &::placeholder { + color: ${({ theme }) => theme.greys['mid-grey']}; + font-style: italic; + } `; const ArrowButton = styled(IconButton)<{ disabled?: boolean }>` @@ -87,7 +91,7 @@ const ArrowButton = styled(IconButton)<{ disabled?: boolean }>` height: var(--size-1x); padding: var(--spacing-half); position: absolute; - right: var(--spacing-half); + right: 0; width: var(--size-1x); &:hover { @@ -95,43 +99,77 @@ const ArrowButton = styled(IconButton)<{ disabled?: boolean }>` } `; -type AutoCompleteMode = 'none' | 'inline' | 'list' | 'both'; +const ClearButton = styled(IconButton)<{ disabled?: boolean }>` + align-items: center; + background-color: transparent; + border: 0; + color: ${({ disabled, theme }) => (disabled ? theme.greys['mid-grey'] : theme.greys['dark-grey'])}; + display: flex; + height: var(--size-1x); + padding: var(--spacing-half); + position: absolute; + right: calc(var(--size-1x) + var(--spacing-1halfx)); + width: var(--size-1x); + + &:after { + border-right: ${({ theme }) => `1px solid ${theme.greys['mid-grey']}`}; + content: ''; + height: calc(var(--size-2x) - var(--spacing-2x)); + margin-left: var(--spacing-1x); + } + + &:hover { + background-color: transparent; + } +`; interface ComboboxProps { /** - * Aria label for the input (used when no visual label is present) + * If true, the input can have a value not included in the list of options */ - ariaLabel?: string; + allowCustomValue?: boolean; /** - * Sets the autocomplete mode. - * - 'none': disables autocomplete, the component behaves like a normal textbox with list of suggestions - * - 'inline': autocompletes the text input - * - 'list': shows and filters the listbox options when text is entered - * - 'both': enables both inline and list autocompletion - * @default 'none' + * Aria label for the input (used when no visual label is present) */ - autoComplete?: AutoCompleteMode; + ariaLabel?: string; className?: string; /** * @default false */ defaultOpen?: boolean; /** - * The default value (and selected option when autoComplete is 'list' or 'both') + * The default value and selected option */ defaultValue?: string; /** * Disables the input */ disabled?: boolean; + /** + * If true, the options won't be filtered. + * Use when the list of options is filtered externally. + * @default false + */ + disableListFiltering?: boolean; + /** + * Text to display in the listbox when no options match the input value. + * Used only when a custom value is not allowed. + */ + emptyListMessage?: string; /** * Disables the default margin - * */ + */ noMargin?: boolean; id?: string; + /** + * @default false + */ + inlineAutoComplete?: boolean; + isLoading?: boolean; label?: string; name?: string; options: ComboboxOption[]; + placeholder?: string; required?: boolean; tooltip?: TooltipProps; /** @@ -158,17 +196,22 @@ interface ComboboxProps { const optionPredicate: (option: ComboboxOption) => boolean = (option) => !option.disabled; export const Combobox: VoidFunctionComponent = ({ + allowCustomValue = false, ariaLabel, - autoComplete = 'none', className, defaultOpen = false, defaultValue, disabled, + disableListFiltering, + emptyListMessage, noMargin, id: providedId, + inlineAutoComplete = false, + isLoading = false, label, onChange, options, + placeholder, name, required, tooltip, @@ -183,33 +226,79 @@ export const Combobox: VoidFunctionComponent = ({ const id = useId(providedId); const dataAttributes = useDataAttributes(otherProps); - const hasAutoComplete: (mode: AutoCompleteMode) => boolean = useCallback( - (mode) => autoComplete === mode || autoComplete === 'both', - [autoComplete], - ); - const textboxRef = useRef(null); const listboxRef = useRef(null); const arrowButtonRef = useRef(null); + const clearButtonRef = useRef(null); const [open, setOpen] = useState(defaultOpen); - const [inputValue, setInputValue] = useState(value ?? defaultValue ?? ''); + function findOptionByValue(searchValue?: string): ComboboxOption | undefined { + return options.find((option) => option.value.toLowerCase() === searchValue?.toLowerCase()); + } - const filteredOptions = useMemo(() => { - if (hasAutoComplete('list')) { - return options.filter( - (option) => option.value.toLowerCase().startsWith(inputValue.toLowerCase()), - ); + function validateInputValue(newValue: string): string { + if (allowCustomValue || newValue === '') { + return newValue; } - return options; - }, [hasAutoComplete, inputValue, options]); + return findOptionByValue(newValue)?.value ?? ''; + } - function findOptionByValue(searchValue?: string): ComboboxOption | undefined { - return options.find((option) => option.value.toLowerCase() === searchValue?.toLowerCase()); + function getInitialInputValue(): string { + return validateInputValue(value ?? defaultValue ?? ''); } + const [inputValue, setInputValue] = useState(getInitialInputValue); + + const getEmptyListMessage: (query: string) => string = useCallback((query) => { + if (emptyListMessage) { + return emptyListMessage; + } + + return query.length > 0 ? t('noResultForQuery', { query }) : t('noResult'); + }, [emptyListMessage, t]); + + const filteredOptions = useMemo(() => { + if (isLoading) { + return [{ + disabled: true, + label: t('loading'), + value: '', + }]; + } + + if (options.length === 0 && inputValue === '') { + return [{ + disabled: true, + label: getEmptyListMessage(''), + value: '', + }]; + } + + if (inputValue === '' || disableListFiltering) { + return options; + } + + const filtered = options.filter( + (option) => option.value.toLowerCase().startsWith(inputValue.toLowerCase()), + ); + + if (filtered.length === 1 && filtered[0].value === inputValue) { + return options; + } + + if (filtered.length === 0 && !allowCustomValue) { + filtered.push({ + disabled: true, + label: getEmptyListMessage(inputValue), + value: '', + }); + } + + return filtered; + }, [allowCustomValue, disableListFiltering, getEmptyListMessage, inputValue, isLoading, options, t]); + const [suggestedInputValue, setSuggestedInputValue] = useState(''); function getSuggestedOption(searchValue: string): ComboboxOption | undefined { @@ -233,6 +322,20 @@ export const Combobox: VoidFunctionComponent = ({ () => findOptionByValue(value ?? defaultValue), ); + const [previousSelectedOption, setPreviousSelectedOption] = useState( + () => findOptionByValue(value ?? defaultValue), + ); + + function selectOption(newOption: ComboboxOption | undefined): void { + setSelectedOption(newOption); + setPreviousSelectedOption(newOption); + } + + const revertInputValue: () => void = useCallback(() => { + setSelectedOption(previousSelectedOption); + changeInputValue(previousSelectedOption?.value ?? ''); + }, [previousSelectedOption]); + const { selectedElement: focusedOption, setSelectedElement: setFocusedOption, @@ -254,11 +357,13 @@ export const Combobox: VoidFunctionComponent = ({ if (newOption) { setInputValue(newOption.value); - setSelectedOption(newOption); + selectOption(newOption); setSuggestedInputValue(''); setFocusedOption(newOption); - } else { + } else if (allowCustomValue) { setInputValue(value ?? ''); + } else { + setInputValue(''); } setPreviousValue(value); @@ -285,38 +390,55 @@ export const Combobox: VoidFunctionComponent = ({ closeListbox(); } - const handleClickOutside: () => void = useCallback(() => { + const handleComponentBlur: () => void = useCallback(() => { + if (focusedOption && (focusedOption !== selectedOption || inputValue !== focusedOption.value)) { + changeInputValue(focusedOption.value); + selectOption(focusedOption); + } else if (!(allowCustomValue || inputValue === '')) { + revertInputValue(); + } + if (open) { - if (focusedOption && focusedOption !== selectedOption) { - changeInputValue(focusedOption.value); - setSelectedOption(focusedOption); - } closeListbox(); } - }, [closeListbox, focusedOption, open, changeInputValue, selectedOption]); + }, [ + allowCustomValue, + changeInputValue, + closeListbox, + focusedOption, + inputValue, + open, + revertInputValue, + selectedOption, + ]); - useClickOutside([textboxRef, listboxRef, arrowButtonRef], handleClickOutside); + const componentTargets = [textboxRef, listboxRef, arrowButtonRef, clearButtonRef]; function handleTextboxBlur(event: FocusEvent): void { - const outsideComponent = event.relatedTarget !== listboxRef.current - && event.relatedTarget !== arrowButtonRef.current; + let outsideComponent = true; - if (open && outsideComponent) { - if (focusedOption && focusedOption !== selectedOption) { - changeInputValue(focusedOption.value); - setSelectedOption(focusedOption); - } - closeListbox(); + if (event.relatedTarget !== null) { + componentTargets.forEach((target) => { + if (target.current === event.relatedTarget) { + outsideComponent = false; + } + }); + } + + if (outsideComponent) { + handleComponentBlur(); } } - function handleTextboxFocus(): void { - if (!open && selectedOption) { + function handleTextboxClick(): void { + if (open) { + closeListbox(); + } else { openListbox(); } } - function handleButtonClick(): void { + function handleArrowButtonClick(): void { if (open) { closeListbox(); } else { @@ -326,6 +448,14 @@ export const Combobox: VoidFunctionComponent = ({ textboxRef.current?.focus(); } + function handleClearButtonClick(): void { + changeInputValue(''); + setFocusedOption(undefined); + selectOption(undefined); + + textboxRef.current?.focus(); + } + function handleListboxOptionClick(option: ComboboxOption): void { if (optionPredicate(option)) { if (option !== focusedOption) { @@ -334,7 +464,7 @@ export const Combobox: VoidFunctionComponent = ({ if (option !== selectedOption) { changeInputValue(option.value); - setSelectedOption(option); + selectOption(option); } closeListbox(); @@ -362,7 +492,7 @@ export const Combobox: VoidFunctionComponent = ({ } else { newFocusedOption = focusedOption ? focusNextOption() : focusFirstOption(); - if (newFocusedOption && hasAutoComplete('inline')) { + if (newFocusedOption && inlineAutoComplete) { setSuggestedInputValue(newFocusedOption.value); suggestionSource.current = 'listbox'; } @@ -376,7 +506,7 @@ export const Combobox: VoidFunctionComponent = ({ } else { newFocusedOption = focusedOption ? focusPreviousOption() : focusLastOption(); - if (newFocusedOption && hasAutoComplete('inline')) { + if (newFocusedOption && inlineAutoComplete) { setSuggestedInputValue(newFocusedOption.value); suggestionSource.current = 'listbox'; } @@ -384,14 +514,14 @@ export const Combobox: VoidFunctionComponent = ({ break; case 'Enter': event.preventDefault(); - if (!open) { - openListbox(); - } else { - if (focusedOption && focusedOption !== selectedOption) { + if (focusedOption) { + if (focusedOption !== selectedOption || inputValue !== focusedOption.value) { changeInputValue(focusedOption.value); - setSelectedOption(focusedOption); + selectOption(focusedOption); } closeListbox(); + } else if (open && (allowCustomValue || inputValue === '')) { + closeListbox(); } break; case 'Escape': @@ -399,7 +529,7 @@ export const Combobox: VoidFunctionComponent = ({ closeListbox(); } else { changeInputValue(''); - setSelectedOption(undefined); + selectOption(undefined); } break; case 'Backspace': @@ -421,7 +551,7 @@ export const Combobox: VoidFunctionComponent = ({ setSuggestedInputValue(''); setFocusedOption(undefined); - if (hasAutoComplete('inline') && !hideInlineAutoComplete.current) { + if (inlineAutoComplete && !hideInlineAutoComplete.current) { const newSuggestedOption = getSuggestedOption(newInputValue); setSuggestedInputValue(newSuggestedOption?.value ?? ''); @@ -434,7 +564,13 @@ export const Combobox: VoidFunctionComponent = ({ } // Select option if the input text is an exact match - setSelectedOption(findOptionByValue(newInputValue)); + const matchingOption = findOptionByValue(newInputValue); + + if (matchingOption) { + selectOption(matchingOption); + } else if (allowCustomValue || newInputValue === '') { + selectOption(undefined); + } } useEffect(() => { @@ -444,7 +580,7 @@ export const Combobox: VoidFunctionComponent = ({ if (suggestedInputValue.length > inputValue.length) { textboxRef.current?.setSelectionRange(inputValue.length, suggestedInputValue.length); - } else if (textboxRef.current?.selectionStart === inputValue.length) { + } else if (textboxRef.current?.selectionStart === inputValue.length || suggestedInputValue.length === 0) { textboxRef.current?.setSelectionRange(inputValue.length, inputValue.length); } }, [inputValue.length, suggestedInputValue.length]); @@ -470,7 +606,7 @@ export const Combobox: VoidFunctionComponent = ({ = ({ data-testid="textbox" id={id} $isMobile={isMobile} - $disabled={disabled} + disabled={disabled} name={name} onBlur={handleTextboxBlur} onChange={handleTextboxChange} - onFocus={handleTextboxFocus} + onClick={handleTextboxClick} onKeyDown={handleTextboxKeyDown} + placeholder={placeholder} ref={textboxRef} role="combobox" tabIndex={0} @@ -492,6 +629,18 @@ export const Combobox: VoidFunctionComponent = ({ value={suggestedInputValue || inputValue} {...dataAttributes /* eslint-disable-line react/jsx-props-no-spreading */} /> + {inputValue !== '' && !disabled && ( + + )} = ({ disabled={disabled} focusable={false} iconName={open ? 'chevronUp' : 'chevronDown'} - onClick={handleButtonClick} + onClick={handleArrowButtonClick} ref={arrowButtonRef} type="button" /> @@ -515,7 +664,7 @@ export const Combobox: VoidFunctionComponent = ({ id={`${id}_listbox`} onOptionClick={handleListboxOptionClick} options={filteredOptions} - value={[selectedOption?.value ?? '']} + value={selectedOption ? [selectedOption.value] : undefined} /> )} diff --git a/packages/react/src/components/dropdown-list/dropdown-list.test.tsx.snap b/packages/react/src/components/dropdown-list/dropdown-list.test.tsx.snap index 6b80838f9b..f46c2e69b6 100644 --- a/packages/react/src/components/dropdown-list/dropdown-list.test.tsx.snap +++ b/packages/react/src/components/dropdown-list/dropdown-list.test.tsx.snap @@ -261,6 +261,7 @@ input + .c2 { role="presentation" >
  • ( ( label="Select an option" hint="Hint" options={provinces} + placeholder="Select the best province" /> ); -export const ListAutocomplete: Story = () => ( +export const WithInlineAutocomplete: Story = () => ( ); -export const InlineAutocomplete: Story = () => ( +export const AllowCustomValue: Story = () => ( -); - -export const BothAutocompletes: Story = () => ( - ); @@ -86,21 +78,41 @@ export const Disabled: Story = () => ( ); -export const Invalid: Story = () => ( - -); +export const RequiredWithValidationError: Story = () => { + const [value, setValue] = useState(undefined); + const [valid, setValid] = useState(true); -export const Required: Story = () => ( - -); + return ( + <> + { setValid(true); setValue(newValue); }} + required + valid={valid} + /> + + + ); +}; -export const WithCallback: Story = () => ( - console.info(`Value: ${newValue}`)} - /> -); +export const WithCallback: Story = () => { + const [output, setOutput] = useState(''); + + return ( + <> + + +
    + {`Value: ${output}`} +
    + + ); +}; WithCallback.parameters = rawCodeParameters; export const WithDefaultValue: Story = () => ( @@ -121,7 +133,6 @@ export const WithControlledValue: Story = () => { options={provinces} onChange={handleChange} value={value} - autoComplete="both" /> @@ -138,3 +149,24 @@ export const WithDisabledOptions: Story = () => { return ; }; + +export const UsingAsyncDataSource: Story = () => { + const [options, setOptions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + function handleChange(newValue: string): void { + if (newValue === '') { + setOptions([]); + return; + } + + setIsLoading(true); + + setTimeout(() => { + setOptions(provinces.filter(({ value }) => value.toLowerCase().startsWith(newValue.toLowerCase()))); + setIsLoading(false); + }, 500); + } + + return ; +};