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