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:
155
src/client/components/SearchBar.test.tsx
Normal file
155
src/client/components/SearchBar.test.tsx
Normal 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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user