304 lines
9.1 KiB
JavaScript
304 lines
9.1 KiB
JavaScript
/** @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 (
|
|
<div ref={ this.bindNode } className="woocommerce-search__autocomplete">
|
|
{ children( { isExpanded, listBoxId, activeId, onChange: this.search } ) }
|
|
{ isExpanded && (
|
|
<div id={ listBoxId } role="listbox" className="woocommerce-search__autocomplete-results">
|
|
{ isExpanded &&
|
|
map( filteredOptions, ( option, index ) => (
|
|
<Button
|
|
key={ option.key }
|
|
id={ `woocommerce-search__autocomplete-${ instanceId }-${ option.key }` }
|
|
role="option"
|
|
aria-selected={ index === selectedIndex }
|
|
disabled={ option.isDisabled }
|
|
className={ classnames( 'woocommerce-search__autocomplete-result', className, {
|
|
'is-selected': index === selectedIndex,
|
|
} ) }
|
|
onClick={ () => this.select( option ) }
|
|
>
|
|
{ option.label }
|
|
</Button>
|
|
) ) }
|
|
</div>
|
|
) }
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
export default compose( [
|
|
withSpokenMessages,
|
|
withInstanceId,
|
|
withFocusOutside, // this MUST be the innermost HOC as it calls handleFocusOutside
|
|
] )( Autocomplete );
|