@vectara/vectara-ui
Version:
Vectara's design system, codified as a React and Sass component library
182 lines (181 loc) • 9.57 kB
JavaScript
var __rest = (this && this.__rest) || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
};
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useRef, useState, useEffect, useMemo } from "react";
import classNames from "classnames";
import { VuiIconButton } from "../button/IconButton";
import { BiSearch, BiX } from "react-icons/bi";
import { VuiIcon } from "../icon/Icon";
import { VuiSearchInputSuggestions } from "./SearchInputSuggestions";
import { createId } from "../../utils/createId";
import { VuiSpinner } from "../spinner/Spinner";
const SIZE = ["s", "m", "l"];
const sizeToIconSizeMap = {
s: "xs",
m: "s",
l: "m"
};
export const VuiSearchInput = (_a) => {
var { className, size = "m", value, onChange, onKeyDown, placeholder, autoFocus, onSubmit, isClearable, onClear, suggestions, onSelectSuggestion, isLoading } = _a, rest = __rest(_a, ["className", "size", "value", "onChange", "onKeyDown", "placeholder", "autoFocus", "onSubmit", "isClearable", "onClear", "suggestions", "onSelectSuggestion", "isLoading"]);
const classes = classNames("vuiSearchInput", `vuiSearchInput--${size}`, className);
const inputRef = useRef(null);
const containerRef = useRef(null);
const [areSuggestionsVisible, setAreSuggestionsVisible] = useState(true);
const suggestionRefs = useRef([]);
const suppressNextFocus = useRef(false);
// Reset suggestions visibility when suggestions change.
const prevSuggestionsRef = useRef(suggestions);
if (prevSuggestionsRef.current !== suggestions) {
prevSuggestionsRef.current = suggestions;
if (suggestions && suggestions.length > 0) {
setAreSuggestionsVisible(true);
}
}
const hasSuggestions = suggestions && suggestions.length > 0 && areSuggestionsVisible;
const controlsId = useMemo(() => `searchSuggestions-${createId()}`, []);
// Derive ghost text from the first suggestion if it extends the current value.
const ghostText = useMemo(() => {
var _a;
if (!hasSuggestions || !value || !((_a = suggestions[0]) === null || _a === void 0 ? void 0 : _a.value))
return "";
const firstValue = suggestions[0].value;
if (firstValue.startsWith(value) && firstValue !== value) {
return firstValue.slice(value.length);
}
return "";
}, [hasSuggestions, value, suggestions]);
useEffect(() => {
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setAreSuggestionsVisible(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const handleInputFocus = () => {
// Don't show suggestions if focus was triggered by Escape key
if (suppressNextFocus.current) {
suppressNextFocus.current = false;
return;
}
// Show suggestions when input receives focus (if suggestions exist).
if (suggestions && suggestions.length > 0) {
setAreSuggestionsVisible(true);
}
};
const handleInputKeyDown = (e) => {
var _a, _b, _c;
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
// Show suggestions if hidden, or move to first suggestion.
if (suggestions && suggestions.length > 0) {
if (!areSuggestionsVisible) {
setAreSuggestionsVisible(true);
}
else {
(_a = suggestionRefs.current[0]) === null || _a === void 0 ? void 0 : _a.focus();
}
}
break;
}
case "Escape": {
e.preventDefault();
setAreSuggestionsVisible(false);
suppressNextFocus.current = true;
(_b = inputRef.current) === null || _b === void 0 ? void 0 : _b.focus();
break;
}
case "Tab": {
// If there's a matching value suggestion, select it instead of default tab.
if (hasSuggestions && ghostText && onSelectSuggestion && ((_c = suggestions[0]) === null || _c === void 0 ? void 0 : _c.value)) {
e.preventDefault();
onSelectSuggestion(suggestions[0]);
}
else {
setAreSuggestionsVisible(false);
}
break;
}
case "Enter": {
// Prevent form submission from triggering a page refresh.
if (!onSubmit)
e.preventDefault();
break;
}
}
};
const handleSuggestionKeyDown = (e, index) => {
var _a, _b, _c, _d, _e, _f, _g;
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
// Move to next suggestion, or wrap to first.
const nextIndex = index + 1;
if (nextIndex < suggestionRefs.current.length) {
(_a = suggestionRefs.current[nextIndex]) === null || _a === void 0 ? void 0 : _a.focus();
}
else {
(_b = suggestionRefs.current[0]) === null || _b === void 0 ? void 0 : _b.focus();
}
break;
}
case "ArrowUp": {
e.preventDefault();
if (index === 0) {
// Move back to input.
(_c = inputRef.current) === null || _c === void 0 ? void 0 : _c.focus();
}
else {
// Move to previous suggestion.
(_d = suggestionRefs.current[index - 1]) === null || _d === void 0 ? void 0 : _d.focus();
}
break;
}
case "Escape": {
e.preventDefault();
setAreSuggestionsVisible(false);
suppressNextFocus.current = true;
(_e = inputRef.current) === null || _e === void 0 ? void 0 : _e.focus();
break;
}
case "Enter": {
// For value suggestions, trigger selection, hide suggestions, and refocus input.
if (suggestions && ((_f = suggestions[index]) === null || _f === void 0 ? void 0 : _f.value) && onSelectSuggestion) {
e.preventDefault();
onSelectSuggestion(suggestions[index]);
setAreSuggestionsVisible(false);
(_g = inputRef.current) === null || _g === void 0 ? void 0 : _g.focus();
}
break;
}
case "Tab": {
// Hide suggestions and allow default tab behavior.
setAreSuggestionsVisible(false);
break;
}
}
};
const inputClasses = classNames("vuiSearchInput__input", {
"vuiSearchInput__input--hasSuggestions": hasSuggestions
});
return (_jsx("form", Object.assign({ onSubmit: onSubmit, role: "search" }, { children: _jsxs("div", Object.assign({ ref: containerRef, className: classes, "aria-live": "polite", "aria-atomic": "true", "aria-busy": isLoading ? "true" : "false" }, { children: [_jsx("input", Object.assign({ ref: inputRef, className: inputClasses, type: "text", autoComplete: "off", autoCapitalize: "off", spellCheck: "false", autoFocus: autoFocus, placeholder: placeholder, value: value, onChange: onChange, onFocus: handleInputFocus, onKeyDown: (e) => {
handleInputKeyDown(e);
onKeyDown === null || onKeyDown === void 0 ? void 0 : onKeyDown(e);
}, "aria-autocomplete": "list", "aria-controls": hasSuggestions ? controlsId : undefined }, rest)), ghostText && (_jsxs("div", Object.assign({ className: "vuiSearchInput__ghostText", "aria-hidden": "true" }, { children: [_jsx("span", Object.assign({ className: "vuiSearchInput__ghostText--hidden" }, { children: value })), _jsx("span", Object.assign({ className: "vuiSearchInput__ghostText--visible" }, { children: ghostText }))] }))), _jsx("div", Object.assign({ className: "vuiSearchInput__icon" }, { children: isLoading ? (_jsx(VuiSpinner, { size: size === "m" ? "s" : "m" })) : (_jsx(VuiIcon, Object.assign({ color: "subdued", size: sizeToIconSizeMap[size] }, { children: _jsx(BiSearch, {}) }))) })), isClearable && value && (_jsx(VuiIconButton, { "aria-label": "Clear input", className: "vuiSearchInput__clearButton", icon: _jsx(VuiIcon, { children: _jsx(BiX, {}) }), onClick: (e) => {
e.preventDefault();
onClear();
} })), hasSuggestions && (_jsx(VuiSearchInputSuggestions, { id: controlsId, suggestions: suggestions, onSuggestionKeyDown: handleSuggestionKeyDown, suggestionRefs: suggestionRefs, onSelectSuggestion: onSelectSuggestion }))] })) })));
};