UNPKG

@wordpress/components

Version:
476 lines (406 loc) 15 kB
import { createElement, Fragment } from "@wordpress/element"; /** * External dependencies */ import classnames from 'classnames'; import { escapeRegExp, find, map, debounce, deburr } from 'lodash'; /** * WordPress dependencies */ import { renderToString, useEffect, useLayoutEffect, useState } from '@wordpress/element'; import { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT, BACKSPACE } from '@wordpress/keycodes'; import { __, _n, sprintf } from '@wordpress/i18n'; import { useInstanceId } from '@wordpress/compose'; import { create, slice, insert, isCollapsed, getTextContent, useAnchorRef } from '@wordpress/rich-text'; /** * Internal dependencies */ import Button from '../button'; import Popover from '../popover'; import withSpokenMessages from '../higher-order/with-spoken-messages'; /** * A raw completer option. * * @typedef {*} CompleterOption */ /** * @callback FnGetOptions * * @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them. */ /** * @callback FnGetOptionKeywords * @param {CompleterOption} option a completer option. * * @return {string[]} list of key words to search. */ /** * @callback FnIsOptionDisabled * @param {CompleterOption} option a completer option. * * @return {string[]} whether or not the given option is disabled. */ /** * @callback FnGetOptionLabel * @param {CompleterOption} option a completer option. * * @return {(string|Array.<(string|WPElement)>)} list of react components to render. */ /** * @callback FnAllowContext * @param {string} before the string before the auto complete trigger and query. * @param {string} after the string after the autocomplete trigger and query. * * @return {boolean} true if the completer can handle. */ /** * @typedef {Object} OptionCompletion * @property {'insert-at-caret'|'replace'} action the intended placement of the completion. * @property {OptionCompletionValue} value the completion value. */ /** * A completion value. * * @typedef {(string|WPElement|Object)} OptionCompletionValue */ /** * @callback FnGetOptionCompletion * @param {CompleterOption} value the value of the completer option. * @param {string} query the text value of the autocomplete query. * * @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an * OptionCompletionValue is returned, the * completion action defaults to `insert-at-caret`. */ /** * @typedef {Object} WPCompleter * @property {string} name a way to identify a completer, useful for selective overriding. * @property {?string} className A class to apply to the popup menu. * @property {string} triggerPrefix the prefix that will display the menu. * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. */ function filterOptions(search, options = [], maxResults = 10) { const filtered = []; for (let i = 0; i < options.length; i++) { const option = options[i]; // Merge label into keywords let { keywords = [] } = option; if ('string' === typeof option.label) { keywords = [...keywords, option.label]; } const isMatch = keywords.some(keyword => search.test(deburr(keyword))); if (!isMatch) { continue; } filtered.push(option); // Abort early if max reached if (filtered.length === maxResults) { break; } } return filtered; } const getAutoCompleterUI = autocompleter => { const useItems = autocompleter.useItems ? autocompleter.useItems : filterValue => { const [items, setItems] = useState([]); /* * We support both synchronous and asynchronous retrieval of completer options * but internally treat all as async so we maintain a single, consistent code path. * * Because networks can be slow, and the internet is wonderfully unpredictable, * we don't want two promises updating the state at once. This ensures that only * the most recent promise will act on `optionsData`. This doesn't use the state * because `setState` is batched, and so there's no guarantee that setting * `activePromise` in the state would result in it actually being in `this.state` * before the promise resolves and we check to see if this is the active promise or not. */ useLayoutEffect(() => { const { options, isDebounced } = autocompleter; const loadOptions = debounce(() => { const promise = Promise.resolve(typeof options === 'function' ? options(filterValue) : options).then(optionsData => { if (promise.canceled) { return; } const keyedOptions = optionsData.map((optionData, optionIndex) => ({ key: `${autocompleter.name}-${optionIndex}`, value: optionData, label: autocompleter.getOptionLabel(optionData), keywords: autocompleter.getOptionKeywords ? autocompleter.getOptionKeywords(optionData) : [], isDisabled: autocompleter.isOptionDisabled ? autocompleter.isOptionDisabled(optionData) : false })); // create a regular expression to filter the options const search = new RegExp('(?:\\b|\\s|^)' + escapeRegExp(filterValue), 'i'); setItems(filterOptions(search, keyedOptions)); }); return promise; }, isDebounced ? 250 : 0); const promise = loadOptions(); return () => { loadOptions.cancel(); if (promise) { promise.canceled = true; } }; }, [filterValue]); return [items]; }; function AutocompleterUI({ filterValue, instanceId, listBoxId, className, selectedIndex, onChangeOptions, onSelect, onReset, value, contentRef }) { const [items] = useItems(filterValue); const anchorRef = useAnchorRef({ ref: contentRef, value }); useLayoutEffect(() => { onChangeOptions(items); }, [items]); if (!items.length > 0) { return null; } return createElement(Popover, { focusOnMount: false, onClose: onReset, position: "top right", className: "components-autocomplete__popover", anchorRef: anchorRef }, createElement("div", { id: listBoxId, role: "listbox", className: "components-autocomplete__results" }, map(items, (option, index) => createElement(Button, { key: option.key, id: `components-autocomplete-item-${instanceId}-${option.key}`, role: "option", "aria-selected": index === selectedIndex, disabled: option.isDisabled, className: classnames('components-autocomplete__result', className, { 'is-selected': index === selectedIndex }), onClick: () => onSelect(option) }, option.label)))); } return AutocompleterUI; }; function Autocomplete({ children, isSelected, record, onChange, onReplace, completers, debouncedSpeak, contentRef }) { const instanceId = useInstanceId(Autocomplete); const [selectedIndex, setSelectedIndex] = useState(0); const [filteredOptions, setFilteredOptions] = useState([]); const [filterValue, setFilterValue] = useState(''); const [autocompleter, setAutocompleter] = useState(null); const [AutocompleterUI, setAutocompleterUI] = useState(null); const [backspacing, setBackspacing] = useState(false); function insertCompletion(replacement) { const end = record.start; const start = end - autocompleter.triggerPrefix.length - filterValue.length; const toInsert = create({ html: renderToString(replacement) }); onChange(insert(record, toInsert, start, end)); } function select(option) { const { getOptionCompletion } = autocompleter || {}; if (option.isDisabled) { return; } if (getOptionCompletion) { const completion = getOptionCompletion(option.value, filterValue); const { action, value } = undefined === completion.action || undefined === completion.value ? { action: 'insert-at-caret', value: completion } : completion; if ('replace' === action) { onReplace([value]); } else if ('insert-at-caret' === action) { insertCompletion(value); } } // Reset autocomplete state after insertion rather than before // so insertion events don't cause the completion menu to redisplay. reset(); } function reset() { setSelectedIndex(0); setFilteredOptions([]); setFilterValue(''); setAutocompleter(null); setAutocompleterUI(null); } function announce(options) { if (!debouncedSpeak) { return; } if (!!options.length) { debouncedSpeak(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.', options.length), options.length), 'assertive'); } else { debouncedSpeak(__('No results.'), 'assertive'); } } /** * Load options for an autocompleter. * * @param {Array} options */ function onChangeOptions(options) { setSelectedIndex(options.length === filteredOptions.length ? selectedIndex : 0); setFilteredOptions(options); announce(options); } function handleKeyDown(event) { setBackspacing(event.keyCode === BACKSPACE); if (!autocompleter) { return; } if (filteredOptions.length === 0) { return; } switch (event.keyCode) { case UP: setSelectedIndex((selectedIndex === 0 ? filteredOptions.length : selectedIndex) - 1); break; case DOWN: setSelectedIndex((selectedIndex + 1) % filteredOptions.length); break; case ESCAPE: setAutocompleter(null); setAutocompleterUI(null); break; case ENTER: select(filteredOptions[selectedIndex]); break; case LEFT: case RIGHT: reset(); return; default: return; } // Any handled keycode should prevent original behavior. This relies on // the early return in the default case. event.preventDefault(); event.stopPropagation(); } let textContent; if (isCollapsed(record)) { textContent = getTextContent(slice(record, 0)); } useEffect(() => { if (!textContent) { return; } const text = deburr(textContent); const textAfterSelection = getTextContent(slice(record, undefined, getTextContent(record).length)); const completer = find(completers, ({ triggerPrefix, allowContext }) => { const index = text.lastIndexOf(triggerPrefix); if (index === -1) { return false; } const textWithoutTrigger = text.slice(index + 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 false; 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 = backspacing && textWithoutTrigger.split(/\s/).length <= 3; if (mismatch && !(matchingWhileBackspacing || hasOneTriggerWord)) { return false; } if (allowContext && !allowContext(text.slice(0, index), textAfterSelection)) { return false; } if (/^\s/.test(textWithoutTrigger) || /\s\s+$/.test(textWithoutTrigger)) { return false; } return /[\u0000-\uFFFF]*$/.test(textWithoutTrigger); }); if (!completer) { reset(); return; } const safeTrigger = escapeRegExp(completer.triggerPrefix); const match = text.slice(text.lastIndexOf(completer.triggerPrefix)).match(new RegExp(`${safeTrigger}([\u0000-\uFFFF]*)$`)); const query = match && match[1]; setAutocompleter(completer); setAutocompleterUI(() => completer !== autocompleter ? getAutoCompleterUI(completer) : AutocompleterUI); setFilterValue(query); }, [textContent]); const { key: selectedKey = '' } = filteredOptions[selectedIndex] || {}; const { className } = autocompleter || {}; const isExpanded = !!autocompleter && filteredOptions.length > 0; const listBoxId = isExpanded ? `components-autocomplete-listbox-${instanceId}` : null; const activeId = isExpanded ? `components-autocomplete-item-${instanceId}-${selectedKey}` : null; return createElement(Fragment, null, children({ isExpanded, listBoxId, activeId, onKeyDown: handleKeyDown }), isSelected && AutocompleterUI && createElement(AutocompleterUI, { className: className, filterValue: filterValue, instanceId: instanceId, listBoxId: listBoxId, selectedIndex: selectedIndex, onChangeOptions: onChangeOptions, onSelect: select, value: record, contentRef: contentRef })); } export default withSpokenMessages(Autocomplete); //# sourceMappingURL=index.js.map