@wordpress/components
Version:
UI components for WordPress.
637 lines (538 loc) • 18 kB
JavaScript
import { createElement } from "@wordpress/element";
/**
* 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 createElement(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 createElement(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 createElement("div", tokenFieldProps, createElement("label", {
htmlFor: `components-form-token-input-${instanceId}`,
className: "components-form-token-field__label"
}, label), createElement("div", {
ref: this.bindTokensAndInput,
className: classes,
tabIndex: "-1",
onMouseDown: this.onContainerTouched,
onTouchStart: this.onContainerTouched
}, this.renderTokensAndInput(), isExpanded && createElement(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
})), __experimentalShowHowTo && createElement("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.')));
/* 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));
//# sourceMappingURL=index.js.map