/** @format */ /** * External dependencies */ import { __, _n, sprintf } from '@wordpress/i18n'; import { Button, withFocusOutside, withSpokenMessages } from '@wordpress/components'; import classnames from 'classnames'; import { Component } from '@wordpress/element'; import { escapeRegExp, map, debounce } from 'lodash'; import { ENTER, ESCAPE, UP, DOWN, LEFT, TAB, RIGHT } from '@wordpress/keycodes'; import { withInstanceId, compose } from '@wordpress/compose'; function filterOptions( search, 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 ]; } const isMatch = keywords.some( keyword => search.test( keyword ) ); if ( ! isMatch ) { continue; } filtered.push( option ); // Abort early if max reached if ( filtered.length === maxResults ) { break; } } return filtered; } export class Autocomplete extends Component { static getInitialState() { return { search: /./, selectedIndex: 0, query: undefined, filteredOptions: [], }; } constructor() { super( ...arguments ); this.bindNode = this.bindNode.bind( this ); this.select = this.select.bind( this ); this.reset = this.reset.bind( this ); this.search = this.search.bind( this ); this.handleKeyDown = this.handleKeyDown.bind( this ); this.debouncedLoadOptions = debounce( this.loadOptions, 250 ); this.state = this.constructor.getInitialState(); } bindNode( node ) { this.node = node; } select( option ) { const { onSelect, completer: { getOptionCompletion } } = this.props; const { query } = this.state; if ( option.isDisabled ) { return; } if ( getOptionCompletion ) { const completion = getOptionCompletion( option.value, query ); onSelect( completion ); } // Reset autocomplete state after insertion rather than before // so insertion events don't cause the completion menu to redisplay. this.reset(); } reset() { const isMounted = !! this.node; // Autocompletions may replace the block containing this component, // so we make sure it is mounted before resetting the state. if ( isMounted ) { this.setState( this.constructor.getInitialState() ); } } handleFocusOutside() { this.reset(); } 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, 'wc-admin' ), filteredOptions.length ), 'assertive' ); } else { debouncedSpeak( __( 'No results.', 'wc-admin' ), 'assertive' ); } } /** * Load options for an autocompleter. * * @param {Completer} completer The autocompleter. * @param {string} query The query, if any. */ loadOptions( completer, query ) { const { options } = completer; /* * We support both synchronous and asynchronous retrieval of completer options * but internally treat all as async so we maintain a single, consistent code path. * * Because networks can be slow, and the internet is wonderfully unpredictable, * we don't want two promises updating the state at once. This ensures that only * the most recent promise will act on `optionsData`. This doesn't use the state * because `setState` is batched, and so there's no guarantee that setting * `activePromise` in the state would result in it actually being in `this.state` * before the promise resolves and we check to see if this is the active promise or not. */ 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, query ), keywords: completer.getOptionKeywords ? completer.getOptionKeywords( optionData ) : [], isDisabled: completer.isOptionDisabled ? completer.isOptionDisabled( optionData ) : false, } ) ); const filteredOptions = filterOptions( this.state.search, keyedOptions ); const selectedIndex = filteredOptions.length === this.state.filteredOptions.length ? this.state.selectedIndex : 0; this.setState( { options: keyedOptions, filteredOptions, selectedIndex, } ); if ( query ) { this.announce( filteredOptions ); } } ) ); } search( event ) { const { query: wasQuery } = this.state; const { completer = {} } = this.props; const container = event.target; // look for the trigger prefix and search query just before the cursor location const query = container.value; // asynchronously load the options for the open completer if ( completer && query !== wasQuery ) { if ( completer.isDebounced ) { this.debouncedLoadOptions( completer, query ); } else { this.loadOptions( completer, query ); } } // create a regular expression to filter the options const search = new RegExp( escapeRegExp( query ), 'i' ); // filter the options we already have const filteredOptions = filterOptions( search, this.state.options ); // update the state this.setState( { selectedIndex: 0, filteredOptions, search, query } ); // announce the count of filtered options but only if they have loaded if ( this.state.options ) { this.announce( filteredOptions ); } } handleKeyDown( event ) { const { selectedIndex, filteredOptions } = this.state; if ( filteredOptions.length === 0 ) { return; } let nextSelectedIndex; switch ( event.keyCode ) { case UP: nextSelectedIndex = ( selectedIndex === 0 ? filteredOptions.length : selectedIndex ) - 1; this.setState( { selectedIndex: nextSelectedIndex } ); break; case TAB: 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(); } toggleKeyEvents( isListening ) { // This exists because we must capture ENTER key presses before RichText. // It seems that react fires the simulated capturing events after the // native browser event has already bubbled so we can't stopPropagation // and avoid RichText getting the event from TinyMCE, hence we must // register a native event handler. const handler = isListening ? 'addEventListener' : 'removeEventListener'; this.node[ handler ]( 'keydown', this.handleKeyDown, true ); } componentDidUpdate( prevProps, prevState ) { const isExpanded = this.state.filteredOptions.length > 0; const wasExpanded = prevState.filteredOptions.length > 0; if ( isExpanded && ! wasExpanded ) { this.toggleKeyEvents( true ); } else if ( ! isExpanded && wasExpanded ) { this.toggleKeyEvents( false ); } } componentWillUnmount() { this.toggleKeyEvents( false ); this.debouncedLoadOptions.cancel(); } render() { const { children, instanceId, completer: { className = '' } } = this.props; const { selectedIndex, filteredOptions, query } = this.state; const { key: selectedKey = '' } = filteredOptions[ selectedIndex ] || {}; const isExpanded = filteredOptions.length > 0 && !! query; const listBoxId = isExpanded ? `woocommerce-search__autocomplete-${ instanceId }` : null; const activeId = isExpanded ? `woocommerce-search__autocomplete-${ instanceId }-${ selectedKey }` : null; return (
{ children( { isExpanded, listBoxId, activeId, onChange: this.search } ) } { isExpanded && (
{ isExpanded && map( filteredOptions, ( option, index ) => ( ) ) }
) }
); } } export default compose( [ withSpokenMessages, withInstanceId, withFocusOutside, // this MUST be the innermost HOC as it calls handleFocusOutside ] )( Autocomplete );