summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/jetpack/extensions/blocks/map/lookup/index.js')
-rw-r--r--plugins/jetpack/extensions/blocks/map/lookup/index.js230
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 );