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:
gsxdsm
2026-03-30 09:06:57 -07:00
parent 45a005b02c
commit 168618efb1
3 changed files with 272 additions and 64 deletions

View File

@@ -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>
);

View File

@@ -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);
});
});

View File

@@ -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 {