UNPKG

@vectara/vectara-ui

Version:

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

149 lines (148 loc) 8.76 kB
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 }))] }))); };