UNPKG

preact-combobox

Version:
1,443 lines (1,441 loc) 62.5 kB
// lib/PreactCombobox.jsx import { createPopper } from "@popperjs/core"; import { createPortal } from "preact/compat"; import { useCallback as useCallback2, useEffect, useId, useLayoutEffect, useMemo as useMemo2, useRef as useRef2, useState as useState2 } from "preact/hooks"; // lib/hooks.js import { useCallback, useMemo, useRef, useState } from "preact/hooks"; function isEqual(value1, value2) { const seenA = /* @__PURE__ */ new WeakMap(); const seenB = /* @__PURE__ */ new WeakMap(); function deepCompare(a, b) { if (Object.is(a, b)) return true; if (a === null || b === null || typeof a !== "object" || typeof b !== "object") { return a === b; } if (a.$$typeof === Symbol.for("react.element") || b.$$typeof === Symbol.for("react.element")) { return a === b; } if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) { return false; } if (seenA.has(a)) return seenA.get(a) === b; if (seenB.has(b)) return seenB.get(b) === a; if (seenA.has(b) || seenB.has(a)) return false; seenA.set(a, b); seenB.set(b, a); if (Array.isArray(a)) { if (a.length !== b.length) { return false; } return a.every((item, index) => deepCompare(item, b[index])); } if (a instanceof Date) { return a.getTime() === b.getTime(); } if (a instanceof RegExp) { return a.toString() === b.toString(); } const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; return keysA.every((key) => keysB.includes(key) && deepCompare(a[key], b[key])); } return deepCompare(value1, value2); } function useDeepMemo(newState) { const state = useRef( /** @type {T} */ null ); if (!isEqual(newState, state.current)) { state.current = newState; } return state.current; } function useLive(initialValue) { const [refreshValue, forceRefresh] = useState(0); const ref = useRef(initialValue); let hasValueChanged = false; const getValue = useMemo(() => { hasValueChanged = true; return () => ref.current; }, [refreshValue]); const setValue = useCallback((value) => { if (value !== ref.current) { ref.current = value; forceRefresh((x) => x + 1); } }, []); return [getValue, setValue, hasValueChanged]; } // lib/PreactCombobox.jsx import { Fragment, jsx, jsxs } from "preact/jsx-runtime"; var defaultEnglishTranslations = { searchPlaceholder: "Search...", noOptionsFound: "No options found", loadingOptions: "Loading...", loadingOptionsAnnouncement: "Loading options, please wait...", optionsLoadedAnnouncement: "Options loaded.", noOptionsFoundAnnouncement: "No options found.", addOption: 'Add "{value}"', typeToLoadMore: "...type to load more options", clearValue: "Clear value", selectedOption: "Selected option.", invalidOption: "Invalid option.", invalidValues: "Invalid values:", fieldContainsInvalidValues: "Field contains invalid values", noOptionsSelected: "No options selected", selectionAdded: "added selection", selectionRemoved: "removed selection", selectionsCurrent: "currently selected", selectionsMore: "and {count} more option", selectionsMorePlural: "and {count} more options", // Function to format the count in badge, receives count and language as parameters selectedCountFormatter: (count, lang) => new Intl.NumberFormat(lang).format(count) }; var isPlaywright = navigator.webdriver === true; var isServerDefault = typeof self === "undefined"; function unique(arr) { return Array.from(new Set(arr)); } function toHTMLId(text) { return text.replace(/[^a-zA-Z0-9\-_:.]/g, ""); } function sortValuesToTop(options, values) { const selectedSet = new Set(values); return options.sort((a, b) => { const aSelected = selectedSet.has(a.value); const bSelected = selectedSet.has(b.value); if (aSelected === bSelected) return 0; return aSelected ? -1 : 1; }); } var Portal = ({ parent = document.body, children, rootElementRef }) => { const [dir, setDir] = useState2( /** @type {string|null} */ null ); useEffect(() => { if (rootElementRef?.current) { const rootDir = window.getComputedStyle(rootElementRef.current).direction; const parentDir = window.getComputedStyle(parent).direction; if (rootDir !== parentDir) { setDir(rootDir); } else { setDir(null); } } }, [rootElementRef, parent]); const wrappedChildren = dir ? /* @__PURE__ */ jsx("div", { dir: ( /** @type {"auto" | "rtl" | "ltr"} */ dir ), style: { direction: dir }, children }) : children; return createPortal(wrappedChildren, parent); }; var dropdownPopperModifiers = [ { name: "flip", enabled: true }, { // make the popper width same as root element name: "referenceElementWidth", enabled: true, phase: "beforeWrite", requires: ["computeStyles"], // @ts-ignore fn: ({ state }) => { state.styles.popper.minWidth = `${state.rects.reference.width}px`; }, // @ts-ignore effect: ({ state }) => { state.elements.popper.style.minWidth = `${state.elements.reference.offsetWidth}px`; } }, { name: "eventListeners", enabled: true, options: { scroll: true, resize: true } } ]; var tooltipPopperModifiers = [ { name: "offset", options: { offset: [0, 2] } }, { name: "eventListeners", enabled: true, options: { scroll: true, resize: true } } ]; var isTouchDevice = typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)")?.matches; var visualViewportInitialHeight = window.visualViewport?.height ?? 0; var wasVisualViewportInitialHeightAnApproximate = true; function subscribeToVirtualKeyboard({ visibleCallback, heightCallback }) { if (!isTouchDevice || typeof window === "undefined" || !window.visualViewport) return null; let isVisible = false; const handleViewportResize = () => { if (!window.visualViewport) return; const heightDiff = visualViewportInitialHeight - window.visualViewport.height; const isVisibleNow = heightDiff > 150; if (isVisible !== isVisibleNow) { isVisible = isVisibleNow; visibleCallback?.(isVisible); } heightCallback?.(heightDiff, isVisible); }; window.visualViewport.addEventListener("resize", handleViewportResize, { passive: true }); return () => { window.visualViewport?.removeEventListener("resize", handleViewportResize); }; } var languageCache = {}; function getExactMatchScore(query, option, language) { const { label, value, ...rest } = option; if (value === query) { return { ...rest, label, value, score: 9, /** @type {'value'} */ matched: "value", /** @type {Array<[number, number]>} */ matchSlices: [[0, value.length]] }; } if (label === query) { return { ...rest, label, value, score: 9, /** @type {'label'} */ matched: "label", /** @type {Array<[number, number]>} */ matchSlices: [[0, label.length]] }; } const { caseMatcher } = ( /** @type {LanguageCache} */ languageCache[language] ); if (caseMatcher.compare(value, query) === 0) { return { ...rest, label, value, score: 7, /** @type {'value'} */ matched: "value", /** @type {Array<[number, number]>} */ matchSlices: [[0, value.length]] }; } if (caseMatcher.compare(label, query) === 0) { return { ...rest, label, value, score: 7, /** @type {'label'} */ matched: "label", /** @type {Array<[number, number]>} */ matchSlices: [[0, label.length]] }; } return null; } function getMatchScore(query, options, language = "en", filterAndSort = true) { query = query.trim(); if (!query) { const matchSlices = ( /** @type {Array<[number, number]>} */ [] ); return options.map((option) => ({ ...option, label: option.label, value: option.value, score: 0, matched: "none", matchSlices })); } if (!languageCache[language]) { languageCache[language] = { baseMatcher: new Intl.Collator(language, { usage: "search", sensitivity: "base" }), caseMatcher: new Intl.Collator(language, { usage: "search", sensitivity: "accent" }), wordSegmenter: new Intl.Segmenter(language, { granularity: "word" }) }; } const { baseMatcher, caseMatcher, wordSegmenter } = languageCache[language]; const isCommaSeparated = query.includes(","); let matches = options.map((option) => { const { label, value, ...rest } = option; if (isCommaSeparated) { const querySegments2 = query.split(","); const matches2 = querySegments2.map((querySegment) => getExactMatchScore(querySegment.trim(), option, language)).filter((match) => match !== null).sort((a, b) => b.score - a.score); return ( /** @type {OptionMatch} */ matches2[0] || { ...rest, label, value, score: 0, matched: "none" } ); } const exactMatch = getExactMatchScore(query, option, language); if (exactMatch) { return exactMatch; } if (baseMatcher.compare(label, query) === 0) { return { ...rest, label, value, score: 5, /** @type {'label'} */ matched: "label", /** @type {Array<[number, number]>} */ matchSlices: [[0, label.length]] }; } if (baseMatcher.compare(value, query) === 0) { return { ...rest, label, value, score: 5, /** @type {'value'} */ matched: "value", /** @type {Array<[number, number]>} */ matchSlices: [[0, value.length]] }; } const querySegments = Array.from(wordSegmenter.segment(query)); const labelWordSegments = Array.from(wordSegmenter.segment(label.trim())); let len = 0; let firstIndex = -1; for (let i = 0; i < labelWordSegments.length; i++) { const labelWordSegment = ( /** @type {Intl.SegmentData} */ labelWordSegments[i] ); const querySegment = querySegments[len]; if (!querySegment) break; if (len === querySegments.length - 1) { const lastQueryWord = querySegment.segment; if (baseMatcher.compare( labelWordSegment.segment.slice(0, lastQueryWord.length), lastQueryWord ) === 0) { return { ...rest, label, value, score: 3, /** @type {'label'} */ matched: "label", /** @type {Array<[number, number]>} */ // @ts-ignore matchSlices: [ [ firstIndex > -1 ? firstIndex : labelWordSegment.index, labelWordSegment.index + lastQueryWord.length ] ] }; } } else if (baseMatcher.compare(labelWordSegment.segment, querySegment.segment) === 0) { len++; if (len === 1) { firstIndex = labelWordSegment.index; } continue; } len = 0; firstIndex = -1; } if (caseMatcher.compare(value.slice(0, query.length), query) === 0) { return { ...rest, label, value, score: 3, /** @type {'value'} */ matched: "value", /** @type {Array<[number, number]>} */ matchSlices: [[0, query.length]] }; } const queryWords = querySegments.filter((s) => s.isWordLike); const labelWords = labelWordSegments.filter((s) => s.isWordLike); const slices = queryWords.map((word) => { const match = labelWords.find( (labelWord) => baseMatcher.compare(labelWord.segment, word.segment) === 0 ); if (match) { return [match.index, match.index + match.segment.length]; } }); const matchSlices = slices.filter((s) => s !== void 0).sort((a, b) => a[0] - b[0]); const wordScoring = matchSlices.length / queryWords.length; return { ...rest, label, value, score: wordScoring, /** @type {'label'|'none'} */ matched: wordScoring ? "label" : "none", matchSlices }; }); if (filterAndSort) { matches = matches.filter((match) => match.score > 0); matches.sort((a, b) => { if (a.score === b.score) { const val = a.label.localeCompare(b.label, void 0, { sensitivity: "base" }); return val === 0 ? a.value.localeCompare(b.value, void 0, { sensitivity: "base" }) : val; } return b.score - a.score; }); } return matches; } function matchSlicesToNodes(matchSlices, text) { const nodes = ( /** @type {VNode[]} */ [] ); let index = 0; matchSlices.map((slice) => { const [start, end] = slice; if (index < start) { nodes.push(/* @__PURE__ */ jsx("span", { children: text.slice(index, start) }, `${index}-${start}`)); } nodes.push(/* @__PURE__ */ jsx("u", { children: text.slice(start, end) }, `${start}-${end}`)); index = end; }); if (index < text.length) { nodes.push(/* @__PURE__ */ jsx("span", { children: text.slice(index) }, `${index}-${text.length}`)); } return nodes; } var defaultWarningIcon = /* @__PURE__ */ jsx( "svg", { className: "PreactCombobox-warningIcon", viewBox: "0 0 24 24", width: "24", height: "24", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" }) } ); var defaultTickIcon = /* @__PURE__ */ jsx( "svg", { className: "PreactCombobox-tickIcon", viewBox: "0 0 24 24", width: "14", height: "14", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z", fill: "currentColor" }) } ); var defaultChevronIcon = /* @__PURE__ */ jsx( "svg", { className: "PreactCombobox-chevron", viewBox: "0 0 24 24", width: "24", height: "24", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M7 10l5 5 5-5z" }) } ); var defaultLoadingRenderer = (loadingText) => loadingText; function defaultOptionRenderer({ option, isSelected, isInvalid, showValue, warningIcon, tickIcon, optionIconRenderer }) { const isLabelSameAsValue = option.value === option.label; const getLabel = (labelNodes, valueNodes) => /* @__PURE__ */ jsxs(Fragment, { children: [ optionIconRenderer?.(option, false), /* @__PURE__ */ jsxs("span", { className: "PreactCombobox-optionLabelFlex", children: [ /* @__PURE__ */ jsx("span", { children: labelNodes }), isLabelSameAsValue || !showValue ? null : /* @__PURE__ */ jsxs("span", { className: "PreactCombobox-optionValue", "aria-hidden": "true", children: [ "(", valueNodes, ")" ] }) ] }) ] }); const { label, value, matched, matchSlices } = option; let labelElement; if (matched === "label" || matched === "value" && value === label) { const labelNodes = matchSlicesToNodes(matchSlices, label); labelElement = getLabel(labelNodes, [value]); } else if (matched === "value") { const valueNodes = matchSlicesToNodes(matchSlices, value); labelElement = getLabel([label], valueNodes); } else { labelElement = getLabel([label], [value]); } return /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx( "span", { className: `PreactCombobox-optionCheckbox ${isSelected ? "PreactCombobox-optionCheckbox--selected" : ""}`, children: isSelected && tickIcon } ), labelElement, isInvalid && warningIcon ] }); } function defaultOptionIconRenderer(option) { return option.icon ? /* @__PURE__ */ jsx("span", { className: "PreactCombobox-optionIcon", "aria-hidden": "true", role: "img", children: option.icon }) : null; } var defaultArrayValue = []; function formatSelectionAnnouncement(selectedValues, diff, optionsLookup, language, translations) { if (!selectedValues || selectedValues.length === 0) { return translations.noOptionsSelected; } const labels = selectedValues.map((value) => optionsLookup[value]?.label || value); const prefix = diff ? diff === "added" ? translations.selectionAdded : translations.selectionRemoved : translations.selectionsCurrent; if (selectedValues.length <= 3) { return `${prefix} ${new Intl.ListFormat(language, { style: "long", type: "conjunction" }).format(labels)}`; } const firstThree = labels.slice(0, 3); const remaining = selectedValues.length - 3; const moreText = remaining === 1 ? translations.selectionsMore.replace("{count}", remaining.toString()) : translations.selectionsMorePlural.replace("{count}", remaining.toString()); return `${prefix} ${firstThree.join(", ")} ${moreText}`; } var PreactCombobox = ({ id: idProp, multiple = true, allowedOptions, allowFreeText = false, onChange, value = multiple ? defaultArrayValue : "", language = "en", placeholder = "", disabled, required, name, portal = document.body, className = "", rootElementProps, inputProps: { tooltipContent = null, ...inputProps } = {}, formSubmitCompatible = false, isServer = isServerDefault, selectElementProps, showValue = true, showClearButton = true, optionRenderer = defaultOptionRenderer, optionIconRenderer = defaultOptionIconRenderer, warningIcon = defaultWarningIcon, tickIcon = defaultTickIcon, chevronIcon = defaultChevronIcon, loadingRenderer = defaultLoadingRenderer, theme = "system", tray = "auto", trayBreakpoint = "768px", trayLabel: trayLabelProp, translations = defaultEnglishTranslations, // private option for now maxNumberOfPresentedOptions = 100 }) => { const mergedTranslations = useDeepMemo( translations === defaultEnglishTranslations ? translations : { ...defaultEnglishTranslations, ...translations } ); const values = multiple ? ( /** @type {string[]} */ value ) : null; const singleSelectValue = multiple ? null : ( /** @type {string} */ value ); let tempArrayValue; if (Array.isArray(value)) { tempArrayValue = /** @type {string[]} */ value; } else { tempArrayValue = value ? [ /** @type {string} */ value ] : []; } const arrayValues = useDeepMemo(tempArrayValue); const arrayValuesLookup = useMemo2(() => new Set(arrayValues), [arrayValues]); const allowedOptionsAsKey = useDeepMemo( typeof allowedOptions === "function" ? null : allowedOptions ); const autoId = useId(); const id = idProp || autoId; const [inputValue, setInputValue] = useState2(""); const [getIsDropdownOpen, setIsDropdownOpen, hasDropdownOpenChanged] = useLive(false); const cachedOptions = useRef2( /** @type {{ [value: string]: Option }} */ {} ); const [filteredOptions, setFilteredOptions] = useState2( /** @type {OptionMatch[]} */ [] ); const [isLoading, setIsLoading] = useState2(false); const [getIsFocused, setIsFocused] = useLive(false); const [lastSelectionAnnouncement, setLastSelectionAnnouncement] = useState2(""); const [loadingAnnouncement, setLoadingAnnouncement] = useState2(""); const activeDescendant = useRef2(""); const [warningIconHovered, setWarningIconHovered] = useState2(false); const inputRef = useRef2( /** @type {HTMLInputElement | null} */ null ); const blurTimeoutRef = useRef2( /** @type {number | undefined} */ void 0 ); const rootElementRef = useRef2( /** @type {HTMLDivElement | null} */ null ); const dropdownPopperRef = useRef2( /** @type {HTMLUListElement | null} */ null ); const dropdownClosedExplicitlyRef = useRef2(false); const warningIconRef = useRef2(null); const tooltipPopperRef = useRef2(null); const undoStack = useRef2( /** @type {string[][]} */ [] ); const redoStack = useRef2( /** @type {string[][]} */ [] ); const [getTrayLabel, setTrayLabel] = useLive(trayLabelProp); const [getIsTrayOpen, setIsTrayOpen, hasTrayOpenChanged] = useLive(false); const [trayInputValue, setTrayInputValue] = useState2(""); const trayInputRef = useRef2( /** @type {HTMLInputElement | null} */ null ); const trayModalRef = useRef2( /** @type {HTMLDivElement | null} */ null ); const trayClosedExplicitlyRef = useRef2(false); const [isMobileScreen, setIsMobileScreen] = useState2(false); const originalOverflowRef = useRef2(""); const [virtualKeyboardHeight, setVirtualKeyboardHeight] = useState2(0); useEffect(() => { if (tray === "auto") { const mediaQuery = window.matchMedia(`(max-width: ${trayBreakpoint})`); setIsMobileScreen(mediaQuery.matches); const handleChange = (e) => setIsMobileScreen(e.matches); mediaQuery.addEventListener("change", handleChange); return () => mediaQuery.removeEventListener("change", handleChange); } }, [tray, trayBreakpoint]); const shouldUseTray = tray === true || tray === "auto" && isMobileScreen; const activeInputValue = getIsTrayOpen() ? trayInputValue : inputValue; const inputTrimmed = activeInputValue.trim(); const computeEffectiveTrayLabel = useCallback2(() => { if (trayLabelProp) return trayLabelProp; if (typeof self === "undefined" || isServer || !inputRef.current) return ""; const inputElement = inputRef.current; const inputId = inputElement.id; const ariaLabelledBy = inputElement.getAttribute("aria-labelledby"); if (ariaLabelledBy) { const labelElement = document.getElementById(ariaLabelledBy); if (labelElement) { return labelElement.textContent?.trim() || ""; } } const ariaLabel = inputElement.getAttribute("aria-label"); if (ariaLabel) { return ariaLabel.trim(); } if (inputId) { const labelElement = document.querySelector(`label[for="${inputId}"]`); if (labelElement) { return labelElement.textContent?.trim() || ""; } } const wrappingLabel = inputElement.closest("label"); if (wrappingLabel) { return wrappingLabel.textContent?.trim() || ""; } const title = inputElement.getAttribute("title"); if (title) { return title.trim(); } return ""; }, [trayLabelProp, isServer]); useLayoutEffect(() => { setTrayLabel(computeEffectiveTrayLabel()); }, [setTrayLabel, computeEffectiveTrayLabel]); const updateCachedOptions = useCallback2( /** @param {Option[]} update */ (update) => { for (const item of update) { cachedOptions.current[item.value] = item; } }, [] ); const allOptions = useDeepMemo( Array.isArray(allowedOptions) ? allowedOptions : Object.values(cachedOptions.current) ); const allOptionsLookup = useMemo2( () => allOptions.reduce( (acc, o) => { acc[o.value] = o; return acc; }, /** @type {{ [value: string]: Option }} */ {} ), [allOptions] ); const invalidValues = useMemo2(() => { if (allowFreeText) return []; return arrayValues?.filter((v) => !allOptionsLookup[v]) || []; }, [allowFreeText, arrayValues, allOptionsLookup]); const updateSelectionAnnouncement = useCallback2( /** * @param {string[]} selectedValues * @param {"added"|"removed"|null} [diff] */ (selectedValues, diff) => { const announcement = formatSelectionAnnouncement( selectedValues, diff, allOptionsLookup, language, mergedTranslations ); setLastSelectionAnnouncement(announcement); }, [allOptionsLookup, mergedTranslations, language] ); const activateDescendant = useCallback2( /** * @param {string} optionValue * @param {boolean} [scroll=true] */ (optionValue, scroll = true) => { if (activeDescendant.current && dropdownPopperRef.current) { const el = dropdownPopperRef.current.querySelector(".PreactCombobox-option--active"); el?.classList.remove("PreactCombobox-option--active"); el?.querySelector('span[data-reader="selected"]')?.setAttribute("aria-hidden", "true"); el?.querySelector('span[data-reader="invalid"]')?.setAttribute("aria-hidden", "true"); } activeDescendant.current = optionValue; const elementId = optionValue ? `${id}-option-${toHTMLId(optionValue)}` : ""; inputRef.current?.setAttribute("aria-activedescendant", elementId); if (elementId && dropdownPopperRef.current) { const activeDescendantElement = dropdownPopperRef.current.querySelector(`#${elementId}`); if (activeDescendantElement) { activeDescendantElement.classList.add("PreactCombobox-option--active"); activeDescendantElement.querySelector('span[data-reader="selected"]')?.setAttribute("aria-hidden", "false"); activeDescendantElement.querySelector('span[data-reader="invalid"]')?.setAttribute("aria-hidden", "false"); if (scroll) { const dropdownRect = dropdownPopperRef.current.getBoundingClientRect(); const itemRect = activeDescendantElement.getBoundingClientRect(); if (itemRect.top < dropdownRect.top) { dropdownPopperRef.current.scrollTop += itemRect.top - dropdownRect.top; } else if (itemRect.bottom > dropdownRect.bottom) { dropdownPopperRef.current.scrollTop += itemRect.bottom - dropdownRect.bottom; } } } } }, [id] ); const closeDropdown = useCallback2( (closedExplicitly = false) => { setIsDropdownOpen(false); if (dropdownPopperRef.current) { dropdownPopperRef.current.style.display = "none"; } if (closedExplicitly) { dropdownClosedExplicitlyRef.current = true; } updateSelectionAnnouncement(arrayValues); activateDescendant(""); }, [setIsDropdownOpen, activateDescendant, updateSelectionAnnouncement, arrayValues] ); useEffect(() => { if (getIsDropdownOpen() && !shouldUseTray && rootElementRef.current && dropdownPopperRef.current) { const computedDir = window.getComputedStyle(rootElementRef.current).direction; const placement = computedDir === "rtl" ? "bottom-end" : "bottom-start"; const popperInstance = createPopper(rootElementRef.current, dropdownPopperRef.current, { placement, // @ts-ignore modifiers: dropdownPopperModifiers }); dropdownPopperRef.current.style.display = "block"; return () => { popperInstance.destroy(); }; } if (shouldUseTray && dropdownPopperRef.current) { dropdownPopperRef.current.style.display = "none"; } }, [getIsDropdownOpen, shouldUseTray]); const abortControllerRef = useRef2( /** @type {AbortController | null} */ null ); const inputTypingDebounceTimer = useRef2( /** @type {any} */ null ); const newUnknownValues = arrayValues.filter((v) => !allOptionsLookup[v]); const newUnknownValuesAsKey = useDeepMemo(newUnknownValues); useEffect(() => { const isOpen = shouldUseTray ? getIsTrayOpen() : getIsDropdownOpen(); const shouldFetchOptions = isOpen || typeof allowedOptions === "function"; if (!shouldFetchOptions) return; const abortController = typeof allowedOptions === "function" ? new AbortController() : null; abortControllerRef.current?.abort(); abortControllerRef.current = abortController; let debounceTime = 0; if (typeof allowedOptions === "function" && !// don't debounce for initial render (when we have to resolve the labels for selected values). // don't debounce for first time the dropdown is opened as well. (newUnknownValues.length > 0 || (isOpen && shouldUseTray ? hasTrayOpenChanged : hasDropdownOpenChanged)) && // Hack: We avoid debouncing to speed up playwright tests !isPlaywright) { debounceTime = 250; } clearTimeout(inputTypingDebounceTimer.current); const callback = async () => { if (typeof allowedOptions === "function") { const signal = ( /** @type {AbortSignal} */ abortController.signal ); const [searchResults, selectedResults] = await Promise.all([ isOpen ? allowedOptions(inputTrimmed, maxNumberOfPresentedOptions, arrayValues, signal) : ( /** @type {Option[]} */ [] ), // We need to fetch unknown options's labels regardless of whether the dropdown // is open or not, because we want to show it in the placeholder. newUnknownValues.length > 0 ? allowedOptions(newUnknownValues, newUnknownValues.length, arrayValues, signal) : null ]).catch((error) => { if (signal.aborted) { return [null, null]; } setIsLoading(false); throw error; }); setIsLoading(false); if (searchResults?.length) { updateCachedOptions(searchResults); } if (selectedResults?.length) { updateCachedOptions(selectedResults); } let updatedOptions = searchResults || []; if (!inputTrimmed) { const unreturnedValues = newUnknownValues.filter((v) => !cachedOptions.current[v]).map((v) => ({ label: v, value: v })); if (unreturnedValues.length > 0) { updateCachedOptions(unreturnedValues); updatedOptions = unreturnedValues.concat(searchResults || []); } } const options = inputTrimmed ? updatedOptions : sortValuesToTop(updatedOptions, arrayValues); setFilteredOptions(getMatchScore(inputTrimmed, options, language, false)); } else { const mergedOptions = arrayValues.filter((v) => !allOptionsLookup[v]).map((v) => ({ label: v, value: v })).concat(allowedOptions); const options = activeInputValue ? mergedOptions : sortValuesToTop(mergedOptions, arrayValues); setFilteredOptions(getMatchScore(activeInputValue, options, language, true)); } }; if (typeof allowedOptions === "function") { setIsLoading(true); } let timer = null; if (debounceTime > 0) { timer = setTimeout(callback, debounceTime); } else { callback(); } inputTypingDebounceTimer.current = timer; return () => { abortController?.abort(); if (timer) clearTimeout(timer); }; }, [ getIsDropdownOpen, getIsTrayOpen, shouldUseTray, inputTrimmed, language, newUnknownValuesAsKey, allowedOptionsAsKey ]); const addNewOptionVisible = !isLoading && allowFreeText && inputTrimmed && !arrayValues.includes(inputTrimmed) && !filteredOptions.find((o) => o.value === inputTrimmed); useEffect(() => { const isOpen = shouldUseTray ? getIsTrayOpen() : getIsDropdownOpen(); if (!isOpen) return; if (activeDescendant.current && filteredOptions.find((o) => o.value === activeDescendant.current)) { activateDescendant(activeDescendant.current); } else if (addNewOptionVisible && activeDescendant.current === inputTrimmed) { activateDescendant(inputTrimmed); } else { activateDescendant(""); } }, [ shouldUseTray, getIsDropdownOpen, getIsTrayOpen, filteredOptions, activateDescendant, addNewOptionVisible, inputTrimmed ]); useEffect(() => { if (invalidValues.length > 0 && warningIconHovered && warningIconRef.current && tooltipPopperRef.current && rootElementRef.current) { const computedDir = window.getComputedStyle(rootElementRef.current).direction; const placement = computedDir === "rtl" ? "bottom-end" : "bottom-start"; const popperInstance = createPopper(warningIconRef.current, tooltipPopperRef.current, { placement, // @ts-ignore modifiers: tooltipPopperModifiers }); tooltipPopperRef.current.style.display = "block"; return () => { popperInstance.destroy(); }; } }, [warningIconHovered, invalidValues.length]); const handleOptionSelect = useCallback2( /** * @param {string} selectedValue * @param {{ toggleSelected?: boolean }} [options] */ (selectedValue, { toggleSelected = false } = {}) => { const option = allOptionsLookup[selectedValue]; if (option?.disabled) { return; } if (values) { const isExistingOption = values.includes(selectedValue); let newValues; if (!isExistingOption || toggleSelected && isExistingOption) { if (toggleSelected && isExistingOption) { newValues = values.filter((v) => v !== selectedValue); } else { newValues = [...values, selectedValue]; } onChange(newValues); updateSelectionAnnouncement( [selectedValue], newValues.length < values.length ? "removed" : "added" ); undoStack.current.push(values); redoStack.current = []; } } else { if (singleSelectValue !== selectedValue || toggleSelected && singleSelectValue === selectedValue) { let newValue; if (toggleSelected && singleSelectValue === selectedValue) { newValue = ""; } else { newValue = selectedValue; } onChange(newValue); updateSelectionAnnouncement([selectedValue], newValue ? "removed" : "added"); undoStack.current.push([newValue]); redoStack.current = []; closeDropdown(); } setInputValue(""); } }, [ onChange, singleSelectValue, values, updateSelectionAnnouncement, closeDropdown, allOptionsLookup ] ); const focusInput = useCallback2( (forceOpenKeyboard = false) => { const input = inputRef.current; if (input) { const shouldTemporarilyDisableInput = getIsFocused() && virtualKeyboardExplicitlyClosedRef.current === true && !forceOpenKeyboard; if (shouldTemporarilyDisableInput) { input.setAttribute("readonly", "readonly"); } input.focus(); if (shouldTemporarilyDisableInput) { setTimeout(() => { input.removeAttribute("readonly"); }, 10); } } }, [getIsFocused] ); const openTray = useCallback2(() => { if (!shouldUseTray) return; const scrollingElement = ( /** @type {HTMLElement} */ document.scrollingElement || document.documentElement ); originalOverflowRef.current = scrollingElement.style.overflow; scrollingElement.style.overflow = "hidden"; setIsTrayOpen(true); setIsDropdownOpen(false); trayClosedExplicitlyRef.current = false; if (!virtualKeyboardHeightAdjustSubscription.current) { if (wasVisualViewportInitialHeightAnApproximate && trayModalRef.current) { trayModalRef.current.style.removeProperty("display"); const height = trayModalRef.current.offsetHeight; if (height > 0) { visualViewportInitialHeight = height; wasVisualViewportInitialHeightAnApproximate = false; } } virtualKeyboardHeightAdjustSubscription.current = subscribeToVirtualKeyboard({ heightCallback(keyboardHeight, isVisible) { setVirtualKeyboardHeight(isVisible ? keyboardHeight : 0); } }); } }, [shouldUseTray, setIsDropdownOpen, setIsTrayOpen]); useEffect(() => { if (shouldUseTray && getIsTrayOpen()) { trayInputRef.current?.focus(); } }, [shouldUseTray, getIsTrayOpen]); const closeTray = useCallback2(() => { setIsTrayOpen(false); setTrayInputValue(""); setVirtualKeyboardHeight(0); virtualKeyboardHeightAdjustSubscription.current?.(); virtualKeyboardHeightAdjustSubscription.current = null; const scrollingElement = ( /** @type {HTMLElement} */ document.scrollingElement || document.documentElement ); scrollingElement.style.overflow = originalOverflowRef.current; trayClosedExplicitlyRef.current = true; focusInput(true); }, [setIsTrayOpen, focusInput]); const handleInputChange = useCallback2( /** * Handle input change * @param {import('preact/compat').ChangeEvent<HTMLInputElement>} e - Input change event */ (e) => { if (shouldUseTray) { e.preventDefault(); openTray(); return; } setInputValue(e.currentTarget.value); if (!dropdownClosedExplicitlyRef.current) { setIsDropdownOpen(true); } }, [setIsDropdownOpen, shouldUseTray, openTray] ); const handleTrayInputChange = useCallback2( /** * Handle tray input change * @param {import('preact/compat').ChangeEvent<HTMLInputElement>} e - Input change event */ (e) => { setTrayInputValue(e.currentTarget.value); }, [] ); const virtualKeyboardExplicitlyClosedRef = useRef2(null); const virtualKeyboardDismissSubscription = useRef2( /** @type {function | null} */ null ); const virtualKeyboardHeightAdjustSubscription = useRef2( /** @type {function | null} */ null ); const handleInputFocus = useCallback2(() => { setIsFocused(true); clearTimeout(blurTimeoutRef.current); blurTimeoutRef.current = void 0; if (shouldUseTray) { if (!trayClosedExplicitlyRef.current) { openTray(); } trayClosedExplicitlyRef.current = false; } else { setIsDropdownOpen(true); dropdownClosedExplicitlyRef.current = false; if (!virtualKeyboardDismissSubscription.current) { virtualKeyboardDismissSubscription.current = subscribeToVirtualKeyboard({ visibleCallback(isVisible) { virtualKeyboardExplicitlyClosedRef.current = !isVisible; } }); } } updateSelectionAnnouncement(arrayValues); }, [ setIsFocused, setIsDropdownOpen, openTray, arrayValues, updateSelectionAnnouncement, shouldUseTray ]); const handleInputBlur = useCallback2(() => { setIsFocused(false); clearTimeout(blurTimeoutRef.current); blurTimeoutRef.current = void 0; closeDropdown(); dropdownClosedExplicitlyRef.current = false; if (!multiple) { if (inputTrimmed && (allowFreeText || allOptionsLookup[inputTrimmed])) { handleOptionSelect(inputTrimmed); } } setInputValue(""); setLastSelectionAnnouncement(""); if (!shouldUseTray) { virtualKeyboardDismissSubscription.current?.(); virtualKeyboardDismissSubscription.current = null; virtualKeyboardExplicitlyClosedRef.current = null; } }, [ setIsFocused, allOptionsLookup, allowFreeText, handleOptionSelect, multiple, inputTrimmed, closeDropdown, shouldUseTray ]); const handleAddNewOption = useCallback2( /** * @param {string} newValue */ (newValue) => { handleOptionSelect(newValue); if (!filteredOptions.find((o) => o.value === newValue)) { setFilteredOptions((options) => { options = [ /** @type {OptionMatch} */ { label: newValue, value: newValue } ].concat(options); const isRemoteSearch = typeof allowedOptions === "function"; return getMatchScore(inputTrimmed, options, language, !isRemoteSearch); }); } activateDescendant(newValue); }, [ allowedOptions, language, handleOptionSelect, activateDescendant, inputTrimmed, filteredOptions ] ); const handleKeyDown = useCallback2( /** * @param {import('preact/compat').KeyboardEvent<HTMLInputElement>} e - Keyboard event */ (e) => { const currentActiveDescendant = activeDescendant.current; if (e.key === "Enter") { e.preventDefault(); const currentIndex = currentActiveDescendant ? filteredOptions.findIndex((o) => o.value === currentActiveDescendant) : -1; if (currentIndex > -1) { const option = ( /** @type {OptionMatch} */ filteredOptions[currentIndex] ); handleOptionSelect(option.value, { toggleSelected: true }); } else if (allowFreeText && inputTrimmed !== "") { handleAddNewOption(inputTrimmed); } } else if (e.key === "ArrowDown") { e.preventDefault(); setIsDropdownOpen(true); dropdownClosedExplicitlyRef.current = false; if (!filteredOptions.length && !addNewOptionVisible) return; const currentIndex = currentActiveDescendant ? filteredOptions.findIndex((o) => o.value === currentActiveDescendant) : -1; if (addNewOptionVisible && currentActiveDescendant !== inputTrimmed && (currentIndex < 0 || currentIndex === filteredOptions.length - 1)) { activateDescendant(inputTrimmed); } else if (filteredOptions.length) { let nextIndex = currentIndex === filteredOptions.length - 1 ? 0 : currentIndex + 1; let attempts = 0; while (attempts < filteredOptions.length) { const option = ( /** @type {OptionMatch} */ filteredOptions[nextIndex] ); if (!option.disabled) { activateDescendant(option.value); break; } nextIndex = nextIndex === filteredOptions.length - 1 ? 0 : nextIndex + 1; attempts++; } } } else if (e.key === "ArrowUp") { e.preventDefault(); setIsDropdownOpen(true); dropdownClosedExplicitlyRef.current = false; if (!filteredOptions.length && !addNewOptionVisible) return; const currentIndex = currentActiveDescendant ? filteredOptions.findIndex((o) => o.value === currentActiveDescendant) : 0; if (addNewOptionVisible && currentActiveDescendant !== inputTrimmed && (currentIndex === 0 && currentActiveDescendant || !filteredOptions.length)) { activateDescendant(inputTrimmed); } else if (filteredOptions.length) { let prevIndex = (currentIndex - 1 + filteredOptions.length) % filteredOptions.length; let attempts = 0; while (attempts < filteredOptions.length) { const option = ( /** @type {OptionMatch} */ filteredOptions[prevIndex] ); if (!option.disabled) { activateDescendant(option.value); break; } prevIndex = (prevIndex - 1 + filteredOptions.length) % filteredOptions.length; attempts++; } } } else if (e.key === "Escape") { closeDropdown(true); } else if (e.key === "Home" && e.ctrlKey && getIsDropdownOpen()) { e.preventDefault(); if (filteredOptions.length > 0) { const firstNonDisabledOption = filteredOptions.find((option) => !option.disabled); if (firstNonDisabledOption) { activateDescendant(firstNonDisabledOption.value); } } else if (addNewOptionVisible) { activateDescendant(inputTrimmed); } } else if (e.key === "End" && e.ctrlKey && getIsDropdownOpen()) { e.preventDefault(); if (filteredOptions.length > 0) { const lastNonDisabledOption = filteredOptions.findLast((option) => !option.disabled); if (lastNonDisabledOption) { activateDescendant(lastNonDisabledOption.value); } } else if (addNewOptionVisible) { activateDescendant(inputTrimmed); } } else if (inputValue === "" && (e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); const prevValues = undoStack.current.pop(); if (prevValues) { onChange(prevValues); updateSelectionAnnouncement(prevValues); redoStack.current.push(Array.isArray(value) ? value : [value]); } } else if (inputValue === "" && (e.ctrlKey || e.metaKey) && e.key === "y") { e.preventDefault(); const nextValues = redoStack.current.pop(); if (nextValues) { onChange(nextValues); updateSelectionAnnouncement(nextValues); undoStack.current.push(Array.isArray(value) ? value : [value]); } } }, [ activateDescendant, allowFreeText, filteredOptions, addNewOptionVisible, handleOptionSelect, handleAddNewOption, inputValue, inputTrimmed, onChange, getIsDropdownOpen, setIsDropdownOpen, value, closeDropdown, updateSelectionAnnouncement ] ); const handlePaste = useCallback2( /** * @param {import('preact/compat').ClipboardEvent<HTMLInputElement>} e - Clipboard event */ (e) => { if (!values) return; const valuesLookup = { ...Object.fromEntries(values.map((v) => [v, v])), ...Object.fromEntries(allOptions.map((o) => [o.value, o.value])) }; const valuesLowerCaseLookup = { ...Object.fromEntries(values.map((v) => [v.toLowerCase(), v])), ...Object.fromEntries(allOptions.map((o) => [o.value.toLowerCase(), o.value])) }; const optionsLabelLookup = Object.fromEntries( allOptions.map((o) => [o.label.toLowerCase(), o.value]) ); const pastedText = e.clipboardData?.getData("text") || ""; if (!pastedText) return; const pastedOptions = pastedText.split(",").map((x) => x.trim()).filter((x) => x !== "").map( (x) => valuesLookup[x] || valuesLowerCaseLookup[x.toLowerCase()] || optionsLabelLookup[x.toLocaleLowerCase()] || x ); const newValues = unique([...values, ...pastedOptions]); onChange(newValues); updateSelectionAnnouncement(newValues, "added"); undoStack.current.push(values); redoStack.current = []; setFilteredOptions((filteredOptions2) => filteredOptions2.slice()); }, [allOptions, onChange, values, updateSelectionAnnouncement] ); const handleClearValue = useCallback2(() => { setInputValue(""); onChange(multiple ? [] : ""); updateSelectionAnnouncement(arrayValues, "removed"); undoStack.current.push(arrayValues); redoStack.current = []; if (getIsFocused()) { focusInput(); } }, [onChange, multiple, arrayValues, updateSelectionAnnouncement, getIsFocused, focusInput]); const handleRootElementClick = useCallback2(() => { if (!disabled) { if (shouldUseTray) { openTray(); } else { if (inputRef.current && document.activeElement !== inputRef.current) { focusInput(true); } setIsDropdownOpen(true); dropdownClosedExplicitlyRef.current = false; } } }, [disabled, shouldUseTray, openTray, focusInput, setIsDropdownOpen]); const selectChildren = useMemo2( () => formSubmitCompatible ? arrayValues.map((val) => /* @__PURE__ */ jsx("option", { value: val, disabled: allOptionsLookup[val]?.disabled, children: allOptionsLookup[val]?.label || val }, val)).concat( typeof allowedOptions !== "function" ? allowedOptions.filter((o) => !arrayValuesLookup.has(o.value)).slice(0, maxNumberOfPresentedOptions - arrayValues.length).map((o) => /* @__PURE__ */ jsx("option", { value: o.value, disabled: o.disabled, children: o.label }, o.value)) : [] ) : null, [ arrayValues, allOptionsLookup, formSubmitCompatible, allowedOptions, arrayValuesLookup, maxNumberOfPresentedOptions ] ); useEffect(() => { const isOpen = getIsDropdownOpen() || getIsTrayOpen(); if (isLoading && isOpen) { setLoadingAnnouncement(mergedTranslations.loadingOptionsAnnouncement); } else if (loadingAnnouncement && !isLoading && isOpen) { setLoadingAnnouncement( filteredOptions.length ? mergedTranslations.optionsLoadedAnnouncement : mergedTranslations.noOptionsFoundAnnouncement ); const timer = setTimeout(() => { setLoadingAnnouncement(""); }, 1e3); return () => clearTimeout(timer); } else if (loadingAnnouncement && !isOpen) { setLoadingAnnouncement(""); } }, [ isLoading, loadingAnnouncement, getIsDropdownOpen, getIsTrayOpen, filteredOptions.length, mergedTranslations.loadingOptionsAnnouncement, mergedTranslations.optionsLoadedAnnouncement, mergedTranslations.noOptionsFoundAnnouncement ]); const isServerSideForm = isServer && formSubmitCompatible; let list = null; if (!isServer) { list = // biome-ignore lint/a11y/useFocusableInteractive: <explanation> /* @__PURE__ */ jsx( "ul", { className: [ "PreactCombobox-options", `PreactCombobox--${theme}`, shouldUseTray ? "PreactCombobox-options--tray" : "" ].filter(Boolean).join(" "), role: "listbox", id: `${id}-options-listbox`, "aria-multiselectable": multiple ? "true" : void 0, hidden: shouldUseTray ? !getIsTrayOpen() : !getIsDropdownOpen(), ref: shouldUseTray ? null : dropdownPopperRef, children: isLoading ? /* @__PURE__ */ jsx("li", { className: "PreactCombobox-option", "aria-disabled": true, children: loadingRenderer(mergedTranslations.loadingOptions) }) : /* @__PURE__ */ jsxs(Fragment, { children: [ addNewOptionVisible && /* @__PURE__ */ jsx( "li", { id: `${id}-option-${toHTMLId(inputTrimmed)}`, className: "PreactCombobox-option", role: "option", tabIndex: -1, "aria-selected": false, onMouseEnter: () => activateDescendant(inputTrimmed, false), onMouseDown