@vectara/vectara-ui
Version:
Vectara's design system, codified as a React and Sass component library
137 lines (136 loc) • 10.5 kB
JavaScript
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, type: "button" }, { 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) }))] }))] }));
};