UNPKG

@wordpress/components

Version:
359 lines (346 loc) 12.6 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.default = Autocomplete; exports.useAutocomplete = useAutocomplete; exports.useAutocompleteProps = useAutocompleteProps; var _removeAccents = _interopRequireDefault(require("remove-accents")); var _element = require("@wordpress/element"); var _compose = require("@wordpress/compose"); var _richText = require("@wordpress/rich-text"); var _a11y = require("@wordpress/a11y"); var _keycodes = require("@wordpress/keycodes"); var _autocompleterUi = require("./autocompleter-ui"); var _strings = require("../utils/strings"); var _withIgnoreImeEvents = require("../utils/with-ignore-ime-events"); var _getNodeText = _interopRequireDefault(require("../utils/get-node-text")); var _jsxRuntime = require("react/jsx-runtime"); /** * External dependencies */ /** * WordPress dependencies */ /** * Internal dependencies */ const EMPTY_FILTERED_OPTIONS = []; // Used for generating the instance ID const AUTOCOMPLETE_HOOK_REFERENCE = {}; function useAutocomplete({ record, onChange, onReplace, completers, contentRef }) { const instanceId = (0, _compose.useInstanceId)(AUTOCOMPLETE_HOOK_REFERENCE); const [selectedIndex, setSelectedIndex] = (0, _element.useState)(0); const [filteredOptions, setFilteredOptions] = (0, _element.useState)(EMPTY_FILTERED_OPTIONS); const [filterValue, setFilterValue] = (0, _element.useState)(''); const [autocompleter, setAutocompleter] = (0, _element.useState)(null); const [AutocompleterUI, setAutocompleterUI] = (0, _element.useState)(null); const backspacingRef = (0, _element.useRef)(false); function insertCompletion(replacement) { if (autocompleter === null) { return; } const end = record.start; const start = end - autocompleter.triggerPrefix.length - filterValue.length; const toInsert = (0, _richText.create)({ html: (0, _element.renderToString)(replacement) }); onChange((0, _richText.insert)(record, toInsert, start, end)); } function select(option) { const { getOptionCompletion } = autocompleter || {}; if (option.isDisabled) { return; } if (getOptionCompletion) { const completion = getOptionCompletion(option.value, filterValue); const isCompletionObject = obj => { return obj !== null && typeof obj === 'object' && 'action' in obj && obj.action !== undefined && 'value' in obj && obj.value !== undefined; }; const completionObject = isCompletionObject(completion) ? completion : { action: 'insert-at-caret', value: completion }; if ('replace' === completionObject.action) { onReplace([completionObject.value]); // When replacing, the component will unmount, so don't reset // state (below) on an unmounted component. return; } else if ('insert-at-caret' === completionObject.action) { insertCompletion(completionObject.value); } } // Reset autocomplete state after insertion rather than before // so insertion events don't cause the completion menu to redisplay. reset(); // Make sure that the content remains focused after making a selection // and that the text cursor position is not lost. contentRef.current?.focus(); } function reset() { setSelectedIndex(0); setFilteredOptions(EMPTY_FILTERED_OPTIONS); setFilterValue(''); setAutocompleter(null); setAutocompleterUI(null); } /** * Load options for an autocompleter. * * @param {Array} options */ function onChangeOptions(options) { setSelectedIndex(options.length === filteredOptions.length ? selectedIndex : 0); setFilteredOptions(options); } function handleKeyDown(event) { backspacingRef.current = event.key === 'Backspace'; if (!autocompleter) { return; } if (filteredOptions.length === 0) { return; } if (event.defaultPrevented) { return; } switch (event.key) { case 'ArrowUp': { const newIndex = (selectedIndex === 0 ? filteredOptions.length : selectedIndex) - 1; setSelectedIndex(newIndex); // See the related PR as to why this is necessary: https://github.com/WordPress/gutenberg/pull/54902. if ((0, _keycodes.isAppleOS)()) { (0, _a11y.speak)((0, _getNodeText.default)(filteredOptions[newIndex].label), 'assertive'); } break; } case 'ArrowDown': { const newIndex = (selectedIndex + 1) % filteredOptions.length; setSelectedIndex(newIndex); if ((0, _keycodes.isAppleOS)()) { (0, _a11y.speak)((0, _getNodeText.default)(filteredOptions[newIndex].label), 'assertive'); } break; } case 'Escape': setAutocompleter(null); setAutocompleterUI(null); event.preventDefault(); break; case 'Enter': select(filteredOptions[selectedIndex]); break; case 'ArrowLeft': case 'ArrowRight': reset(); return; default: return; } // Any handled key should prevent original behavior. This relies on // the early return in the default case. event.preventDefault(); } // textContent is a primitive (string), memoizing is not strictly necessary // but this is a preemptive performance improvement, since the autocompleter // is a potential bottleneck for the editor type metric. const textContent = (0, _element.useMemo)(() => { if ((0, _richText.isCollapsed)(record)) { return (0, _richText.getTextContent)((0, _richText.slice)(record, 0)); } return ''; }, [record]); (0, _element.useEffect)(() => { if (!textContent) { if (autocompleter) { reset(); } return; } // Find the completer with the highest triggerPrefix index in the // textContent. const completer = completers.reduce((lastTrigger, currentCompleter) => { const triggerIndex = textContent.lastIndexOf(currentCompleter.triggerPrefix); const lastTriggerIndex = lastTrigger !== null ? textContent.lastIndexOf(lastTrigger.triggerPrefix) : -1; return triggerIndex > lastTriggerIndex ? currentCompleter : lastTrigger; }, null); if (!completer) { if (autocompleter) { reset(); } return; } const { allowContext, triggerPrefix } = completer; const triggerIndex = textContent.lastIndexOf(triggerPrefix); const textWithoutTrigger = textContent.slice(triggerIndex + triggerPrefix.length); const tooDistantFromTrigger = textWithoutTrigger.length > 50; // 50 chars seems to be a good limit. // This is a final barrier to prevent the effect from completing with // an extremely long string, which causes the editor to slow-down // significantly. This could happen, for example, if `matchingWhileBackspacing` // is true and one of the "words" end up being too long. If that's the case, // it will be caught by this guard. if (tooDistantFromTrigger) { return; } const mismatch = filteredOptions.length === 0; const wordsFromTrigger = textWithoutTrigger.split(/\s/); // We need to allow the effect to run when not backspacing and if there // was a mismatch. i.e when typing a trigger + the match string or when // clicking in an existing trigger word on the page. We do that if we // detect that we have one word from trigger in the current textual context. // // Ex.: "Some text @a" <-- "@a" will be detected as the trigger word and // allow the effect to run. It will run until there's a mismatch. const hasOneTriggerWord = wordsFromTrigger.length === 1; // This is used to allow the effect to run when backspacing and if // "touching" a word that "belongs" to a trigger. We consider a "trigger // word" any word up to the limit of 3 from the trigger character. // Anything beyond that is ignored if there's a mismatch. This allows // us to "escape" a mismatch when backspacing, but still imposing some // sane limits. // // Ex: "Some text @marcelo sekkkk" <--- "kkkk" caused a mismatch, but // if the user presses backspace here, it will show the completion popup again. const matchingWhileBackspacing = backspacingRef.current && wordsFromTrigger.length <= 3; if (mismatch && !(matchingWhileBackspacing || hasOneTriggerWord)) { if (autocompleter) { reset(); } return; } const textAfterSelection = (0, _richText.getTextContent)((0, _richText.slice)(record, undefined, (0, _richText.getTextContent)(record).length)); if (allowContext && !allowContext(textContent.slice(0, triggerIndex), textAfterSelection)) { if (autocompleter) { reset(); } return; } if (/^\s/.test(textWithoutTrigger) || /\s\s+$/.test(textWithoutTrigger)) { if (autocompleter) { reset(); } return; } if (!/[\u0000-\uFFFF]*$/.test(textWithoutTrigger)) { if (autocompleter) { reset(); } return; } const safeTrigger = (0, _strings.escapeRegExp)(completer.triggerPrefix); const text = (0, _removeAccents.default)(textContent); const match = text.slice(text.lastIndexOf(completer.triggerPrefix)).match(new RegExp(`${safeTrigger}([\u0000-\uFFFF]*)$`)); const query = match && match[1]; setAutocompleter(completer); setAutocompleterUI(() => completer !== autocompleter ? (0, _autocompleterUi.getAutoCompleterUI)(completer) : AutocompleterUI); setFilterValue(query === null ? '' : query); // We want to avoid introducing unexpected side effects. // See https://github.com/WordPress/gutenberg/pull/41820 }, [textContent]); const { key: selectedKey = '' } = filteredOptions[selectedIndex] || {}; const { className } = autocompleter || {}; const isExpanded = !!autocompleter && filteredOptions.length > 0; const listBoxId = isExpanded ? `components-autocomplete-listbox-${instanceId}` : undefined; const activeId = isExpanded ? `components-autocomplete-item-${instanceId}-${selectedKey}` : null; const hasSelection = record.start !== undefined; const showPopover = !!textContent && hasSelection && !!AutocompleterUI; return { listBoxId, activeId, onKeyDown: (0, _withIgnoreImeEvents.withIgnoreIMEEvents)(handleKeyDown), popover: showPopover && /*#__PURE__*/(0, _jsxRuntime.jsx)(AutocompleterUI, { className: className, filterValue: filterValue, instanceId: instanceId, listBoxId: listBoxId, selectedIndex: selectedIndex, onChangeOptions: onChangeOptions, onSelect: select, value: record, contentRef: contentRef, reset: reset }) }; } function useLastDifferentValue(value) { const history = (0, _element.useRef)(new Set()); history.current.add(value); // Keep the history size to 2. if (history.current.size > 2) { history.current.delete(Array.from(history.current)[0]); } return Array.from(history.current)[0]; } function useAutocompleteProps(options) { const ref = (0, _element.useRef)(null); const onKeyDownRef = (0, _element.useRef)(); const { record } = options; const previousRecord = useLastDifferentValue(record); const { popover, listBoxId, activeId, onKeyDown } = useAutocomplete({ ...options, contentRef: ref }); onKeyDownRef.current = onKeyDown; const mergedRefs = (0, _compose.useMergeRefs)([ref, (0, _compose.useRefEffect)(element => { function _onKeyDown(event) { onKeyDownRef.current?.(event); } element.addEventListener('keydown', _onKeyDown); return () => { element.removeEventListener('keydown', _onKeyDown); }; }, [])]); // We only want to show the popover if the user has typed something. const didUserInput = record.text !== previousRecord?.text; if (!didUserInput) { return { ref: mergedRefs }; } return { ref: mergedRefs, children: popover, 'aria-autocomplete': listBoxId ? 'list' : undefined, 'aria-owns': listBoxId, 'aria-activedescendant': activeId }; } function Autocomplete({ children, isSelected, ...options }) { const { popover, ...props } = useAutocomplete(options); return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, { children: [children(props), isSelected && popover] }); } //# sourceMappingURL=index.js.map