From b64490ea7668dd73b40dfe829a189cd4f7ebda4f Mon Sep 17 00:00:00 2001 From: Zach Kuzmic Date: Fri, 4 Feb 2022 11:28:03 -0600 Subject: [PATCH] Add autocomplete to TagInput (#1400) --- index.d.ts | 1 + src/tag-input/__tests__/TagInput.test.js | 200 ++++++++++++++++++++++ src/tag-input/src/TagInput.js | 136 +++++++++++++-- src/tag-input/stories/TagInput.stories.js | 25 ++- 4 files changed, 343 insertions(+), 19 deletions(-) create mode 100644 src/tag-input/__tests__/TagInput.test.js diff --git a/index.d.ts b/index.d.ts index d166f813b..f363891a8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -2497,6 +2497,7 @@ export declare const TabNavigation: BoxComponent export interface TagInputOwnProps { addOnBlur?: boolean + autocompleteItems?: Array className?: string disabled?: boolean isInvalid?: boolean diff --git a/src/tag-input/__tests__/TagInput.test.js b/src/tag-input/__tests__/TagInput.test.js new file mode 100644 index 000000000..77ddc3a03 --- /dev/null +++ b/src/tag-input/__tests__/TagInput.test.js @@ -0,0 +1,200 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import TagInput from '../src/TagInput' + +const TEST_VALUES = ['one', 'two', 'three'] +const TEST_PLACEHOLDER = 'Enter something...' + +describe('', () => { + describe('onAdd', () => { + it('should be called when a new value is entered', () => { + const mockOnAdd = jest.fn() + const newTestVal = 'Testing' + + render() + userEvent.type(screen.getByPlaceholderText(TEST_PLACEHOLDER), `${newTestVal}{enter}`) + + expect(screen.queryByTestId('TagInput-autocomplete-toggle')).not.toBeInTheDocument() + expect(mockOnAdd).toHaveBeenCalledWith([newTestVal]) + }) + }) + + describe('onRemove', () => { + it('should be called after hitting backspace', () => { + const mockOnRemove = jest.fn() + + render() + userEvent.type(screen.getByPlaceholderText(TEST_PLACEHOLDER), '{backspace}') + const lastValueIndex = TEST_VALUES.length - 1 + + expect(mockOnRemove).toHaveBeenCalledWith(TEST_VALUES[lastValueIndex], lastValueIndex) + }) + }) + + describe('onChange', () => { + it('should be called when a value is added', () => { + const mockOnChange = jest.fn() + const newTestVal = 'Testing' + + render() + userEvent.type(screen.getByPlaceholderText(TEST_PLACEHOLDER), `${newTestVal}{enter}`) + + expect(mockOnChange).toHaveBeenLastCalledWith(TEST_VALUES.concat([newTestVal])) + }) + + it('should be called when a value is removed', () => { + const mockOnChange = jest.fn() + + render() + userEvent.type(screen.getByPlaceholderText(TEST_PLACEHOLDER), '{backspace}') + const valuesLastRemoved = TEST_VALUES.slice(0, -1) + + expect(mockOnChange).toHaveBeenLastCalledWith(valuesLastRemoved) + }) + }) + + describe('tagSubmitKey', () => { + it('should allow entering values with space key', () => { + const mockOnAdd = jest.fn() + const newTestVal = 'Testing' + + render( + + ) + userEvent.type(screen.getByPlaceholderText(TEST_PLACEHOLDER), `${newTestVal}{space}`) + + expect(mockOnAdd).toHaveBeenCalledWith([newTestVal]) + }) + }) + + describe('disabled', () => { + it('prop should disable input', () => { + render() + + expect(screen.getByPlaceholderText(TEST_PLACEHOLDER)).toBeDisabled() + }) + + it('prop should remove X icons', () => { + render() + + TEST_VALUES.forEach(value => { + // Checks to make sure the "X" icon is not within each tag + expect(screen.getByText(value).children.length).toBe(0) + }) + }) + }) + + describe('addOnBlur', () => { + it('should allow adding new value on blur', () => { + const mockOnAdd = jest.fn() + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb()) + const newTestVal = 'Testing' + + render( + + ) + userEvent.type(screen.getByPlaceholderText(TEST_PLACEHOLDER), newTestVal) + screen.getByPlaceholderText(TEST_PLACEHOLDER).blur() + fireEvent.blur(screen.getByPlaceholderText(TEST_PLACEHOLDER)) + + expect(mockOnAdd).toHaveBeenCalledWith([newTestVal]) + + window.requestAnimationFrame.mockRestore() + }) + }) + + describe('separator', () => { + it('prop should allow entering multiple values at a time', () => { + const mockOnAdd = jest.fn() + const newTestVal = 'Testing|123' + + render( + + ) + userEvent.type(screen.getByPlaceholderText(TEST_PLACEHOLDER), `${newTestVal}{enter}`) + + expect(mockOnAdd).toHaveBeenCalledWith(['Testing', '123']) + }) + }) + + describe('autocompleteItems', () => { + it('should render a toggle button when provided', () => { + const mockOnAdd = jest.fn() + const testAutocompleteItems = ['Testing1', 'Testing2', 'Testing3', 'Other'] + + render( + + ) + + testAutocompleteItems.forEach(item => { + expect(screen.queryByText(item)).not.toBeInTheDocument() + }) + userEvent.click(screen.getByTestId('TagInput-autocomplete-toggle')) + testAutocompleteItems.forEach(item => { + expect(screen.queryByText(item)).toBeInTheDocument() + }) + }) + + it('should reveal options based on search query', () => { + const mockOnAdd = jest.fn() + const testAutocompleteItems = ['Testing1', 'Testing2', 'Testing3', 'Other'] + const testSearch = 'Test' + + render( + + ) + + testAutocompleteItems.forEach(item => { + expect(screen.queryByText(item)).not.toBeInTheDocument() + }) + userEvent.type(screen.getByPlaceholderText(TEST_PLACEHOLDER), testSearch) + testAutocompleteItems.forEach(item => { + if (item.startsWith(testSearch)) { + expect(screen.queryByText(item)).toBeInTheDocument() + } else { + expect(screen.queryByText(item)).not.toBeInTheDocument() + } + }) + }) + + it('should allow user to add item via popover', () => { + const mockOnAdd = jest.fn() + const testAutocompleteItems = ['Testing1', 'Testing2', 'Testing3', 'Other'] + + render( + + ) + userEvent.click(screen.getByTestId('TagInput-autocomplete-toggle')) + userEvent.click(screen.getByText(testAutocompleteItems[0])) + + expect(mockOnAdd).toHaveBeenCalledWith([testAutocompleteItems[0]]) + }) + }) +}) diff --git a/src/tag-input/src/TagInput.js b/src/tag-input/src/TagInput.js index ef66049f2..afb216314 100644 --- a/src/tag-input/src/TagInput.js +++ b/src/tag-input/src/TagInput.js @@ -6,9 +6,12 @@ import React, { memo, forwardRef, useState } from 'react' import cx from 'classnames' import PropTypes from 'prop-types' import Box from 'ui-box' +import { Autocomplete } from '../../autocomplete' +import { Button } from '../../buttons' import { useId, useStyleConfig } from '../../hooks' +import { CaretDownIcon } from '../../icons' import safeInvoke from '../../lib/safe-invoke' -import { majorScale } from '../../scales' +import { majorScale, minorScale } from '../../scales' import { TextInput } from '../../text-input' import Tag from './Tag' @@ -23,7 +26,8 @@ const emptyArray = [] const internalStyles = { alignItems: 'center', display: 'inline-flex', - flexWrap: 'wrap' + flexWrap: 'wrap', + position: 'relative' } const pseudoSelectors = { @@ -52,12 +56,16 @@ const TagInput = memo( inputProps = emptyProps, inputRef, isInvalid, + autocompleteItems, ...rest } = props const [inputValue, setInputValue] = useState('') const [isFocused, setIsFocused] = useState(false) const id = useId('TagInput') + const inputId = inputProps && inputProps.id ? inputProps.id : id + const hasAutocomplete = Array.isArray(autocompleteItems) && autocompleteItems.length > 0 + const getValues = (inputValue = '') => separator ? inputValue @@ -98,6 +106,7 @@ const TagInput = memo( if (!container.contains(document.activeElement)) { if (addOnBlur && inputValue) { addTags(inputValue) + setInputValue('') } setIsFocused(false) @@ -147,6 +156,7 @@ const TagInput = memo( key={`${tag}:${index}`} data-tag-index={index} marginX={majorScale(1)} + marginY={minorScale(1) * 1.5} onRemove={disabled ? null : handleRemoveTag} isRemovable={!disabled} {...propsForElement} @@ -166,30 +176,118 @@ const TagInput = memo( return ( {values.map(maybeRenderTag)} - + + { + addTags(changedItem) + setInputValue('') + }} + items={hasAutocomplete ? autocompleteItems : []} + id={inputId} + selectedItem="" + inputValue={inputValue} + > + {autocompleteProps => { + const { + closeMenu, + getInputProps, + getRef: autocompleteGetRef, + getToggleButtonProps, + highlightedIndex + } = autocompleteProps + + const { + onBlur: autocompleteOnBlur, + onChange: autocompleteOnChange, + onKeyDown: autocompleteKeyDown, + ...autocompleteRestProps + } = getInputProps() + + const handleAutocompleteKeydown = e => { + autocompleteKeyDown(e) + if (e.key === 'Backspace' || !(highlightedIndex > -1)) { + handleKeyDown(e) + if (e.key === GET_KEY_FOR_TAG_DELIMITER[tagSubmitKey]) { + closeMenu() + setInputValue('') + } + } + if (e.key === 'Backspace' && e.target.selectionEnd === 0) { + closeMenu() + } + } + + return ( + <> + { + autocompleteGetRef(textInputRef) + if (inputRef instanceof Function) { + inputRef(textInputRef) + } else if (inputRef) { + inputRef.current = textInputRef + } + }} + onBlur={e => { + autocompleteOnBlur(e) + safeInvoke(inputProps.onBlur, e) + }} + onFocus={e => { + handleInputFocus(e) + safeInvoke(inputProps.onFocus, e) + }} + onChange={e => { + handleInputChange(e) + autocompleteOnChange(e) + }} + onKeyDown={handleAutocompleteKeydown} + /> + {hasAutocomplete && ( + + )} + + ) + }} + + ) }) @@ -198,6 +296,8 @@ const TagInput = memo( TagInput.propTypes = { /** Whether or not the inputValue should be added to the tags when the input blurs. */ addOnBlur: PropTypes.bool, + /** Autocomplete options to show when typing in a new value */ + autocompleteItems: PropTypes.array, /** The class name to apply to the container component. */ className: PropTypes.string, /** Whether or not the input should be disabled. */ diff --git a/src/tag-input/stories/TagInput.stories.js b/src/tag-input/stories/TagInput.stories.js index e2e861501..2e1930988 100644 --- a/src/tag-input/stories/TagInput.stories.js +++ b/src/tag-input/stories/TagInput.stories.js @@ -9,6 +9,7 @@ const StoryHeader = props => const StoryHeading = props => const StorySection = props => const initialValues = ['First', 'Second', 'Third'] +const autocompleteValues = initialValues.concat('Fourth', 'Fifth', 'Sixth', 'Seventh', 'Eighth', 'Ninth', 'Tenth') class StateManager extends React.PureComponent { static propTypes = { @@ -129,7 +130,7 @@ storiesOf('tag-input', module).add('TagInput', () => ( {({ addValues, removeValue, values }) => ( ( )} + + + With Autocomplete + + + {({ addValues, handleChange, removeValue, values }) => { + const autocompleteItems = autocompleteValues.filter(i => !values.includes(i)) + return ( + + ) + }} + + ))