feat(FN-1504): add non-mobile search toggle for board/list views

- Add search toggle button to header controls for non-mobile viewports
- Toggle persists search visibility state across board and list views
- Implement responsive visibility rules (show on non-mobile, hide on mobile)
- Add comprehensive tests for tablet header controls and Header component
- Update Header component with search toggle functionality
This commit is contained in:
gsxdsm
2026-04-09 23:03:05 -07:00
parent 7b4b54cec3
commit bba1e45de7
4 changed files with 285 additions and 45 deletions

View File

@@ -303,15 +303,57 @@ describe("tablet header controls", () => {
// ── Search on tablet ───────────────────────────────────────────
it("renders desktop-style search input on tablet (not mobile search trigger)", () => {
it("renders search toggle button on tablet board view (not mobile search trigger)", () => {
renderTabletHeader({ onSearchChange: noop, view: "board" });
// Tablet uses the desktop-style inline search, not the mobile trigger
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
expect(screen.queryByTitle("Open search")).toBeNull();
// Tablet uses the desktop-style toggle, not the mobile trigger
expect(screen.getByTestId("desktop-header-search-btn")).toBeDefined();
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
it("does not render search when view is not board on tablet", () => {
it("renders search toggle button on tablet list view", () => {
renderTabletHeader({ onSearchChange: noop, view: "list" });
// List view now also supports search on tablet
expect(screen.getByTestId("desktop-header-search-btn")).toBeDefined();
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
it("opens search input when toggle is clicked on tablet board view", () => {
renderTabletHeader({ onSearchChange: noop, view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
});
it("opens search input when toggle is clicked on tablet list view", () => {
renderTabletHeader({ onSearchChange: noop, view: "list" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
});
it("closes search and clears query when close button is clicked on tablet", () => {
const onSearchChange = vi.fn();
renderTabletHeader({ onSearchChange, view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
fireEvent.click(screen.getByLabelText("Close search"));
expect(onSearchChange).toHaveBeenCalledWith("");
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
it("keeps search open when searchQuery is non-empty on tablet", () => {
renderTabletHeader({ onSearchChange: noop, view: "board", searchQuery: "test" });
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("does not render search toggle when view is agents on tablet", () => {
renderTabletHeader({ onSearchChange: noop, view: "agents" });
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
it("does not render search toggle when view is missions on tablet", () => {
renderTabletHeader({ onSearchChange: noop, view: "missions" });
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});

View File

@@ -666,60 +666,98 @@ describe("Header", () => {
});
});
describe("search functionality", () => {
it("does not render search input when onSearchChange is not provided", () => {
describe("non-mobile search toggle", () => {
it("does not render search toggle when onSearchChange is not provided", () => {
renderHeader({ view: "board" });
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("renders search toggle button when onSearchChange and view='board' are provided", () => {
renderHeader({ onSearchChange: vi.fn(), view: "board" });
expect(screen.getByTestId("desktop-header-search-btn")).toBeDefined();
});
it("renders search toggle button when onSearchChange and view='list' are provided", () => {
renderHeader({ onSearchChange: vi.fn(), view: "list" });
expect(screen.getByTestId("desktop-header-search-btn")).toBeDefined();
});
it("does not render search toggle when view is 'agents'", () => {
renderHeader({ onSearchChange: vi.fn(), view: "agents" });
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("does not render search toggle when view is 'missions'", () => {
renderHeader({ onSearchChange: vi.fn(), view: "missions" });
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("does not render search input by default when toggle is visible", () => {
renderHeader({ onSearchChange: vi.fn(), view: "board" });
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
it("renders search input when onSearchChange and view='board' are provided", () => {
renderHeader({ onSearchChange: vi.fn(), view: "board" });
it("opens search input when toggle button is clicked", () => {
const onSearchChange = vi.fn();
renderHeader({ onSearchChange, view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
});
it("does not render search input when view is 'list'", () => {
renderHeader({ onSearchChange: vi.fn(), view: "list" });
it("closes search when close button is clicked", () => {
renderHeader({ onSearchChange: vi.fn(), view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
fireEvent.click(screen.getByLabelText("Close search"));
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
it("clears search query when close button is clicked", () => {
const onSearchChange = vi.fn();
renderHeader({ onSearchChange, view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
fireEvent.click(screen.getByLabelText("Close search"));
expect(onSearchChange).toHaveBeenCalledWith("");
});
it("keeps search open when searchQuery is non-empty", () => {
renderHeader({ onSearchChange: vi.fn(), view: "board", searchQuery: "test" });
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("shows search input with active query and hides toggle", () => {
renderHeader({ onSearchChange: vi.fn(), view: "list", searchQuery: "test" });
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
expect(screen.getByDisplayValue("test")).toBeDefined();
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("calls onSearchChange when typing in search input", () => {
const onSearchChange = vi.fn();
renderHeader({ onSearchChange, view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
const input = screen.getByPlaceholderText("Search tasks...");
fireEvent.change(input, { target: { value: "test query" } });
expect(onSearchChange).toHaveBeenCalledWith("test query");
});
it("shows clear button when search query is not empty", () => {
renderHeader({ onSearchChange: vi.fn(), view: "board", searchQuery: "test" });
expect(screen.getByLabelText("Clear search")).toBeDefined();
});
it("does not show clear button when search query is empty", () => {
renderHeader({ onSearchChange: vi.fn(), view: "board", searchQuery: "" });
expect(screen.queryByLabelText("Clear search")).toBeNull();
});
it("calls onSearchChange with empty string when clear button is clicked", () => {
const onSearchChange = vi.fn();
renderHeader({ onSearchChange, view: "board", searchQuery: "test" });
fireEvent.click(screen.getByLabelText("Clear search"));
expect(onSearchChange).toHaveBeenCalledWith("");
});
it("search input has correct placeholder text", () => {
renderHeader({ onSearchChange: vi.fn(), view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
const input = screen.getByPlaceholderText("Search tasks...");
expect(input).toBeDefined();
});
it("renders search input inside header-floating-search on desktop board view", () => {
const { container } = renderHeader({ onSearchChange: vi.fn(), view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(container.querySelector(".header-floating-search .header-search")).not.toBeNull();
});
it("does not render search input inside header-actions", () => {
const { container } = renderHeader({ onSearchChange: vi.fn(), view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(container.querySelector(".header-actions .header-search")).toBeNull();
});
@@ -728,7 +766,34 @@ describe("Header", () => {
const wrapper = container.querySelector(".header-wrapper");
expect(wrapper).not.toBeNull();
expect(wrapper.querySelector("header.header")).not.toBeNull();
expect(wrapper.querySelector(".header-floating-search")).not.toBeNull();
});
it("toggling search twice reopens the search (use close button to dismiss)", () => {
renderHeader({ onSearchChange: vi.fn(), view: "board" });
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
// Second toggle click reopens search since first close was via toggle
// (toggle always opens, use close button to dismiss)
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
// Search stays open because toggle only opens
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
// Use close button to dismiss
fireEvent.click(screen.getByLabelText("Close search"));
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
it("supports search toggle flow on list view", () => {
const onSearchChange = vi.fn();
renderHeader({ onSearchChange, view: "list" });
// Toggle visible on list view
expect(screen.getByTestId("desktop-header-search-btn")).toBeDefined();
// Click toggle
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
// Search opens
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
// Close and clear
fireEvent.click(screen.getByLabelText("Close search"));
expect(onSearchChange).toHaveBeenCalledWith("");
});
});

View File

@@ -107,6 +107,9 @@ export function Header({
const isCompact = isMobile || isTablet;
const hideFullNav = isMobile && mobileNavEnabled;
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const [isNonMobileSearchOpen, setIsNonMobileSearchOpen] = useState(false);
// Track when user has explicitly closed the search (used for toggle visibility)
const [isNonMobileSearchExplicitlyClosed, setIsNonMobileSearchExplicitlyClosed] = useState(false);
const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
const [isTerminalSubmenuOpen, setIsTerminalSubmenuOpen] = useState(false);
const [overflowScripts, setOverflowScripts] = useState<Record<string, string>>({});
@@ -159,6 +162,19 @@ export function Header({
// Keep mobile search open if there's an active search query
const shouldShowMobileSearch = isMobileSearchOpen || searchQuery.length > 0;
// Non-mobile search: toggled open OR has active query, but not if explicitly closed
const shouldShowNonMobileSearch = (isNonMobileSearchOpen || searchQuery.length > 0) && !isNonMobileSearchExplicitlyClosed;
// Show toggle when search is available, NOT currently shown, NOT explicitly closed, AND query is empty
const canShowNonMobileSearchToggle = (view === "board" || view === "list") && !isMobile && onSearchChange && !isNonMobileSearchExplicitlyClosed && searchQuery.length === 0;
const canShowNonMobileSearch = (view === "board" || view === "list") && !isMobile && onSearchChange;
// Reset explicit close flag when query becomes empty (so toggle reappears)
useEffect(() => {
if (searchQuery === "") {
setIsNonMobileSearchExplicitlyClosed(false);
}
}, [searchQuery]);
// Close overflow menu on outside click
useEffect(() => {
if (!isOverflowMenuOpen) return;
@@ -199,6 +215,17 @@ export function Header({
setIsMobileSearchOpen((prev) => !prev);
}, []);
const handleNonMobileSearchToggle = useCallback(() => {
setIsNonMobileSearchOpen(true);
setIsNonMobileSearchExplicitlyClosed(false);
}, []);
const handleNonMobileSearchClose = useCallback(() => {
setIsNonMobileSearchOpen(false);
setIsNonMobileSearchExplicitlyClosed(true);
if (onSearchChange) onSearchChange("");
}, [onSearchChange]);
const handleOverflowToggle = useCallback(() => {
setIsOverflowMenuOpen((prev) => !prev);
}, []);
@@ -299,6 +326,19 @@ export function Header({
</button>
)}
{/* Desktop/Tablet Search Toggle - show icon when search is available but hidden */}
{canShowNonMobileSearchToggle && (
<button
className="btn-icon"
onClick={handleNonMobileSearchToggle}
title="Open search"
aria-label="Open search"
data-testid="desktop-header-search-btn"
>
<Search size={16} />
</button>
)}
{/* Usage button on mobile when mobile bottom nav is active */}
{isMobile && hideFullNav && onOpenUsage && (
<button
@@ -769,27 +809,26 @@ export function Header({
</div>
</header>
{/* Desktop Search - floating below header, only in board view */}
{onSearchChange && view === "board" && !isMobile && (
{/* Desktop/Tablet Search - floating below header, in board or list view */}
{canShowNonMobileSearch && shouldShowNonMobileSearch && (
<div className="header-floating-search">
<div className="header-search">
<Search size={14} className="header-search-icon" />
<input
autoFocus
type="text"
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="header-search-input"
/>
{searchQuery && (
<button
className="header-search-clear"
onClick={() => onSearchChange("")}
aria-label="Clear search"
>
<X size={14} />
</button>
)}
<button
className="header-search-clear"
onClick={handleNonMobileSearchClose}
aria-label="Close search"
>
<X size={14} />
</button>
</div>
</div>
)}

View File

@@ -330,7 +330,7 @@ describe("Header", () => {
// ── Search Visibility by View ─────────────────────────────────────
it("shows search input when view is 'board'", () => {
it("shows search toggle when view is 'board' on desktop", () => {
const onSearchChange = vi.fn();
render(
<Header
@@ -340,10 +340,12 @@ describe("Header", () => {
onSearchChange={onSearchChange}
/>
);
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
// Toggle button is visible, search input is hidden by default
expect(screen.getByTestId("desktop-header-search-btn")).toBeDefined();
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
it("hides search input when view is 'list' on desktop", () => {
it("shows search toggle when view is 'list' on desktop", () => {
const onSearchChange = vi.fn();
render(
<Header
@@ -353,11 +355,88 @@ describe("Header", () => {
onSearchChange={onSearchChange}
/>
);
// Desktop search only appears in board view
// Toggle button is visible on list view
expect(screen.getByTestId("desktop-header-search-btn")).toBeDefined();
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
it("hides search input when view is 'agents'", () => {
it("opens search input when toggle is clicked on board view", () => {
const onSearchChange = vi.fn();
render(
<Header
view="board"
onChangeView={vi.fn()}
searchQuery=""
onSearchChange={onSearchChange}
/>
);
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
});
it("opens search input when toggle is clicked on list view", () => {
const onSearchChange = vi.fn();
render(
<Header
view="list"
onChangeView={vi.fn()}
searchQuery=""
onSearchChange={onSearchChange}
/>
);
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
});
it("keeps search open when searchQuery is non-empty (board view)", () => {
const onSearchChange = vi.fn();
render(
<Header
view="board"
onChangeView={vi.fn()}
searchQuery="test query"
onSearchChange={onSearchChange}
/>
);
// Search visible, toggle hidden
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("keeps search open when searchQuery is non-empty (list view)", () => {
const onSearchChange = vi.fn();
render(
<Header
view="list"
onChangeView={vi.fn()}
searchQuery="test query"
onSearchChange={onSearchChange}
/>
);
// Search visible, toggle hidden
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("closes search and clears query when close button is clicked", () => {
const onSearchChange = vi.fn();
render(
<Header
view="board"
onChangeView={vi.fn()}
searchQuery="test query"
onSearchChange={onSearchChange}
/>
);
fireEvent.click(screen.getByLabelText("Close search"));
expect(onSearchChange).toHaveBeenCalledWith("");
// Search should close (in real app, parent would update searchQuery prop)
// In test without state update, search remains visible with cleared input
// The toggle does not reappear until searchQuery prop becomes empty
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("hides search input and toggle when view is 'agents'", () => {
const onSearchChange = vi.fn();
render(
<Header
@@ -368,6 +447,21 @@ describe("Header", () => {
/>
);
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("hides search input and toggle when view is 'missions'", () => {
const onSearchChange = vi.fn();
render(
<Header
view="missions"
onChangeView={vi.fn()}
searchQuery=""
onSearchChange={onSearchChange}
/>
);
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
// ── Terminal Button ─────────────────────────────────────────────