From b93ad5f0d511dab04f0f080124a8c40e103a8b43 Mon Sep 17 00:00:00 2001 From: Austin Green Date: Fri, 7 Dec 2018 14:03:43 -0800 Subject: [PATCH 1/7] feat: introduce new Autocomplete component --- packages/autocomplete/package.json | 2 + .../src/containers/AutocompleteContainer.js | 11 + .../src/elements/Autocomplete.example.md | 89 ++++++ .../autocomplete/src/elements/Autocomplete.js | 270 ++++++++++++++++++ .../src/elements/Autocomplete.spec.js | 203 +++++++++++++ packages/autocomplete/src/index.js | 4 + packages/autocomplete/styleguide.config.js | 5 + utils/test/jest.config.js | 3 +- 8 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 packages/autocomplete/src/elements/Autocomplete.example.md create mode 100644 packages/autocomplete/src/elements/Autocomplete.js create mode 100644 packages/autocomplete/src/elements/Autocomplete.spec.js diff --git a/packages/autocomplete/package.json b/packages/autocomplete/package.json index 080c272a1e2..a1ebb75d593 100644 --- a/packages/autocomplete/package.json +++ b/packages/autocomplete/package.json @@ -19,8 +19,10 @@ "start": "../../utils/scripts/start.sh" }, "dependencies": { + "@zendeskgarden/react-menus": "^4.2.0", "@zendeskgarden/react-selection": "^4.6.2", "@zendeskgarden/react-tooltips": "^5.0.2", + "@zendeskgarden/react-textfields": "^3.6.2", "@zendeskgarden/react-utilities": "^0.2.5", "classnames": "^2.2.5", "dom-helpers": "^3.3.1", 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..efeb7d7e2c4 --- /dev/null +++ b/packages/autocomplete/src/elements/Autocomplete.example.md @@ -0,0 +1,89 @@ +### 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] + }); +} + +const optionsMap = options.reduce((dictionary, option) => { + dictionary[option.value] = option.label; + return dictionary; +}, {}); + +initialState = { + selectedValue: options[0].value, + inputValue: '', + placeholder: 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..a040cfa1689 --- /dev/null +++ b/packages/autocomplete/src/elements/Autocomplete.js @@ -0,0 +1,270 @@ +/** + * 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 } 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; + } + `} +`; + +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, + 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, 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, + select: true, + small, + validation, + 'aria-label': ariaLabel, + inputRef: ref => { + this.wrapperRef = ref; + triggerRef(ref); + } + }); + + if (disabled) { + triggerProps = { disabled: true, small, validation }; + } + + return ( + + {!isOpen && {optionDictionary[selectedValue]}} + { + 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} + + )} + + ); + } +} 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/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..6aa428c11ae 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' 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/**' ], From d052f3d9bbbf4ec6bce46f072c85a22ef195405f Mon Sep 17 00:00:00 2001 From: Austin Green Date: Wed, 12 Dec 2018 15:41:39 -0800 Subject: [PATCH 2/7] Update label manipulation --- .../autocomplete/src/elements/Autocomplete.js | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/autocomplete/src/elements/Autocomplete.js b/packages/autocomplete/src/elements/Autocomplete.js index a040cfa1689..a8ff85d08a7 100644 --- a/packages/autocomplete/src/elements/Autocomplete.js +++ b/packages/autocomplete/src/elements/Autocomplete.js @@ -44,6 +44,10 @@ const StyledInput = styled(Input)` `} `; +const StyledFauxInput = styled(FauxInput)` + cursor: text; +`; + export default class Autocomplete extends Component { static propTypes = { label: PropTypes.string, @@ -78,6 +82,7 @@ export default class Autocomplete extends Component { state = { isOpen: false, isFocused: false, + isHovered: false, focusedKey: undefined, inputValue: '', value: undefined @@ -143,7 +148,7 @@ export default class Autocomplete extends Component { small, validation } = this.props; - const { isOpen, focusedKey, isFocused, inputValue } = this.state; + const { isOpen, focusedKey, isFocused, isHovered, inputValue } = this.state; const optionDictionary = options.reduce((dictionary, option) => { dictionary[option.value] = option.label; @@ -155,7 +160,24 @@ export default class Autocomplete extends Component { {({ getLabelProps, getInputProps: getFieldInputProps, getHintProps }) => ( - {label && } + {label && ( + + )} {hint && {hint}} + {!isOpen && {optionDictionary[selectedValue]}} { + this.inputRef = ref; triggerInputRef(ref); inputRef && inputRef(ref); }, @@ -232,7 +256,7 @@ export default class Autocomplete extends Component { ) )} /> - + ); }} > From eda27b71aa4b680cea5378129458d4bd74305510 Mon Sep 17 00:00:00 2001 From: Austin Green Date: Fri, 14 Dec 2018 13:14:22 -0800 Subject: [PATCH 3/7] feat: add updated focus interaction --- .../src/elements/Autocomplete.example.md | 38 ++++++++-------- .../autocomplete/src/elements/Autocomplete.js | 43 ++++++++++++------- 2 files changed, 46 insertions(+), 35 deletions(-) diff --git a/packages/autocomplete/src/elements/Autocomplete.example.md b/packages/autocomplete/src/elements/Autocomplete.example.md index efeb7d7e2c4..63cc2fdb5a0 100644 --- a/packages/autocomplete/src/elements/Autocomplete.example.md +++ b/packages/autocomplete/src/elements/Autocomplete.example.md @@ -1,29 +1,31 @@ ### Basic Example ```jsx +const items = ['Option 1', 'Option 2', 'Option 3']; +const options = []; + +for (let x = 0; x < items.length; x++) { + options.push({ + value: `value-${x}`, + label: items[x] + }); +} + +const optionsMap = options.reduce((dictionary, option) => { + dictionary[option.value] = option.label; + return dictionary; +}, {}); + initialState = { - selectedValue: 'option-1' + selectedValue: options[0].value }; setState({ selectedValue })} - options={[ - { - value: 'option-1', - label: 'Option 1' - }, - { - value: 'option-2', - label: 'Option 2' - }, - { - value: 'option-3', - label: 'Option 3' - } - ]} + options={options} />; ``` @@ -73,9 +75,7 @@ const optionsMap = options.reduce((dictionary, option) => { }, {}); initialState = { - selectedValue: options[0].value, - inputValue: '', - placeholder: options[0].value + selectedValue: options[0].value }; - !props.isOpen && - ` - && { - opacity: 0; - height: 0; - min-height: 0; - width: 0; - min-width: 0; - } - `} + && { + display: inline-block; + ${props => !props.isOpen && 'width: 1px;'} + } `; 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, @@ -164,8 +162,10 @@ export default class Autocomplete extends Component { - {message} + {message && {message}} )} From 01c30aa72853f7ef6a8b4eb1898319e6b75623b4 Mon Sep 17 00:00:00 2001 From: Austin Green Date: Fri, 21 Dec 2018 11:31:34 -0800 Subject: [PATCH 7/7] Update dependencies from rebase --- packages/autocomplete/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/autocomplete/package.json b/packages/autocomplete/package.json index a1ebb75d593..510a3df0e35 100644 --- a/packages/autocomplete/package.json +++ b/packages/autocomplete/package.json @@ -19,10 +19,10 @@ "start": "../../utils/scripts/start.sh" }, "dependencies": { - "@zendeskgarden/react-menus": "^4.2.0", + "@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-textfields": "^3.6.2", "@zendeskgarden/react-utilities": "^0.2.5", "classnames": "^2.2.5", "dom-helpers": "^3.3.1",