@wordpress/components
Version:
UI components for WordPress.
395 lines (339 loc) • 11 kB
JavaScript
import { createElement } from "@wordpress/element";
/**
* External dependencies
*/
import { take, difference, each, identity } 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 { ENTER, UP, DOWN, LEFT, RIGHT, ESCAPE } from '@wordpress/keycodes';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* Internal dependencies
*/
import TokenInput from './token-input';
import SuggestionsList from './suggestions-list';
import withSpokenMessages from '../higher-order/with-spoken-messages';
const initialState = {
incompleteTokenValue: '',
isActive: false,
isExpanded: false,
selectedSuggestion: null
};
class ComboboxControl extends Component {
constructor() {
super(...arguments);
this.state = initialState;
this.onKeyDown = this.onKeyDown.bind(this);
this.onFocus = this.onFocus.bind(this);
this.onBlur = this.onBlur.bind(this);
this.onContainerTouched = this.onContainerTouched.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();
}
}
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) {
// 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
});
} 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() {
this.setState({
isActive: false,
incompleteTokenValue: this.props.value,
isExpanded: false
});
}
onKeyDown(event) {
let preventDefault = false;
switch (event.keyCode) {
case ENTER:
if (this.state.selectedSuggestion) {
this.onSuggestionSelected(this.state.selectedSuggestion);
preventDefault = true;
}
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 ESCAPE:
preventDefault = this.handleEscapeKey(event);
event.stopPropagation();
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();
}
}
onSuggestionHovered(suggestion) {
this.setState({
selectedSuggestion: suggestion
});
}
onInputChange(event) {
const text = event.value;
this.setState({
incompleteTokenValue: text
}, this.updateSuggestions);
this.props.onInputChange(text);
}
handleUpArrowKey() {
const matchingSuggestions = this.getMatchingSuggestions();
const index = matchingSuggestions.indexOf(this.state.selectedSuggestion);
if (index === 0 || index === -1) {
this.setState({
selectedSuggestion: matchingSuggestions[matchingSuggestions.length - 1]
});
} else {
this.setState({
selectedSuggestion: matchingSuggestions[index - 1]
});
}
return true; // preventDefault
}
handleDownArrowKey() {
const matchingSuggestions = this.getMatchingSuggestions();
const index = matchingSuggestions.indexOf(this.state.selectedSuggestion);
if (index === matchingSuggestions.length - 1 || index === -1) {
this.setState({
selectedSuggestion: matchingSuggestions[0]
});
} else {
this.setState({
selectedSuggestion: matchingSuggestions[index + 1]
});
}
return true; // preventDefault
}
handleEscapeKey(event) {
this.setState({
incompleteTokenValue: event.target.value,
isExpanded: false,
selectedSuggestion: null
});
return true; // preventDefault
}
onSuggestionSelected(newValue) {
this.props.onChange(newValue);
this.props.speak(this.props.messages.selected, 'assertive');
if (this.state.isActive) {
this.input.focus();
}
this.setState({
incompleteTokenValue: newValue,
selectedSuggestion: null,
isExpanded: false
});
}
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 || match.length === 0) {
suggestions = difference(suggestions, value);
} else {
match = match.toLocaleLowerCase();
each(suggestions, suggestion => {
const index = suggestion.toLocaleLowerCase().indexOf(match);
if (index === 0) {
startsWithMatch.push(suggestion);
} else if (index > 0) {
containsMatch.push(suggestion);
}
});
suggestions = startsWithMatch.concat(containsMatch);
}
return take(suggestions, maxSuggestions);
}
updateSuggestions() {
const {
incompleteTokenValue,
selectedSuggestion
} = this.state;
const inputHasMinimumChars = !!incompleteTokenValue && incompleteTokenValue.trim().length > 1;
const matchingSuggestions = this.getMatchingSuggestions(incompleteTokenValue);
const hasMatchingSuggestions = matchingSuggestions.length > 0;
const newState = {
isExpanded: inputHasMinimumChars && hasMatchingSuggestions
};
if (matchingSuggestions.indexOf(selectedSuggestion) === -1) {
newState.selectedSuggestion = null;
}
if (!incompleteTokenValue || matchingSuggestions.indexOf(this.props.value) === -1) {
this.props.onChange(null);
}
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');
}
}
renderInput() {
const {
autoCapitalize,
autoComplete,
maxLength,
value,
instanceId
} = this.props;
const matchingSuggestions = this.getMatchingSuggestions();
let props = {
instanceId,
autoCapitalize,
autoComplete,
ref: this.bindInput,
key: 'input',
disabled: this.props.disabled,
value: this.state.incompleteTokenValue,
onBlur: this.onBlur,
isExpanded: this.state.isExpanded,
selectedSuggestionIndex: matchingSuggestions.indexOf(this.state.selectedSuggestion)
};
if (!(maxLength && value.length >= maxLength)) {
props = { ...props,
onChange: this.onInputChange
};
}
return createElement(TokenInput, props);
}
render() {
const {
disabled,
label = __('Select item'),
instanceId,
className
} = this.props;
const {
isExpanded,
selectedSuggestion,
incompleteTokenValue
} = 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,
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.renderInput(), isExpanded && createElement(SuggestionsList, {
instanceId: instanceId,
match: this.props.saveTransform(incompleteTokenValue),
displayTransform: this.props.displayTransform,
suggestions: matchingSuggestions,
selectedIndex: matchingSuggestions.indexOf(selectedSuggestion),
onHover: this.onSuggestionHovered,
onSelect: this.onSuggestionSelected
})));
/* eslint-enable jsx-a11y/no-static-element-interactions */
}
}
ComboboxControl.defaultProps = {
suggestions: Object.freeze([]),
maxSuggestions: 100,
value: null,
displayTransform: identity,
saveTransform: identity,
onChange: () => {},
onInputChange: () => {},
isBorderless: false,
disabled: false,
messages: {
selected: __('Item selected.')
}
};
export default withSpokenMessages(withInstanceId(ComboboxControl));
//# sourceMappingURL=combobox.js.map