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 cbcb61ecd6
commit d9be6a838d
5 changed files with 145 additions and 82 deletions

View File

@@ -92,12 +92,38 @@ describe("mobile-header-controls.css", () => {
}
});
it("has position relative on header-actions for absolute positioning", () => {
// Find the .header-actions rule and check for position: relative
const headerActionsMatch = cssContent.match(/\.header-actions\s*\{([^}]+)\}/);
expect(headerActionsMatch).toBeTruthy();
if (headerActionsMatch) {
expect(headerActionsMatch[1]).toContain("position: relative");
it("has header-wrapper with position relative for positioning context", () => {
const headerWrapperMatch = cssContent.match(/\.header-wrapper\s*\{([^}]+)\}/);
expect(headerWrapperMatch).toBeTruthy();
if (headerWrapperMatch) {
expect(headerWrapperMatch[1]).toContain("position: relative");
}
});
it("has header-floating-search styles for floating search container", () => {
const floatingSearchMatch = cssContent.match(/\.header-floating-search\s*\{([^}]+)\}/);
expect(floatingSearchMatch).toBeTruthy();
if (floatingSearchMatch) {
expect(floatingSearchMatch[1]).toContain("background:");
expect(floatingSearchMatch[1]).toContain("padding:");
expect(floatingSearchMatch[1]).toContain("border:");
expect(floatingSearchMatch[1]).toContain("box-shadow:");
}
});
it("has header-floating-search .header-search with full width", () => {
const fullWidthMatch = cssContent.match(/\.header-floating-search\s+\.header-search\s*\{([^}]+)\}/);
expect(fullWidthMatch).toBeTruthy();
if (fullWidthMatch) {
expect(fullWidthMatch[1]).toContain("width: 100%");
expect(fullWidthMatch[1]).toContain("max-width: none");
}
});
it("has mobile-search-expanded with full width in floating container", () => {
// In the mobile media query, .mobile-search-expanded should have width: 100%
expect(mobileCss).toContain(".mobile-search-expanded");
// The selector should have width: 100% rule
expect(mobileCss).toMatch(/\.mobile-search-expanded\s*\{[^}]*width:\s*100%/);
});
});

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'", () => {

View File

@@ -253,6 +253,25 @@ body {
background: var(--surface);
}
.header-wrapper {
position: relative;
}
.header-floating-search {
background: var(--surface);
padding: var(--space-xs) var(--space-md) var(--space-sm);
border: 1px solid var(--border);
border-top: none;
border-bottom-left-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.header-floating-search .header-search {
width: 100%;
max-width: none;
}
.header-left {
display: flex;
align-items: center;
@@ -6075,15 +6094,9 @@ body {
justify-content: center;
}
/* Mobile search expanded - inside header-floating-search container */
.mobile-search-expanded {
position: absolute;
right: 120px;
left: var(--space-md);
top: 50%;
transform: translateY(-50%);
z-index: 10;
background: var(--surface);
box-shadow: var(--shadow-md);
width: 100%;
}
/* Modal: full-screen on mobile */