UNPKG

@wordpress/components

Version:
529 lines (528 loc) 17.4 kB
// packages/components/src/form-token-field/index.tsx import clsx from "clsx"; 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 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 { 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"; var identity = (value) => value; 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 = void 0, 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, tokenizeOnBlur = false } = useDeprecated36pxDefaultSizeProp(props); maybeWarnDeprecated36pxSize({ componentName: "FormTokenField", size: void 0, __next40pxDefaultSize }); const instanceId = useInstanceId(FormTokenField); 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(() => { if (isActive && !hasFocus()) { focus(); } }, [isActive]); useEffect(() => { const suggestionsDidUpdate = !isShallowEqual(suggestions, prevSuggestions || []); if (suggestionsDidUpdate || value !== prevValue) { updateSuggestions(suggestionsDidUpdate); } }, [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 (hasFocus() || event.target === tokensAndInput.current) { setIsActive(true); setIsExpanded(__experimentalExpandOnFocus || isExpanded); } else { setIsActive(false); } if ("function" === typeof onFocus) { onFocus(event); } } function onBlur(event) { if (inputHasValidValue() && __experimentalValidateInput(incompleteTokenValue)) { setIsActive(false); if (tokenizeOnBlur && inputHasValidValue()) { addNewToken(incompleteTokenValue); } } else { setIncompleteTokenValue(""); setInputOffsetFromEnd(0); setIsActive(false); if (__experimentalExpandOnFocus) { const hasFocusWithin = event.relatedTarget === tokensAndInput.current; setIsExpanded(hasFocusWithin); } else { 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) { 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; } function handleDownArrowKey() { setSelectedSuggestionIndex((index) => { return (index + 1) % getMatchingSuggestions(incompleteTokenValue, suggestions, value, maxSuggestions, saveTransform).length; }); setSelectedSuggestionScroll(true); return true; } 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; } function handleTabKey(event) { collapseSuggestionsList(event); return false; } function handleCommaKey() { if (inputHasValidValue()) { addNewToken(incompleteTokenValue); } return true; } 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]); 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 void 0; } 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 matchingSuggestions2 = getMatchingSuggestions(incompleteTokenValue); const hasMatchingSuggestions = matchingSuggestions2.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.", matchingSuggestions2.length), matchingSuggestions2.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 : void 0; const termPosition = index + 1; const termsCount = tokens.length; return /* @__PURE__ */ _jsx(FlexItem, { children: /* @__PURE__ */ _jsx(Token, { value: _value, status, title: typeof token !== "string" ? token.title : void 0, displayTransform, onClickRemove: onTokenClickRemove, isBorderless: typeof token !== "string" && token.isBorderless || isBorderless, onMouseEnter: typeof token !== "string" ? token.onMouseEnter : void 0, onMouseLeave: typeof token !== "string" ? token.onMouseLeave : void 0, disabled: "error" !== status && disabled, messages, termsCount, 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 : void 0, 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 }); } 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, hasTokens: !!value.length, children: renderTokensAndInput() }), isExpanded && /* @__PURE__ */ _jsx(SuggestionsList, { instanceId, match: saveTransform(incompleteTokenValue), displayTransform, suggestions: matchingSuggestions, selectedIndex: selectedSuggestionIndex, scrollIntoView: selectedSuggestionScroll, onHover: onSuggestionHovered, onSelect: onSuggestionSelected, __experimentalRenderItem })] }), __experimentalShowHowTo && /* @__PURE__ */ _jsx(StyledHelp, { id: `components-form-token-suggestions-howto-${instanceId}`, className: "components-form-token-field__help", __nextHasNoMarginBottom: true, children: tokenizeOnSpace ? __("Separate with commas, spaces, or the Enter key.") : __("Separate with commas or the Enter key.") })] }); } var form_token_field_default = FormTokenField; export { FormTokenField, form_token_field_default as default }; //# sourceMappingURL=index.js.map