/** @format */ /** * External dependencies */ import { Button } from '@wordpress/components'; import classnames from 'classnames'; import { Component, createRef } from '@wordpress/element'; import { isEqual } from 'lodash'; import { ENTER, ESCAPE, UP, DOWN, LEFT, TAB, RIGHT } from '@wordpress/keycodes'; import PropTypes from 'prop-types'; /** * A list box that displays filtered options after search. */ class List extends Component { constructor() { super( ...arguments ); this.handleKeyDown = this.handleKeyDown.bind( this ); this.select = this.select.bind( this ); this.optionRefs = {}; this.listbox = createRef(); } componentDidUpdate( prevProps ) { const { filteredOptions } = this.props; // Remove old option refs to avoid memory leaks. if ( ! isEqual( filteredOptions, prevProps.filteredOptions ) ) { this.optionRefs = {}; } } getOptionRef( index ) { if ( ! this.optionRefs.hasOwnProperty( index ) ) { this.optionRefs[ index ] = createRef(); } return this.optionRefs[ index ]; } select( option ) { const { onSelect } = this.props; if ( option.isDisabled ) { return; } onSelect( option ); } scrollToOption( index ) { const listbox = this.listbox.current; if ( listbox.scrollHeight > listbox.clientHeight ) { const option = this.optionRefs[ index ].current; const scrollBottom = listbox.clientHeight + listbox.scrollTop; const elementBottom = option.offsetTop + option.offsetHeight; if ( elementBottom > scrollBottom ) { listbox.scrollTop = elementBottom - listbox.clientHeight; } else if ( option.offsetTop < listbox.scrollTop ) { listbox.scrollTop = option.offsetTop; } } } handleKeyDown( event ) { const { filteredOptions, onChange, onSearch, selectedIndex } = this.props; if ( filteredOptions.length === 0 ) { return; } let nextSelectedIndex; switch ( event.keyCode ) { case UP: nextSelectedIndex = null !== selectedIndex ? ( selectedIndex === 0 ? filteredOptions.length : selectedIndex ) - 1 : filteredOptions.length - 1; onChange( nextSelectedIndex ); this.scrollToOption( nextSelectedIndex ); event.preventDefault(); event.stopPropagation(); break; case TAB: case DOWN: nextSelectedIndex = null !== selectedIndex ? ( selectedIndex + 1 ) % filteredOptions.length : 0; onChange( nextSelectedIndex ); this.scrollToOption( nextSelectedIndex ); event.preventDefault(); event.stopPropagation(); break; case ENTER: this.select( filteredOptions[ selectedIndex ] ); event.preventDefault(); event.stopPropagation(); break; case LEFT: case RIGHT: onChange( null ); break; case ESCAPE: onChange( null ); onSearch( null ); return; default: return; } } toggleKeyEvents( isListening ) { const { node } = this.props; // 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'; node[ handler ]( 'keydown', this.handleKeyDown, true ); } componentDidMount() { this.toggleKeyEvents( true ); } componentWillUnmount() { this.toggleKeyEvents( false ); } render() { const { filteredOptions, instanceId, listboxId, selectedIndex, staticList } = this.props; const listboxClasses = classnames( 'woocommerce-autocomplete__listbox', { 'is-static': staticList, } ); return (