UNPKG

@wordpress/components

Version:
586 lines (578 loc) 21.2 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.FormTokenField = FormTokenField; exports.default = void 0; var _clsx = _interopRequireDefault(require("clsx")); var _element = require("@wordpress/element"); var _i18n = require("@wordpress/i18n"); var _compose = require("@wordpress/compose"); var _a11y = require("@wordpress/a11y"); var _isShallowEqual = _interopRequireDefault(require("@wordpress/is-shallow-equal")); var _deprecated = _interopRequireDefault(require("@wordpress/deprecated")); var _token = _interopRequireDefault(require("./token")); var _tokenInput = _interopRequireDefault(require("./token-input")); var _styles = require("./styles"); var _suggestionsList = _interopRequireDefault(require("./suggestions-list")); var _flex = require("../flex"); var _baseControlStyles = require("../base-control/styles/base-control-styles"); var _spacer = require("../spacer"); var _useDeprecatedProps = require("../utils/use-deprecated-props"); var _withIgnoreImeEvents = require("../utils/with-ignore-ime-events"); var _deprecated36pxSize = require("../utils/deprecated-36px-size"); var _jsxRuntime = require("react/jsx-runtime"); /** * External dependencies */ /** * WordPress dependencies */ /** * Internal dependencies */ const identity = value => value; /** * A `FormTokenField` is a field similar to the tags and categories fields in the interim editor chrome, * or the "to" field in Mail on OS X. Tokens can be entered by typing them or selecting them from a list of suggested tokens. * * Up to one hundred suggestions that match what the user has typed so far will be shown from which the user can pick from (auto-complete). * Tokens are separated by the "," character. Suggestions can be selected with the up or down arrows and added with the tab or enter key. * * The `value` property is handled in a manner similar to controlled form components. * See [Forms](https://react.dev/reference/react-dom/components#form-components) in the React Documentation for more information. */ function FormTokenField(props) { const { autoCapitalize, autoComplete, maxLength, placeholder, label = (0, _i18n.__)('Add item'), className, suggestions = [], maxSuggestions = 100, value = [], displayTransform = identity, saveTransform = token => token.trim(), onChange = () => {}, onInputChange = () => {}, onFocus = undefined, isBorderless = false, disabled = false, tokenizeOnSpace = false, messages = { added: (0, _i18n.__)('Item added.'), removed: (0, _i18n.__)('Item removed.'), remove: (0, _i18n.__)('Remove item'), __experimentalInvalid: (0, _i18n.__)('Invalid item') }, __experimentalRenderItem, __experimentalExpandOnFocus = false, __experimentalValidateInput = () => true, __experimentalShowHowTo = true, __next40pxDefaultSize = false, __experimentalAutoSelectFirstMatch = false, __nextHasNoMarginBottom = false, tokenizeOnBlur = false } = (0, _useDeprecatedProps.useDeprecated36pxDefaultSizeProp)(props); if (!__nextHasNoMarginBottom) { (0, _deprecated.default)('Bottom margin styles for wp.components.FormTokenField', { since: '6.7', version: '7.0', hint: 'Set the `__nextHasNoMarginBottom` prop to true to start opting into the new styles, which will become the default in a future version.' }); } (0, _deprecated36pxSize.maybeWarnDeprecated36pxSize)({ componentName: 'FormTokenField', size: undefined, __next40pxDefaultSize }); const instanceId = (0, _compose.useInstanceId)(FormTokenField); // We reset to these initial values again in the onBlur const [incompleteTokenValue, setIncompleteTokenValue] = (0, _element.useState)(''); const [inputOffsetFromEnd, setInputOffsetFromEnd] = (0, _element.useState)(0); const [isActive, setIsActive] = (0, _element.useState)(false); const [isExpanded, setIsExpanded] = (0, _element.useState)(false); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = (0, _element.useState)(-1); const [selectedSuggestionScroll, setSelectedSuggestionScroll] = (0, _element.useState)(false); const prevSuggestions = (0, _compose.usePrevious)(suggestions); const prevValue = (0, _compose.usePrevious)(value); const input = (0, _element.useRef)(null); const tokensAndInput = (0, _element.useRef)(null); const debouncedSpeak = (0, _compose.useDebounce)(_a11y.speak, 500); (0, _element.useEffect)(() => { // Make sure to focus the input when the isActive state is true. if (isActive && !hasFocus()) { focus(); } }, [isActive]); (0, _element.useEffect)(() => { const suggestionsDidUpdate = !(0, _isShallowEqual.default)(suggestions, prevSuggestions || []); if (suggestionsDidUpdate || value !== prevValue) { updateSuggestions(suggestionsDidUpdate); } // TODO: updateSuggestions() should first be refactored so its actual deps are clearer. }, [suggestions, prevSuggestions, value, prevValue]); (0, _element.useEffect)(() => { updateSuggestions(); }, [incompleteTokenValue]); (0, _element.useEffect)(() => { updateSuggestions(); }, [__experimentalAutoSelectFirstMatch]); if (disabled && isActive) { setIsActive(false); setIncompleteTokenValue(''); } function focus() { input.current?.focus(); } function hasFocus() { return input.current === input.current?.ownerDocument.activeElement; } function onFocusHandler(event) { // If focus is on the input or on the container, set the isActive state to true. if (hasFocus() || event.target === tokensAndInput.current) { setIsActive(true); setIsExpanded(__experimentalExpandOnFocus || 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(). */ setIsActive(false); } if ('function' === typeof onFocus) { onFocus(event); } } function onBlur(event) { if (inputHasValidValue() && __experimentalValidateInput(incompleteTokenValue)) { setIsActive(false); if (tokenizeOnBlur && inputHasValidValue()) { addNewToken(incompleteTokenValue); } } else { // Reset to initial state setIncompleteTokenValue(''); setInputOffsetFromEnd(0); setIsActive(false); if (__experimentalExpandOnFocus) { // If `__experimentalExpandOnFocus` is true, don't close the suggestions list when // the user clicks on it (`tokensAndInput` will be the element that caused the blur). const hasFocusWithin = event.relatedTarget === tokensAndInput.current; setIsExpanded(hasFocusWithin); } else { // Else collapse the suggestion list. This will result in the suggestion list closing // after a suggestion has been submitted since that causes a blur. setIsExpanded(false); } setSelectedSuggestionIndex(-1); setSelectedSuggestionScroll(false); } } function onKeyDown(event) { let preventDefault = false; if (event.defaultPrevented) { return; } switch (event.key) { case 'Backspace': preventDefault = handleDeleteKey(deleteTokenBeforeInput); break; case 'Enter': preventDefault = addCurrentToken(); break; case 'ArrowLeft': preventDefault = handleLeftArrowKey(); break; case 'ArrowUp': preventDefault = handleUpArrowKey(); break; case 'ArrowRight': preventDefault = handleRightArrowKey(); break; case 'ArrowDown': preventDefault = handleDownArrowKey(); break; case 'Delete': preventDefault = handleDeleteKey(deleteTokenAfterInput); break; case 'Space': if (tokenizeOnSpace) { preventDefault = addCurrentToken(); } break; case 'Escape': preventDefault = handleEscapeKey(event); break; case 'Tab': preventDefault = handleTabKey(event); break; default: break; } if (preventDefault) { event.preventDefault(); } } function onKeyPress(event) { let preventDefault = false; switch (event.key) { case ',': preventDefault = handleCommaKey(); break; default: break; } if (preventDefault) { event.preventDefault(); } } function onContainerTouched(event) { // Prevent clicking/touching the tokensAndInput container from blurring // the input and adding the current token. if (event.target === tokensAndInput.current && isActive) { event.preventDefault(); } } function onTokenClickRemove(event) { deleteToken(event.value); focus(); } function onSuggestionHovered(suggestion) { const index = getMatchingSuggestions().indexOf(suggestion); if (index >= 0) { setSelectedSuggestionIndex(index); setSelectedSuggestionScroll(false); } } function onSuggestionSelected(suggestion) { addNewToken(suggestion); } function onInputChangeHandler(event) { const text = event.value; const separator = tokenizeOnSpace ? /[ ,\t]+/ : /[,\t]+/; const items = text.split(separator); const tokenValue = items[items.length - 1] || ''; if (items.length > 1) { addNewTokens(items.slice(0, -1)); } setIncompleteTokenValue(tokenValue); onInputChange(tokenValue); } function handleDeleteKey(_deleteToken) { let preventDefault = false; if (hasFocus() && isInputEmpty()) { _deleteToken(); preventDefault = true; } return preventDefault; } function handleLeftArrowKey() { let preventDefault = false; if (isInputEmpty()) { moveInputBeforePreviousToken(); preventDefault = true; } return preventDefault; } function handleRightArrowKey() { let preventDefault = false; if (isInputEmpty()) { moveInputAfterNextToken(); preventDefault = true; } return preventDefault; } function handleUpArrowKey() { setSelectedSuggestionIndex(index => { return (index === 0 ? getMatchingSuggestions(incompleteTokenValue, suggestions, value, maxSuggestions, saveTransform).length : index) - 1; }); setSelectedSuggestionScroll(true); return true; // PreventDefault. } function handleDownArrowKey() { setSelectedSuggestionIndex(index => { return (index + 1) % getMatchingSuggestions(incompleteTokenValue, suggestions, value, maxSuggestions, saveTransform).length; }); setSelectedSuggestionScroll(true); return true; // PreventDefault. } function collapseSuggestionsList(event) { if (event.target instanceof HTMLInputElement) { setIncompleteTokenValue(event.target.value); setIsExpanded(false); setSelectedSuggestionIndex(-1); setSelectedSuggestionScroll(false); } } function handleEscapeKey(event) { collapseSuggestionsList(event); return true; // PreventDefault. } function handleTabKey(event) { collapseSuggestionsList(event); return false; // Do not prevent the default behavior. } function handleCommaKey() { if (inputHasValidValue()) { addNewToken(incompleteTokenValue); } return true; // PreventDefault. } function moveInputToIndex(index) { setInputOffsetFromEnd(value.length - Math.max(index, -1) - 1); } function moveInputBeforePreviousToken() { setInputOffsetFromEnd(prevInputOffsetFromEnd => { return Math.min(prevInputOffsetFromEnd + 1, value.length); }); } function moveInputAfterNextToken() { setInputOffsetFromEnd(prevInputOffsetFromEnd => { return Math.max(prevInputOffsetFromEnd - 1, 0); }); } function deleteTokenBeforeInput() { const index = getIndexOfInput() - 1; if (index > -1) { deleteToken(value[index]); } } function deleteTokenAfterInput() { const index = getIndexOfInput(); if (index < value.length) { deleteToken(value[index]); // Update input offset since it's the offset from the last token. moveInputToIndex(index); } } function addCurrentToken() { let preventDefault = false; const selectedSuggestion = getSelectedSuggestion(); if (selectedSuggestion) { addNewToken(selectedSuggestion); preventDefault = true; } else if (inputHasValidValue()) { addNewToken(incompleteTokenValue); preventDefault = true; } return preventDefault; } function addNewTokens(tokens) { const tokensToAdd = [...new Set(tokens.map(saveTransform).filter(Boolean).filter(token => !valueContainsToken(token)))]; if (tokensToAdd.length > 0) { const newValue = [...value]; newValue.splice(getIndexOfInput(), 0, ...tokensToAdd); onChange(newValue); } } function addNewToken(token) { if (!__experimentalValidateInput(token)) { (0, _a11y.speak)(messages.__experimentalInvalid, 'assertive'); return; } addNewTokens([token]); (0, _a11y.speak)(messages.added, 'assertive'); setIncompleteTokenValue(''); setSelectedSuggestionIndex(-1); setSelectedSuggestionScroll(false); setIsExpanded(!__experimentalExpandOnFocus); if (isActive && !tokenizeOnBlur) { focus(); } } function deleteToken(token) { const newTokens = value.filter(item => { return getTokenValue(item) !== getTokenValue(token); }); onChange(newTokens); (0, _a11y.speak)(messages.removed, 'assertive'); } function getTokenValue(token) { if ('object' === typeof token) { return token.value; } return token; } function getMatchingSuggestions(searchValue = incompleteTokenValue, _suggestions = suggestions, _value = value, _maxSuggestions = maxSuggestions, _saveTransform = saveTransform) { let match = _saveTransform(searchValue); const startsWithMatch = []; const containsMatch = []; const normalizedValue = _value.map(item => { if (typeof item === 'string') { return item; } return item.value; }); if (match.length === 0) { _suggestions = _suggestions.filter(suggestion => !normalizedValue.includes(suggestion)); } else { match = match.normalize('NFKC').toLocaleLowerCase(); _suggestions.forEach(suggestion => { const index = suggestion.normalize('NFKC').toLocaleLowerCase().indexOf(match); if (normalizedValue.indexOf(suggestion) === -1) { if (index === 0) { startsWithMatch.push(suggestion); } else if (index > 0) { containsMatch.push(suggestion); } } }); _suggestions = startsWithMatch.concat(containsMatch); } return _suggestions.slice(0, _maxSuggestions); } function getSelectedSuggestion() { if (selectedSuggestionIndex !== -1) { return getMatchingSuggestions()[selectedSuggestionIndex]; } return undefined; } function valueContainsToken(token) { return value.some(item => { return getTokenValue(token) === getTokenValue(item); }); } function getIndexOfInput() { return value.length - inputOffsetFromEnd; } function isInputEmpty() { return incompleteTokenValue.length === 0; } function inputHasValidValue() { return saveTransform(incompleteTokenValue).length > 0; } function updateSuggestions(resetSelectedSuggestion = true) { const inputHasMinimumChars = incompleteTokenValue.trim().length > 1; const matchingSuggestions = getMatchingSuggestions(incompleteTokenValue); const hasMatchingSuggestions = matchingSuggestions.length > 0; const shouldExpandIfFocuses = hasFocus() && __experimentalExpandOnFocus; setIsExpanded(shouldExpandIfFocuses || inputHasMinimumChars && hasMatchingSuggestions); if (resetSelectedSuggestion) { if (__experimentalAutoSelectFirstMatch && inputHasMinimumChars && hasMatchingSuggestions) { setSelectedSuggestionIndex(0); setSelectedSuggestionScroll(true); } else { setSelectedSuggestionIndex(-1); setSelectedSuggestionScroll(false); } } if (inputHasMinimumChars) { const message = hasMatchingSuggestions ? (0, _i18n.sprintf)(/* translators: %d: number of results. */ (0, _i18n._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) : (0, _i18n.__)('No results.'); debouncedSpeak(message, 'assertive'); } } function renderTokensAndInput() { const components = value.map(renderToken); components.splice(getIndexOfInput(), 0, renderInput()); return components; } function renderToken(token, index, tokens) { const _value = getTokenValue(token); const status = typeof token !== 'string' ? token.status : undefined; const termPosition = index + 1; const termsCount = tokens.length; return /*#__PURE__*/(0, _jsxRuntime.jsx)(_flex.FlexItem, { children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_token.default, { value: _value, status: status, title: typeof token !== 'string' ? token.title : undefined, displayTransform: displayTransform, onClickRemove: onTokenClickRemove, isBorderless: typeof token !== 'string' && token.isBorderless || isBorderless, onMouseEnter: typeof token !== 'string' ? token.onMouseEnter : undefined, onMouseLeave: typeof token !== 'string' ? token.onMouseLeave : undefined, disabled: 'error' !== status && disabled, messages: messages, termsCount: termsCount, termPosition: termPosition }) }, 'token-' + _value); } function renderInput() { const inputProps = { instanceId, autoCapitalize, autoComplete, placeholder: value.length === 0 ? placeholder : '', disabled, value: incompleteTokenValue, onBlur, isExpanded, selectedSuggestionIndex }; return /*#__PURE__*/(0, _jsxRuntime.jsx)(_tokenInput.default, { ...inputProps, onChange: !(maxLength && value.length >= maxLength) ? onInputChangeHandler : undefined, ref: input }, "input"); } const classes = (0, _clsx.default)(className, 'components-form-token-field__input-container', { 'is-active': isActive, 'is-disabled': disabled }); let tokenFieldProps = { className: 'components-form-token-field', tabIndex: -1 }; const matchingSuggestions = getMatchingSuggestions(); if (!disabled) { tokenFieldProps = Object.assign({}, tokenFieldProps, { onKeyDown: (0, _withIgnoreImeEvents.withIgnoreIMEEvents)(onKeyDown), onKeyPress, onFocus: onFocusHandler }); } // 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 /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", { ...tokenFieldProps, children: [label && /*#__PURE__*/(0, _jsxRuntime.jsx)(_baseControlStyles.StyledLabel, { htmlFor: `components-form-token-input-${instanceId}`, className: "components-form-token-field__label", children: label }), /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", { ref: tokensAndInput, className: classes, tabIndex: -1, onMouseDown: onContainerTouched, onTouchStart: onContainerTouched, children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_styles.TokensAndInputWrapperFlex, { justify: "flex-start", align: "center", gap: 1, wrap: true, __next40pxDefaultSize: __next40pxDefaultSize, hasTokens: !!value.length, children: renderTokensAndInput() }), isExpanded && /*#__PURE__*/(0, _jsxRuntime.jsx)(_suggestionsList.default, { instanceId: instanceId, match: saveTransform(incompleteTokenValue), displayTransform: displayTransform, suggestions: matchingSuggestions, selectedIndex: selectedSuggestionIndex, scrollIntoView: selectedSuggestionScroll, onHover: onSuggestionHovered, onSelect: onSuggestionSelected, __experimentalRenderItem: __experimentalRenderItem })] }), !__nextHasNoMarginBottom && /*#__PURE__*/(0, _jsxRuntime.jsx)(_spacer.Spacer, { marginBottom: 2 }), __experimentalShowHowTo && /*#__PURE__*/(0, _jsxRuntime.jsx)(_baseControlStyles.StyledHelp, { id: `components-form-token-suggestions-howto-${instanceId}`, className: "components-form-token-field__help", __nextHasNoMarginBottom: __nextHasNoMarginBottom, children: tokenizeOnSpace ? (0, _i18n.__)('Separate with commas, spaces, or the Enter key.') : (0, _i18n.__)('Separate with commas or the Enter key.') })] }); /* eslint-enable jsx-a11y/no-static-element-interactions */ } var _default = exports.default = FormTokenField; //# sourceMappingURL=index.js.map