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)
This commit is contained in:
Mikkel Georgsen 2026-04-01 13:02:34 +02:00
parent c7e0d9361f
commit 8e16cec7a9
2 changed files with 257 additions and 0 deletions

View file

@ -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<typeof createRoot>;
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(<ChatInput onSend={() => {}} />);
});
expect(getTextarea()).not.toBeNull();
});
it("renders send button with aria-label='Send message'", () => {
act(() => {
root.render(<ChatInput onSend={() => {}} />);
});
expect(getSendButton()).not.toBeNull();
});
it("send button is disabled when input is empty", () => {
act(() => {
root.render(<ChatInput onSend={() => {}} />);
});
expect(getSendButton().disabled).toBe(true);
});
it("Enter key calls onSend with current value and clears input", () => {
const onSend = vi.fn();
act(() => {
root.render(<ChatInput onSend={onSend} />);
});
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(<ChatInput onSend={onSend} />);
});
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(<ChatInput onSend={() => {}} />);
});
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(<ChatInput onSend={() => {}} 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(<ChatInput onSend={() => {}} isSubmitting />);
});
expect(getTextarea().disabled).toBe(true);
});
it("send button is disabled when isSubmitting=true", () => {
act(() => {
root.render(<ChatInput onSend={() => {}} isSubmitting />);
});
expect(getSendButton().disabled).toBe(true);
});
});

View file

@ -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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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 (
<div className={cn("flex items-end gap-2 border-t border-border p-3", className)}>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
setValue(e.target.value);
adjustHeight();
}}
onKeyDown={handleKeyDown}
placeholder="Send a message..."
disabled={isSubmitting}
aria-label="Message input"
className={cn(
"flex-1 resize-none bg-muted border border-border px-3 py-2 text-sm",
"placeholder:text-muted-foreground",
"focus:outline-none focus:ring-1 focus:ring-ring",
"disabled:opacity-50 disabled:cursor-not-allowed",
)}
style={{ minHeight: 40, maxHeight: 160, fieldSizing: "content" } as React.CSSProperties & { fieldSizing?: string }}
rows={1}
/>
<Button
variant="default"
size="icon"
onClick={handleSend}
disabled={isEmpty || isSubmitting}
aria-label="Send message"
aria-disabled={isEmpty || isSubmitting}
className="h-10 w-10 shrink-0"
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
);
}