@wordpress/components
Version:
UI components for WordPress.
723 lines (633 loc) • 17.6 kB
JavaScript
/**
* External dependencies
*/
import {
last,
take,
clone,
uniq,
map,
difference,
each,
identity,
some,
} from 'lodash';
import classnames from 'classnames';
/**
* WordPress dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import { withInstanceId } from '@wordpress/compose';
import {
BACKSPACE,
ENTER,
UP,
DOWN,
LEFT,
RIGHT,
SPACE,
DELETE,
ESCAPE,
} from '@wordpress/keycodes';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* Internal dependencies
*/
import Token from './token';
import TokenInput from './token-input';
import SuggestionsList from './suggestions-list';
import withSpokenMessages from '../higher-order/with-spoken-messages';
const initialState = {
incompleteTokenValue: '',
inputOffsetFromEnd: 0,
isActive: false,
isExpanded: false,
selectedSuggestionIndex: -1,
selectedSuggestionScroll: false,
};
class FormTokenField extends Component {
constructor() {
super( ...arguments );
this.state = initialState;
this.onKeyDown = this.onKeyDown.bind( this );
this.onKeyPress = this.onKeyPress.bind( this );
this.onFocus = this.onFocus.bind( this );
this.onBlur = this.onBlur.bind( this );
this.deleteTokenBeforeInput = this.deleteTokenBeforeInput.bind( this );
this.deleteTokenAfterInput = this.deleteTokenAfterInput.bind( this );
this.addCurrentToken = this.addCurrentToken.bind( this );
this.onContainerTouched = this.onContainerTouched.bind( this );
this.renderToken = this.renderToken.bind( this );
this.onTokenClickRemove = this.onTokenClickRemove.bind( this );
this.onSuggestionHovered = this.onSuggestionHovered.bind( this );
this.onSuggestionSelected = this.onSuggestionSelected.bind( this );
this.onInputChange = this.onInputChange.bind( this );
this.bindInput = this.bindInput.bind( this );
this.bindTokensAndInput = this.bindTokensAndInput.bind( this );
this.updateSuggestions = this.updateSuggestions.bind( this );
}
componentDidUpdate( prevProps ) {
// Make sure to focus the input when the isActive state is true.
if ( this.state.isActive && ! this.input.hasFocus() ) {
this.input.focus();
}
const { suggestions, value } = this.props;
const suggestionsDidUpdate = ! isShallowEqual(
suggestions,
prevProps.suggestions
);
if ( suggestionsDidUpdate || value !== prevProps.value ) {
this.updateSuggestions( suggestionsDidUpdate );
}
}
static getDerivedStateFromProps( props, state ) {
if ( ! props.disabled || ! state.isActive ) {
return null;
}
return {
isActive: false,
incompleteTokenValue: '',
};
}
bindInput( ref ) {
this.input = ref;
}
bindTokensAndInput( ref ) {
this.tokensAndInput = ref;
}
onFocus( event ) {
const { __experimentalExpandOnFocus } = this.props;
// If focus is on the input or on the container, set the isActive state to true.
if ( this.input.hasFocus() || event.target === this.tokensAndInput ) {
this.setState( {
isActive: true,
isExpanded:
!! __experimentalExpandOnFocus || this.state.isExpanded,
} );
} else {
/*
* Otherwise, focus is on one of the token "remove" buttons and we
* set the isActive state to false to prevent the input to be
* re-focused, see componentDidUpdate().
*/
this.setState( { isActive: false } );
}
if ( 'function' === typeof this.props.onFocus ) {
this.props.onFocus( event );
}
}
onBlur() {
if ( this.inputHasValidValue() ) {
this.setState( { isActive: false } );
} else {
this.setState( initialState );
}
}
onKeyDown( event ) {
let preventDefault = false;
switch ( event.keyCode ) {
case BACKSPACE:
preventDefault = this.handleDeleteKey(
this.deleteTokenBeforeInput
);
break;
case ENTER:
preventDefault = this.addCurrentToken();
break;
case LEFT:
preventDefault = this.handleLeftArrowKey();
break;
case UP:
preventDefault = this.handleUpArrowKey();
break;
case RIGHT:
preventDefault = this.handleRightArrowKey();
break;
case DOWN:
preventDefault = this.handleDownArrowKey();
break;
case DELETE:
preventDefault = this.handleDeleteKey(
this.deleteTokenAfterInput
);
break;
case SPACE:
if ( this.props.tokenizeOnSpace ) {
preventDefault = this.addCurrentToken();
}
break;
case ESCAPE:
preventDefault = this.handleEscapeKey( event );
event.stopPropagation();
break;
default:
break;
}
if ( preventDefault ) {
event.preventDefault();
}
}
onKeyPress( event ) {
let preventDefault = false;
switch ( event.charCode ) {
case 44: // comma
preventDefault = this.handleCommaKey();
break;
default:
break;
}
if ( preventDefault ) {
event.preventDefault();
}
}
onContainerTouched( event ) {
// Prevent clicking/touching the tokensAndInput container from blurring
// the input and adding the current token.
if ( event.target === this.tokensAndInput && this.state.isActive ) {
event.preventDefault();
}
}
onTokenClickRemove( event ) {
this.deleteToken( event.value );
this.input.focus();
}
onSuggestionHovered( suggestion ) {
const index = this.getMatchingSuggestions().indexOf( suggestion );
if ( index >= 0 ) {
this.setState( {
selectedSuggestionIndex: index,
selectedSuggestionScroll: false,
} );
}
}
onSuggestionSelected( suggestion ) {
this.addNewToken( suggestion );
}
onInputChange( event ) {
const text = event.value;
const separator = this.props.tokenizeOnSpace ? /[ ,\t]+/ : /[,\t]+/;
const items = text.split( separator );
const tokenValue = last( items ) || '';
if ( items.length > 1 ) {
this.addNewTokens( items.slice( 0, -1 ) );
}
this.setState(
{ incompleteTokenValue: tokenValue },
this.updateSuggestions
);
this.props.onInputChange( tokenValue );
}
handleDeleteKey( deleteToken ) {
let preventDefault = false;
if ( this.input.hasFocus() && this.isInputEmpty() ) {
deleteToken();
preventDefault = true;
}
return preventDefault;
}
handleLeftArrowKey() {
let preventDefault = false;
if ( this.isInputEmpty() ) {
this.moveInputBeforePreviousToken();
preventDefault = true;
}
return preventDefault;
}
handleRightArrowKey() {
let preventDefault = false;
if ( this.isInputEmpty() ) {
this.moveInputAfterNextToken();
preventDefault = true;
}
return preventDefault;
}
handleUpArrowKey() {
this.setState( ( state, props ) => ( {
selectedSuggestionIndex:
( state.selectedSuggestionIndex === 0
? this.getMatchingSuggestions(
state.incompleteTokenValue,
props.suggestions,
props.value,
props.maxSuggestions,
props.saveTransform
).length
: state.selectedSuggestionIndex ) - 1,
selectedSuggestionScroll: true,
} ) );
return true; // preventDefault
}
handleDownArrowKey() {
this.setState( ( state, props ) => ( {
selectedSuggestionIndex:
( state.selectedSuggestionIndex + 1 ) %
this.getMatchingSuggestions(
state.incompleteTokenValue,
props.suggestions,
props.value,
props.maxSuggestions,
props.saveTransform
).length,
selectedSuggestionScroll: true,
} ) );
return true; // preventDefault
}
handleEscapeKey( event ) {
this.setState( {
incompleteTokenValue: event.target.value,
isExpanded: false,
selectedSuggestionIndex: -1,
selectedSuggestionScroll: false,
} );
return true; // preventDefault
}
handleCommaKey() {
if ( this.inputHasValidValue() ) {
this.addNewToken( this.state.incompleteTokenValue );
}
return true; // preventDefault
}
moveInputToIndex( index ) {
this.setState( ( state, props ) => ( {
inputOffsetFromEnd: props.value.length - Math.max( index, -1 ) - 1,
} ) );
}
moveInputBeforePreviousToken() {
this.setState( ( state, props ) => ( {
inputOffsetFromEnd: Math.min(
state.inputOffsetFromEnd + 1,
props.value.length
),
} ) );
}
moveInputAfterNextToken() {
this.setState( ( state ) => ( {
inputOffsetFromEnd: Math.max( state.inputOffsetFromEnd - 1, 0 ),
} ) );
}
deleteTokenBeforeInput() {
const index = this.getIndexOfInput() - 1;
if ( index > -1 ) {
this.deleteToken( this.props.value[ index ] );
}
}
deleteTokenAfterInput() {
const index = this.getIndexOfInput();
if ( index < this.props.value.length ) {
this.deleteToken( this.props.value[ index ] );
// update input offset since it's the offset from the last token
this.moveInputToIndex( index );
}
}
addCurrentToken() {
let preventDefault = false;
const selectedSuggestion = this.getSelectedSuggestion();
if ( selectedSuggestion ) {
this.addNewToken( selectedSuggestion );
preventDefault = true;
} else if ( this.inputHasValidValue() ) {
this.addNewToken( this.state.incompleteTokenValue );
preventDefault = true;
}
return preventDefault;
}
addNewTokens( tokens ) {
const tokensToAdd = uniq(
tokens
.map( this.props.saveTransform )
.filter( Boolean )
.filter( ( token ) => ! this.valueContainsToken( token ) )
);
if ( tokensToAdd.length > 0 ) {
const newValue = clone( this.props.value );
newValue.splice.apply(
newValue,
[ this.getIndexOfInput(), 0 ].concat( tokensToAdd )
);
this.props.onChange( newValue );
}
}
addNewToken( token ) {
const {
__experimentalExpandOnFocus,
__experimentalValidateInput,
} = this.props;
if ( ! __experimentalValidateInput( token ) ) {
this.props.speak(
this.props.messages.__experimentalInvalid,
'assertive'
);
return;
}
this.addNewTokens( [ token ] );
this.props.speak( this.props.messages.added, 'assertive' );
this.setState( {
incompleteTokenValue: '',
selectedSuggestionIndex: -1,
selectedSuggestionScroll: false,
isExpanded: ! __experimentalExpandOnFocus,
} );
if ( this.state.isActive ) {
this.input.focus();
}
}
deleteToken( token ) {
const newTokens = this.props.value.filter( ( item ) => {
return this.getTokenValue( item ) !== this.getTokenValue( token );
} );
this.props.onChange( newTokens );
this.props.speak( this.props.messages.removed, 'assertive' );
}
getTokenValue( token ) {
if ( 'object' === typeof token ) {
return token.value;
}
return token;
}
getMatchingSuggestions(
searchValue = this.state.incompleteTokenValue,
suggestions = this.props.suggestions,
value = this.props.value,
maxSuggestions = this.props.maxSuggestions,
saveTransform = this.props.saveTransform
) {
let match = saveTransform( searchValue );
const startsWithMatch = [];
const containsMatch = [];
if ( match.length === 0 ) {
suggestions = difference( suggestions, value );
} else {
match = match.toLocaleLowerCase();
each( suggestions, ( suggestion ) => {
const index = suggestion.toLocaleLowerCase().indexOf( match );
if ( value.indexOf( suggestion ) === -1 ) {
if ( index === 0 ) {
startsWithMatch.push( suggestion );
} else if ( index > 0 ) {
containsMatch.push( suggestion );
}
}
} );
suggestions = startsWithMatch.concat( containsMatch );
}
return take( suggestions, maxSuggestions );
}
getSelectedSuggestion() {
if ( this.state.selectedSuggestionIndex !== -1 ) {
return this.getMatchingSuggestions()[
this.state.selectedSuggestionIndex
];
}
}
valueContainsToken( token ) {
return some( this.props.value, ( item ) => {
return this.getTokenValue( token ) === this.getTokenValue( item );
} );
}
getIndexOfInput() {
return this.props.value.length - this.state.inputOffsetFromEnd;
}
isInputEmpty() {
return this.state.incompleteTokenValue.length === 0;
}
inputHasValidValue() {
return (
this.props.saveTransform( this.state.incompleteTokenValue ).length >
0
);
}
updateSuggestions( resetSelectedSuggestion = true ) {
const { __experimentalExpandOnFocus } = this.props;
const { incompleteTokenValue } = this.state;
const inputHasMinimumChars = incompleteTokenValue.trim().length > 1;
const matchingSuggestions = this.getMatchingSuggestions(
incompleteTokenValue
);
const hasMatchingSuggestions = matchingSuggestions.length > 0;
const newState = {
isExpanded:
__experimentalExpandOnFocus ||
( inputHasMinimumChars && hasMatchingSuggestions ),
};
if ( resetSelectedSuggestion ) {
newState.selectedSuggestionIndex = -1;
newState.selectedSuggestionScroll = false;
}
this.setState( newState );
if ( inputHasMinimumChars ) {
const { debouncedSpeak } = this.props;
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.' );
debouncedSpeak( message, 'assertive' );
}
}
renderTokensAndInput() {
const components = map( this.props.value, this.renderToken );
components.splice( this.getIndexOfInput(), 0, this.renderInput() );
return components;
}
renderToken( token, index, tokens ) {
const value = this.getTokenValue( token );
const status = token.status ? token.status : undefined;
const termPosition = index + 1;
const termsCount = tokens.length;
return (
<Token
key={ 'token-' + value }
value={ value }
status={ status }
title={ token.title }
displayTransform={ this.props.displayTransform }
onClickRemove={ this.onTokenClickRemove }
isBorderless={ token.isBorderless || this.props.isBorderless }
onMouseEnter={ token.onMouseEnter }
onMouseLeave={ token.onMouseLeave }
disabled={ 'error' !== status && this.props.disabled }
messages={ this.props.messages }
termsCount={ termsCount }
termPosition={ termPosition }
/>
);
}
renderInput() {
const {
autoCapitalize,
autoComplete,
maxLength,
placeholder,
value,
instanceId,
} = this.props;
let props = {
instanceId,
autoCapitalize,
autoComplete,
placeholder: value.length === 0 ? placeholder : '',
ref: this.bindInput,
key: 'input',
disabled: this.props.disabled,
value: this.state.incompleteTokenValue,
onBlur: this.onBlur,
isExpanded: this.state.isExpanded,
selectedSuggestionIndex: this.state.selectedSuggestionIndex,
};
if ( ! ( maxLength && value.length >= maxLength ) ) {
props = { ...props, onChange: this.onInputChange };
}
return <TokenInput { ...props } />;
}
render() {
const {
disabled,
label = __( 'Add item' ),
instanceId,
className,
__experimentalShowHowTo,
} = this.props;
const { isExpanded } = this.state;
const classes = classnames(
className,
'components-form-token-field__input-container',
{
'is-active': this.state.isActive,
'is-disabled': disabled,
}
);
let tokenFieldProps = {
className: 'components-form-token-field',
tabIndex: '-1',
};
const matchingSuggestions = this.getMatchingSuggestions();
if ( ! disabled ) {
tokenFieldProps = Object.assign( {}, tokenFieldProps, {
onKeyDown: this.onKeyDown,
onKeyPress: this.onKeyPress,
onFocus: this.onFocus,
} );
}
// 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 (
<div { ...tokenFieldProps }>
<label
htmlFor={ `components-form-token-input-${ instanceId }` }
className="components-form-token-field__label"
>
{ label }
</label>
<div
ref={ this.bindTokensAndInput }
className={ classes }
tabIndex="-1"
onMouseDown={ this.onContainerTouched }
onTouchStart={ this.onContainerTouched }
>
{ this.renderTokensAndInput() }
{ isExpanded && (
<SuggestionsList
instanceId={ instanceId }
match={ this.props.saveTransform(
this.state.incompleteTokenValue
) }
displayTransform={ this.props.displayTransform }
suggestions={ matchingSuggestions }
selectedIndex={ this.state.selectedSuggestionIndex }
scrollIntoView={
this.state.selectedSuggestionScroll
}
onHover={ this.onSuggestionHovered }
onSelect={ this.onSuggestionSelected }
/>
) }
</div>
{ __experimentalShowHowTo && (
<p
id={ `components-form-token-suggestions-howto-${ instanceId }` }
className="components-form-token-field__help"
>
{ this.props.tokenizeOnSpace
? __(
'Separate with commas, spaces, or the Enter key.'
)
: __( 'Separate with commas or the Enter key.' ) }
</p>
) }
</div>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
}
}
FormTokenField.defaultProps = {
suggestions: Object.freeze( [] ),
maxSuggestions: 100,
value: Object.freeze( [] ),
displayTransform: identity,
saveTransform: ( token ) => token.trim(),
onChange: () => {},
onInputChange: () => {},
isBorderless: false,
disabled: false,
tokenizeOnSpace: false,
messages: {
added: __( 'Item added.' ),
removed: __( 'Item removed.' ),
remove: __( 'Remove item' ),
__experimentalInvalid: __( 'Invalid item' ),
},
__experimentalExpandOnFocus: false,
__experimentalValidateInput: () => true,
__experimentalShowHowTo: true,
};
export default withSpokenMessages( withInstanceId( FormTokenField ) );