feat(KB-252): implement collapse-on-blur behavior for QuickEntryBox

- Collapse QuickEntryBox on blur regardless of content (only check dropdowns open state)
- Change expanded controls to show only when focused (isExpanded), not when content exists
- Remove dependency on text content for determining expanded state
- Update all QuickEntryBox tests to reflect new blur behavior and focus requirements
- Fix test async handling with act() for timer advancement
This commit is contained in:
gsxdsm
2026-03-30 20:18:06 -07:00
parent c4ea1cdb24
commit d070d13414
2 changed files with 40 additions and 21 deletions

View File

@@ -269,11 +269,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
clearTimeout(blurTimeoutRef.current);
}
// Collapse if empty and no dropdowns are open (after a short delay to allow click events)
// Collapse after a short delay to allow click events on dropdowns
// Collapse regardless of content - only check if dropdowns are open
blurTimeoutRef.current = setTimeout(() => {
// Check current textarea value directly for most accurate state
const currentValue = textareaRef.current?.value || "";
if (!currentValue.trim() && !showDeps && !showModels) {
if (!showDeps && !showModels) {
setIsExpanded(false);
// Reset height when collapsing
if (textareaRef.current) {
@@ -373,8 +372,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}
}, [availableModels]);
// Show expanded controls when there's content or user has interacted
const showExpandedControls = isExpanded || description.trim().length > 0;
// Show expanded controls only when focused/interacted (isExpanded)
const showExpandedControls = isExpanded;
return (
<div className="quick-entry-box" data-testid="quick-entry-box">

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { QuickEntryBox } from "../QuickEntryBox";
import type { Task } from "@kb/core";
@@ -121,14 +121,16 @@ describe("QuickEntryBox", () => {
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
fireEvent.blur(textarea);
vi.advanceTimersByTime(250);
await act(async () => {
vi.advanceTimersByTime(250);
});
await waitFor(() => {
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
});
});
it("stays expanded on blur when has content", async () => {
it("collapses on blur even when has content", async () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
@@ -136,10 +138,14 @@ describe("QuickEntryBox", () => {
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);
await act(async () => {
vi.advanceTimersByTime(250);
});
// Wait for React to re-render after state change
await waitFor(() => {
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
});
});
it("creates task on Enter key with TaskCreateInput", async () => {
@@ -346,35 +352,37 @@ describe("QuickEntryBox", () => {
});
describe("Rich creation features", () => {
it("shows dependency button when typing", () => {
it("shows dependency button when focused", () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Initially, no controls are visible before focus
expect(screen.queryByTestId("quick-entry-deps-button")).toBeNull();
// Type something
// Focus and type something
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with deps" } });
// Now the dependency button should be visible
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
});
it("shows model selector button when typing", () => {
it("shows model selector button when focused", () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Initially, no controls are visible
expect(screen.queryByTestId("quick-entry-models-button")).toBeNull();
// Type something
// Focus and type something
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with models" } });
// Now the model selector button should be visible
expect(screen.getByTestId("quick-entry-models-button")).toBeTruthy();
});
it("shows Plan and Subtask buttons when typing", () => {
it("shows Plan and Subtask buttons when focused", () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
@@ -382,7 +390,8 @@ describe("QuickEntryBox", () => {
expect(screen.queryByTestId("plan-button")).toBeNull();
expect(screen.queryByTestId("subtask-button")).toBeNull();
// Type something
// Focus and type something
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to plan" } });
// Now the Plan and Subtask buttons should be visible
@@ -394,6 +403,7 @@ describe("QuickEntryBox", () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with deps" } });
fireEvent.click(screen.getByTestId("quick-entry-deps-button"));
@@ -406,6 +416,7 @@ describe("QuickEntryBox", () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with models" } });
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
@@ -417,6 +428,7 @@ describe("QuickEntryBox", () => {
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with deps" } });
fireEvent.click(screen.getByTestId("quick-entry-deps-button"));
@@ -443,6 +455,7 @@ describe("QuickEntryBox", () => {
const { props } = renderQuickEntryBox({ onPlanningMode });
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Plan this task" } });
fireEvent.click(screen.getByTestId("plan-button"));
@@ -459,6 +472,7 @@ describe("QuickEntryBox", () => {
const { props } = renderQuickEntryBox({ onSubtaskBreakdown });
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Break this down" } });
fireEvent.click(screen.getByTestId("subtask-button"));
@@ -474,7 +488,8 @@ describe("QuickEntryBox", () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Type something first to make buttons appear
// Focus and type something first to make buttons appear
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Some task" } });
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
@@ -500,7 +515,8 @@ describe("QuickEntryBox", () => {
renderQuickEntryBox({ addToast });
const textarea = screen.getByTestId("quick-entry-input");
// Type something first to make buttons appear
// Focus and type something first to make buttons appear
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Some task" } });
// Clear input
@@ -518,6 +534,7 @@ describe("QuickEntryBox", () => {
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with model" } });
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
@@ -547,6 +564,7 @@ describe("QuickEntryBox", () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with dropdown" } });
fireEvent.click(screen.getByTestId("quick-entry-deps-button"));
@@ -567,6 +585,7 @@ describe("QuickEntryBox", () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to clear" } });
// First Escape closes any dropdowns
@@ -584,6 +603,7 @@ describe("QuickEntryBox", () => {
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to reset" } });
fireEvent.keyDown(textarea, { key: "Enter" });