@vectara/vectara-ui
Version:
Vectara's design system, codified as a React and Sass component library
149 lines (148 loc) • 8.76 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useRef, useState } from "react";
import { BiPaperclip } 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 { VuiButtonPrimary } from "../button/ButtonPrimary";
import { VuiBadge } from "../badge/Badge";
import { VuiSpacer } from "../spacer/Spacer";
import { VuiTextArea } from "../form";
import { VuiFileDropTarget } from "../fileDropTarget/FileDropTarget";
import { useComposerHistory } from "./useComposerHistory";
const MOD_ORDER = ["mod", "alt", "shift"];
// Single-character keys are matched case-insensitively; named keys keep their case.
const normalizeKeyName = (key) => (key.length === 1 ? key.toLowerCase() : key);
const canonical = (mods, key) => [...MOD_ORDER.filter((mod) => mods.has(mod)), normalizeKeyName(key)].join("+");
const eventSignature = (e) => {
const mods = new Set();
// Treat Cmd and Ctrl interchangeably so "Mod+k" works across platforms.
if (e.metaKey || e.ctrlKey)
mods.add("mod");
if (e.altKey)
mods.add("alt");
if (e.shiftKey)
mods.add("shift");
return canonical(mods, e.key);
};
const comboSignature = (combo) => {
var _a;
const segments = combo.split("+").map((segment) => segment.trim());
const key = (_a = segments.pop()) !== null && _a !== void 0 ? _a : "";
const mods = new Set();
for (const segment of segments) {
const mod = segment.toLowerCase();
mods.add(mod === "ctrl" || mod === "meta" ? "mod" : mod);
}
return canonical(mods, key);
};
export const VuiComposer = ({ onSubmit, isRunning, onCancel, isSendDisabled, sendLabel = "Send", cancelLabel = "Cancel", placeholder, autoFocus, onChange, value, enableHistory, historyKey, canUploadFiles, accept, validateFile, onFilesRejected, fileDropScope = "document", fileDropMessage, onShortcutKeys, leadingActions, footer, className, "data-testid": dataTestId }) => {
const isControlled = value !== undefined;
const [internalValue, setInternalValue] = useState("");
const [files, setFiles] = useState([]);
const fileInputRef = useRef(null);
const composerRef = useRef(null);
const currentValue = isControlled ? value : internalValue;
const setValue = (next) => {
if (!isControlled)
setInternalValue(next);
onChange === null || onChange === void 0 ? void 0 : onChange(next);
};
const clear = () => setValue("");
const history = useComposerHistory({ storageKey: historyKey, value: currentValue, setValue });
const isEmptyMessage = currentValue.trim() === "" && files.length === 0;
const addFiles = (incoming) => {
const existingNames = new Set(files.map((file) => file.name));
const deduped = incoming.filter((file) => !existingNames.has(file.name));
if (!validateFile) {
if (deduped.length > 0)
setFiles((prev) => [...prev, ...deduped]);
return;
}
const valid = [];
const rejected = [];
for (const file of deduped) {
const message = validateFile(file);
if (message)
rejected.push({ file, message });
else
valid.push(file);
}
if (valid.length > 0)
setFiles((prev) => [...prev, ...valid]);
if (rejected.length > 0)
onFilesRejected === null || onFilesRejected === void 0 ? void 0 : onFilesRejected(rejected);
};
const removeFile = (index) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const submit = () => {
if (isEmptyMessage || isSendDisabled)
return;
onSubmit({ text: currentValue, files });
if (enableHistory)
history.record(currentValue);
setValue("");
setFiles([]);
};
const handleChange = (e) => {
setValue(e.target.value);
// Any edit cancels in-progress history navigation.
if (enableHistory)
history.reset();
};
const handleKeyDown = (e) => {
var _a;
// Consumer shortcuts run first; preventDefault suppresses built-in handling.
if (onShortcutKeys) {
const signature = eventSignature(e);
for (const combo in onShortcutKeys) {
if (comboSignature(combo) === signature) {
onShortcutKeys[combo](e, { value: currentValue, setValue, clear });
break;
}
}
if (e.defaultPrevented)
return;
}
// Enter submits; Shift+Enter falls through so the textarea inserts a newline.
if (!e.shiftKey && e.code === "Enter") {
e.preventDefault();
submit();
return;
}
// Only let history cycling kick in when the caret is at the textarea's
// vertical boundary in the direction of the arrow key — otherwise the native
// textarea must move the caret between internal lines. Without this gate, the
// hook's primed→cycling state machine (designed for a single-line input)
// would replace the textarea contents regardless of caret position.
if (enableHistory && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
const el = e.currentTarget;
const pos = (_a = el.selectionStart) !== null && _a !== void 0 ? _a : 0;
// A caret at position 0 is always on the first line. We special-case it
// because lastIndexOf("\n", -1) clamps its negative fromIndex to 0 and then
// inspects index 0 — so a value that begins with a newline would be wrongly
// reported as "not first line", trapping history navigation. Otherwise,
// lastIndexOf("\n", pos - 1) scans backward from the character just before
// the caret; -1 means no newline precedes it, so it is on the first line.
const cursorOnFirstLine = pos === 0 || el.value.lastIndexOf("\n", pos - 1) === -1;
// indexOf("\n", pos) scans forward from the caret; -1 means no newline at or
// after it, so it is on the last line.
const cursorOnLastLine = el.value.indexOf("\n", pos) === -1;
if (e.key === "ArrowUp" && !cursorOnFirstLine)
return;
if (e.key === "ArrowDown" && !cursorOnLastLine)
return;
history.handleKeyDown(e);
}
};
const handleFileInputChange = (e) => {
if (e.target.files)
addFiles(Array.from(e.target.files));
// Reset so selecting the same file again still fires a change event.
e.target.value = "";
};
return (_jsxs("div", Object.assign({ className: classNames("vuiComposer", className), "data-testid": dataTestId, ref: composerRef }, { children: [_jsxs(VuiFlexContainer, Object.assign({ alignItems: "end", fullWidth: true, spacing: "xs" }, { children: [canUploadFiles && (_jsx(VuiFlexItem, { children: _jsx(VuiIconButton, { color: "neutral", "aria-label": "Attach files", icon: _jsx(VuiIcon, { children: _jsx(BiPaperclip, {}) }), onClick: () => { var _a; return (_a = fileInputRef.current) === null || _a === void 0 ? void 0 : _a.click(); } }) })), leadingActions, _jsx(VuiFlexItem, Object.assign({ grow: 1 }, { children: _jsx(VuiTextArea, { autoFocus: autoFocus, autoGrow: true, fullWidth: true, rows: 1, placeholder: placeholder, value: currentValue, onChange: handleChange, onKeyDown: handleKeyDown }) })), _jsx(VuiFlexItem, { children: isRunning ? (_jsx(VuiButtonPrimary, Object.assign({ color: "subdued", size: "l", onClick: onCancel }, { children: cancelLabel }))) : (_jsx(VuiButtonPrimary, Object.assign({ color: "primary", size: "l", onClick: submit, isDisabled: isEmptyMessage || isSendDisabled }, { children: sendLabel }))) })] })), canUploadFiles && (_jsx("input", { ref: fileInputRef, type: "file", multiple: true, accept: accept, style: { display: "none" }, onChange: handleFileInputChange })), files.length > 0 && (_jsxs(_Fragment, { children: [_jsx(VuiSpacer, { size: "s" }), _jsx(VuiFlexContainer, Object.assign({ fullWidth: true, wrap: true, spacing: "xxs" }, { children: files.map((file, index) => (_jsx(VuiBadge, Object.assign({ color: "primary", onClose: () => removeFile(index) }, { children: file.name }), file.name))) }))] })), footer, canUploadFiles && (_jsx(VuiFileDropTarget, { onFilesDropped: addFiles, message: fileDropMessage, scopeRef: fileDropScope === "composer" ? composerRef : undefined }))] })));
};