UNPKG

@vectara/vectara-ui

Version:

Vectara's design system, codified as a React and Sass component library

137 lines (136 loc) 10.5 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { useEffect, useRef, useState } from "react"; import { BiChat, BiExpand, BiExpandVertical, BiPaperPlane, BiX } from "react-icons/bi"; import classNames from "classnames"; import { VuiFlexContainer } from "../flex/FlexContainer"; import { VuiFlexItem } from "../flex/FlexItem"; import { VuiIcon } from "../icon/Icon"; import { VuiIconButton } from "../button/IconButton"; import { VuiTextInput } from "../form"; import { CHAT_STYLE_ORDER } from "./types"; import { VuiButtonSecondary } from "../button/ButtonSecondary"; import { VuiChatInspector } from "./ChatInspector"; import { VuiSpacer } from "../spacer/Spacer"; import { VuiButtonTertiary } from "../button/ButtonTertiary"; import { VuiChatTurn } from "./ChatTurn"; import { VuiChatPanel } from "./ChatPanel"; const chatStyleToIconMap = { closed: _jsx(BiX, {}), condensed: _jsx(BiExpandVertical, {}), tall: _jsx(BiExpand, {}), fullScreen: _jsx(BiX, {}) }; const chatStyleToAriaLabelMap = { closed: "Show chat", condensed: "Expand height of chat", tall: "Full-screen chat", fullScreen: "Hide chat" }; export const VuiChat = ({ openPrompt, chatStyle, setChatStyle, introduction, suggestions, onInput, onRetry, onReset, conversation, settings, isInspectionEnabled }) => { const [isTouched, setIsTouched] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [input, setInput] = useState(""); const [inspectedTurn, setInspectedTurn] = useState(); const buttonRef = useRef(null); const conversationRef = useRef(null); const inputRef = useRef(null); const isScrolledToBottomRef = useRef(true); const prevConversationRef = useRef({ isBottomQuestionLoading: true, length: 0 }); const isOpen = chatStyle !== "closed"; useEffect(() => { var _a; const onScrollChat = (e) => { isScrolledToBottomRef.current = conversationRef.current ? Math.abs(conversationRef.current.scrollHeight - conversationRef.current.clientHeight - conversationRef.current.scrollTop) < 1 : true; }; // We're going to track the scroll position, which will determine // or not the user is at the bottom of the chat. (_a = conversationRef.current) === null || _a === void 0 ? void 0 : _a.addEventListener("scroll", onScrollChat); return () => { var _a; (_a = conversationRef.current) === null || _a === void 0 ? void 0 : _a.removeEventListener("scroll", onScrollChat); }; }, []); useEffect(() => { // Scrolling UX rules: // * Scroll down if the last recorded scroll position was already // at the bottom of the list and if the last question has resolved // to an answer. // * If the user has scrolled to another position, then don’t // auto-scroll. // * If the question that has resolved is not the last question, // don’t auto-scroll. // // This way if the user takes control of the scroll position, they // remain in control. If the user hasn’t taken control of the scroll // position, then the scroll feels stable (by staying at the // bottom) as opposed to scrolling unpredictably through the list // as questions resolve. var _a, _b, _c; const hasBottomQuestionJustChanged = // A new question has been added to the bottom of the list. prevConversationRef.current.length !== conversation.length || // The last question has just resolved to an answer. prevConversationRef.current.isBottomQuestionLoading !== Boolean((_a = conversation[conversation.length - 1]) === null || _a === void 0 ? void 0 : _a.isLoading); // If the intro is really long, the chat can be in a state where // the user is at the top of the chat and their first question is // off-screen. In this case, we want to scroll to the bottom. const shouldStickToBottom = conversation.length === 1 || (isScrolledToBottomRef.current && hasBottomQuestionJustChanged); if (isOpen && shouldStickToBottom) { // Scroll to the bottom of the chat to keep the latest turn in view. (_b = conversationRef.current) === null || _b === void 0 ? void 0 : _b.scrollTo({ left: 0, top: (_c = conversationRef.current) === null || _c === void 0 ? void 0 : _c.scrollHeight, behavior: "smooth" }); } prevConversationRef.current = { length: conversation.length, isBottomQuestionLoading: conversation.length > 0 ? Boolean(conversation[conversation.length - 1].isLoading) : false }; }, [conversation]); useEffect(() => { var _a, _b; // Only autofocus if the user has interacted with the component. // This prevents the component stealing focus when it first mounts. if (isTouched) { if (isOpen) { (_a = inputRef.current) === null || _a === void 0 ? void 0 : _a.focus(); } else { (_b = buttonRef.current) === null || _b === void 0 ? void 0 : _b.focus(); } } }, [isOpen]); const onSubmit = () => { if (!(input === null || input === void 0 ? void 0 : input.trim())) return; onInput(input); setInput(""); }; const cycleChatStyle = () => { setIsTouched(true); const currentIndex = CHAT_STYLE_ORDER.indexOf(chatStyle); setChatStyle(currentIndex === CHAT_STYLE_ORDER.length - 1 ? CHAT_STYLE_ORDER[0] : CHAT_STYLE_ORDER[currentIndex + 1]); }; const buttonClasses = classNames("vuiChatButton", { "vuiChatButton-isHidden": isOpen }); const classes = classNames("vuiChat", `vuiChat--${chatStyle}`); return (_jsxs(_Fragment, { children: [_jsx("button", Object.assign({ // @ts-expect-error React doesn't support inert yet inert: isOpen ? "" : null, className: buttonClasses, onClick: () => setChatStyle("condensed"), ref: buttonRef }, { children: _jsxs(VuiFlexContainer, Object.assign({ alignItems: "center", spacing: "s" }, { children: [_jsx(VuiFlexItem, Object.assign({ shrink: false, grow: false }, { children: _jsx(VuiIcon, Object.assign({ size: "s" }, { children: _jsx(BiChat, {}) })) })), _jsx(VuiFlexItem, Object.assign({ grow: 1 }, { children: _jsx("div", Object.assign({ className: "vuiChatButton__prompt" }, { children: openPrompt })) }))] })) })), _jsxs("div", Object.assign({ // @ts-expect-error React doesn't support inert yet inert: !isOpen ? "" : null, className: classes, onKeyDown: (e) => { if (e.key === "Escape") setChatStyle("closed"); } }, { children: [_jsx("div", Object.assign({ className: "vuiChat__header" }, { children: _jsxs(VuiFlexContainer, Object.assign({ alignItems: "center", justifyContent: "spaceBetween" }, { children: [_jsx(VuiFlexItem, Object.assign({ grow: 1 }, { children: _jsxs(VuiFlexContainer, Object.assign({ alignItems: "center", spacing: "s" }, { children: [_jsx(VuiFlexItem, Object.assign({ shrink: false, grow: false }, { children: _jsx(VuiIcon, Object.assign({ size: "s" }, { children: _jsx(BiChat, {}) })) })), _jsx(VuiFlexItem, Object.assign({ grow: 1 }, { children: _jsx("div", Object.assign({ className: "vuiChatButton__prompt" }, { children: _jsx("h2", { children: openPrompt }) })) }))] })) })), settings && (_jsx(VuiFlexItem, Object.assign({ shrink: false, grow: false }, { children: _jsx(VuiButtonSecondary, Object.assign({ color: "neutral", size: "xs", onClick: () => setIsSettingsOpen(true) }, { children: "Settings" })) })))] })) })), _jsxs("div", Object.assign({ className: "vuiChat__conversation", ref: conversationRef }, { children: [(introduction || suggestions) && (_jsxs("div", Object.assign({ className: "vuiChat__introduction" }, { children: [introduction, introduction && _jsx(VuiSpacer, { size: "s" }), suggestions === null || suggestions === void 0 ? void 0 : suggestions.map((suggestion) => (_jsx("div", { children: _jsx(VuiButtonTertiary, Object.assign({ size: "s", color: "primary", onClick: () => onInput(suggestion), noPadding: true }, { children: suggestion }), suggestion) }))), suggestions && suggestions.length > 0 && _jsx(VuiSpacer, { size: "s" })] }))), conversation.length > 0 && (_jsx("div", Object.assign({ className: "vuiChat__turns" }, { children: conversation.map((turn, index) => (_jsx(VuiChatTurn, { turn: turn, isInspectionEnabled: isInspectionEnabled, setInspectedTurn: setInspectedTurn, onRetry: onRetry }, index))) }))), conversation.length > 0 && (_jsx("div", Object.assign({ className: "vuiChat__conversationActions" }, { children: _jsx(VuiFlexContainer, Object.assign({ alignItems: "center", justifyContent: "center" }, { children: _jsx(VuiFlexItem, { children: _jsx(VuiButtonSecondary, Object.assign({ color: "neutral", size: "xs", onClick: onReset }, { children: "Start over" })) }) })) })))] })), _jsx("div", Object.assign({ className: "vuiChat__input" }, { children: _jsxs(VuiFlexContainer, Object.assign({ alignItems: "center", spacing: "xxs" }, { children: [_jsx(VuiFlexItem, Object.assign({ grow: 1 }, { children: _jsx(VuiTextInput, { value: input, onChange: (e) => { setInput(e.currentTarget.value); }, onSubmit: onSubmit, fullWidth: true, ref: inputRef }) })), _jsx(VuiFlexItem, Object.assign({ shrink: false, grow: false }, { children: _jsx(VuiIconButton, { "aria-label": "Send message", icon: _jsx(VuiIcon, { children: _jsx(BiPaperPlane, {}) }), color: "primary", onClick: onSubmit }) })), _jsx(VuiFlexItem, Object.assign({ shrink: false, grow: false }, { children: _jsx(VuiIconButton, { "aria-label": chatStyleToAriaLabelMap[chatStyle], icon: _jsx(VuiIcon, { children: chatStyleToIconMap[chatStyle] }), color: "neutral", onClick: cycleChatStyle }) }))] })) })), isSettingsOpen && (_jsx(VuiChatPanel, Object.assign({ title: "Chat settings", onClose: () => setIsSettingsOpen(false) }, { children: settings }))), Boolean(inspectedTurn) && (_jsx(VuiChatInspector, { turn: inspectedTurn, onClose: () => setInspectedTurn(undefined) }))] }))] })); };