/** * External dependencies */ import { Button } from '@wordpress/components'; import { RefObject } from 'react'; import classnames from 'classnames'; import { createElement, Component, createRef } from '@wordpress/element'; import { isEqual, isNumber } from 'lodash'; import { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT, TAB } from '@wordpress/keycodes'; /** * Internal dependencies */ import { Option } from './types'; type Props = { /** * ID of the main SelectControl instance. */ listboxId?: string; /** * ID used for a11y in the listbox. */ instanceId: number; /** * Parent node to bind keyboard events to. */ node: HTMLElement | null; /** * Function to execute when an option is selected. */ onSelect: ( option: Option ) => void; /** * Array of options to display. */ options: Array< Option >; /** * Integer for the currently selected item. */ selectedIndex: number | null | undefined; /** * Bool to determine if the list should be positioned absolutely or statically. */ staticList: boolean; /** * Function to execute when keyboard navigation should decrement the selected index. */ decrementSelectedIndex: () => void; /** * Function to execute when keyboard navigation should increment the selected index. */ incrementSelectedIndex: () => void; /** * Function to execute when the search value changes. */ onSearch: ( option: string | null ) => void; /** * Function to execute when the list should be expanded or collapsed. */ setExpanded: ( expanded: boolean ) => void; }; /** * A list box that displays filtered options after search. */ class List extends Component< Props > { optionRefs: { [ key: number ]: RefObject< HTMLButtonElement > }; listbox: RefObject< HTMLDivElement >; constructor( props: Props ) { super( props ); this.handleKeyDown = this.handleKeyDown.bind( this ); this.select = this.select.bind( this ); this.optionRefs = {}; this.listbox = createRef(); } componentDidUpdate( prevProps: Props ) { const { options, selectedIndex } = this.props; // Remove old option refs to avoid memory leaks. if ( ! isEqual( options, prevProps.options ) ) { this.optionRefs = {}; } if ( selectedIndex !== prevProps.selectedIndex && isNumber( selectedIndex ) ) { this.scrollToOption( selectedIndex ); } } getOptionRef( index: number ) { if ( ! this.optionRefs.hasOwnProperty( index ) ) { this.optionRefs[ index ] = createRef(); } return this.optionRefs[ index ]; } select( option: Option ) { const { onSelect } = this.props; if ( option.isDisabled ) { return; } onSelect( option ); } scrollToOption( index: number ) { const listbox = this.listbox.current; if ( ! listbox ) { return; } if ( listbox.scrollHeight <= listbox.clientHeight ) { return; } if ( ! this.optionRefs[ index ] ) { return; } const option = this.optionRefs[ index ].current; if ( ! option ) { // eslint-disable-next-line no-console console.warn( 'Option not found, index:', index ); return; } 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: KeyboardEvent ) { const { decrementSelectedIndex, incrementSelectedIndex, options, onSearch, selectedIndex, setExpanded, } = this.props; if ( options.length === 0 ) { return; } switch ( event.keyCode ) { case UP: decrementSelectedIndex(); event.preventDefault(); event.stopPropagation(); break; case DOWN: incrementSelectedIndex(); event.preventDefault(); event.stopPropagation(); break; case ENTER: if ( isNumber( selectedIndex ) && options[ selectedIndex ] ) { this.select( options[ selectedIndex ] ); } event.preventDefault(); event.stopPropagation(); break; case LEFT: case RIGHT: setExpanded( false ); break; case ESCAPE: setExpanded( false ); onSearch( null ); return; case TAB: if ( isNumber( selectedIndex ) && options[ selectedIndex ] ) { this.select( options[ selectedIndex ] ); } setExpanded( false ); break; default: } } toggleKeyEvents( isListening: boolean ) { const { node } = this.props; if ( ! node ) { // eslint-disable-next-line no-console console.warn( 'No node to bind events to.' ); return; } // 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 as ( e: Event ) => void, true ); } componentDidMount() { const { selectedIndex } = this.props; if ( isNumber( selectedIndex ) && selectedIndex > -1 ) { this.scrollToOption( selectedIndex ); } this.toggleKeyEvents( true ); } componentWillUnmount() { this.toggleKeyEvents( false ); } render() { const { instanceId, listboxId, options, selectedIndex, staticList } = this.props; const listboxClasses = classnames( 'woocommerce-select-control__listbox', { 'is-static': staticList, } ); return (