diff --git a/src/client/components/SearchBar.test.tsx b/src/client/components/SearchBar.test.tsx new file mode 100644 index 0000000..ee12cb4 --- /dev/null +++ b/src/client/components/SearchBar.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import React from "react"; +import { render, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { SearchBar } from "./SearchBar"; + +const defaultProps = { + query: "", + onQueryChange: vi.fn(), + matchCount: 0, + currentMatchPosition: -1, + onNext: vi.fn(), + onPrev: vi.fn(), +}; + +function renderSearchBar(overrides: Partial = {}) { + return render(); +} + +describe("SearchBar", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("empty state visibility", () => { + it("does not render match count or navigation when query is empty", () => { + const { container } = renderSearchBar(); + // No match count badge should be visible + expect(container.querySelector("[data-testid='match-count']")).toBeNull(); + // No navigation arrows should be visible + expect(container.querySelector("[aria-label='Previous match']")).toBeNull(); + expect(container.querySelector("[aria-label='Next match']")).toBeNull(); + }); + + it("shows keyboard hint when no query is entered", () => { + const { container } = renderSearchBar(); + const kbd = container.querySelector("kbd"); + expect(kbd).toBeInTheDocument(); + expect(kbd?.textContent).toBe("/"); + }); + }); + + describe("active search state", () => { + it("shows match count when query has results", () => { + const { container } = renderSearchBar({ + query: "test", + matchCount: 5, + currentMatchPosition: 2, + }); + const badge = container.querySelector("[data-testid='match-count']"); + expect(badge).toBeInTheDocument(); + expect(badge?.textContent).toContain("3"); + expect(badge?.textContent).toContain("5"); + }); + + it("shows 'No results' when query has no matches", () => { + const { container } = renderSearchBar({ + query: "nonexistent", + matchCount: 0, + currentMatchPosition: -1, + }); + const badge = container.querySelector("[data-testid='match-count']"); + expect(badge).toBeInTheDocument(); + expect(badge?.textContent).toContain("No results"); + }); + + it("shows navigation arrows when there are results", () => { + const { container } = renderSearchBar({ + query: "test", + matchCount: 3, + currentMatchPosition: 0, + }); + expect(container.querySelector("[aria-label='Previous match']")).toBeInTheDocument(); + expect(container.querySelector("[aria-label='Next match']")).toBeInTheDocument(); + }); + + it("shows clear button when input has text", () => { + const { container } = renderSearchBar({ + query: "test", + matchCount: 1, + currentMatchPosition: 0, + }); + expect(container.querySelector("[aria-label='Clear search']")).toBeInTheDocument(); + }); + }); + + describe("arrow key navigation", () => { + it("calls onNext when ArrowDown is pressed in the input", () => { + const onNext = vi.fn(); + const { container } = renderSearchBar({ + query: "test", + matchCount: 3, + currentMatchPosition: 0, + onNext, + }); + const input = container.querySelector("input")!; + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(onNext).toHaveBeenCalledTimes(1); + }); + + it("calls onPrev when ArrowUp is pressed in the input", () => { + const onPrev = vi.fn(); + const { container } = renderSearchBar({ + query: "test", + matchCount: 3, + currentMatchPosition: 1, + onPrev, + }); + const input = container.querySelector("input")!; + fireEvent.keyDown(input, { key: "ArrowUp" }); + expect(onPrev).toHaveBeenCalledTimes(1); + }); + }); + + describe("navigation button clicks", () => { + it("calls onPrev when previous button is clicked", () => { + const onPrev = vi.fn(); + const { container } = renderSearchBar({ + query: "test", + matchCount: 3, + currentMatchPosition: 1, + onPrev, + }); + fireEvent.click(container.querySelector("[aria-label='Previous match']")!); + expect(onPrev).toHaveBeenCalledTimes(1); + }); + + it("calls onNext when next button is clicked", () => { + const onNext = vi.fn(); + const { container } = renderSearchBar({ + query: "test", + matchCount: 3, + currentMatchPosition: 0, + onNext, + }); + fireEvent.click(container.querySelector("[aria-label='Next match']")!); + expect(onNext).toHaveBeenCalledTimes(1); + }); + }); + + describe("clear button", () => { + it("clears query when clear button is clicked", () => { + const onQueryChange = vi.fn(); + const { container } = renderSearchBar({ + query: "test", + matchCount: 1, + currentMatchPosition: 0, + onQueryChange, + }); + fireEvent.click(container.querySelector("[aria-label='Clear search']")!); + expect(onQueryChange).toHaveBeenCalledWith(""); + }); + }); +}); diff --git a/src/client/components/SearchBar.tsx b/src/client/components/SearchBar.tsx index 3cd903f..779fbba 100644 --- a/src/client/components/SearchBar.tsx +++ b/src/client/components/SearchBar.tsx @@ -90,10 +90,21 @@ export function SearchBar({ onNext(); } } + + // Arrow keys navigate between matches while in the search input + if (e.key === "ArrowDown") { + e.preventDefault(); + onNext(); + } + if (e.key === "ArrowUp") { + e.preventDefault(); + onPrev(); + } } const hasResults = query && matchCount > 0; const hasNoResults = query && matchCount === 0; + const showControls = !!localQuery || !!query; return (
@@ -131,70 +142,102 @@ export function SearchBar({ focus:outline-none" /> - {/* Right-side controls — all inside the unified bar */} -
- {/* Match count badge */} - {query && ( -
- {hasNoResults ? ( - No results - ) : ( - {currentMatchPosition >= 0 ? currentMatchPosition + 1 : 0}/{matchCount} - )} -
- )} - - {/* Navigation arrows — only when there are results */} - {hasResults && ( -
- - -
- )} + {hasNoResults ? ( + No results + ) : ( + + + {currentMatchPosition >= 0 ? currentMatchPosition + 1 : 0} + + / + {matchCount} + + )} +
+ )} - {/* Clear button or keyboard hint */} - {localQuery ? ( + {/* Navigation arrows — only when there are results */} + {hasResults && ( +
+ + +
+ )} + + {/* Divider between nav and clear */} + {hasResults && ( +
+ )} + + {/* Clear button */} - ) : ( - +
+ ) : ( +
+ / - )} -
+
+ )} );