diff --git a/src/components/forms/ComboBox/ComboBox.stories.tsx b/src/components/forms/ComboBox/ComboBox.stories.tsx index 774bb38af7..6240d6f15f 100644 --- a/src/components/forms/ComboBox/ComboBox.stories.tsx +++ b/src/components/forms/ComboBox/ComboBox.stories.tsx @@ -71,9 +71,7 @@ export const withLabel = (): React.ReactElement => { return (
- + ({ })) describe('ComboBox component', () => { + const scrollFunction = jest.fn() + window.HTMLElement.prototype.scrollIntoView = scrollFunction + it('renders without errors', () => { const { getByTestId } = render( { expect(getByTestId('combo-box-option-list')).toBeVisible() }) - xit('highlights the first option when opening the menu, when no default value exists', () => { - // TODO: 🐛 right now highlights nothing + it('highlights the first option when opening the menu, when no default value exists', () => { const { getByTestId } = render( { /> ) - userEvent.click(getByTestId('combo-box-input')) + const firstItem = getByTestId('combo-box-option-list').children[0] - expect(getByTestId('combo-box-option-apple')).toHaveAttribute( - 'aria-selected', - 'true' - ) + userEvent.click(getByTestId('combo-box-toggle')) + + expect(firstItem).toBeVisible() + expect(firstItem).not.toHaveFocus() + expect(firstItem).toHaveClass('usa-combo-box__list-option--focused') }) it('highlights the default value when opening the menu, when one exists', () => { @@ -260,6 +263,27 @@ describe('ComboBox component', () => { 'scrolls options list to the very top when the menu opens if nothing is selected' ) + it('scrolls to the selected option when the list is opened', () => { + const { getByTestId } = render( + + ) + + const mango = getByTestId('combo-box-option-mango') + + jest.clearAllMocks() + userEvent.click(getByTestId('combo-box-toggle')) + expect(mango).toHaveClass( + 'usa-combo-box__list-option--focused usa-combo-box__list-option--selected' + ) + expect(scrollFunction).toHaveBeenCalledTimes(1) + }) + describe('filtering', () => { it('shows all options on initial load when no default value exists', () => { const { getByTestId } = render( @@ -351,6 +375,40 @@ describe('ComboBox component', () => { ) }) + it('clears filters when ComboBox is un-focused and no option was selected (implicitly clearing the ComboBox)', () => { + const { getByTestId } = render( + <> +
+ + + ) + + const list = getByTestId('combo-box-option-list') + const input = getByTestId('combo-box-input') + + // Filter the list + userEvent.type(input, 'Av') + expect(list.children.length).toEqual(2) // Avocado and Guava + + // Click somewhere else + userEvent.click(getByTestId('outside')) + expect(input).toHaveTextContent('') + expect(list.children.length).toEqual(fruitOptions.length) + + // Return the combo box + userEvent.click(input) + expect(input).toHaveTextContent('') + expect(list.children.length).toEqual(fruitOptions.length) + expect(list.children[0]).toHaveClass( + 'usa-combo-box__list-option--focused' + ) + }) + it('shows no results message when there is no match', () => { const { getByTestId } = render( { fruitOptions.length ) }) + + it('resets the list of items if filtered then blurred without selecting an element', () => { + const { getByTestId } = render( + + ) + + const input = getByTestId('combo-box-input') + const optionsList = getByTestId('combo-box-option-list') + + userEvent.type(input, 'apple') + expect(optionsList.children.length).toBeLessThan(fruitOptions.length) + + fireEvent.blur(input) + expect(input).toHaveTextContent('') + expect(optionsList.children.length).toEqual(fruitOptions.length) + }) + + it('does not hide items when disableFiltering is selected', () => { + const { getByTestId } = render( + + ) + + const input = getByTestId('combo-box-input') + userEvent.click(input) + userEvent.type(input, 'zzzzzzzzzz') + + expect(getByTestId('combo-box-option-list').children.length).toBe( + fruitOptions.length + ) + }) }) describe('clear button', () => { @@ -573,6 +672,52 @@ describe('ComboBox component', () => { expect(comboBoxOptionList.children.length).toBe(fruitOptions.length) }) + it('reverts to the selected option when there is not an exact match and enter is pressed', () => { + const { getByTestId } = render( + + ) + + const input = getByTestId('combo-box-input') + + userEvent.type(input, 'zzz{enter}') + + expect(getByTestId('combo-box-option-list')).not.toBeVisible() + expect(input).toHaveValue('Avocado') + expect(input).toHaveFocus() + expect(getByTestId('combo-box-clear-button')).toBeVisible() + }) + + it('selects the exactly matching option when an item was previously selected and enter is pressed', () => { + const { getByTestId } = render( + + ) + + const input = getByTestId('combo-box-input') + + for (let i = 0; i < 'avocado'.length; i++) { + userEvent.type(input, '{backspace}') + } + + userEvent.type(input, 'Banana{enter}') + + expect(getByTestId('combo-box-option-list')).not.toBeVisible() + expect(input).toHaveValue('Banana') + expect(input).toHaveFocus() + expect(getByTestId('combo-box-clear-button')).toBeVisible() + }) + it('focuses the first filtered option with tab', () => { const { getByTestId } = render( { expect(getByTestId('combo-box-option-list')).toBeVisible() }) - it('deselects option when pressing delete inside input', () => { + it('hides the clear button when typing in the input, but does not clear the selection', () => { const { getByTestId } = render( ) const input = getByTestId('combo-box-input') + const clearButton = getByTestId('combo-box-clear-button') userEvent.type(input, '{backspace}') - expect(getByTestId('combo-box-clear-button')).not.toBeVisible() + expect(clearButton).not.toBeVisible() expect(getByTestId('combo-box-option-list').children.length).toEqual(1) + + fireEvent.blur(input) + + expect(input).toHaveValue('Avocado') + expect(clearButton).toBeVisible() }) it('does not hijack focus while tabbing when another field has focus', () => { @@ -1194,8 +1345,7 @@ describe('ComboBox component', () => { }) describe('accessibility and internationalization', () => { - xit('adds correct aria attributes to options when no item selected', () => { - // TODO: 🐛 Currently no item focused, should highlighting first item in list in this case + it('adds correct aria attributes to options when no item selected', () => { const { getByTestId } = render( with options AND a separate and dropdown
    with items. The select is usa-sr-only and is always hidden via CSS. The input and dropdown list are the elements used for interaction. @@ -9,6 +9,9 @@ import { ActionTypes, Action, State, useCombobox } from './useCombobox' There is the ability to pass in custom props directly to the select and input. This should be using sparingly and not with existing Combobox props such as disabled, onChange, defaultValue. */ + +const DEFAULT_FILTER = '.*{{query}}.*' + export interface ComboBoxOption { value: string label: string @@ -25,6 +28,11 @@ export enum FocusMode { Item, } +export interface CustomizableFilter { + filter: string + extras?: Record +} + interface ComboBoxProps { id: string name: string @@ -38,6 +46,8 @@ interface ComboBoxProps { inputProps?: JSX.IntrinsicElements['input'] selectProps?: JSX.IntrinsicElements['select'] ulProps?: JSX.IntrinsicElements['ul'] + customFilter?: CustomizableFilter + disableFiltering?: boolean } interface InputProps { @@ -81,7 +91,8 @@ export const ComboBox = ({ selectProps, inputProps, ulProps, - ...customProps + customFilter, + disableFiltering = false, }: ComboBoxProps): React.ReactElement => { const isDisabled = !!disabled @@ -92,20 +103,28 @@ export const ComboBox = ({ }) } + const filter: CustomizableFilter = customFilter + ? customFilter + : { filter: DEFAULT_FILTER } + const initialState: State = { isOpen: false, selectedOption: defaultOption ? defaultOption : undefined, focusedOption: undefined, focusMode: FocusMode.None, filteredOptions: options, - filter: undefined, inputValue: defaultOption ? defaultOption.label : '', } - const [state, dispatch] = useCombobox(initialState, options) + const [state, dispatch] = useComboBox( + initialState, + options, + disableFiltering, + filter + ) const containerRef = useRef(null) - const itemRef = useRef(null) + const focusedItemRef = useRef(null) useEffect(() => { onChange && onChange(state.selectedOption?.value || undefined) @@ -115,12 +134,24 @@ export const ComboBox = ({ if ( state.focusMode === FocusMode.Item && state.focusedOption && - itemRef.current + focusedItemRef.current ) { - itemRef.current.focus() + focusedItemRef.current.focus() } }, [state.focusMode, state.focusedOption]) + // When opened, the list should scroll to the closest match + useEffect(() => { + if ( + state.isOpen && + state.focusedOption && + focusedItemRef.current && + state.focusMode === FocusMode.Input + ) { + focusedItemRef.current.scrollIntoView(false) + } + }, [state.isOpen, state.focusedOption]) + // If the focused element (activeElement) is outside of the combo box, // make sure the focusMode is BLUR useEffect(() => { @@ -131,7 +162,7 @@ export const ComboBox = ({ }) } } - }) + }, [state.focusMode]) const handleInputKeyDown = (event: KeyboardEvent): void => { if (event.key === 'Escape') { @@ -140,18 +171,24 @@ export const ComboBox = ({ event.preventDefault() dispatch({ type: ActionTypes.FOCUS_OPTION, - option: state.filteredOptions[0], + option: + state.selectedOption || + state.focusedOption || + state.filteredOptions[0], }) } else if (event.key === 'Tab') { // Clear button is not visible in this case so manually handle focus if (state.isOpen && !state.selectedOption) { // If there are filtered options, prevent default // If there are "No Results Found", tab over to prevent a keyboard trap - if (state.filteredOptions.length > 0) { + const optionToFocus = disableFiltering + ? state.focusedOption + : state.selectedOption || state.focusedOption + if (optionToFocus) { event.preventDefault() dispatch({ type: ActionTypes.FOCUS_OPTION, - option: state.filteredOptions[0], + option: optionToFocus, }) } else { dispatch({ @@ -165,19 +202,27 @@ export const ComboBox = ({ type: ActionTypes.BLUR, }) } - } else if (event.key === 'Enter' && !state.selectedOption) { - event.preventDefault() - const selectedOption = state.filteredOptions.find( - (option) => - option.label.toLowerCase() === state.inputValue.toLowerCase() - ) - if (selectedOption) { - dispatch({ - type: ActionTypes.SELECT_OPTION, - option: selectedOption, - }) - } else { - dispatch({ type: ActionTypes.CLEAR }) + } else if (event.key === 'Enter') { + if (state.isOpen) { + event.preventDefault() + const exactMatch = state.filteredOptions.find( + (option) => + option.label.toLowerCase() === state.inputValue.toLowerCase() + ) + if (exactMatch) { + dispatch({ + type: ActionTypes.SELECT_OPTION, + option: exactMatch, + }) + } else { + if (state.selectedOption) { + dispatch({ + type: ActionTypes.CLOSE_LIST, + }) + } else { + dispatch({ type: ActionTypes.CLEAR }) + } + } } } } @@ -188,7 +233,7 @@ export const ComboBox = ({ !newTarget || (newTarget instanceof Node && !containerRef.current?.contains(newTarget)) - if (newTargetIsOutside) { + if (newTargetIsOutside && state.focusMode !== FocusMode.None) { dispatch({ type: ActionTypes.BLUR }) } } @@ -262,8 +307,11 @@ export const ComboBox = ({ } } + const isPristine = + state.selectedOption && state.selectedOption.label === state.inputValue + const containerClasses = classnames('usa-combo-box', className, { - 'usa-combo-box--pristine': state.selectedOption, + 'usa-combo-box--pristine': isPristine, }) const listID = `combobox-${name}-list` const assistiveHintID = `combobox-${name}-assistive-hint` @@ -273,8 +321,7 @@ export const ComboBox = ({ data-testid="combo-box" className={containerClasses} id={id} - ref={containerRef} - {...customProps}> + ref={containerRef}>