diff --git a/packages/autocomplete/package.json b/packages/autocomplete/package.json index 080c272a1e2..510a3df0e35 100644 --- a/packages/autocomplete/package.json +++ b/packages/autocomplete/package.json @@ -19,7 +19,9 @@ "start": "../../utils/scripts/start.sh" }, "dependencies": { + "@zendeskgarden/react-menus": "^4.3.1", "@zendeskgarden/react-selection": "^4.6.2", + "@zendeskgarden/react-textfields": "^3.7.0", "@zendeskgarden/react-tooltips": "^5.0.2", "@zendeskgarden/react-utilities": "^0.2.5", "classnames": "^2.2.5", diff --git a/packages/autocomplete/src/containers/AutocompleteContainer.example.md b/packages/autocomplete/src/containers/AutocompleteContainer.example.md index 8ed6afd23ee..b61b7c2cee5 100644 --- a/packages/autocomplete/src/containers/AutocompleteContainer.example.md +++ b/packages/autocomplete/src/containers/AutocompleteContainer.example.md @@ -9,12 +9,3 @@ The examples below show two common layouts using assorted Garden packages: - [@zendeskgarden/react-textfields](/react-components/textfields/) - [@zendeskgarden/react-menus](/react-components/menus/) - [@zendeskgarden/react-tags](/react-components/tags/) - -### WARNING - -This package comes _"with some assembly required"_. - -Due to the wide variety of customizations and the assumed complexity of creating an autocomplete, -this package contains **no visual elements**. - -See the examples below for suggested layouts and interactions: diff --git a/packages/autocomplete/src/containers/AutocompleteContainer.js b/packages/autocomplete/src/containers/AutocompleteContainer.js index e6bcee85a05..846db9e03cc 100644 --- a/packages/autocomplete/src/containers/AutocompleteContainer.js +++ b/packages/autocomplete/src/containers/AutocompleteContainer.js @@ -404,6 +404,7 @@ class AutocompleteContainer extends ControlledComponent { id = this.getItemId(key), role = 'option', onClick, + onMouseMove, index, ...other } = {}) => { @@ -426,6 +427,16 @@ class AutocompleteContainer extends ControlledComponent { onClick: composeEventHandlers(onClick, () => { this.selectItem(key); }), + /** + * onMouseMove is used as it is only triggered by actual mouse movement + */ + onMouseMove: composeEventHandlers(onMouseMove, () => { + if (key !== focusedKey) { + this.setControlledState({ + focusedKey: key + }); + } + }), ...other }; }; diff --git a/packages/autocomplete/src/elements/Autocomplete.example.md b/packages/autocomplete/src/elements/Autocomplete.example.md new file mode 100644 index 00000000000..3c3e3c6d090 --- /dev/null +++ b/packages/autocomplete/src/elements/Autocomplete.example.md @@ -0,0 +1,80 @@ +### Basic Example + +```jsx +initialState = { + selectedValue: 'option-1' +}; + + setState({ selectedValue })} + options={[ + { + value: 'option-1', + label: 'Option 1' + }, + { + value: 'option-2', + label: 'Option 2' + }, + { + value: 'option-3', + label: 'Option 3' + } + ]} +/>; +``` + +### Advanced Example + +```jsx +const phonetics = [ + 'Alfa', + 'Bravo', + 'Charlie', + 'Delta', + 'Echo', + 'Foxtrot', + 'Golf', + 'Hotel', + 'India', + 'Juliett', + 'Kilo', + 'Lima', + 'Mike', + 'November', + 'Oscar', + 'Papa', + 'Quebec', + 'Romeo', + 'Sierra', + 'Tango', + 'Uniform', + 'Victor', + 'Whiskey', + 'X-ray', + 'Yankee', + 'Zulu' +]; +const options = []; + +for (let x = 0; x < phonetics.length; x++) { + options.push({ + value: `value-${phonetics[x]}`, + label: phonetics[x] + }); +} + +initialState = { + selectedValue: options[0].value +}; + + setState({ selectedValue })} + options={options} +/>; +``` diff --git a/packages/autocomplete/src/elements/Autocomplete.js b/packages/autocomplete/src/elements/Autocomplete.js new file mode 100644 index 00000000000..c1541dc49a9 --- /dev/null +++ b/packages/autocomplete/src/elements/Autocomplete.js @@ -0,0 +1,312 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import styled from 'styled-components'; +import { MenuView, Item } from '@zendeskgarden/react-menus'; +import { TextGroup, Label, FauxInput, Input, Hint, Message } from '@zendeskgarden/react-textfields'; +import { FieldContainer, KEY_CODES } from '@zendeskgarden/react-selection'; +import AutocompleteContainer from '../containers/AutocompleteContainer'; + +const VALIDATION = { + SUCCESS: 'success', + WARNING: 'warning', + ERROR: 'error', + NONE: 'none' +}; + +const StyledNoItemsMessage = styled.div` + margin: 16px; + text-align: center; +`; + +const StyledMenuOverflow = styled.div` + overflow-y: auto; + max-height: ${props => props.maxHeight || 'inherit'}; +`; + +const StyledInput = styled(Input)` + ${props => + !props.isOpen && + ` + && { + opacity: 0; + height: 0; + min-height: 0; + width: 0; + min-width: 0; + } + `} +`; + +const StyledFauxInput = styled(FauxInput)` + cursor: text; +`; + +const StyledValueWrapper = styled.div` + display: inline-block; + vertical-align: middle; +`; + +export default class Autocomplete extends Component { + static propTypes = { + label: PropTypes.string, + 'aria-label': PropTypes.string, + validation: PropTypes.oneOf([ + VALIDATION.SUCCESS, + VALIDATION.WARNING, + VALIDATION.ERROR, + VALIDATION.NONE + ]), + message: PropTypes.node, + hint: PropTypes.node, + small: PropTypes.bool, + selectedValue: PropTypes.string, + onChange: PropTypes.func, + inputRef: PropTypes.func, + placeholder: PropTypes.string, + maxHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + disabled: PropTypes.bool, + options: PropTypes.array.isRequired, + noOptionsMessage: PropTypes.string, + renderOption: PropTypes.func, + renderDropdown: PropTypes.func, + optionFilter: PropTypes.func + }; + + static defaultProps = { + noOptionsMessage: 'No matches found', + maxHeight: '400px' + }; + + state = { + isOpen: false, + isFocused: false, + isHovered: false, + focusedKey: undefined, + inputValue: '', + value: undefined + }; + + defaultOptionFilter = (original, comparison) => { + const formattedOriginal = original.replace(/ /u, '').toLocaleLowerCase(); + const formattedComparison = comparison.replace(/ /u, '').toLocaleLowerCase(); + + return formattedOriginal.indexOf(formattedComparison) !== -1; + }; + + getMatchingOptions = () => { + const optionFilter = this.props.optionFilter || this.defaultOptionFilter; + + return this.props.options.filter(option => optionFilter(option.label, this.state.inputValue)); + }; + + renderMenuItems = getItemProps => { + const { noOptionsMessage, renderOption, selectedValue } = this.props; + const { focusedKey } = this.state; + + const matchingOptions = this.getMatchingOptions().map(option => { + const checked = selectedValue === option.value; + + const props = getItemProps({ + key: option.value, + focused: focusedKey === option.value, + checked, + 'aria-selected': checked + }); + + props.children = option.label; + + if (renderOption) { + return renderOption(props); + } + + return ; + }); + + if (matchingOptions.length > 0) { + return matchingOptions; + } + + return {noOptionsMessage}; + }; + + render() { + const { + label, + 'aria-label': ariaLabel, + hint, + disabled, + options, + selectedValue, + onChange, + placeholder, + maxHeight, + renderDropdown, + inputRef, + message, + small, + validation + } = this.props; + const { isOpen, focusedKey, isFocused, isHovered, inputValue } = this.state; + + const optionDictionary = options.reduce((dictionary, option) => { + dictionary[option.value] = option.label; + + return dictionary; + }, {}); + + return ( + + {({ getLabelProps, getInputProps: getFieldInputProps, getHintProps }) => ( + + {label && ( + + )} + {hint && {hint}} + { + onChange && onChange(selectedKey); + }} + onStateChange={newState => { + if (!newState.isOpen) { + newState.inputValue = ''; + } + + this.setState(newState); + }} + trigger={({ + getTriggerProps, + getInputProps, + triggerRef, + inputRef: triggerInputRef + }) => { + let triggerProps = getTriggerProps({ + open: isOpen, + focused: isFocused || isOpen, + hovered: isHovered, + select: true, + small, + validation, + 'aria-label': ariaLabel, + inputRef: ref => { + this.wrapperRef = ref; + triggerRef(ref); + } + }); + + if (disabled) { + triggerProps = { + disabled: true, + small, + validation, + select: true, + 'aria-label': ariaLabel + }; + } + + return ( + + {!isOpen && ( + {optionDictionary[selectedValue]} + )} + { + this.inputRef = ref; + triggerInputRef(ref); + inputRef && inputRef(ref); + }, + value: inputValue, + isOpen, + placeholder, + onChange: e => { + this.setState({ inputValue: e.target.value }); + }, + onFocus: () => { + this.setState({ isFocused: true }); + }, + onBlur: () => { + this.setState({ isFocused: false }); + }, + onKeyDown: e => { + if ( + e.keyCode === KEY_CODES.ENTER && + (!e.target.value || e.target.value.trim().length === 0) && + !focusedKey && + isOpen + ) { + e.preventDefault(); + } + } + }, + { isDescribed: false } + ) + )} + /> + + ); + }} + > + {({ getMenuProps, getItemProps, placement }) => { + const props = getMenuProps({ + placement, + animate: true, + small, + style: { + width: this.wrapperRef ? this.wrapperRef.getBoundingClientRect().width : 0 + } + }); + + props.children = this.renderMenuItems(getItemProps); + + if (renderDropdown) { + return renderDropdown(props); + } + + const { children: menuChildren, ...otherMenuProps } = props; + + return ( + + {menuChildren} + + ); + }} + + {message && {message}} + + )} + + ); + } +} diff --git a/packages/autocomplete/src/elements/Autocomplete.spec.js b/packages/autocomplete/src/elements/Autocomplete.spec.js new file mode 100644 index 00000000000..91df176480d --- /dev/null +++ b/packages/autocomplete/src/elements/Autocomplete.spec.js @@ -0,0 +1,203 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import React from 'react'; +import { Label, FauxInput, Input } from '@zendeskgarden/react-textfields'; +import { MenuView, Item } from '@zendeskgarden/react-menus'; +import { mountWithTheme } from '@zendeskgarden/react-testing'; +import { KEY_CODES } from '@zendeskgarden/react-selection'; +import Autocomplete from './Autocomplete'; + +describe('Autocomplete', () => { + it('renders label if provided', () => { + const label = 'Test Label'; + const wrapper = mountWithTheme(); + + expect(wrapper.find(Label)).toHaveText(label); + }); + + it('renders aria-label if provided', () => { + const ariaLabel = 'Test Aria Label'; + const wrapper = mountWithTheme(); + + expect(wrapper.find(FauxInput)).toHaveProp('aria-label', ariaLabel); + }); + + it('renders validation styling if provided', () => { + const validation = 'warning'; + const wrapper = mountWithTheme(); + + expect(wrapper.find(FauxInput)).toHaveProp('validation', validation); + }); + + it('renders small styling if provided', () => { + const wrapper = mountWithTheme(); + const triggerElement = wrapper.find(FauxInput); + + expect(triggerElement).toHaveProp('small'); + + triggerElement.simulate('click'); + + expect(wrapper.find(MenuView)).toHaveProp('small'); + }); + + it('renders disabled styling if provided', () => { + const wrapper = mountWithTheme(); + const triggerElement = wrapper.find(FauxInput); + + expect(triggerElement).toHaveProp('disabled'); + + triggerElement.simulate('click'); + + expect(wrapper.find(MenuView)).not.toExist(); + }); + + it('applies inputRef if provided', () => { + const inputRefSpy = jest.fn(); + + mountWithTheme(); + expect(inputRefSpy).toHaveBeenCalled(); + }); + + describe('Input', () => { + it('applies focused styling when focused', () => { + const wrapper = mountWithTheme(); + const triggerElement = wrapper.find(FauxInput); + + triggerElement.simulate('click'); + wrapper.find(Input).simulate('focus'); + + expect(triggerElement).toHaveProp('focused'); + }); + + it('removes focused styling when blured', () => { + const wrapper = mountWithTheme(); + const triggerElement = wrapper.find(FauxInput); + + triggerElement.simulate('click'); + wrapper.find(Input).simulate('blur'); + + expect(triggerElement).toHaveProp('focused', false); + }); + + it('updates input text with onChange event', () => { + const inputValue = 'test'; + const wrapper = mountWithTheme(); + const triggerElement = wrapper.find(FauxInput); + + triggerElement.simulate('click'); + + const input = wrapper.find(Input); + + input.simulate('change', { target: { value: inputValue } }); + wrapper.update(); + + expect(wrapper).toHaveState('inputValue', inputValue); + }); + + it('does not select option if ENTER key is used without input text', () => { + const onChangeSpy = jest.fn(); + const wrapper = mountWithTheme( + + ); + const triggerElement = wrapper.find(FauxInput); + + triggerElement.simulate('click'); + + wrapper.find(Input).simulate('keydown', { keyCode: KEY_CODES.ENTER, target: { value: '' } }); + expect(onChangeSpy).not.toHaveBeenCalled(); + + wrapper + .find(Input) + .simulate('keydown', { keyCode: KEY_CODES.ENTER, target: { value: ' ' } }); + expect(onChangeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Options', () => { + it('renders options correctly', () => { + const wrapper = mountWithTheme( + + ); + + const triggerElement = wrapper.find(FauxInput); + + triggerElement.simulate('click'); + + const menuItems = wrapper.find(Item); + + expect(menuItems).toHaveLength(3); + expect(menuItems.at(0)).toHaveText('Option 1'); + expect(menuItems.at(1)).toHaveText('Option 2'); + expect(menuItems.at(2)).toHaveText('Option 3'); + }); + + it('call onChange correctly when option is selected', () => { + const onChangeSpy = jest.fn(); + const wrapper = mountWithTheme( + + ); + + const triggerElement = wrapper.find(FauxInput); + + triggerElement.simulate('click'); + wrapper + .find(Item) + .at(0) + .simulate('click'); + + expect(onChangeSpy).toHaveBeenCalledWith('option-1'); + }); + }); +}); diff --git a/packages/autocomplete/src/examples/autocomplete.md b/packages/autocomplete/src/examples/autocomplete.md deleted file mode 100644 index 200b3e9ac3c..00000000000 --- a/packages/autocomplete/src/examples/autocomplete.md +++ /dev/null @@ -1,190 +0,0 @@ -The `Autocomplete` layout operates as a "filterable select" and can -optionally allow users to add content with the "Add" Menu Item. - -```jsx -const { MenuView, Item, AddItem, Separator } = require('@zendeskgarden/react-menus/src'); -const { TextGroup, Label, FauxInput, Input } = require('@zendeskgarden/react-textfields/src'); -const { FieldContainer, KEY_CODES } = require('@zendeskgarden/react-selection/src'); - -const NoItemsMessage = styled.div` - margin: 16px; - text-align: center; -`; - -const stringContains = (original, comparison) => { - const formattedOriginal = original.replace(/ /g, '').toLocaleLowerCase(); - const formattedComparison = comparison.replace(/ /g, '').toLocaleLowerCase(); - - return formattedOriginal.indexOf(formattedComparison) !== -1; -}; - -const getMatchingMenuItems = (searchValue, selectedValue, getItemProps, focusedKey) => { - const menuItems = state.natoPhonetics - .filter(phonetic => stringContains(phonetic, searchValue)) - .map(phonetic => ( - - {phonetic} - - )); - - return menuItems.length === 0 ? No items found : menuItems; -}; - -initialState = { - value: 'Default value', - inputValue: '', - selectedKeys: {}, - isFocused: false, - isOpen: false, - focusedKey: undefined, - natoPhonetics: [ - 'Alfa', - 'Bravo', - 'Charlie', - 'Delta', - 'Echo', - 'Foxtrot', - 'Golf', - 'Hotel', - 'India', - 'Juliett', - 'Kilo', - 'Lima', - 'Mike', - 'November', - 'Oscar', - 'Papa', - 'Quebec', - 'Romeo', - 'Sierra', - 'Tango', - 'Uniform', - 'Victor', - 'Whiskey', - 'X-ray', - 'Yankee', - 'Zulu' - ] -}; - - - {({ getLabelProps, getInputProps: getFieldInputProps }) => ( - - - { - let natoPhonetics = state.natoPhonetics; - - if (selectedKey === 'add-item') { - natoPhonetics = natoPhonetics.slice(); - natoPhonetics.push(state.inputValue); - selectedKey = state.inputValue; - } - - setState({ value: selectedKey, natoPhonetics, inputValue: '' }); - }} - onStateChange={newState => { - let inputValue = state.inputValue; - - if (typeof newState.isOpen !== 'undefined' && !newState.isOpen) { - newState.inputValue = ''; - } - - setState(newState); - }} - trigger={({ getTriggerProps, getInputProps, triggerRef, inputRef, isOpen }) => { - return ( - { - this.wrapperRef = ref; - triggerRef(ref); - } - })} - > - {!isOpen && {state.value}} - { - setState({ inputValue: e.target.value }); - }, - placeholder: state.value, - onFocus: () => { - setState({ isFocused: true }); - }, - onBlur: () => { - setState({ isFocused: false }); - }, - onKeyDown: e => { - if ( - e.keyCode === KEY_CODES.ENTER && - (!e.target.value || e.target.value.trim().length === 0) && - !state.focusedKey && - state.isOpen - ) { - e.preventDefault(); - } - }, - style: !isOpen - ? { opacity: 0, height: 0, minHeight: 0, width: 0, minWidth: 0 } - : {} - }, - { isDescribed: false } - ) - )} - /> - - ); - }} - > - {({ getMenuProps, getItemProps, placement, focusedKey }) => { - const menuItems = getMatchingMenuItems( - state.inputValue, - state.value, - getItemProps, - focusedKey - ); - - const addItemProps = - state.inputValue.length === 0 - ? { disabled: true } - : getItemProps({ key: 'add-item', focused: focusedKey === 'add-item' }); - - return ( - -
{menuItems}
- - Add item -
- ); - }} -
-
- )} -
; -``` diff --git a/packages/autocomplete/src/index.js b/packages/autocomplete/src/index.js index 3dcbb8a85ec..a393142c122 100644 --- a/packages/autocomplete/src/index.js +++ b/packages/autocomplete/src/index.js @@ -5,4 +5,8 @@ * found at http://www.apache.org/licenses/LICENSE-2.0. */ +import '@zendeskgarden/react-menus/dist/styles.css'; +import '@zendeskgarden/react-textfields/dist/styles.css'; + +export { default as Autocomplete } from './elements/Autocomplete'; export { default as AutocompleteContainer } from './containers/AutocompleteContainer'; diff --git a/packages/autocomplete/styleguide.config.js b/packages/autocomplete/styleguide.config.js index 184e941d75e..b0b846d6e5b 100644 --- a/packages/autocomplete/styleguide.config.js +++ b/packages/autocomplete/styleguide.config.js @@ -10,11 +10,16 @@ * https://github.com/styleguidist/react-styleguidist/blob/master/docs/Configuration.md */ module.exports = { + require: ['../../packages/menus/dist/styles.css', '../../packages/textfields/dist/styles.css'], sections: [ { name: '', content: '../../packages/autocomplete/README.md' }, + { + name: 'Elements', + components: '../../packages/autocomplete/src/elements/[A-Z]*.js' + }, { name: 'Containers', components: '../../packages/autocomplete/src/containers/[A-Z]*.js' @@ -22,10 +27,6 @@ module.exports = { { name: 'Examples', sections: [ - { - name: 'Autocomplete', - content: '../../packages/autocomplete/src/examples/autocomplete.md' - }, { name: 'Multi-Select', content: '../../packages/autocomplete/src/examples/multiselect.md' diff --git a/utils/test/jest.config.js b/utils/test/jest.config.js index f81e1cfe142..8dd3892edeb 100644 --- a/utils/test/jest.config.js +++ b/utils/test/jest.config.js @@ -26,8 +26,9 @@ module.exports = { '\\.(svg)$': '/utils/test/svg-mock.js' }, collectCoverageFrom: [ - '/packages/*!(.template)/src/**/*.{js,jsx}', + '/packages/*/src/**/*.{js,jsx}', '!/packages/*/src/index.js', + '!/packages/.template', '!**/node_modules/**', '!**/vendor/**' ],