diff --git a/changelog.d/added-docs-navbar-palette-tests.md b/changelog.d/added-docs-navbar-palette-tests.md
new file mode 100644
index 0000000..89c6ccb
--- /dev/null
+++ b/changelog.d/added-docs-navbar-palette-tests.md
@@ -0,0 +1 @@
+- Add behavior tests for the DocsNavbar command palette (Ctrl/⌘K toggle, arrow-key and Enter result navigation, debounce, blank-query reset, API error recovery, backdrop/Escape teardown) and the shared `useHoverDropdown` hover-menu lifecycle.
diff --git a/src/__tests__/DocsNavbar.search-keyboard.test.tsx b/src/__tests__/DocsNavbar.search-keyboard.test.tsx
new file mode 100644
index 0000000..8117a1a
--- /dev/null
+++ b/src/__tests__/DocsNavbar.search-keyboard.test.tsx
@@ -0,0 +1,441 @@
+// @vitest-environment jsdom
+import React, { type ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+
+vi.mock("next/link", () => ({
+ default: ({
+ href,
+ children,
+ ...rest
+ }: {
+ href?: string;
+ children?: ReactNode;
+ }) => (
+ key !== "prefetch")
+ )}
+ >
+ {children}
+
+ ),
+}));
+
+vi.mock("next/image", () => ({
+ default: ({ alt, src, ...rest }: { alt?: string; src?: string }) =>
+ React.createElement("img", { alt, src, ...rest }),
+}));
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/docs/hive/overview/introduction",
+}));
+
+vi.mock("next-themes", () => ({
+ useTheme: () => ({ resolvedTheme: "dark" }),
+}));
+
+vi.mock("../components/docs/VersionSelector", () => ({
+ VersionSelector: () =>
Version
,
+}));
+
+import DocsNavbar from "../components/docs/DocsNavbar";
+import { DROPDOWN_HIDE_DELAY_MS } from "../components/navbar/useHoverDropdown";
+
+const RESULTS = [
+ {
+ title: "First Result",
+ url: "/docs/hive/first",
+ category: "Docs",
+ snippet: "first",
+ highlightedSnippet: "first",
+ matchType: "title",
+ },
+ {
+ title: "Second Result",
+ url: "/docs/hive/second",
+ category: "Docs",
+ snippet: "second",
+ highlightedSnippet: "second",
+ matchType: "content",
+ },
+];
+
+const PLACEHOLDER = "Search documentation...";
+const DEBOUNCE_MS = 300;
+
+let searchResponse: () => Response | Promise;
+
+function jsonResponse(body: unknown, status = 200) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+async function openPaletteAndSearch(query: string) {
+ fireEvent.click(screen.getAllByLabelText("Search documentation")[0]);
+ const input = await screen.findByPlaceholderText(PLACEHOLDER);
+ fireEvent.change(input, { target: { value: query } });
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, DEBOUNCE_MS + 50));
+ });
+ return input;
+}
+
+function selectedResultTitle(): string | undefined {
+ const selected = Array.from(
+ document.querySelectorAll("a.border-honey")
+ ) as HTMLAnchorElement[];
+ return selected[0]?.textContent?.trim().split("\n")[0];
+}
+
+beforeEach(() => {
+ searchResponse = () => jsonResponse({ results: RESULTS });
+ global.fetch = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.startsWith("/api/search")) return searchResponse();
+ return jsonResponse({ value: "42" });
+ }) as unknown as typeof fetch;
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+});
+
+describe("DocsNavbar command palette keyboard shortcuts", () => {
+ it("toggles the palette with Ctrl+K and ⌘K and focuses the input", async () => {
+ render();
+ expect(screen.queryByPlaceholderText(PLACEHOLDER)).toBeNull();
+
+ fireEvent.keyDown(document, { key: "k", ctrlKey: true });
+ const input = await screen.findByPlaceholderText(PLACEHOLDER);
+ await waitFor(() => expect(document.activeElement).toBe(input));
+
+ fireEvent.keyDown(document, { key: "k", metaKey: true });
+ await waitFor(() =>
+ expect(screen.queryByPlaceholderText(PLACEHOLDER)).toBeNull()
+ );
+ });
+
+ it("moves the selection with ArrowDown/ArrowUp, clamping at both ends", async () => {
+ render();
+ await openPaletteAndSearch("result");
+
+ expect(await screen.findByText("First Result")).toBeTruthy();
+ expect(screen.getByText("Second Result")).toBeTruthy();
+ expect(screen.getByText("First Result").closest("a")?.className).toContain(
+ "border-honey"
+ );
+
+ fireEvent.keyDown(document, { key: "ArrowDown" });
+ await waitFor(() =>
+ expect(
+ screen.getByText("Second Result").closest("a")?.className
+ ).toContain("border-honey")
+ );
+
+ // Already at the last result: ArrowDown must not move past it.
+ fireEvent.keyDown(document, { key: "ArrowDown" });
+ await act(async () => {});
+ expect(screen.getByText("Second Result").closest("a")?.className).toContain(
+ "border-honey"
+ );
+
+ fireEvent.keyDown(document, { key: "ArrowUp" });
+ await waitFor(() =>
+ expect(
+ screen.getByText("First Result").closest("a")?.className
+ ).toContain("border-honey")
+ );
+
+ // Already at the first result: ArrowUp clamps to 0.
+ fireEvent.keyDown(document, { key: "ArrowUp" });
+ await act(async () => {});
+ expect(screen.getByText("First Result").closest("a")?.className).toContain(
+ "border-honey"
+ );
+ expect(selectedResultTitle()).toContain("First Result");
+ });
+
+ it("hovering a result selects it, and Enter navigates to the selected URL", async () => {
+ const assigned: string[] = [];
+ const originalLocation = window.location;
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: {
+ ...originalLocation,
+ set href(value: string) {
+ assigned.push(value);
+ },
+ get href() {
+ return assigned[assigned.length - 1] ?? "http://localhost/";
+ },
+ },
+ });
+
+ try {
+ render();
+ await openPaletteAndSearch("result");
+ const second = (await screen.findByText("Second Result")).closest("a")!;
+
+ fireEvent.mouseEnter(second);
+ await waitFor(() => expect(second.className).toContain("border-honey"));
+
+ fireEvent.keyDown(document, { key: "Enter" });
+ await waitFor(() => expect(assigned).toEqual(["/docs/hive/second"]));
+ } finally {
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: originalLocation,
+ });
+ }
+ });
+
+ it("ignores arrow keys and Enter when there are no results", async () => {
+ searchResponse = () => jsonResponse({ results: [] });
+ render();
+ await openPaletteAndSearch("nothing");
+
+ expect(
+ await screen.findByText(/No results found for "nothing"/)
+ ).toBeTruthy();
+
+ const down = fireEvent.keyDown(document, { key: "ArrowDown" });
+ const enter = fireEvent.keyDown(document, { key: "Enter" });
+ // Not prevented → the handler never reached the navigation branch.
+ expect(down).toBe(true);
+ expect(enter).toBe(true);
+ });
+});
+
+describe("DocsNavbar search request lifecycle", () => {
+ it("clears results and the spinner without calling the API for a blank query", async () => {
+ render();
+ const input = await openPaletteAndSearch("result");
+ expect(await screen.findByText("First Result")).toBeTruthy();
+
+ const searchCalls = () =>
+ (global.fetch as ReturnType).mock.calls.filter(([u]) =>
+ String(u).startsWith("/api/search")
+ ).length;
+ const before = searchCalls();
+
+ fireEvent.change(input, { target: { value: " " } });
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, DEBOUNCE_MS + 50));
+ });
+
+ expect(screen.queryByText("First Result")).toBeNull();
+ expect(screen.queryByText("Searching documentation...")).toBeNull();
+ expect(screen.getByText(/Search for any word or phrase/)).toBeTruthy();
+ expect(searchCalls()).toBe(before);
+ });
+
+ it("debounces rapid typing into a single API call", async () => {
+ render();
+ fireEvent.click(screen.getAllByLabelText("Search documentation")[0]);
+ const input = await screen.findByPlaceholderText(PLACEHOLDER);
+
+ fireEvent.change(input, { target: { value: "r" } });
+ fireEvent.change(input, { target: { value: "re" } });
+ fireEvent.change(input, { target: { value: "res" } });
+ expect(screen.getByText("Searching documentation...")).toBeTruthy();
+
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, DEBOUNCE_MS + 50));
+ });
+
+ const searchUrls = (global.fetch as ReturnType).mock.calls
+ .map(([u]) => String(u))
+ .filter(u => u.startsWith("/api/search"));
+ expect(searchUrls).toEqual(["/api/search?q=res"]);
+ });
+
+ it("logs and empties results when the API responds with an error status", async () => {
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+ searchResponse = () => jsonResponse({ error: "boom" }, 500);
+
+ render();
+ await openPaletteAndSearch("broken");
+
+ expect(
+ await screen.findByText(/No results found for "broken"/)
+ ).toBeTruthy();
+ expect(consoleError).toHaveBeenCalledWith(
+ "Search error:",
+ expect.any(Error)
+ );
+ expect(screen.queryByText("Searching documentation...")).toBeNull();
+ });
+
+ it("recovers when fetch itself rejects", async () => {
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+ searchResponse = () => Promise.reject(new Error("network down"));
+
+ render();
+ await openPaletteAndSearch("offline");
+
+ expect(
+ await screen.findByText(/No results found for "offline"/)
+ ).toBeTruthy();
+ expect(consoleError).toHaveBeenCalled();
+ });
+
+ it("treats a payload without a results array as zero results", async () => {
+ searchResponse = () => jsonResponse({});
+ render();
+ await openPaletteAndSearch("empty");
+ expect(
+ await screen.findByText(/No results found for "empty"/)
+ ).toBeTruthy();
+ });
+});
+
+describe("DocsNavbar palette open/close affordances", () => {
+ it("opens from the mobile search button and closes on backdrop click, resetting state", async () => {
+ render();
+ const [, mobileButton] = screen.getAllByLabelText("Search documentation");
+ fireEvent.click(mobileButton);
+ const input = await screen.findByPlaceholderText(PLACEHOLDER);
+ await waitFor(() => expect(document.activeElement).toBe(input));
+
+ fireEvent.change(input, { target: { value: "result" } });
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, DEBOUNCE_MS + 50));
+ });
+ expect(await screen.findByText("First Result")).toBeTruthy();
+
+ fireEvent.click(document.querySelector(".fixed.inset-0")!);
+ await waitFor(() =>
+ expect(screen.queryByPlaceholderText(PLACEHOLDER)).toBeNull()
+ );
+
+ // Reopening shows the empty-state prompt, not the stale query/results.
+ fireEvent.click(screen.getAllByLabelText("Search documentation")[0]);
+ const reopened = await screen.findByPlaceholderText(PLACEHOLDER);
+ expect((reopened as HTMLInputElement).value).toBe("");
+ expect(screen.queryByText("First Result")).toBeNull();
+ expect(screen.getByText(/Search for any word or phrase/)).toBeTruthy();
+ });
+
+ it("Escape resets the query and results, not just visibility", async () => {
+ render();
+ await openPaletteAndSearch("result");
+ expect(await screen.findByText("First Result")).toBeTruthy();
+
+ fireEvent.keyDown(document, { key: "Escape" });
+ await waitFor(() =>
+ expect(screen.queryByPlaceholderText(PLACEHOLDER)).toBeNull()
+ );
+
+ fireEvent.keyDown(document, { key: "k", ctrlKey: true });
+ const reopened = await screen.findByPlaceholderText(PLACEHOLDER);
+ expect((reopened as HTMLInputElement).value).toBe("");
+ expect(screen.queryByText("First Result")).toBeNull();
+ });
+});
+
+describe("DocsNavbar hover dropdowns", () => {
+ function trigger(name: "contribute" | "community" | "github") {
+ const expanded = Array.from(
+ document.querySelectorAll('[aria-haspopup="true"]')
+ );
+ const index = { contribute: 0, community: 1, github: 2 }[name];
+ return expanded[index] as HTMLElement;
+ }
+
+ it("opens on hover and closes after the shared hide delay", async () => {
+ vi.useFakeTimers();
+ render();
+ await act(async () => {});
+
+ const contribute = trigger("contribute");
+ const wrapper = contribute.parentElement!;
+ expect(contribute.getAttribute("aria-expanded")).toBe("false");
+
+ fireEvent.mouseEnter(wrapper);
+ expect(contribute.getAttribute("aria-expanded")).toBe("true");
+ expect(wrapper.querySelectorAll("a").length).toBeGreaterThan(0);
+
+ fireEvent.mouseLeave(wrapper);
+ act(() => {
+ vi.advanceTimersByTime(DROPDOWN_HIDE_DELAY_MS - 1);
+ });
+ expect(contribute.getAttribute("aria-expanded")).toBe("true");
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(contribute.getAttribute("aria-expanded")).toBe("false");
+ });
+
+ it("re-entering the open menu cancels the pending close", async () => {
+ vi.useFakeTimers();
+ render();
+ await act(async () => {});
+
+ const community = trigger("community");
+ const wrapper = community.parentElement!;
+ fireEvent.mouseEnter(wrapper);
+ expect(community.getAttribute("aria-expanded")).toBe("true");
+
+ fireEvent.mouseLeave(wrapper);
+ act(() => {
+ vi.advanceTimersByTime(DROPDOWN_HIDE_DELAY_MS / 2);
+ });
+ // Pointer moves onto the menu panel itself.
+ const panel = wrapper.querySelector(":scope > div:not([aria-haspopup])")!;
+ fireEvent.mouseEnter(panel);
+ act(() => {
+ vi.advanceTimersByTime(DROPDOWN_HIDE_DELAY_MS * 2);
+ });
+ expect(community.getAttribute("aria-expanded")).toBe("true");
+ });
+
+ it("switching to another trigger replaces the open dropdown immediately", async () => {
+ vi.useFakeTimers();
+ render();
+ await act(async () => {});
+
+ const contribute = trigger("contribute");
+ const github = trigger("github");
+ fireEvent.mouseEnter(contribute.parentElement!);
+ fireEvent.mouseLeave(contribute.parentElement!);
+ fireEvent.mouseEnter(github.parentElement!);
+
+ expect(contribute.getAttribute("aria-expanded")).toBe("false");
+ expect(github.getAttribute("aria-expanded")).toBe("true");
+
+ // The stale close timer from "contribute" was cleared, so "github" stays open.
+ act(() => {
+ vi.advanceTimersByTime(DROPDOWN_HIDE_DELAY_MS * 2);
+ });
+ expect(github.getAttribute("aria-expanded")).toBe("true");
+ });
+
+ it("Escape closes an open dropdown when the palette is not open", async () => {
+ vi.useFakeTimers();
+ render();
+ await act(async () => {});
+
+ const github = trigger("github");
+ fireEvent.mouseEnter(github.parentElement!);
+ expect(github.getAttribute("aria-expanded")).toBe("true");
+
+ fireEvent.keyDown(document, { key: "Escape" });
+ expect(github.getAttribute("aria-expanded")).toBe("false");
+ });
+});