Polish SearchBar with arrow key navigation and conditional controls

Arrow Up/Down keys now cycle through search matches while the input
is focused, matching the behavior of browser find-in-page. Navigation
buttons gain active:scale-95 press feedback and explicit sizing.

The right-side control region is now conditionally rendered: keyboard
hint (/) shows when empty, match count + nav + clear show when active.
A visual divider separates navigation arrows from the clear button.
The match count badge highlights the current position number with a
distinct weight.

Tests cover empty state visibility, active search state rendering,
arrow key and button navigation, and clear button behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 13:35:55 -05:00
parent 10f23ccecc
commit 957f9bc744
2 changed files with 246 additions and 48 deletions

View File

@@ -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<typeof defaultProps> = {}) {
return render(<SearchBar {...defaultProps} {...overrides} />);
}
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("");
});
});
});

View File

@@ -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 (
<div className="w-80 sm:w-96">
@@ -131,32 +142,48 @@ export function SearchBar({
focus:outline-none"
/>
{/* Right-side controls — all inside the unified bar */}
<div className="flex items-center gap-0.5 pr-2 flex-shrink-0">
{/* Right-side controls — only rendered when there's content */}
{showControls ? (
<div className="flex items-center gap-1 pr-2.5 flex-shrink-0 animate-fade-in">
{/* Match count badge */}
{query && (
<div className={`
flex items-center gap-1 px-2 py-0.5 rounded-md text-caption tabular-nums whitespace-nowrap mr-0.5
<div
data-testid="match-count"
className={`
flex items-center px-2 py-0.5 rounded-md text-caption tabular-nums whitespace-nowrap
transition-colors duration-150
${hasNoResults
? "text-red-400 bg-red-500/10"
: "text-foreground-muted bg-surface-overlay/50"
}
`}>
`}
>
{hasNoResults ? (
<span>No results</span>
) : (
<span>{currentMatchPosition >= 0 ? currentMatchPosition + 1 : 0}<span className="text-foreground-muted/50 mx-0.5">/</span>{matchCount}</span>
<span>
<span className="text-foreground-secondary font-medium">
{currentMatchPosition >= 0 ? currentMatchPosition + 1 : 0}
</span>
<span className="text-foreground-muted/40 mx-0.5">/</span>
<span>{matchCount}</span>
</span>
)}
</div>
)}
{/* Navigation arrows — only when there are results */}
{hasResults && (
<div className="flex items-center border-l border-border-muted/60 ml-1 pl-1">
<div className="flex items-center gap-0.5 ml-0.5">
<button
onClick={onPrev}
className="p-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors"
className="flex items-center justify-center w-6 h-6 rounded-md
text-foreground-muted hover:text-foreground
hover:bg-surface-overlay/60
active:bg-surface-overlay/80 active:scale-95
transition-all duration-100"
aria-label="Previous match"
tabIndex={-1}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
@@ -164,8 +191,13 @@ export function SearchBar({
</button>
<button
onClick={onNext}
className="p-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors"
className="flex items-center justify-center w-6 h-6 rounded-md
text-foreground-muted hover:text-foreground
hover:bg-surface-overlay/60
active:bg-surface-overlay/80 active:scale-95
transition-all duration-100"
aria-label="Next match"
tabIndex={-1}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
@@ -174,27 +206,38 @@ export function SearchBar({
</div>
)}
{/* Clear button or keyboard hint */}
{localQuery ? (
{/* Divider between nav and clear */}
{hasResults && (
<div className="w-px h-4 bg-border-muted/50 mx-0.5" />
)}
{/* Clear button */}
<button
onClick={() => {
setLocalQuery("");
onQueryChange("");
inputRef.current?.focus();
}}
className="p-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors ml-0.5"
className="flex items-center justify-center w-6 h-6 rounded-md
text-foreground-muted hover:text-foreground
hover:bg-surface-overlay/60
active:bg-surface-overlay/80 active:scale-95
transition-all duration-100"
aria-label="Clear search"
tabIndex={-1}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
) : (
<kbd className="hidden sm:inline-flex items-center px-1.5 py-0.5 text-caption text-foreground-muted/70 bg-surface-overlay/40 border border-border-muted rounded font-mono leading-none">
<div className="pr-2.5 flex-shrink-0">
<kbd className="hidden sm:inline-flex items-center justify-center w-5 h-5 text-[11px] text-foreground-secondary bg-surface-overlay border border-border rounded font-mono">
/
</kbd>
)}
</div>
)}
</div>
</div>
);