@wordpress/components
Version:
UI components for WordPress.
495 lines (412 loc) • 15.7 kB
JavaScript
;
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var _element = require("@wordpress/element");
var _classnames = _interopRequireDefault(require("classnames"));
var _lodash = require("lodash");
var _keycodes = require("@wordpress/keycodes");
var _i18n = require("@wordpress/i18n");
var _compose = require("@wordpress/compose");
var _richText = require("@wordpress/rich-text");
var _button = _interopRequireDefault(require("../button"));
var _popover = _interopRequireDefault(require("../popover"));
var _withSpokenMessages = _interopRequireDefault(require("../higher-order/with-spoken-messages"));
/**
* External dependencies
*/
/**
* WordPress dependencies
*/
/**
* Internal dependencies
*/
/**
* 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((0, _lodash.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] = (0, _element.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.
*/
(0, _element.useLayoutEffect)(() => {
const {
options,
isDebounced
} = autocompleter;
const loadOptions = (0, _lodash.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|^)' + (0, _lodash.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 = (0, _richText.useAnchorRef)({
ref: contentRef,
value
});
(0, _element.useLayoutEffect)(() => {
onChangeOptions(items);
}, [items]);
if (!items.length > 0) {
return null;
}
return (0, _element.createElement)(_popover.default, {
focusOnMount: false,
onClose: onReset,
position: "top right",
className: "components-autocomplete__popover",
anchorRef: anchorRef
}, (0, _element.createElement)("div", {
id: listBoxId,
role: "listbox",
className: "components-autocomplete__results"
}, (0, _lodash.map)(items, (option, index) => (0, _element.createElement)(_button.default, {
key: option.key,
id: `components-autocomplete-item-${instanceId}-${option.key}`,
role: "option",
"aria-selected": index === selectedIndex,
disabled: option.isDisabled,
className: (0, _classnames.default)('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 = (0, _compose.useInstanceId)(Autocomplete);
const [selectedIndex, setSelectedIndex] = (0, _element.useState)(0);
const [filteredOptions, setFilteredOptions] = (0, _element.useState)([]);
const [filterValue, setFilterValue] = (0, _element.useState)('');
const [autocompleter, setAutocompleter] = (0, _element.useState)(null);
const [AutocompleterUI, setAutocompleterUI] = (0, _element.useState)(null);
const [backspacing, setBackspacing] = (0, _element.useState)(false);
function insertCompletion(replacement) {
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 {
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((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.', options.length), options.length), 'assertive');
} else {
debouncedSpeak((0, _i18n.__)('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 === _keycodes.BACKSPACE);
if (!autocompleter) {
return;
}
if (filteredOptions.length === 0) {
return;
}
switch (event.keyCode) {
case _keycodes.UP:
setSelectedIndex((selectedIndex === 0 ? filteredOptions.length : selectedIndex) - 1);
break;
case _keycodes.DOWN:
setSelectedIndex((selectedIndex + 1) % filteredOptions.length);
break;
case _keycodes.ESCAPE:
setAutocompleter(null);
setAutocompleterUI(null);
break;
case _keycodes.ENTER:
select(filteredOptions[selectedIndex]);
break;
case _keycodes.LEFT:
case _keycodes.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 ((0, _richText.isCollapsed)(record)) {
textContent = (0, _richText.getTextContent)((0, _richText.slice)(record, 0));
}
(0, _element.useEffect)(() => {
if (!textContent) {
return;
}
const text = (0, _lodash.deburr)(textContent);
const textAfterSelection = (0, _richText.getTextContent)((0, _richText.slice)(record, undefined, (0, _richText.getTextContent)(record).length));
const completer = (0, _lodash.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 = (0, _lodash.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 (0, _element.createElement)(_element.Fragment, null, children({
isExpanded,
listBoxId,
activeId,
onKeyDown: handleKeyDown
}), isSelected && AutocompleterUI && (0, _element.createElement)(AutocompleterUI, {
className: className,
filterValue: filterValue,
instanceId: instanceId,
listBoxId: listBoxId,
selectedIndex: selectedIndex,
onChangeOptions: onChangeOptions,
onSelect: select,
value: record,
contentRef: contentRef
}));
}
var _default = (0, _withSpokenMessages.default)(Autocomplete);
exports.default = _default;
//# sourceMappingURL=index.js.map