From 35c323665dc3f7d29411ebce5956c42edbfef191 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Fri, 18 Sep 2020 10:47:00 +0100 Subject: [PATCH 1/9] Try autocomplete combobox control --- .../src/form-token-field/combobox.js | 412 ++++++++++++++++++ .../src/form-token-field/stories/index.js | 37 +- 2 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 packages/components/src/form-token-field/combobox.js diff --git a/packages/components/src/form-token-field/combobox.js b/packages/components/src/form-token-field/combobox.js new file mode 100644 index 0000000000000..e26d355fabec8 --- /dev/null +++ b/packages/components/src/form-token-field/combobox.js @@ -0,0 +1,412 @@ +/** + * External dependencies + */ +import { take, difference, each, identity } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __, _n, sprintf } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { withInstanceId } from '@wordpress/compose'; +import { ENTER, UP, DOWN, LEFT, RIGHT, ESCAPE } from '@wordpress/keycodes'; +import isShallowEqual from '@wordpress/is-shallow-equal'; + +/** + * Internal dependencies + */ +import TokenInput from './token-input'; +import SuggestionsList from './suggestions-list'; +import withSpokenMessages from '../higher-order/with-spoken-messages'; + +const initialState = { + incompleteTokenValue: '', + isActive: false, + isExpanded: false, + selectedSuggestion: null, +}; + +class ComboboxControl extends Component { + constructor() { + super( ...arguments ); + this.state = initialState; + this.onKeyDown = this.onKeyDown.bind( this ); + this.onFocus = this.onFocus.bind( this ); + this.onBlur = this.onBlur.bind( this ); + this.onContainerTouched = this.onContainerTouched.bind( this ); + this.onSuggestionHovered = this.onSuggestionHovered.bind( this ); + this.onSuggestionSelected = this.onSuggestionSelected.bind( this ); + this.onInputChange = this.onInputChange.bind( this ); + this.bindInput = this.bindInput.bind( this ); + this.bindTokensAndInput = this.bindTokensAndInput.bind( this ); + this.updateSuggestions = this.updateSuggestions.bind( this ); + } + + componentDidUpdate( prevProps ) { + // Make sure to focus the input when the isActive state is true. + if ( this.state.isActive && ! this.input.hasFocus() ) { + this.input.focus(); + } + + const { suggestions, value } = this.props; + const suggestionsDidUpdate = ! isShallowEqual( + suggestions, + prevProps.suggestions + ); + if ( suggestionsDidUpdate || value !== prevProps.value ) { + this.updateSuggestions(); + } + } + + static getDerivedStateFromProps( props, state ) { + if ( ! props.disabled || ! state.isActive ) { + return null; + } + + return { + isActive: false, + incompleteTokenValue: '', + }; + } + + bindInput( ref ) { + this.input = ref; + } + + bindTokensAndInput( ref ) { + this.tokensAndInput = ref; + } + + onFocus( event ) { + // If focus is on the input or on the container, set the isActive state to true. + if ( this.input.hasFocus() || event.target === this.tokensAndInput ) { + this.setState( { isActive: true } ); + } else { + /* + * Otherwise, focus is on one of the token "remove" buttons and we + * set the isActive state to false to prevent the input to be + * re-focused, see componentDidUpdate(). + */ + this.setState( { isActive: false } ); + } + + if ( 'function' === typeof this.props.onFocus ) { + this.props.onFocus( event ); + } + } + + onBlur() { + this.setState( { + isActive: false, + incompleteTokenValue: this.props.value, + isExpanded: false, + } ); + } + + onKeyDown( event ) { + let preventDefault = false; + + switch ( event.keyCode ) { + case ENTER: + if ( this.state.selectedSuggestion ) { + this.onSuggestionSelected( this.state.selectedSuggestion ); + preventDefault = true; + } + break; + case LEFT: + preventDefault = this.handleLeftArrowKey(); + break; + case UP: + preventDefault = this.handleUpArrowKey(); + break; + case RIGHT: + preventDefault = this.handleRightArrowKey(); + break; + case DOWN: + preventDefault = this.handleDownArrowKey(); + break; + case ESCAPE: + preventDefault = this.handleEscapeKey( event ); + event.stopPropagation(); + break; + default: + break; + } + + if ( preventDefault ) { + event.preventDefault(); + } + } + + onContainerTouched( event ) { + // Prevent clicking/touching the tokensAndInput container from blurring + // the input and adding the current token. + if ( event.target === this.tokensAndInput && this.state.isActive ) { + event.preventDefault(); + } + } + + onSuggestionHovered( suggestion ) { + this.setState( { + selectedSuggestion: suggestion, + } ); + } + + onInputChange( event ) { + const text = event.value; + + this.setState( { incompleteTokenValue: text }, this.updateSuggestions ); + this.props.onInputChange( text ); + } + + handleUpArrowKey() { + const matchingSuggestions = this.getMatchingSuggestions(); + const index = matchingSuggestions.indexOf( + this.state.selectedSuggestion + ); + if ( index === 0 || index === -1 ) { + this.setState( { + selectedSuggestion: + matchingSuggestions[ matchingSuggestions.length - 1 ], + } ); + } else { + this.setState( { + selectedSuggestion: matchingSuggestions[ index - 1 ], + } ); + } + + return true; // preventDefault + } + + handleDownArrowKey() { + const matchingSuggestions = this.getMatchingSuggestions(); + const index = matchingSuggestions.indexOf( + this.state.selectedSuggestion + ); + if ( index === matchingSuggestions.length - 1 || index === -1 ) { + this.setState( { + selectedSuggestion: matchingSuggestions[ 0 ], + } ); + } else { + this.setState( { + selectedSuggestion: matchingSuggestions[ index + 1 ], + } ); + } + return true; // preventDefault + } + + handleEscapeKey( event ) { + this.setState( { + incompleteTokenValue: event.target.value, + isExpanded: false, + selectedSuggestion: null, + } ); + return true; // preventDefault + } + + onSuggestionSelected( newValue ) { + this.props.onChange( newValue ); + this.props.speak( this.props.messages.selected, 'assertive' ); + + if ( this.state.isActive ) { + this.input.focus(); + } + + this.setState( { + incompleteTokenValue: newValue, + selectedSuggestion: null, + isExpanded: false, + } ); + } + + getMatchingSuggestions( + searchValue = this.state.incompleteTokenValue, + suggestions = this.props.suggestions, + value = this.props.value, + maxSuggestions = this.props.maxSuggestions, + saveTransform = this.props.saveTransform + ) { + let match = saveTransform( searchValue ); + const startsWithMatch = []; + const containsMatch = []; + + if ( match.length === 0 ) { + suggestions = difference( suggestions, value ); + } else { + match = match.toLocaleLowerCase(); + + each( suggestions, ( suggestion ) => { + const index = suggestion.toLocaleLowerCase().indexOf( match ); + if ( index === 0 ) { + startsWithMatch.push( suggestion ); + } else if ( index > 0 ) { + containsMatch.push( suggestion ); + } + } ); + + suggestions = startsWithMatch.concat( containsMatch ); + } + + return take( suggestions, maxSuggestions ); + } + + updateSuggestions() { + const { incompleteTokenValue } = this.state; + + const inputHasMinimumChars = incompleteTokenValue.trim().length > 1; + const matchingSuggestions = this.getMatchingSuggestions( + incompleteTokenValue + ); + const hasMatchingSuggestions = matchingSuggestions.length > 0; + + const newState = { + isExpanded: inputHasMinimumChars && hasMatchingSuggestions, + }; + if ( + matchingSuggestions.indexOf( this.state.selectedSuggestion ) === -1 + ) { + newState.selectedSuggestion = null; + } + + this.setState( newState ); + + if ( inputHasMinimumChars ) { + const { debouncedSpeak } = this.props; + + const message = hasMatchingSuggestions + ? sprintf( + /* translators: %d: number of results. */ + _n( + '%d result found, use up and down arrow keys to navigate.', + '%d results found, use up and down arrow keys to navigate.', + matchingSuggestions.length + ), + matchingSuggestions.length + ) + : __( 'No results.' ); + + debouncedSpeak( message, 'assertive' ); + } + } + + renderInput() { + const { + autoCapitalize, + autoComplete, + maxLength, + value, + instanceId, + } = this.props; + const matchingSuggestions = this.getMatchingSuggestions(); + + let props = { + instanceId, + autoCapitalize, + autoComplete, + ref: this.bindInput, + key: 'input', + disabled: this.props.disabled, + value: this.state.incompleteTokenValue, + onBlur: this.onBlur, + isExpanded: this.state.isExpanded, + selectedSuggestionIndex: matchingSuggestions.indexOf( + this.state.selectedSuggestion + ), + }; + + if ( ! ( maxLength && value.length >= maxLength ) ) { + props = { ...props, onChange: this.onInputChange }; + } + + return ; + } + + render() { + const { + disabled, + label = __( 'Select item' ), + instanceId, + className, + } = this.props; + const { isExpanded } = this.state; + const classes = classnames( + className, + 'components-form-token-field__input-container', + { + 'is-active': this.state.isActive, + 'is-disabled': disabled, + } + ); + + let tokenFieldProps = { + className: 'components-form-token-field', + tabIndex: '-1', + }; + const matchingSuggestions = this.getMatchingSuggestions(); + + if ( ! disabled ) { + tokenFieldProps = Object.assign( {}, tokenFieldProps, { + onKeyDown: this.onKeyDown, + onFocus: this.onFocus, + } ); + } + + // Disable reason: There is no appropriate role which describes the + // input container intended accessible usability. + // TODO: Refactor click detection to use blur to stop propagation. + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( +
+ +
+ { this.renderInput() } + { isExpanded && ( + + ) } +
+
+ ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + } +} + +ComboboxControl.defaultProps = { + suggestions: Object.freeze( [] ), + maxSuggestions: 100, + value: null, + displayTransform: identity, + saveTransform: identity, + onChange: () => {}, + onInputChange: () => {}, + isBorderless: false, + disabled: false, + messages: { + selected: __( 'Item selected.' ), + }, +}; + +export default withSpokenMessages( withInstanceId( ComboboxControl ) ); diff --git a/packages/components/src/form-token-field/stories/index.js b/packages/components/src/form-token-field/stories/index.js index afe7d46838f3d..13bb9e531a89e 100644 --- a/packages/components/src/form-token-field/stories/index.js +++ b/packages/components/src/form-token-field/stories/index.js @@ -7,6 +7,7 @@ import { useState } from '@wordpress/element'; * Internal dependencies */ import FormTokenField from '../'; +import ComboboxControl from '../combobox'; export default { title: 'Components/FormTokenField', @@ -43,12 +44,14 @@ const FormTokenFieldAsyncExample = () => { const [ selectedContinents, setSelectedContinents ] = useState( [] ); const [ availableContinents, setAvailableContinents ] = useState( [] ); const searchContinents = ( input ) => { - setTimeout( () => { + const timeout = setTimeout( () => { const available = continents.filter( ( continent ) => continent.toLowerCase().includes( input.toLowerCase() ) ); setAvailableContinents( available ); }, 1000 ); + + return () => clearTimeout( timeout ); }; return ( @@ -65,3 +68,35 @@ const FormTokenFieldAsyncExample = () => { export const _async = () => { return ; }; + +const ComboboxExample = () => { + const [ selectedContinent, setSelectedContinent ] = useState( null ); + const [ availableContinents, setAvailableContinents ] = useState( [] ); + const searchContinents = ( input ) => { + const timeout = setTimeout( () => { + const available = continents.filter( ( continent ) => + continent.toLowerCase().includes( input.toLowerCase() ) + ); + setAvailableContinents( available ); + }, 1000 ); + + return () => clearTimeout( timeout ); + }; + + return ( + <> + setSelectedContinent( tokens ) } + onInputChange={ searchContinents } + placeholder="Type a continent" + /> +

Value: { selectedContinent }

+ + ); +}; + +export const _combobox = () => { + return ; +}; From 13c23be50256bc8ba6498533879dca50167c8f0a Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Tue, 22 Sep 2020 10:33:39 +0100 Subject: [PATCH 2/9] Fix a couple of JS errors --- packages/components/src/form-token-field/combobox.js | 5 +++-- packages/components/src/form-token-field/token-input.js | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/components/src/form-token-field/combobox.js b/packages/components/src/form-token-field/combobox.js index e26d355fabec8..1781459422d14 100644 --- a/packages/components/src/form-token-field/combobox.js +++ b/packages/components/src/form-token-field/combobox.js @@ -231,7 +231,7 @@ class ComboboxControl extends Component { const startsWithMatch = []; const containsMatch = []; - if ( match.length === 0 ) { + if ( ! match || match.length === 0 ) { suggestions = difference( suggestions, value ); } else { match = match.toLocaleLowerCase(); @@ -254,7 +254,8 @@ class ComboboxControl extends Component { updateSuggestions() { const { incompleteTokenValue } = this.state; - const inputHasMinimumChars = incompleteTokenValue.trim().length > 1; + const inputHasMinimumChars = + !! incompleteTokenValue && incompleteTokenValue.trim().length > 1; const matchingSuggestions = this.getMatchingSuggestions( incompleteTokenValue ); diff --git a/packages/components/src/form-token-field/token-input.js b/packages/components/src/form-token-field/token-input.js index e2ab6b87468bf..b239320877056 100644 --- a/packages/components/src/form-token-field/token-input.js +++ b/packages/components/src/form-token-field/token-input.js @@ -36,7 +36,7 @@ class TokenInput extends Component { selectedSuggestionIndex, ...props } = this.props; - const size = value.length + 1; + const size = value ? value.length + 1 : 0; return ( Date: Tue, 22 Sep 2020 10:37:40 +0100 Subject: [PATCH 3/9] Replace placeholder with label --- packages/components/src/form-token-field/README.md | 1 - packages/components/src/form-token-field/stories/index.js | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/components/src/form-token-field/README.md b/packages/components/src/form-token-field/README.md index 8d4a984ea11dd..2689d9786913f 100644 --- a/packages/components/src/form-token-field/README.md +++ b/packages/components/src/form-token-field/README.md @@ -65,7 +65,6 @@ const MyFormTokenField = withState( { value={ tokens } suggestions={ suggestions } onChange={ tokens => setState( { tokens } ) } - placeholder="Type a continent" /> ) ); ``` diff --git a/packages/components/src/form-token-field/stories/index.js b/packages/components/src/form-token-field/stories/index.js index 13bb9e531a89e..086ae2ba7c9f2 100644 --- a/packages/components/src/form-token-field/stories/index.js +++ b/packages/components/src/form-token-field/stories/index.js @@ -31,7 +31,7 @@ const FormTokenFieldExample = () => { value={ selectedContinents } suggestions={ continents } onChange={ ( tokens ) => setSelectedContinents( tokens ) } - placeholder="Type a continent" + label="Type a continent" /> ); }; @@ -60,7 +60,7 @@ const FormTokenFieldAsyncExample = () => { suggestions={ availableContinents } onChange={ ( tokens ) => setSelectedContinents( tokens ) } onInputChange={ searchContinents } - placeholder="Type a continent" + label="Type a continent" /> ); }; @@ -90,7 +90,7 @@ const ComboboxExample = () => { suggestions={ availableContinents } onChange={ ( tokens ) => setSelectedContinent( tokens ) } onInputChange={ searchContinents } - placeholder="Type a continent" + label="Type a continent" />

Value: { selectedContinent }

From 785d2567ba3f3d49f863bd4163022625372c18c5 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Thu, 24 Sep 2020 09:57:36 +0100 Subject: [PATCH 4/9] Fix the value reset behavior --- .../src/form-token-field/combobox.js | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/components/src/form-token-field/combobox.js b/packages/components/src/form-token-field/combobox.js index 1781459422d14..9e84591ad0ce7 100644 --- a/packages/components/src/form-token-field/combobox.js +++ b/packages/components/src/form-token-field/combobox.js @@ -155,7 +155,6 @@ class ComboboxControl extends Component { onInputChange( event ) { const text = event.value; - this.setState( { incompleteTokenValue: text }, this.updateSuggestions ); this.props.onInputChange( text ); } @@ -252,8 +251,7 @@ class ComboboxControl extends Component { } updateSuggestions() { - const { incompleteTokenValue } = this.state; - + const { incompleteTokenValue, selectedSuggestion } = this.state; const inputHasMinimumChars = !! incompleteTokenValue && incompleteTokenValue.trim().length > 1; const matchingSuggestions = this.getMatchingSuggestions( @@ -264,10 +262,16 @@ class ComboboxControl extends Component { const newState = { isExpanded: inputHasMinimumChars && hasMatchingSuggestions, }; + + if ( matchingSuggestions.indexOf( selectedSuggestion ) === -1 ) { + newState.selectedSuggestion = null; + } + if ( - matchingSuggestions.indexOf( this.state.selectedSuggestion ) === -1 + ! incompleteTokenValue || + matchingSuggestions.indexOf( this.props.value ) === -1 ) { - newState.selectedSuggestion = null; + this.props.onChange( null ); } this.setState( newState ); @@ -330,7 +334,11 @@ class ComboboxControl extends Component { instanceId, className, } = this.props; - const { isExpanded } = this.state; + const { + isExpanded, + selectedSuggestion, + incompleteTokenValue, + } = this.state; const classes = classnames( className, 'components-form-token-field__input-container', @@ -377,12 +385,12 @@ class ComboboxControl extends Component { Date: Thu, 24 Sep 2020 11:32:38 +0100 Subject: [PATCH 5/9] Use React hooks --- .../components/src/combobox-control/index.js | 295 +++++++++++------- .../src/combobox-control/stories/index.js | 56 ++-- .../src/form-token-field/stories/index.js | 33 -- .../src/form-token-field/suggestions-list.js | 2 +- packages/components/src/index.js | 1 + 5 files changed, 222 insertions(+), 165 deletions(-) diff --git a/packages/components/src/combobox-control/index.js b/packages/components/src/combobox-control/index.js index 6de9decef0b15..bd7863faaa064 100644 --- a/packages/components/src/combobox-control/index.js +++ b/packages/components/src/combobox-control/index.js @@ -1,131 +1,214 @@ -/** - * External dependencies - */ -import { useCombobox } from 'downshift'; -import classnames from 'classnames'; - /** * WordPress dependencies */ -import { chevronDown, check, Icon } from '@wordpress/icons'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { useState, useMemo, useRef, useEffect } from '@wordpress/element'; +import { useInstanceId } from '@wordpress/compose'; +import { ENTER, UP, DOWN, ESCAPE } from '@wordpress/keycodes'; +import { speak } from '@wordpress/a11y'; /** * Internal dependencies */ -import { Button } from '../'; +import TokenInput from '../form-token-field/token-input'; +import SuggestionsList from '../form-token-field/suggestions-list'; -const itemToString = ( item ) => item && item.name; -export default function ComboboxControl( { - className, - hideLabelFromVision, +function ComboboxControl( { + value, label, - options: items, - onInputValueChange: onInputValueChange, - onChange: onSelectedItemChange, - value: _selectedItem, + options, + onChange, + onInputChange: onInputChangeProp = () => {}, + messages = { + selected: __( 'Item selected.' ), + }, } ) { - const { - getLabelProps, - getToggleButtonProps, - getComboboxProps, - getInputProps, - getMenuProps, - getItemProps, - isOpen, - highlightedIndex, - selectedItem, - } = useCombobox( { - initialSelectedItem: items[ 0 ], - items, - itemToString, - onInputValueChange, - onSelectedItemChange, - selectedItem: _selectedItem, - } ); - const menuProps = getMenuProps( { - className: 'components-combobox-control__menu', - } ); - // We need this here, because the null active descendant is not - // fully ARIA compliant. - if ( - menuProps[ 'aria-activedescendant' ] && - menuProps[ 'aria-activedescendant' ].slice( - 0, - 'downshift-null'.length - ) === 'downshift-null' - ) { - delete menuProps[ 'aria-activedescendant' ]; - } + const instanceId = useInstanceId( ComboboxControl ); + const [ selectedSuggestion, setSelectedSuggestion ] = useState( null ); + const [ isExpanded, setIsExpanded ] = useState( false ); + const [ inputValue, setInputValue ] = useState( '' ); + const inputContainer = useRef(); + + const matchingSuggestions = useMemo( () => { + if ( ! inputValue || inputValue.length === 0 ) { + return options.filter( ( option ) => option.value !== value ); + } + const startsWithMatch = []; + const containsMatch = []; + const match = inputValue.toLocaleLowerCase(); + options.forEach( ( option ) => { + const index = option.label.toLocaleLowerCase().indexOf( match ); + if ( index === 0 ) { + startsWithMatch.push( option ); + } else if ( index > 0 ) { + containsMatch.push( option ); + } + } ); + + return startsWithMatch.concat( containsMatch ); + }, [ inputValue, options, value ] ); + + const onSuggestionSelected = ( newSelectedSuggestion ) => { + onChange( newSelectedSuggestion.value ); + speak( messages.selected, 'assertive' ); + setSelectedSuggestion( newSelectedSuggestion ); + setInputValue( selectedSuggestion.label ); + setIsExpanded( false ); + }; + + const handleArrowNavigation = ( offset = 1 ) => { + const index = matchingSuggestions.indexOf( selectedSuggestion ); + let nextIndex = index + offset; + if ( nextIndex < 0 ) { + nextIndex = matchingSuggestions.length - 1; + } else if ( nextIndex >= matchingSuggestions.length ) { + nextIndex = 0; + } + setSelectedSuggestion( matchingSuggestions[ nextIndex ] ); + }; + + const onKeyDown = ( event ) => { + let preventDefault = false; + + switch ( event.keyCode ) { + case ENTER: + if ( selectedSuggestion ) { + onSuggestionSelected( selectedSuggestion ); + preventDefault = true; + } + break; + case UP: + handleArrowNavigation( -1 ); + preventDefault = true; + break; + case DOWN: + handleArrowNavigation( 1 ); + preventDefault = true; + break; + case ESCAPE: + setIsExpanded( false ); + setSelectedSuggestion( null ); + preventDefault = true; + event.stopPropagation(); + break; + default: + break; + } + + if ( preventDefault ) { + event.preventDefault(); + } + }; + + const updateExpandedState = () => { + const inputHasMinimumChars = + !! inputValue && inputValue.trim().length > 1; + setIsExpanded( inputHasMinimumChars ); + }; + + const onFocus = () => { + // Avoid focus loss when touching the container. + // TODO: TokenInput should preferably forward ref + inputContainer.current.input.focus(); + updateExpandedState(); + }; + + const onBlur = () => { + const currentOption = options.find( + ( option ) => option.value === value + ); + setInputValue( currentOption?.label ?? '' ); + setIsExpanded( false ); + }; + + const onInputChange = ( event ) => { + const text = event.value; + setInputValue( text ); + onInputChangeProp( text ); + }; + + // Expand the suggetions + useEffect( updateExpandedState, [ inputValue ] ); + + // Reset the value on change + useEffect( () => { + if ( matchingSuggestions.indexOf( selectedSuggestion ) === -1 ) { + setSelectedSuggestion( null ); + } + if ( ! inputValue || matchingSuggestions.length === 0 ) { + onChange( null ); + } + }, [ matchingSuggestions, inputValue, value ] ); + + // Announcements + useEffect( () => { + const hasMatchingSuggestions = matchingSuggestions.length > 0; + if ( isExpanded ) { + const message = hasMatchingSuggestions + ? sprintf( + /* translators: %d: number of results. */ + _n( + '%d result found, use up and down arrow keys to navigate.', + '%d results found, use up and down arrow keys to navigate.', + matchingSuggestions.length + ), + matchingSuggestions.length + ) + : __( 'No results.' ); + + speak( message, 'assertive' ); + } + }, [ matchingSuggestions, isExpanded ] ); + + // Disable reason: There is no appropriate role which describes the + // input container intended accessible usability. + // TODO: Refactor click detection to use blur to stop propagation. + /* eslint-disable jsx-a11y/no-static-element-interactions */ return (
- { /* eslint-disable-next-line jsx-a11y/label-has-associated-control, jsx-a11y/label-has-for */ }
- - + ) }
-
    - { isOpen && - items.map( ( item, index ) => ( - // eslint-disable-next-line react/jsx-key -
  • - { item === selectedItem && ( - - ) } - { item.name } -
  • - ) ) } -
); + /* eslint-enable jsx-a11y/no-static-element-interactions */ } + +export default ComboboxControl; diff --git a/packages/components/src/combobox-control/stories/index.js b/packages/components/src/combobox-control/stories/index.js index aa621666466fe..b134ca2d24d1a 100644 --- a/packages/components/src/combobox-control/stories/index.js +++ b/packages/components/src/combobox-control/stories/index.js @@ -8,46 +8,52 @@ import { useState } from '@wordpress/element'; */ import ComboboxControl from '../'; -export default { title: 'ComboboxControl', component: ComboboxControl }; +export default { + title: 'Components/ComboboxControl', + component: ComboboxControl, +}; const options = [ { - key: 'small', - name: 'Small', - style: { fontSize: '50%' }, + value: 'small', + label: 'Small', }, { - key: 'normal', - name: 'Normal', - style: { fontSize: '100%' }, + value: 'normal', + label: 'Normal', }, { - key: 'large', - name: 'Large', - style: { fontSize: '200%' }, + value: 'large', + label: 'Large', }, { - key: 'huge', - name: 'Huge', - style: { fontSize: '300%' }, + value: 'huge', + label: 'Huge', }, ]; function ComboboxControlWithState() { const [ filteredOptions, setFilteredOptions ] = useState( options ); + const [ value, setValue ] = useState( null ); + return ( - - setFilteredOptions( - options.filter( ( option ) => - option.name - .toLowerCase() - .startsWith( inputValue.toLowerCase() ) + <> + + setFilteredOptions( + options.filter( ( option ) => + option.label + .toLowerCase() + .startsWith( filter.toLowerCase() ) + ) ) - ) - } - /> + } + /> +

Value: { value }

+ ); } export const _default = () => ; diff --git a/packages/components/src/form-token-field/stories/index.js b/packages/components/src/form-token-field/stories/index.js index 086ae2ba7c9f2..14eaf8e04699e 100644 --- a/packages/components/src/form-token-field/stories/index.js +++ b/packages/components/src/form-token-field/stories/index.js @@ -7,7 +7,6 @@ import { useState } from '@wordpress/element'; * Internal dependencies */ import FormTokenField from '../'; -import ComboboxControl from '../combobox'; export default { title: 'Components/FormTokenField', @@ -68,35 +67,3 @@ const FormTokenFieldAsyncExample = () => { export const _async = () => { return ; }; - -const ComboboxExample = () => { - const [ selectedContinent, setSelectedContinent ] = useState( null ); - const [ availableContinents, setAvailableContinents ] = useState( [] ); - const searchContinents = ( input ) => { - const timeout = setTimeout( () => { - const available = continents.filter( ( continent ) => - continent.toLowerCase().includes( input.toLowerCase() ) - ); - setAvailableContinents( available ); - }, 1000 ); - - return () => clearTimeout( timeout ); - }; - - return ( - <> - setSelectedContinent( tokens ) } - onInputChange={ searchContinents } - label="Type a continent" - /> -

Value: { selectedContinent }

- - ); -}; - -export const _combobox = () => { - return ; -}; diff --git a/packages/components/src/form-token-field/suggestions-list.js b/packages/components/src/form-token-field/suggestions-list.js index a44e263ff79b1..af8a2141352ab 100644 --- a/packages/components/src/form-token-field/suggestions-list.js +++ b/packages/components/src/form-token-field/suggestions-list.js @@ -110,7 +110,7 @@ class SuggestionsList extends Component { id={ `components-form-token-suggestions-${ this.props.instanceId }-${ index }` } role="option" className={ classeName } - key={ suggestion } + key={ this.props.displayTransform( suggestion ) } onMouseDown={ this.handleMouseDown } onClick={ this.handleClick( suggestion ) } onMouseEnter={ this.handleHover( suggestion ) } diff --git a/packages/components/src/index.js b/packages/components/src/index.js index 177bfc406480a..bdb599254ff36 100644 --- a/packages/components/src/index.js +++ b/packages/components/src/index.js @@ -30,6 +30,7 @@ export { default as ClipboardButton } from './clipboard-button'; export { default as ColorIndicator } from './color-indicator'; export { default as ColorPalette } from './color-palette'; export { default as ColorPicker } from './color-picker'; +export { default as ComboboxControl } from './combobox-control'; export { default as CustomSelectControl } from './custom-select-control'; export { default as Dashicon } from './dashicon'; export { DateTimePicker, DatePicker, TimePicker } from './date-time'; From 3a19944a033088d3036f1068c7e6b43a0df64f9e Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Thu, 24 Sep 2020 11:48:53 +0100 Subject: [PATCH 6/9] Add a bigger data set --- .../components/src/combobox-control/index.js | 1 + .../src/combobox-control/stories/index.js | 287 ++++++++++++++++-- 2 files changed, 263 insertions(+), 25 deletions(-) diff --git a/packages/components/src/combobox-control/index.js b/packages/components/src/combobox-control/index.js index bd7863faaa064..210c3ac2af090 100644 --- a/packages/components/src/combobox-control/index.js +++ b/packages/components/src/combobox-control/index.js @@ -203,6 +203,7 @@ function ComboboxControl( { ) } onHover={ setSelectedSuggestion } onSelect={ onSuggestionSelected } + scrollIntoView /> ) } diff --git a/packages/components/src/combobox-control/stories/index.js b/packages/components/src/combobox-control/stories/index.js index b134ca2d24d1a..9cb120fb382c4 100644 --- a/packages/components/src/combobox-control/stories/index.js +++ b/packages/components/src/combobox-control/stories/index.js @@ -8,31 +8,265 @@ import { useState } from '@wordpress/element'; */ import ComboboxControl from '../'; +const countries = [ + { name: 'Afghanistan', code: 'AF' }, + { name: 'Ă…land Islands', code: 'AX' }, + { name: 'Albania', code: 'AL' }, + { name: 'Algeria', code: 'DZ' }, + { name: 'American Samoa', code: 'AS' }, + { name: 'AndorrA', code: 'AD' }, + { name: 'Angola', code: 'AO' }, + { name: 'Anguilla', code: 'AI' }, + { name: 'Antarctica', code: 'AQ' }, + { name: 'Antigua and Barbuda', code: 'AG' }, + { name: 'Argentina', code: 'AR' }, + { name: 'Armenia', code: 'AM' }, + { name: 'Aruba', code: 'AW' }, + { name: 'Australia', code: 'AU' }, + { name: 'Austria', code: 'AT' }, + { name: 'Azerbaijan', code: 'AZ' }, + { name: 'Bahamas', code: 'BS' }, + { name: 'Bahrain', code: 'BH' }, + { name: 'Bangladesh', code: 'BD' }, + { name: 'Barbados', code: 'BB' }, + { name: 'Belarus', code: 'BY' }, + { name: 'Belgium', code: 'BE' }, + { name: 'Belize', code: 'BZ' }, + { name: 'Benin', code: 'BJ' }, + { name: 'Bermuda', code: 'BM' }, + { name: 'Bhutan', code: 'BT' }, + { name: 'Bolivia', code: 'BO' }, + { name: 'Bosnia and Herzegovina', code: 'BA' }, + { name: 'Botswana', code: 'BW' }, + { name: 'Bouvet Island', code: 'BV' }, + { name: 'Brazil', code: 'BR' }, + { name: 'British Indian Ocean Territory', code: 'IO' }, + { name: 'Brunei Darussalam', code: 'BN' }, + { name: 'Bulgaria', code: 'BG' }, + { name: 'Burkina Faso', code: 'BF' }, + { name: 'Burundi', code: 'BI' }, + { name: 'Cambodia', code: 'KH' }, + { name: 'Cameroon', code: 'CM' }, + { name: 'Canada', code: 'CA' }, + { name: 'Cape Verde', code: 'CV' }, + { name: 'Cayman Islands', code: 'KY' }, + { name: 'Central African Republic', code: 'CF' }, + { name: 'Chad', code: 'TD' }, + { name: 'Chile', code: 'CL' }, + { name: 'China', code: 'CN' }, + { name: 'Christmas Island', code: 'CX' }, + { name: 'Cocos (Keeling) Islands', code: 'CC' }, + { name: 'Colombia', code: 'CO' }, + { name: 'Comoros', code: 'KM' }, + { name: 'Congo', code: 'CG' }, + { name: 'Congo, The Democratic Republic of the', code: 'CD' }, + { name: 'Cook Islands', code: 'CK' }, + { name: 'Costa Rica', code: 'CR' }, + { name: "Cote D'Ivoire", code: 'CI' }, + { name: 'Croatia', code: 'HR' }, + { name: 'Cuba', code: 'CU' }, + { name: 'Cyprus', code: 'CY' }, + { name: 'Czech Republic', code: 'CZ' }, + { name: 'Denmark', code: 'DK' }, + { name: 'Djibouti', code: 'DJ' }, + { name: 'Dominica', code: 'DM' }, + { name: 'Dominican Republic', code: 'DO' }, + { name: 'Ecuador', code: 'EC' }, + { name: 'Egypt', code: 'EG' }, + { name: 'El Salvador', code: 'SV' }, + { name: 'Equatorial Guinea', code: 'GQ' }, + { name: 'Eritrea', code: 'ER' }, + { name: 'Estonia', code: 'EE' }, + { name: 'Ethiopia', code: 'ET' }, + { name: 'Falkland Islands (Malvinas)', code: 'FK' }, + { name: 'Faroe Islands', code: 'FO' }, + { name: 'Fiji', code: 'FJ' }, + { name: 'Finland', code: 'FI' }, + { name: 'France', code: 'FR' }, + { name: 'French Guiana', code: 'GF' }, + { name: 'French Polynesia', code: 'PF' }, + { name: 'French Southern Territories', code: 'TF' }, + { name: 'Gabon', code: 'GA' }, + { name: 'Gambia', code: 'GM' }, + { name: 'Georgia', code: 'GE' }, + { name: 'Germany', code: 'DE' }, + { name: 'Ghana', code: 'GH' }, + { name: 'Gibraltar', code: 'GI' }, + { name: 'Greece', code: 'GR' }, + { name: 'Greenland', code: 'GL' }, + { name: 'Grenada', code: 'GD' }, + { name: 'Guadeloupe', code: 'GP' }, + { name: 'Guam', code: 'GU' }, + { name: 'Guatemala', code: 'GT' }, + { name: 'Guernsey', code: 'GG' }, + { name: 'Guinea', code: 'GN' }, + { name: 'Guinea-Bissau', code: 'GW' }, + { name: 'Guyana', code: 'GY' }, + { name: 'Haiti', code: 'HT' }, + { name: 'Heard Island and Mcdonald Islands', code: 'HM' }, + { name: 'Holy See (Vatican City State)', code: 'VA' }, + { name: 'Honduras', code: 'HN' }, + { name: 'Hong Kong', code: 'HK' }, + { name: 'Hungary', code: 'HU' }, + { name: 'Iceland', code: 'IS' }, + { name: 'India', code: 'IN' }, + { name: 'Indonesia', code: 'ID' }, + { name: 'Iran, Islamic Republic Of', code: 'IR' }, + { name: 'Iraq', code: 'IQ' }, + { name: 'Ireland', code: 'IE' }, + { name: 'Isle of Man', code: 'IM' }, + { name: 'Israel', code: 'IL' }, + { name: 'Italy', code: 'IT' }, + { name: 'Jamaica', code: 'JM' }, + { name: 'Japan', code: 'JP' }, + { name: 'Jersey', code: 'JE' }, + { name: 'Jordan', code: 'JO' }, + { name: 'Kazakhstan', code: 'KZ' }, + { name: 'Kenya', code: 'KE' }, + { name: 'Kiribati', code: 'KI' }, + { name: "Korea, Democratic People'S Republic of", code: 'KP' }, + { name: 'Korea, Republic of', code: 'KR' }, + { name: 'Kuwait', code: 'KW' }, + { name: 'Kyrgyzstan', code: 'KG' }, + { name: "Lao People'S Democratic Republic", code: 'LA' }, + { name: 'Latvia', code: 'LV' }, + { name: 'Lebanon', code: 'LB' }, + { name: 'Lesotho', code: 'LS' }, + { name: 'Liberia', code: 'LR' }, + { name: 'Libyan Arab Jamahiriya', code: 'LY' }, + { name: 'Liechtenstein', code: 'LI' }, + { name: 'Lithuania', code: 'LT' }, + { name: 'Luxembourg', code: 'LU' }, + { name: 'Macao', code: 'MO' }, + { name: 'Macedonia, The Former Yugoslav Republic of', code: 'MK' }, + { name: 'Madagascar', code: 'MG' }, + { name: 'Malawi', code: 'MW' }, + { name: 'Malaysia', code: 'MY' }, + { name: 'Maldives', code: 'MV' }, + { name: 'Mali', code: 'ML' }, + { name: 'Malta', code: 'MT' }, + { name: 'Marshall Islands', code: 'MH' }, + { name: 'Martinique', code: 'MQ' }, + { name: 'Mauritania', code: 'MR' }, + { name: 'Mauritius', code: 'MU' }, + { name: 'Mayotte', code: 'YT' }, + { name: 'Mexico', code: 'MX' }, + { name: 'Micronesia, Federated States of', code: 'FM' }, + { name: 'Moldova, Republic of', code: 'MD' }, + { name: 'Monaco', code: 'MC' }, + { name: 'Mongolia', code: 'MN' }, + { name: 'Montserrat', code: 'MS' }, + { name: 'Morocco', code: 'MA' }, + { name: 'Mozambique', code: 'MZ' }, + { name: 'Myanmar', code: 'MM' }, + { name: 'Namibia', code: 'NA' }, + { name: 'Nauru', code: 'NR' }, + { name: 'Nepal', code: 'NP' }, + { name: 'Netherlands', code: 'NL' }, + { name: 'Netherlands Antilles', code: 'AN' }, + { name: 'New Caledonia', code: 'NC' }, + { name: 'New Zealand', code: 'NZ' }, + { name: 'Nicaragua', code: 'NI' }, + { name: 'Niger', code: 'NE' }, + { name: 'Nigeria', code: 'NG' }, + { name: 'Niue', code: 'NU' }, + { name: 'Norfolk Island', code: 'NF' }, + { name: 'Northern Mariana Islands', code: 'MP' }, + { name: 'Norway', code: 'NO' }, + { name: 'Oman', code: 'OM' }, + { name: 'Pakistan', code: 'PK' }, + { name: 'Palau', code: 'PW' }, + { name: 'Palestinian Territory, Occupied', code: 'PS' }, + { name: 'Panama', code: 'PA' }, + { name: 'Papua New Guinea', code: 'PG' }, + { name: 'Paraguay', code: 'PY' }, + { name: 'Peru', code: 'PE' }, + { name: 'Philippines', code: 'PH' }, + { name: 'Pitcairn', code: 'PN' }, + { name: 'Poland', code: 'PL' }, + { name: 'Portugal', code: 'PT' }, + { name: 'Puerto Rico', code: 'PR' }, + { name: 'Qatar', code: 'QA' }, + { name: 'Reunion', code: 'RE' }, + { name: 'Romania', code: 'RO' }, + { name: 'Russian Federation', code: 'RU' }, + { name: 'RWANDA', code: 'RW' }, + { name: 'Saint Helena', code: 'SH' }, + { name: 'Saint Kitts and Nevis', code: 'KN' }, + { name: 'Saint Lucia', code: 'LC' }, + { name: 'Saint Pierre and Miquelon', code: 'PM' }, + { name: 'Saint Vincent and the Grenadines', code: 'VC' }, + { name: 'Samoa', code: 'WS' }, + { name: 'San Marino', code: 'SM' }, + { name: 'Sao Tome and Principe', code: 'ST' }, + { name: 'Saudi Arabia', code: 'SA' }, + { name: 'Senegal', code: 'SN' }, + { name: 'Serbia and Montenegro', code: 'CS' }, + { name: 'Seychelles', code: 'SC' }, + { name: 'Sierra Leone', code: 'SL' }, + { name: 'Singapore', code: 'SG' }, + { name: 'Slovakia', code: 'SK' }, + { name: 'Slovenia', code: 'SI' }, + { name: 'Solomon Islands', code: 'SB' }, + { name: 'Somalia', code: 'SO' }, + { name: 'South Africa', code: 'ZA' }, + { name: 'South Georgia and the South Sandwich Islands', code: 'GS' }, + { name: 'Spain', code: 'ES' }, + { name: 'Sri Lanka', code: 'LK' }, + { name: 'Sudan', code: 'SD' }, + { name: 'Suriname', code: 'SR' }, + { name: 'Svalbard and Jan Mayen', code: 'SJ' }, + { name: 'Swaziland', code: 'SZ' }, + { name: 'Sweden', code: 'SE' }, + { name: 'Switzerland', code: 'CH' }, + { name: 'Syrian Arab Republic', code: 'SY' }, + { name: 'Taiwan, Province of China', code: 'TW' }, + { name: 'Tajikistan', code: 'TJ' }, + { name: 'Tanzania, United Republic of', code: 'TZ' }, + { name: 'Thailand', code: 'TH' }, + { name: 'Timor-Leste', code: 'TL' }, + { name: 'Togo', code: 'TG' }, + { name: 'Tokelau', code: 'TK' }, + { name: 'Tonga', code: 'TO' }, + { name: 'Trinidad and Tobago', code: 'TT' }, + { name: 'Tunisia', code: 'TN' }, + { name: 'Turkey', code: 'TR' }, + { name: 'Turkmenistan', code: 'TM' }, + { name: 'Turks and Caicos Islands', code: 'TC' }, + { name: 'Tuvalu', code: 'TV' }, + { name: 'Uganda', code: 'UG' }, + { name: 'Ukraine', code: 'UA' }, + { name: 'United Arab Emirates', code: 'AE' }, + { name: 'United Kingdom', code: 'GB' }, + { name: 'United States', code: 'US' }, + { name: 'United States Minor Outlying Islands', code: 'UM' }, + { name: 'Uruguay', code: 'UY' }, + { name: 'Uzbekistan', code: 'UZ' }, + { name: 'Vanuatu', code: 'VU' }, + { name: 'Venezuela', code: 'VE' }, + { name: 'Viet Nam', code: 'VN' }, + { name: 'Virgin Islands, British', code: 'VG' }, + { name: 'Virgin Islands, U.S.', code: 'VI' }, + { name: 'Wallis and Futuna', code: 'WF' }, + { name: 'Western Sahara', code: 'EH' }, + { name: 'Yemen', code: 'YE' }, + { name: 'Zambia', code: 'ZM' }, + { name: 'Zimbabwe', code: 'ZW' }, +]; + export default { title: 'Components/ComboboxControl', component: ComboboxControl, }; -const options = [ - { - value: 'small', - label: 'Small', - }, - { - value: 'normal', - label: 'Normal', - }, - { - value: 'large', - label: 'Large', - }, - { - value: 'huge', - label: 'Huge', - }, -]; function ComboboxControlWithState() { - const [ filteredOptions, setFilteredOptions ] = useState( options ); + const mapCountryOption = ( country ) => ( { + value: country.code, + label: country.name, + } ); + const [ filteredOptions, setFilteredOptions ] = useState( + countries.map( mapCountryOption ) + ); const [ value, setValue ] = useState( null ); return ( @@ -40,15 +274,18 @@ function ComboboxControlWithState() { setFilteredOptions( - options.filter( ( option ) => - option.label - .toLowerCase() - .startsWith( filter.toLowerCase() ) - ) + countries + .filter( ( country ) => + country.name + .toLowerCase() + .startsWith( filter.toLowerCase() ) + ) + .split( 0, 20 ) + .map( mapCountryOption ) ) } /> From f8fceadba4cd934b06bdf3b3d2961a447f4493be Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Fri, 25 Sep 2020 11:42:30 +0100 Subject: [PATCH 7/9] Polisht the README of the component --- .../components/src/combobox-control/README.md | 81 +++++-------------- 1 file changed, 22 insertions(+), 59 deletions(-) diff --git a/packages/components/src/combobox-control/README.md b/packages/components/src/combobox-control/README.md index f888a3211e364..1dfbd7532b5c6 100644 --- a/packages/components/src/combobox-control/README.md +++ b/packages/components/src/combobox-control/README.md @@ -1,6 +1,6 @@ # ComboboxControl -`ComboboxControl` is an enhanced version of a [`CustomSelectControl`](/packages/components/src/custom-select-control/readme.md), with the addition of being able to search for options using a search input. +`ComboboxControl` is an enhanced version of a [`SelectControl`](/packages/components/src/select-control/readme.md), with the addition of being able to search for options using a search input. ## Table of contents @@ -10,7 +10,7 @@ ## Design guidelines -These are the same as [the ones for `CustomSelectControl`s](/packages/components/src/select-control/readme.md#design-guidelines), but this component is better suited for when there are too many items to scroll through or load at once so you need to filter them based on user input. +These are the same as [the ones for `SelectControl`s](/packages/components/src/select-control/readme.md#design-guidelines), but this component is better suited for when there are too many items to scroll through or load at once so you need to filter them based on user input. ## Development guidelines @@ -25,62 +25,39 @@ import { useState } from "@wordpress/compose"; const options = [ { - key: "small", - name: "Small", - style: { fontSize: "50%" } + value: "small", + label: "Small" }, { - key: "normal", - name: "Normal", - style: { fontSize: "100%" } + value: "normal", + label: "Normal" }, { - key: "large", - name: "Large", - style: { fontSize: "200%" } + value: "large", + label: "Large" }, { - key: "huge", - name: "Huge", - style: { fontSize: "300%" } + value: "huge", + label: "Huge" } ]; function MyComboboxControl() { - const [, setFontSize] = useState(); + const [fontSize, setFontSize] = useState(); const [filteredOptions, setFilteredOptions] = useState(options); return ( + onInputChange={(inputValue) => setFilteredOptions( options.filter(option => - option.name.toLowerCase().startsWith(inputValue.toLowerCase()) + option.label.toLowerCase().startsWith(inputValue.toLowerCase()) ) ) } - onChange={({ selectedItem }) => setFontSize(selectedItem)} - /> - ); -} - -function MyControlledComboboxControl() { - const [fontSize, setFontSize] = useState(options[0]); - const [filteredOptions, setFilteredOptions] = useState(options); - return ( - - setFilteredOptions( - options.filter(option => - option.name.toLowerCase().startsWith(inputValue.toLowerCase()) - ) - ) - } - onChange={({ selectedItem }) => setFontSize(selectedItem)} - value={options.find(option => option.key === fontSize.key)} /> ); } @@ -88,20 +65,6 @@ function MyControlledComboboxControl() { ### Props -#### className - -A custom class name to append to the outer `
`. - -- Type: `String` -- Required: No - -#### hideLabelFromVision - -Used to visually hide the label. It will always be visible to screen readers. - -- Type: `Boolean` -- Required: No - #### label The label for the control. @@ -113,29 +76,29 @@ The label for the control. The options that can be chosen from. -- Type: `Array<{ key: String, name: String, style: ?{}, ...rest }>` +- Type: `Array<{ value: String, label: String }>` - Required: Yes -#### onInputValueChange +#### onInputChange -Function called with the control's search input value changes. The `inputValue` property contains the next input value. +Function called with the control's search input value changes. The argument contains the next input value. - Type: `Function` - Required: No #### onChange -Function called with the control's internal state changes. The `selectedItem` property contains the next selected item. +Function called with the selected value changes. - Type: `Function` - Required: No #### value -Can be used to externally control the value of the control, like in the `MyControlledComboboxControl` example above. +The current value of the input. -- Type: `Object` -- Required: No +- Type: `mixed` +- Required: Yes ## Related components From d2de11068d2f8790dc6e2d60260524215ad149e0 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Fri, 25 Sep 2020 13:38:00 +0100 Subject: [PATCH 8/9] Reuse BaseControl and simplify styles to match other controls --- .../components/src/combobox-control/README.md | 12 +++ .../components/src/combobox-control/index.js | 24 +++--- .../src/combobox-control/style.scss | 74 +++++-------------- .../src/form-token-field/token-input.js | 11 ++- 4 files changed, 52 insertions(+), 69 deletions(-) diff --git a/packages/components/src/combobox-control/README.md b/packages/components/src/combobox-control/README.md index 1dfbd7532b5c6..b0be706b7dc2b 100644 --- a/packages/components/src/combobox-control/README.md +++ b/packages/components/src/combobox-control/README.md @@ -72,6 +72,18 @@ The label for the control. - Type: `String` - Required: Yes +#### hideLabelFromVision +If true, the label will only be visible to screen readers. + +- Type: `Boolean` +- Required: No + +#### help +If this property is added, a help text will be generated using help property as the content. + +- Type: `String` +- Required: No + #### options The options that can be chosen from. diff --git a/packages/components/src/combobox-control/index.js b/packages/components/src/combobox-control/index.js index 210c3ac2af090..e6d9efa5245a9 100644 --- a/packages/components/src/combobox-control/index.js +++ b/packages/components/src/combobox-control/index.js @@ -12,6 +12,7 @@ import { speak } from '@wordpress/a11y'; */ import TokenInput from '../form-token-field/token-input'; import SuggestionsList from '../form-token-field/suggestions-list'; +import BaseControl from '../base-control'; function ComboboxControl( { value, @@ -19,6 +20,8 @@ function ComboboxControl( { options, onChange, onInputChange: onInputChangeProp = () => {}, + hideLabelFromVision, + help, messages = { selected: __( 'Item selected.' ), }, @@ -165,23 +168,22 @@ function ComboboxControl( { // TODO: Refactor click detection to use blur to stop propagation. /* eslint-disable jsx-a11y/no-static-element-interactions */ return ( -
-
) }
-
+ ); /* eslint-enable jsx-a11y/no-static-element-interactions */ } diff --git a/packages/components/src/combobox-control/style.scss b/packages/components/src/combobox-control/style.scss index 57ceff9e1befd..7015bca4e3a09 100644 --- a/packages/components/src/combobox-control/style.scss +++ b/packages/components/src/combobox-control/style.scss @@ -1,68 +1,28 @@ .components-combobox-control { - position: relative; + width: 100%; } -.components-combobox-control__label { - display: block; - margin-bottom: 5px; -} - -.components-combobox-control__button { - border: 1px solid $gray-600; - border-radius: 4px; - display: inline-block; - min-height: 30px; - min-width: 130px; - position: relative; - text-align: left; +.components-combobox-control__input { + width: 100%; + border: none; + box-shadow: none; &:focus { - border-color: var(--wp-admin-theme-color); - } - - &-input { - border: none; - height: calc(100% - 2px); - left: 1px; - padding: 0 4px; - position: absolute; - top: 1px; - width: calc(100% - 2px); + outline: none; + box-shadow: none; } - - &-button:hover { - box-shadow: none !important; - } - - &-icon { - height: 100%; - padding: 0 4px; - position: absolute; - right: 0; - top: 0; - } -} - -.components-combobox-control__menu { - background: $white; - min-width: 100%; - padding: 0; - position: absolute; - z-index: z-index(".components-popover"); } -.components-combobox-control__item { - align-items: center; +.components-combobox-control__suggestions-container { + @include input-control; display: flex; - list-style-type: none; - padding: 10px 5px 10px 25px; - - &.is-highlighted { - background: $gray-300; - } - - &-icon { - margin-left: -20px; - margin-right: 0; + flex-wrap: wrap; + align-items: flex-start; + width: 100%; + margin: 0 0 $grid-unit-10 0; + padding: $grid-unit-05; + + &:focus-within { + @include input-style__focus(); } } diff --git a/packages/components/src/form-token-field/token-input.js b/packages/components/src/form-token-field/token-input.js index b239320877056..4f385f7c3add0 100644 --- a/packages/components/src/form-token-field/token-input.js +++ b/packages/components/src/form-token-field/token-input.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ @@ -34,6 +39,7 @@ class TokenInput extends Component { isExpanded, instanceId, selectedSuggestionIndex, + className, ...props } = this.props; const size = value ? value.length + 1 : 0; @@ -47,7 +53,10 @@ class TokenInput extends Component { value={ value || '' } onChange={ this.onChange } size={ size } - className="components-form-token-field__input" + className={ classnames( + className, + 'components-form-token-field__input' + ) } role="combobox" aria-expanded={ isExpanded } aria-autocomplete="list" From 379e755f22cae49e306ea6fa77b5f64b90d6698e Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Mon, 28 Sep 2020 08:57:52 +0100 Subject: [PATCH 9/9] Tweak interactions to allow selecting from results without typing --- packages/components/src/combobox-control/index.js | 13 +++---------- .../src/combobox-control/stories/index.js | 2 +- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/components/src/combobox-control/index.js b/packages/components/src/combobox-control/index.js index e6d9efa5245a9..e3ee035f86fdf 100644 --- a/packages/components/src/combobox-control/index.js +++ b/packages/components/src/combobox-control/index.js @@ -68,6 +68,7 @@ function ComboboxControl( { nextIndex = 0; } setSelectedSuggestion( matchingSuggestions[ nextIndex ] ); + setIsExpanded( true ); }; const onKeyDown = ( event ) => { @@ -103,17 +104,11 @@ function ComboboxControl( { } }; - const updateExpandedState = () => { - const inputHasMinimumChars = - !! inputValue && inputValue.trim().length > 1; - setIsExpanded( inputHasMinimumChars ); - }; - const onFocus = () => { // Avoid focus loss when touching the container. // TODO: TokenInput should preferably forward ref inputContainer.current.input.focus(); - updateExpandedState(); + setIsExpanded( true ); }; const onBlur = () => { @@ -128,11 +123,9 @@ function ComboboxControl( { const text = event.value; setInputValue( text ); onInputChangeProp( text ); + setIsExpanded( true ); }; - // Expand the suggetions - useEffect( updateExpandedState, [ inputValue ] ); - // Reset the value on change useEffect( () => { if ( matchingSuggestions.indexOf( selectedSuggestion ) === -1 ) { diff --git a/packages/components/src/combobox-control/stories/index.js b/packages/components/src/combobox-control/stories/index.js index 9cb120fb382c4..dbe474fb2b6d0 100644 --- a/packages/components/src/combobox-control/stories/index.js +++ b/packages/components/src/combobox-control/stories/index.js @@ -284,7 +284,7 @@ function ComboboxControlWithState() { .toLowerCase() .startsWith( filter.toLowerCase() ) ) - .split( 0, 20 ) + .slice( 0, 20 ) .map( mapCountryOption ) ) }