From 8e16cec7a933665a69325f6ee40bad68ba363b63 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Wed, 1 Apr 2026 13:02:34 +0200 Subject: [PATCH] feat(21-02): add ChatInput component with auto-resize and keyboard shortcuts - Auto-resize textarea from 1 to 6 lines (max-height 160px) - Enter sends message and clears input - Shift+Enter inserts newline without sending - Escape clears input when non-empty, calls onClose when empty - Disabled state and loading spinner when isSubmitting=true - Accessible: aria-label on textarea and send button - 9 tests green (jsdom environment) --- ui/src/components/ChatInput.test.tsx | 162 +++++++++++++++++++++++++++ ui/src/components/ChatInput.tsx | 95 ++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 ui/src/components/ChatInput.test.tsx create mode 100644 ui/src/components/ChatInput.tsx diff --git a/ui/src/components/ChatInput.test.tsx b/ui/src/components/ChatInput.test.tsx new file mode 100644 index 00000000..1cec0ac7 --- /dev/null +++ b/ui/src/components/ChatInput.test.tsx @@ -0,0 +1,162 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ChatInput } from "./ChatInput"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("ChatInput", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + function getTextarea(): HTMLTextAreaElement { + return container.querySelector('[aria-label="Message input"]') as HTMLTextAreaElement; + } + + function getSendButton(): HTMLButtonElement { + return container.querySelector('[aria-label="Send message"]') as HTMLButtonElement; + } + + it("renders textarea with aria-label='Message input'", () => { + act(() => { + root.render( {}} />); + }); + expect(getTextarea()).not.toBeNull(); + }); + + it("renders send button with aria-label='Send message'", () => { + act(() => { + root.render( {}} />); + }); + expect(getSendButton()).not.toBeNull(); + }); + + it("send button is disabled when input is empty", () => { + act(() => { + root.render( {}} />); + }); + expect(getSendButton().disabled).toBe(true); + }); + + it("Enter key calls onSend with current value and clears input", () => { + const onSend = vi.fn(); + act(() => { + root.render(); + }); + + const textarea = getTextarea(); + + // Type some text + act(() => { + const event = new Event("input", { bubbles: true }); + Object.defineProperty(textarea, "value", { value: "hello", writable: true, configurable: true }); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + + // Simulate React-controlled input change + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + nativeInputValueSetter?.call(textarea, "hello"); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + + // Fire Enter keydown + act(() => { + textarea.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", shiftKey: false, bubbles: true }), + ); + }); + + expect(onSend).toHaveBeenCalledWith("hello"); + expect(getTextarea().value).toBe(""); + }); + + it("Shift+Enter does not call onSend", () => { + const onSend = vi.fn(); + act(() => { + root.render(); + }); + + const textarea = getTextarea(); + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + nativeInputValueSetter?.call(textarea, "hello"); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + + act(() => { + textarea.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true }), + ); + }); + + expect(onSend).not.toHaveBeenCalled(); + }); + + it("Escape clears input when input has content", () => { + act(() => { + root.render( {}} />); + }); + + const textarea = getTextarea(); + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + nativeInputValueSetter?.call(textarea, "some text"); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + + act(() => { + textarea.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + }); + + expect(getTextarea().value).toBe(""); + }); + + it("Escape calls onClose when input is empty", () => { + const onClose = vi.fn(); + act(() => { + root.render( {}} onClose={onClose} />); + }); + + const textarea = getTextarea(); + act(() => { + textarea.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("textarea is disabled when isSubmitting=true", () => { + act(() => { + root.render( {}} isSubmitting />); + }); + expect(getTextarea().disabled).toBe(true); + }); + + it("send button is disabled when isSubmitting=true", () => { + act(() => { + root.render( {}} isSubmitting />); + }); + expect(getSendButton().disabled).toBe(true); + }); +}); diff --git a/ui/src/components/ChatInput.tsx b/ui/src/components/ChatInput.tsx new file mode 100644 index 00000000..5225df53 --- /dev/null +++ b/ui/src/components/ChatInput.tsx @@ -0,0 +1,95 @@ +import { useCallback, useRef, useState, type KeyboardEvent } from "react"; +import { Loader2, Send } from "lucide-react"; +import { cn } from "../lib/utils"; +import { Button } from "@/components/ui/button"; + +interface ChatInputProps { + onSend: (content: string) => void; + onClose?: () => void; + isSubmitting?: boolean; + className?: string; +} + +export function ChatInput({ onSend, onClose, isSubmitting = false, className }: ChatInputProps) { + const [value, setValue] = useState(""); + const textareaRef = useRef(null); + + const adjustHeight = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, 160) + "px"; + }, []); + + const handleSend = useCallback(() => { + const trimmed = value.trim(); + if (!trimmed || isSubmitting) return; + onSend(trimmed); + setValue(""); + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + } + }, [value, isSubmitting, onSend]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } else if (e.key === "Escape") { + e.preventDefault(); + if (value.trim()) { + setValue(""); + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + } + } else { + onClose?.(); + } + } + }, + [handleSend, value, onClose], + ); + + const isEmpty = value.trim().length === 0; + + return ( +
+