@wordpress/components
Version:
UI components for WordPress.
257 lines (237 loc) • 6.61 kB
JavaScript
/**
* External dependencies
*/
import classnames from 'classnames';
import { noop, deburr } from 'lodash';
/**
* WordPress dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import {
Component,
useState,
useMemo,
useRef,
useEffect,
} from '@wordpress/element';
import { useInstanceId } from '@wordpress/compose';
import { ENTER, UP, DOWN, ESCAPE } from '@wordpress/keycodes';
import { speak } from '@wordpress/a11y';
import { closeSmall } from '@wordpress/icons';
/**
* Internal dependencies
*/
import TokenInput from '../form-token-field/token-input';
import SuggestionsList from '../form-token-field/suggestions-list';
import BaseControl from '../base-control';
import Button from '../button';
import { Flex, FlexBlock, FlexItem } from '../flex';
import withFocusOutside from '../higher-order/with-focus-outside';
const DetectOutside = withFocusOutside(
class extends Component {
handleFocusOutside( event ) {
this.props.onFocusOutside( event );
}
render() {
return this.props.children;
}
}
);
function ComboboxControl( {
value,
label,
options,
onChange,
onFilterValueChange = noop,
hideLabelFromVision,
help,
allowReset = true,
className,
messages = {
selected: __( 'Item selected.' ),
},
} ) {
const instanceId = useInstanceId( ComboboxControl );
const [ selectedSuggestion, setSelectedSuggestion ] = useState( null );
const [ isExpanded, setIsExpanded ] = useState( false );
const [ inputValue, setInputValue ] = useState( '' );
const inputContainer = useRef();
const currentOption = options.find( ( option ) => option.value === value );
const currentLabel = currentOption?.label ?? '';
const matchingSuggestions = useMemo( () => {
const startsWithMatch = [];
const containsMatch = [];
const match = deburr( inputValue.toLocaleLowerCase() );
options.forEach( ( option ) => {
const index = deburr( option.label )
.toLocaleLowerCase()
.indexOf( match );
if ( index === 0 ) {
startsWithMatch.push( option );
} else if ( index > 0 ) {
containsMatch.push( option );
}
} );
return startsWithMatch.concat( containsMatch );
}, [ inputValue, options, value ] );
const onSuggestionSelected = ( newSelectedSuggestion ) => {
onChange( newSelectedSuggestion.value );
speak( messages.selected, 'assertive' );
setSelectedSuggestion( newSelectedSuggestion );
setInputValue( '' );
setIsExpanded( false );
};
const handleArrowNavigation = ( offset = 1 ) => {
const index = matchingSuggestions.indexOf( selectedSuggestion );
let nextIndex = index + offset;
if ( nextIndex < 0 ) {
nextIndex = matchingSuggestions.length - 1;
} else if ( nextIndex >= matchingSuggestions.length ) {
nextIndex = 0;
}
setSelectedSuggestion( matchingSuggestions[ nextIndex ] );
setIsExpanded( true );
};
const onKeyDown = ( event ) => {
let preventDefault = false;
switch ( event.keyCode ) {
case ENTER:
if ( selectedSuggestion ) {
onSuggestionSelected( selectedSuggestion );
preventDefault = true;
}
break;
case UP:
handleArrowNavigation( -1 );
preventDefault = true;
break;
case DOWN:
handleArrowNavigation( 1 );
preventDefault = true;
break;
case ESCAPE:
setIsExpanded( false );
setSelectedSuggestion( null );
preventDefault = true;
event.stopPropagation();
break;
default:
break;
}
if ( preventDefault ) {
event.preventDefault();
}
};
const onFocus = () => {
setIsExpanded( true );
onFilterValueChange( '' );
setInputValue( '' );
};
const onFocusOutside = () => {
setIsExpanded( false );
};
const onInputChange = ( event ) => {
const text = event.value;
setInputValue( text );
onFilterValueChange( text );
setIsExpanded( true );
};
const handleOnReset = () => {
onChange( null );
inputContainer.current.input.focus();
};
// Announcements
useEffect( () => {
const hasMatchingSuggestions = matchingSuggestions.length > 0;
if ( isExpanded ) {
const message = hasMatchingSuggestions
? sprintf(
/* translators: %d: number of results. */
_n(
'%d result found, use up and down arrow keys to navigate.',
'%d results found, use up and down arrow keys to navigate.',
matchingSuggestions.length
),
matchingSuggestions.length
)
: __( 'No results.' );
speak( message, 'polite' );
}
}, [ matchingSuggestions, isExpanded ] );
// Disable reason: There is no appropriate role which describes the
// input container intended accessible usability.
// TODO: Refactor click detection to use blur to stop propagation.
/* eslint-disable jsx-a11y/no-static-element-interactions */
return (
<DetectOutside onFocusOutside={ onFocusOutside }>
<BaseControl
className={ classnames(
className,
'components-combobox-control'
) }
tabIndex="-1"
label={ label }
id={ `components-form-token-input-${ instanceId }` }
hideLabelFromVision={ hideLabelFromVision }
help={ help }
>
<div
className="components-combobox-control__suggestions-container"
tabIndex="-1"
onKeyDown={ onKeyDown }
>
<Flex>
<FlexBlock>
<TokenInput
className="components-combobox-control__input"
instanceId={ instanceId }
ref={ inputContainer }
value={ isExpanded ? inputValue : currentLabel }
aria-label={
currentLabel
? `${ currentLabel }, ${ label }`
: null
}
onFocus={ onFocus }
isExpanded={ isExpanded }
selectedSuggestionIndex={ matchingSuggestions.indexOf(
selectedSuggestion
) }
onChange={ onInputChange }
/>
</FlexBlock>
{ allowReset && (
<FlexItem>
<Button
className="components-combobox-control__reset"
icon={ closeSmall }
disabled={ ! value }
onClick={ handleOnReset }
label={ __( 'Reset' ) }
/>
</FlexItem>
) }
</Flex>
{ isExpanded && (
<SuggestionsList
instanceId={ instanceId }
match={ { label: inputValue } }
displayTransform={ ( suggestion ) =>
suggestion.label
}
suggestions={ matchingSuggestions }
selectedIndex={ matchingSuggestions.indexOf(
selectedSuggestion
) }
onHover={ setSelectedSuggestion }
onSelect={ onSuggestionSelected }
scrollIntoView
/>
) }
</div>
</BaseControl>
</DetectOutside>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
}
export default ComboboxControl;