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:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user