UNPKG

@wordpress/components

Version:
637 lines (538 loc) 18 kB
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