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:
parent
c7e0d9361f
commit
8e16cec7a9
2 changed files with 257 additions and 0 deletions
162
ui/src/components/ChatInput.test.tsx
Normal file
162
ui/src/components/ChatInput.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
95
ui/src/components/ChatInput.tsx
Normal file
95
ui/src/components/ChatInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue