diff options
Diffstat (limited to 'plugins/jetpack/extensions/blocks/map/lookup/index.js')
-rw-r--r-- | plugins/jetpack/extensions/blocks/map/lookup/index.js | 230 |
1 files changed, 230 insertions, 0 deletions
diff --git a/plugins/jetpack/extensions/blocks/map/lookup/index.js b/plugins/jetpack/extensions/blocks/map/lookup/index.js new file mode 100644 index 00000000..c9d41969 --- /dev/null +++ b/plugins/jetpack/extensions/blocks/map/lookup/index.js @@ -0,0 +1,230 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { Button, Popover, withFocusOutside, withSpokenMessages } from '@wordpress/components'; +import { Component } from '@wordpress/element'; +import { debounce, map } from 'lodash'; +import { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT } from '@wordpress/keycodes'; +import { withInstanceId, compose } from '@wordpress/compose'; + +function filterOptions( options = [], maxResults = 10 ) { + const filtered = []; + for ( let i = 0; i < options.length; i++ ) { + const option = options[ i ]; + + // Merge label into keywords + let { keywords = [] } = option; + if ( 'string' === typeof option.label ) { + keywords = [ ...keywords, option.label ]; + } + + filtered.push( option ); + + // Abort early if max reached + if ( filtered.length === maxResults ) { + break; + } + } + + return filtered; +} + +export class Lookup extends Component { + static getInitialState() { + return { + selectedIndex: 0, + query: undefined, + filteredOptions: [], + isOpen: false, + }; + } + + constructor() { + super( ...arguments ); + this.debouncedLoadOptions = debounce( this.loadOptions, 250 ); + this.state = this.constructor.getInitialState(); + } + + componentWillUnmount() { + this.debouncedLoadOptions.cancel(); + } + + select = option => { + const { completer } = this.props; + const getOptionCompletion = completer.getOptionCompletion || {}; + getOptionCompletion( option ); + this.reset(); + }; + + reset = () => { + this.setState( this.constructor.getInitialState() ); + }; + + handleFocusOutside() { + this.reset(); + } + + loadOptions( completer, query ) { + const { options } = completer; + const promise = ( this.activePromise = Promise.resolve( + typeof options === 'function' ? options( query ) : options + ).then( optionsData => { + if ( promise !== this.activePromise ) { + // Another promise has become active since this one was asked to resolve, so do nothing, + // or else we might end triggering a race condition updating the state. + return; + } + const keyedOptions = optionsData.map( ( optionData, optionIndex ) => ( { + key: `${ optionIndex }`, + value: optionData, + label: completer.getOptionLabel( optionData ), + keywords: completer.getOptionKeywords ? completer.getOptionKeywords( optionData ) : [], + } ) ); + + const filteredOptions = filterOptions( keyedOptions ); + const selectedIndex = + filteredOptions.length === this.state.filteredOptions.length ? this.state.selectedIndex : 0; + this.setState( { + [ 'options' ]: keyedOptions, + filteredOptions, + selectedIndex, + isOpen: filteredOptions.length > 0, + } ); + this.announce( filteredOptions ); + } ) ); + } + + onChange = query => { + const { completer } = this.props; + const { options } = this.state; + + if ( ! query ) { + this.reset(); + return; + } + + if ( completer ) { + if ( completer.isDebounced ) { + this.debouncedLoadOptions( completer, query ); + } else { + this.loadOptions( completer, query ); + } + } + + const filteredOptions = completer ? filterOptions( options ) : []; + if ( completer ) { + this.setState( { selectedIndex: 0, filteredOptions, query } ); + } + }; + + onKeyDown = event => { + const { isOpen, selectedIndex, filteredOptions } = this.state; + if ( ! isOpen ) { + return; + } + let nextSelectedIndex; + switch ( event.keyCode ) { + case UP: + nextSelectedIndex = ( selectedIndex === 0 ? filteredOptions.length : selectedIndex ) - 1; + this.setState( { selectedIndex: nextSelectedIndex } ); + break; + + case DOWN: + nextSelectedIndex = ( selectedIndex + 1 ) % filteredOptions.length; + this.setState( { selectedIndex: nextSelectedIndex } ); + break; + + case ENTER: + this.select( filteredOptions[ selectedIndex ] ); + break; + + case LEFT: + case RIGHT: + case ESCAPE: + this.reset(); + return; + + default: + return; + } + + // Any handled keycode should prevent original behavior. This relies on + // the early return in the default case. + event.preventDefault(); + event.stopPropagation(); + }; + announce( filteredOptions ) { + const { debouncedSpeak } = this.props; + if ( ! debouncedSpeak ) { + return; + } + if ( filteredOptions.length ) { + debouncedSpeak( + sprintf( + _n( + '%d result found, use up and down arrow keys to navigate.', + '%d results found, use up and down arrow keys to navigate.', + filteredOptions.length, + 'jetpack', + 'jetpack' + ), + filteredOptions.length + ), + 'assertive' + ); + } else { + debouncedSpeak( __( 'No results.', 'jetpack' ), 'assertive' ); + } + } + render() { + const { onChange, onKeyDown } = this; + const { children, instanceId, completer } = this.props; + const { selectedIndex, filteredOptions } = this.state; + const { key: selectedKey = '' } = filteredOptions[ selectedIndex ] || {}; + const { className } = completer; + const isExpanded = filteredOptions.length > 0; + const listBoxId = isExpanded ? `components-autocomplete-listbox-${ instanceId }` : null; + const activeId = isExpanded + ? `components-autocomplete-item-${ instanceId }-${ selectedKey }` + : null; + return ( + <div className="components-autocomplete"> + { children( { isExpanded, listBoxId, activeId, onChange, onKeyDown } ) } + { isExpanded && ( + <Popover + focusOnMount={ false } + onClose={ this.reset } + position="top center" + className="components-autocomplete__popover" + noArrow + > + <div id={ listBoxId } role="listbox" className="components-autocomplete__results"> + { map( filteredOptions, ( option, index ) => ( + <Button + key={ option.key } + id={ `components-autocomplete-item-${ instanceId }-${ option.key }` } + role="option" + aria-selected={ index === selectedIndex } + disabled={ option.isDisabled } + className={ classnames( 'components-autocomplete__result', className, { + 'is-selected': index === selectedIndex, + } ) } + onClick={ () => this.select( option ) } + > + { option.label } + </Button> + ) ) } + </div> + </Popover> + ) } + </div> + ); + } +} +export default compose( [ + withSpokenMessages, + withInstanceId, + withFocusOutside, // this MUST be the innermost HOC as it calls handleFocusOutside +] )( Lookup ); |