feat(FN-1335): render header search box as floating element below header

- Convert header search box to floating dropdown positioned below header
- Add mobile header controls test for floating search behavior
- Add Header component tests for search box rendering
- Update Header.tsx to render search as floating element with absolute positioning
- Add styles for floating search box with backdrop blur and shadow
This commit is contained in:
gsxdsm
2026-04-09 15:38:52 -07:00
parent 700ae8c480
commit 0e295c3239
5 changed files with 145 additions and 82 deletions

View File

@@ -712,6 +712,24 @@ describe("Header", () => {
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" });
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" });
expect(container.querySelector(".header-actions .header-search")).toBeNull();
});
it("renders header-wrapper containing both header and floating search", () => {
const { container } = renderHeader({ onSearchChange: vi.fn(), view: "board" });
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();
});
});
describe("schedules button", () => {

View File

@@ -215,9 +215,10 @@ export function Header({
}, [onSearchChange]);
return (
<header className="header">
<div className="header-left">
<div className="header-brand">
<div className="header-wrapper">
<header className="header">
<div className="header-left">
<div className="header-brand">
<svg
className="header-logo"
width={24}
@@ -284,68 +285,18 @@ export function Header({
</div>
<div className="header-actions">
{/* Desktop Search - only show in board view */}
{onSearchChange && (view === "board" || view === "list") && !isMobile && (
<div className="header-search">
<Search size={14} className="header-search-icon" />
<input
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>
)}
</div>
)}
{/* Mobile Search Trigger - show in board and list views when mobile nav is hidden (hideFullNav) or in board view when mobile nav is visible */}
{onSearchChange && isMobile && (hideFullNav || view === "board" || view === "list") && (
<>
{!shouldShowMobileSearch ? (
<button
className="btn-icon mobile-search-trigger"
onClick={handleMobileSearchToggle}
title="Open search"
aria-label="Open search"
aria-expanded={false}
data-testid="mobile-header-search-btn"
>
<Search size={16} />
</button>
) : (
<div
ref={mobileSearchRef}
className="header-search mobile-search-expanded"
>
<Search size={14} className="header-search-icon" />
<input
ref={mobileSearchInputRef}
autoFocus
type="text"
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="header-search-input"
/>
<button
className="header-search-clear"
onClick={handleMobileSearchClose}
aria-label="Close search"
>
<X size={14} />
</button>
</div>
)}
</>
{/* Mobile Search Trigger - only on mobile, show trigger button in header */}
{onSearchChange && isMobile && (hideFullNav || view === "board" || view === "list") && !shouldShowMobileSearch && (
<button
className="btn-icon mobile-search-trigger"
onClick={handleMobileSearchToggle}
title="Open search"
aria-label="Open search"
aria-expanded={false}
data-testid="mobile-header-search-btn"
>
<Search size={16} />
</button>
)}
{/* Usage button on mobile when mobile bottom nav is active */}
@@ -817,5 +768,59 @@ export function Header({
)}
</div>
</header>
);
{/* Desktop Search - floating below header, only in board view */}
{onSearchChange && view === "board" && !isMobile && (
<div className="header-floating-search">
<div className="header-search">
<Search size={14} className="header-search-icon" />
<input
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>
)}
</div>
</div>
)}
{/* Mobile Search Expanded - floating below header */}
{onSearchChange && isMobile && shouldShowMobileSearch && (
<div className="header-floating-search">
<div
ref={mobileSearchRef}
className="header-search mobile-search-expanded"
>
<Search size={14} className="header-search-icon" />
<input
ref={mobileSearchInputRef}
autoFocus
type="text"
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="header-search-input"
/>
<button
className="header-search-clear"
onClick={handleMobileSearchClose}
aria-label="Close search"
>
<X size={14} />
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -343,7 +343,7 @@ describe("Header", () => {
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
});
it("shows search input when view is 'list'", () => {
it("hides search input when view is 'list' on desktop", () => {
const onSearchChange = vi.fn();
render(
<Header
@@ -353,7 +353,8 @@ describe("Header", () => {
onSearchChange={onSearchChange}
/>
);
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
// Desktop search only appears in board view
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
it("hides search input when view is 'agents'", () => {