UNPKG

@wordpress/components

Version:
576 lines (571 loc) 20.2 kB
/** * External dependencies */ import clsx from 'clsx'; /** * WordPress dependencies */ import { useEffect, useRef, useState } from '@wordpress/element'; import { __, _n, sprintf } from '@wordpress/i18n'; import { useDebounce, useInstanceId, usePrevious } from '@wordpress/compose'; import { speak } from '@wordpress/a11y'; import isShallowEqual from '@wordpress/is-shallow-equal'; import deprecated from '@wordpress/deprecated'; /** * Internal dependencies */ import Token from './token'; import TokenInput from './token-input'; import { TokensAndInputWrapperFlex } from './styles'; import SuggestionsList from './suggestions-list'; import { FlexItem } from '../flex'; import { StyledHelp, StyledLabel } from '../base-control/styles/base-control-styles'; import { Spacer } from '../spacer'; import { useDeprecated36pxDefaultSizeProp } from '../utils/use-deprecated-props'; import { withIgnoreIMEEvents } from '../utils/with-ignore-ime-events'; import { maybeWarnDeprecated36pxSize } from '../utils/deprecated-36px-size'; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; 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. */ export function FormTokenField(props) { const { autoCapitalize, autoComplete, maxLength, placeholder, label = __('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: __('Item added.'), removed: __('Item removed.'), remove: __('Remove item'), __experimentalInvalid: __('Invalid item') }, __experimentalRenderItem, __experimentalExpandOnFocus = false, __experimentalValidateInput = () => true, __experimentalShowHowTo = true, __next40pxDefaultSize = false, __experimentalAutoSelectFirstMatch = false, __nextHasNoMarginBottom = false, tokenizeOnBlur = false } = useDeprecated36pxDefaultSizeProp(props); if (!__nextHasNoMarginBottom) { deprecated('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.' }); } maybeWarnDeprecated36pxSize({ componentName: 'FormTokenField', size: undefined, __next40pxDefaultSize }); const instanceId = useInstanceId(FormTokenField); // We reset to these initial values again in the onBlur const [incompleteTokenValue, setIncompleteTokenValue] = useState(''); const [inputOffsetFromEnd, setInputOffsetFromEnd] = useState(0); const [isActive, setIsActive] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); const [selectedSuggestionScroll, setSelectedSuggestionScroll] = useState(false); const prevSuggestions = usePrevious(suggestions); const prevValue = usePrevious(value); const input = useRef(null); const tokensAndInput = useRef(null); const debouncedSpeak = useDebounce(speak, 500); useEffect(() => { // Make sure to focus the input when the isActive state is true. if (isActive && !hasFocus()) { focus(); } }, [isActive]); useEffect(() => { const suggestionsDidUpdate = !isShallowEqual(suggestions, prevSuggestions || []); if (suggestionsDidUpdate || value !== prevValue) { updateSuggestions(suggestionsDidUpdate); } // TODO: updateSuggestions() should first be refactored so its actual deps are clearer. }, [suggestions, prevSuggestions, value, prevValue]); useEffect(() => { updateSuggestions(); }, [incompleteTokenValue]); 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)) { speak(messages.__experimentalInvalid, 'assertive'); return; } addNewTokens([token]); 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); 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 ? 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'); } } 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__*/_jsx(FlexItem, { children: /*#__PURE__*/_jsx(Token, { 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__*/_jsx(TokenInput, { ...inputProps, onChange: !(maxLength && value.length >= maxLength) ? onInputChangeHandler : undefined, ref: input }, "input"); } const classes = clsx(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: 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__*/_jsxs("div", { ...tokenFieldProps, children: [label && /*#__PURE__*/_jsx(StyledLabel, { htmlFor: `components-form-token-input-${instanceId}`, className: "components-form-token-field__label", children: label }), /*#__PURE__*/_jsxs("div", { ref: tokensAndInput, className: classes, tabIndex: -1, onMouseDown: onContainerTouched, onTouchStart: onContainerTouched, children: [/*#__PURE__*/_jsx(TokensAndInputWrapperFlex, { justify: "flex-start", align: "center", gap: 1, wrap: true, __next40pxDefaultSize: __next40pxDefaultSize, hasTokens: !!value.length, children: renderTokensAndInput() }), isExpanded && /*#__PURE__*/_jsx(SuggestionsList, { instanceId: instanceId, match: saveTransform(incompleteTokenValue), displayTransform: displayTransform, suggestions: matchingSuggestions, selectedIndex: selectedSuggestionIndex, scrollIntoView: selectedSuggestionScroll, onHover: onSuggestionHovered, onSelect: onSuggestionSelected, __experimentalRenderItem: __experimentalRenderItem })] }), !__nextHasNoMarginBottom && /*#__PURE__*/_jsx(Spacer, { marginBottom: 2 }), __experimentalShowHowTo && /*#__PURE__*/_jsx(StyledHelp, { id: `components-form-token-suggestions-howto-${instanceId}`, className: "components-form-token-field__help", __nextHasNoMarginBottom: __nextHasNoMarginBottom, children: tokenizeOnSpace ? __('Separate with commas, spaces, or the Enter key.') : __('Separate with commas or the Enter key.') })] }); /* eslint-enable jsx-a11y/no-static-element-interactions */ } export default FormTokenField; //# sourceMappingURL=index.js.map