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:
@@ -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%/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'", () => {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user