UNPKG

@wordpress/components

Version:
316 lines (315 loc) 8.82 kB
// packages/components/src/autocomplete/index.tsx import { renderToString, useEffect, useMemo, useReducer, useRef } from "@wordpress/element"; import { useInstanceId, useMergeRefs, useRefEffect } from "@wordpress/compose"; import { create, slice, insert, isCollapsed, getTextContent } from "@wordpress/rich-text"; import { speak } from "@wordpress/a11y"; import { isAppleOS } from "@wordpress/keycodes"; import { AutocompleterUI } from "./autocompleter-ui.mjs"; import { getAutocompleteMatch } from "./get-autocomplete-match.mjs"; import { withIgnoreIMEEvents } from "../utils/with-ignore-ime-events.mjs"; import getNodeText from "../utils/get-node-text.mjs"; import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; var EMPTY_FILTERED_OPTIONS = []; var AUTOCOMPLETE_HOOK_REFERENCE = {}; function getCompletionObject(completion) { if (completion !== null && typeof completion === "object" && "action" in completion && completion.action !== void 0 && "value" in completion && completion.value !== void 0) { return completion; } return { action: "insert-at-caret", value: completion }; } var initialState = { selectedIndex: 0, filteredOptions: EMPTY_FILTERED_OPTIONS, filterValue: "", autocompleter: null }; function autocompleteReducer(state, action) { switch (action.type) { case "RESET": return initialState; case "SELECT": return { ...state, selectedIndex: action.index }; case "OPTIONS": return { ...state, filteredOptions: action.options, selectedIndex: action.options.length === state.filteredOptions.length ? state.selectedIndex : 0 }; case "MATCH": return { ...state, autocompleter: action.completer, filterValue: action.query }; } } function useAutocomplete({ record, onChange, onReplace, completers, contentRef }) { const instanceId = useInstanceId(AUTOCOMPLETE_HOOK_REFERENCE); const [state, dispatch] = useReducer(autocompleteReducer, initialState); const { selectedIndex, filteredOptions, filterValue, autocompleter } = state; const backspacingRef = useRef(false); const prevRecordTextRef = useRef(""); const lastCompletionRef = useRef(null); function insertCompletion(replacement) { if (autocompleter === null) { return ""; } const end = record.start; const start = end - autocompleter.triggerPrefix.length - filterValue.length; const toInsert = create({ html: renderToString(replacement) }); onChange(insert(record, toInsert, start, end)); return getTextContent(toInsert); } function select(option) { if (option.isDisabled || !autocompleter) { return; } const { getOptionCompletion } = autocompleter; if (!getOptionCompletion) { dispatch({ type: "RESET" }); contentRef.current?.focus(); return; } const completionObject = getCompletionObject(getOptionCompletion(option.value, filterValue)); if ("replace" === completionObject.action) { onReplace([completionObject.value]); return; } if ("insert-at-caret" === completionObject.action) { const completionText = insertCompletion(completionObject.value); if (completionText.startsWith(autocompleter.triggerPrefix)) { const afterPrefix = completionText.slice(autocompleter.triggerPrefix.length); if (afterPrefix) { lastCompletionRef.current = { name: autocompleter.name, value: afterPrefix }; } } } dispatch({ type: "RESET" }); contentRef.current?.focus(); } function onChangeOptions(options) { dispatch({ type: "OPTIONS", 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": case "ArrowDown": { const offset = event.key === "ArrowUp" ? -1 : 1; const newIndex = (selectedIndex + offset + filteredOptions.length) % filteredOptions.length; dispatch({ type: "SELECT", index: newIndex }); if (isAppleOS()) { speak(getNodeText(filteredOptions[newIndex].label), "assertive"); } break; } case "Escape": dispatch({ type: "RESET" }); event.preventDefault(); break; case "Enter": select(filteredOptions[selectedIndex]); break; case "ArrowLeft": case "ArrowRight": dispatch({ type: "RESET" }); return; default: return; } event.preventDefault(); } const textContent = useMemo(() => { if (isCollapsed(record)) { return getTextContent(slice(record, 0)); } return ""; }, [record]); useEffect(() => { const isTextChange = record.text !== prevRecordTextRef.current; prevRecordTextRef.current = record.text; function getTextAfterSelection() { return textContent ? getTextContent(slice(record, void 0, getTextContent(record).length)) : ""; } const match = getAutocompleteMatch(textContent, completers, { matchCount: filteredOptions.length, isBackspacing: backspacingRef.current, getTextAfterSelection, lastCompletion: lastCompletionRef.current }); if (!match) { if (autocompleter) { dispatch({ type: "RESET" }); } return; } const { completer, filterValue: query } = match; if (!autocompleter && !isTextChange) { return; } if (lastCompletionRef.current && lastCompletionRef.current.name === completer.name) { lastCompletionRef.current = null; } dispatch({ type: "MATCH", completer, query }); }, [textContent]); const { key: selectedKey = "" } = filteredOptions[selectedIndex] || {}; const { className } = autocompleter || {}; const isExpanded = !!autocompleter && filteredOptions.length > 0; const listBoxId = isExpanded ? `components-autocomplete-listbox-${instanceId}` : void 0; const activeId = isExpanded ? `components-autocomplete-item-${instanceId}-${selectedKey}` : null; const hasSelection = record.start !== void 0; const showPopover = !!textContent && hasSelection && !!autocompleter; return { listBoxId, activeId, onKeyDown: withIgnoreIMEEvents(handleKeyDown), popover: showPopover && /* @__PURE__ */ _jsx(AutocompleterUI, { autocompleter, className, filterValue, instanceId, listBoxId, selectedIndex, onChangeOptions, onSelect: select, contentRef, reset: () => dispatch({ type: "RESET" }) }, autocompleter.name + autocompleter.triggerPrefix) }; } function recordValuesMatch(a, b) { return a.text === b.text && a.start === b.start && a.end === b.end; } function useLastDifferentValue(value) { const history = useRef([]); const lastEntry = history.current[history.current.length - 1]; if (!lastEntry || !recordValuesMatch(value, lastEntry)) { history.current.push(value); } if (history.current.length > 2) { history.current.shift(); } return history.current[0]; } function useAutocompleteProps(options) { const ref = useRef(null); const onKeyDownRef = useRef(void 0); const { record } = options; const previousRecord = useLastDifferentValue(record); const { popover, listBoxId, activeId, onKeyDown } = useAutocomplete({ ...options, contentRef: ref }); onKeyDownRef.current = onKeyDown; const mergedRefs = useMergeRefs([ref, useRefEffect((element) => { function _onKeyDown(event) { onKeyDownRef.current?.(event); } element.addEventListener("keydown", _onKeyDown); return () => { element.removeEventListener("keydown", _onKeyDown); }; }, [])]); const didUserInput = record.text !== previousRecord?.text; if (!didUserInput) { return { ref: mergedRefs }; } return { ref: mergedRefs, children: popover, "aria-autocomplete": listBoxId ? "list" : void 0, "aria-owns": listBoxId, "aria-activedescendant": activeId }; } function Autocomplete({ children, isSelected, ...options }) { const { popover, ...props } = useAutocomplete(options); return /* @__PURE__ */ _jsxs(_Fragment, { children: [children(props), isSelected && popover] }); } export { Autocomplete as default, useAutocomplete, useAutocompleteProps, useLastDifferentValue }; //# sourceMappingURL=index.mjs.map