feat(KB-160): implement expandable textarea in QuickEntryBox
- Add expandable textarea with auto-resize functionality to QuickEntryBox - Update QuickEntryBox tests for new expandable behavior - Refactor PlanningModeModal tests for improved consistency - Remove obsolete changeset for planning-mode-streaming fix - Add CSS transitions for smooth textarea expansion
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface QuickEntryBoxProps {
|
||||
@@ -9,7 +9,48 @@ interface QuickEntryBoxProps {
|
||||
export function QuickEntryBox({ onCreate, addToast }: QuickEntryBoxProps) {
|
||||
const [description, setDescription] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const blurTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blurTimeoutRef.current) {
|
||||
clearTimeout(blurTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-resize textarea based on content
|
||||
const autoResize = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
// Reset height to auto to get accurate scrollHeight
|
||||
textarea.style.height = "auto";
|
||||
// Set to scrollHeight (capped at max-height via CSS)
|
||||
const newHeight = Math.min(textarea.scrollHeight, 200);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
}, []);
|
||||
|
||||
// Resize when description changes
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
autoResize();
|
||||
}
|
||||
}, [description, isExpanded, autoResize]);
|
||||
|
||||
// Restore focus after submission completes (when textarea is re-enabled)
|
||||
useEffect(() => {
|
||||
if (!isSubmitting && description === "" && textareaRef.current) {
|
||||
// Use setTimeout to ensure focus happens after React re-enables the textarea
|
||||
const focusTimeout = setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, 0);
|
||||
return () => clearTimeout(focusTimeout);
|
||||
}
|
||||
}, [isSubmitting, description]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const trimmed = description.trim();
|
||||
@@ -18,10 +59,13 @@ export function QuickEntryBox({ onCreate, addToast }: QuickEntryBoxProps) {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onCreate(trimmed);
|
||||
// Clear input and keep focus for rapid entry
|
||||
// Clear input for rapid entry
|
||||
setDescription("");
|
||||
// Focus stays on input for next entry
|
||||
inputRef.current?.focus();
|
||||
// Reset height after clearing
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
// Note: Focus restoration is handled by useEffect when isSubmitting becomes false
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to create task", "error");
|
||||
// Keep input content on failure so user can retry
|
||||
@@ -31,8 +75,14 @@ export function QuickEntryBox({ onCreate, addToast }: QuickEntryBoxProps) {
|
||||
}, [description, isSubmitting, onCreate, addToast]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
if (e.shiftKey && isExpanded) {
|
||||
// Allow Shift+Enter to insert newline when expanded
|
||||
// Don't prevent default - let the newline be inserted
|
||||
return;
|
||||
}
|
||||
// Enter without Shift submits
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
@@ -40,24 +90,63 @@ export function QuickEntryBox({ onCreate, addToast }: QuickEntryBoxProps) {
|
||||
if (description.trim()) {
|
||||
// Clear non-empty input on Escape
|
||||
setDescription("");
|
||||
// Reset height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
}
|
||||
// Collapse on escape
|
||||
setIsExpanded(false);
|
||||
// Clear any pending blur timeout
|
||||
if (blurTimeoutRef.current) {
|
||||
clearTimeout(blurTimeoutRef.current);
|
||||
blurTimeoutRef.current = null;
|
||||
}
|
||||
textareaRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[handleSubmit, description],
|
||||
[handleSubmit, description, isExpanded],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsExpanded(true);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
// Clear any existing timeout
|
||||
if (blurTimeoutRef.current) {
|
||||
clearTimeout(blurTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Collapse if empty (after a short delay to allow click events)
|
||||
blurTimeoutRef.current = setTimeout(() => {
|
||||
// Check current textarea value directly for most accurate state
|
||||
const currentValue = textareaRef.current?.value || "";
|
||||
if (!currentValue.trim()) {
|
||||
setIsExpanded(false);
|
||||
// Reset height when collapsing
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
}
|
||||
blurTimeoutRef.current = null;
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="quick-entry-box" data-testid="quick-entry-box">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="quick-entry-input"
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={`quick-entry-input ${isExpanded ? "quick-entry-input--expanded" : ""}`}
|
||||
placeholder={isSubmitting ? "Creating..." : "Add a task..."}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={isSubmitting}
|
||||
data-testid="quick-entry-input"
|
||||
rows={1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QuickEntryBox } from "../QuickEntryBox";
|
||||
|
||||
@@ -12,97 +12,197 @@ function renderQuickEntryBox() {
|
||||
}
|
||||
|
||||
describe("QuickEntryBox", () => {
|
||||
it("renders input with placeholder", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders textarea with placeholder", () => {
|
||||
renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
expect(input).toBeTruthy();
|
||||
expect((input as HTMLInputElement).placeholder).toBe("Add a task...");
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
expect(textarea).toBeTruthy();
|
||||
expect(textarea.tagName.toLowerCase()).toBe("textarea");
|
||||
expect((textarea as HTMLTextAreaElement).placeholder).toBe("Add a task...");
|
||||
});
|
||||
|
||||
it("expands on focus", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
});
|
||||
|
||||
it("collapses on blur when empty", async () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
|
||||
fireEvent.blur(textarea);
|
||||
vi.advanceTimersByTime(250);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("stays expanded on blur when has content", async () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Some task" } });
|
||||
|
||||
fireEvent.blur(textarea);
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
// Should stay expanded because there's content
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
});
|
||||
|
||||
it("creates task on Enter key", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(input, { target: { value: "New task description" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
fireEvent.change(textarea, { target: { value: "New task description" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalledWith("New task description");
|
||||
});
|
||||
});
|
||||
|
||||
it("allows Shift+Enter to insert newline when expanded", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Line 1" } });
|
||||
|
||||
// Shift+Enter should not prevent default (allow newline)
|
||||
const event = fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true });
|
||||
|
||||
// Event should not be prevented (returns false if preventDefault was called)
|
||||
expect(event).toBe(true);
|
||||
});
|
||||
|
||||
it("submits on Enter even when expanded (without Shift)", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to submit" } });
|
||||
|
||||
// Enter without Shift should submit
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalledWith("Task to submit");
|
||||
});
|
||||
});
|
||||
|
||||
it("prevents default on Enter key (without Shift)", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task" } });
|
||||
const event = fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
// Event is prevented (returns false)
|
||||
expect(event).toBe(false);
|
||||
});
|
||||
|
||||
it("shows loading state during creation", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
// Slow down the promise to see loading state
|
||||
props.onCreate.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(input, { target: { value: "New task" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(textarea, { target: { value: "New task" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
// Check loading placeholder
|
||||
await waitFor(() => {
|
||||
expect((input as HTMLInputElement).placeholder).toBe("Creating...");
|
||||
expect((textarea as HTMLTextAreaElement).placeholder).toBe("Creating...");
|
||||
});
|
||||
|
||||
// Input should be disabled during creation
|
||||
expect(input).toBeDisabled();
|
||||
// Textarea should be disabled during creation
|
||||
expect(textarea).toBeDisabled();
|
||||
});
|
||||
|
||||
it("clears input after successful creation", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(input, { target: { value: "Task to create" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
fireEvent.change(textarea, { target: { value: "Task to create" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect((input as HTMLInputElement).value).toBe("");
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("shows error toast on failure and keeps input content", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
props.onCreate.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(input, { target: { value: "Failed task" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(textarea, { target: { value: "Failed task" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalledWith("Network error", "error");
|
||||
});
|
||||
|
||||
// Input content should be preserved for retry
|
||||
expect((input as HTMLInputElement).value).toBe("Failed task");
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("Failed task");
|
||||
});
|
||||
|
||||
it("clears non-empty input on Escape key", () => {
|
||||
renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(input, { target: { value: "Some text" } });
|
||||
expect((input as HTMLInputElement).value).toBe("Some text");
|
||||
fireEvent.change(textarea, { target: { value: "Some text" } });
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("Some text");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect((input as HTMLInputElement).value).toBe("");
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("collapses and blurs on Escape key", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not clear empty input on Escape key", () => {
|
||||
renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect((input as HTMLInputElement).value).toBe("");
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("does not submit on Enter if input is empty", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
// Wait a bit to ensure no async call happens
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
@@ -112,43 +212,48 @@ describe("QuickEntryBox", () => {
|
||||
|
||||
it("does not submit on Enter if input is only whitespace", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(input, { target: { value: " " } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
fireEvent.change(textarea, { target: { value: " " } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(props.onCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prevents default on Enter key", () => {
|
||||
it("updates textarea value on change", () => {
|
||||
renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(input, { target: { value: "Task" } });
|
||||
const prevented = !fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(prevented).toBe(true);
|
||||
});
|
||||
|
||||
it("updates input value on change", () => {
|
||||
renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(input, { target: { value: "Updated text" } });
|
||||
expect((input as HTMLInputElement).value).toBe("Updated text");
|
||||
fireEvent.change(textarea, { target: { value: "Updated text" } });
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("Updated text");
|
||||
});
|
||||
|
||||
it("trims whitespace when creating task", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(input, { target: { value: " Task with spaces " } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
fireEvent.change(textarea, { target: { value: " Task with spaces " } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalledWith("Task with spaces");
|
||||
});
|
||||
});
|
||||
|
||||
it("maintains focus after successful creation", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task to create" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// After successful creation, focus should be maintained
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7515,9 +7515,23 @@ html .column.drag-over * {
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
resize: none;
|
||||
min-height: 36px;
|
||||
max-height: 200px;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), min-height 0.2s ease;
|
||||
}
|
||||
|
||||
.quick-entry-input--expanded {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.quick-entry-input--expanded {
|
||||
min-height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-entry-input:focus {
|
||||
|
||||
Reference in New Issue
Block a user