diff --git a/src/__tests__/header.test.tsx b/src/__tests__/header.test.tsx index a2325b1fc..c34edbd3e 100644 --- a/src/__tests__/header.test.tsx +++ b/src/__tests__/header.test.tsx @@ -30,6 +30,7 @@ const defaultUnifiedSearchResult = { _id: "skills:weather", slug: "weather", displayName: "Weather Skill", + categories: ["development"], ownerUserId: "users:local", stats: { downloads: 1, stars: 2 }, createdAt: 1, @@ -47,6 +48,7 @@ const defaultUnifiedSearchResult = { channel: "community", isOfficial: false, summary: "Plugin weather tools.", + categories: ["channels"], ownerHandle: "local", createdAt: 1, updatedAt: 2, @@ -225,9 +227,12 @@ function compactHeaderCss() { throw new Error("Missing compact header media query"); } +const scrollIntoViewMock = vi.fn(); + describe("Header", () => { beforeEach(() => { vi.clearAllMocks(); + Element.prototype.scrollIntoView = scrollIntoViewMock; authStatusMock.mockReturnValue({ isAuthenticated: false, isLoading: false, @@ -414,9 +419,7 @@ describe("Header", () => { expect(document.activeElement).toBe(input); expect(input.getAttribute("aria-expanded")).toBe("true"); - expect(screen.getByRole("tablist", { name: "Result type" })).toBeTruthy(); - expect(screen.getByText("tabs")).toBeTruthy(); - expect(screen.getByText("results")).toBeTruthy(); + expect(screen.queryByRole("tablist", { name: "Result type" })).toBeNull(); expect(screen.getByText("Start typing to search skills and plugins")).toBeTruthy(); expect(useUnifiedSearchMock).toHaveBeenLastCalledWith( "", @@ -425,27 +428,23 @@ describe("Header", () => { ); }); - it("preserves caret navigation in the search input and switches focused tabs with arrows", () => { + it("preserves caret navigation and moves through the unified results with vertical arrows", () => { render(
); const input = screen.getByPlaceholderText("Search skills and plugins"); fireEvent.focus(input); fireEvent.change(input, { target: { value: "weather plugin" } }); - const skillsTab = screen.getByRole("tab", { name: "Skills" }); - const pluginsTab = screen.getByRole("tab", { name: "Plugins" }); - expect(fireEvent.keyDown(input, { key: "ArrowLeft" })).toBe(true); - expect(skillsTab.getAttribute("aria-selected")).toBe("true"); - - skillsTab.focus(); - fireEvent.keyDown(skillsTab, { key: "ArrowRight" }); - - expect(pluginsTab.getAttribute("aria-selected")).toBe("true"); - expect(document.activeElement).toBe(pluginsTab); + const firstActiveId = input.getAttribute("aria-activedescendant"); + const initialScrollCount = scrollIntoViewMock.mock.calls.length; + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(input.getAttribute("aria-activedescendant")).not.toBe(firstActiveId); + expect(scrollIntoViewMock.mock.calls.length).toBeGreaterThan(initialScrollCount); + expect(scrollIntoViewMock).toHaveBeenLastCalledWith({ block: "nearest" }); }); - it("shows tabbed skills and plugins typeahead without users", () => { + it("shows skills and plugins together in grouped typeahead sections", () => { navigateMock.mockReset(); render(
); @@ -454,16 +453,17 @@ describe("Header", () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: "weather" } }); - const tablist = screen.getByRole("tablist", { name: "Result type" }); - expect(within(tablist).getByRole("tab", { name: "Skills" })).toBeTruthy(); - expect(within(tablist).getByRole("tab", { name: "Plugins" })).toBeTruthy(); - expect(screen.getByText("tabs")).toBeTruthy(); - expect(screen.getByText("results")).toBeTruthy(); - const typeahead = screen.getByRole("listbox"); + const skillGroup = within(typeahead).getByRole("group", { name: "Skills" }); + const pluginGroup = within(typeahead).getByRole("group", { name: "Plugins" }); expect(screen.getByText("Weather Skill")).toBeTruthy(); expect(screen.getByText("@local / weather")).toBeTruthy(); - expect(screen.queryByText("Weather Plugin")).toBeNull(); + expect(screen.getByText("Weather Plugin")).toBeTruthy(); + expect(screen.getByText("@local / weather-plugin")).toBeTruthy(); + expect(skillGroup.querySelector("svg.lucide-wrench")).not.toBeNull(); + expect(pluginGroup.querySelector("svg.lucide-message-circle")).not.toBeNull(); + expect(typeahead.querySelector("svg.lucide-package")).toBeNull(); + expect(screen.queryByRole("tablist", { name: "Result type" })).toBeNull(); expect(input.getAttribute("role")).toBe("combobox"); expect(input.getAttribute("aria-autocomplete")).toBe("list"); expect(input.getAttribute("aria-expanded")).toBe("true"); @@ -473,11 +473,6 @@ describe("Header", () => { expect(within(typeahead).queryByText("Publishers")).toBeNull(); expect(within(typeahead).queryByText('See user results for "weather"')).toBeNull(); - fireEvent.click(within(tablist).getByRole("tab", { name: "Plugins" })); - expect(screen.getByText("Weather Plugin")).toBeTruthy(); - expect(screen.queryByText("Weather Skill")).toBeNull(); - - fireEvent.click(within(tablist).getByRole("tab", { name: "Skills" })); fireEvent.keyDown(input, { key: "ArrowDown" }); fireEvent.keyDown(input, { key: "Enter" }); @@ -543,9 +538,7 @@ describe("Header", () => { fireEvent.change(input, { target: { value: "zzzz" } }); expect(screen.getByText('No skills or plugins found for "zzzz"')).toBeTruthy(); - expect(screen.getByRole("tablist", { name: "Result type" })).toBeTruthy(); - expect(screen.getByText("tabs")).toBeTruthy(); - expect(screen.getByText("results")).toBeTruthy(); + expect(screen.queryByRole("tablist", { name: "Result type" })).toBeNull(); expect(screen.queryByText('See skill results for "zzzz"')).toBeNull(); expect(screen.queryByText('See plugin results for "zzzz"')).toBeNull(); }); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index dde7d955a..b4aa71f64 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -97,7 +97,7 @@ function GitHubLogo({ className }: { className?: string }) { ); } -type TypeaheadTab = "skills" | "plugins"; +type TypeaheadSection = "skills" | "plugins"; type TypeaheadItem = | { @@ -113,7 +113,7 @@ type TypeaheadItem = | { kind: "footer"; key: string; - section: TypeaheadTab; + section: TypeaheadSection; label: string; }; @@ -135,7 +135,6 @@ export default function Header() { ); const [navSearchQuery, setNavSearchQuery] = useState(""); const [typeaheadOpen, setTypeaheadOpen] = useState(false); - const [typeaheadTab, setTypeaheadTab] = useState("skills"); const [typeaheadActiveIndex, setTypeaheadActiveIndex] = useState(0); const [mobileSearchOpen, setMobileSearchOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); @@ -193,7 +192,10 @@ export default function Header() { return items; }, [hasNavSearchQuery, pluginResults, trimmedNavSearchQuery]); - const typeaheadItems = typeaheadTab === "skills" ? typeaheadSkillItems : typeaheadPluginItems; + const typeaheadItems = useMemo( + () => [...typeaheadSkillItems, ...typeaheadPluginItems], + [typeaheadPluginItems, typeaheadSkillItems], + ); const activeTypeaheadItem = showTypeahead ? typeaheadItems[typeaheadActiveIndex] : undefined; const activeTypeaheadId = activeTypeaheadItem ? getTypeaheadOptionId(activeTypeaheadItem) @@ -201,17 +203,12 @@ export default function Header() { useEffect(() => { setTypeaheadActiveIndex(0); - setTypeaheadTab("skills"); }, [trimmedNavSearchQuery]); useEffect(() => { setTypeaheadActiveIndex((index) => Math.min(index, Math.max(typeaheadItems.length - 1, 0))); }, [typeaheadItems.length]); - useEffect(() => { - setTypeaheadActiveIndex(0); - }, [typeaheadTab]); - useEffect(() => { if (!typeaheadOpen && !mobileSearchOpen) return () => {}; const handlePointerDown = (event: PointerEvent) => { @@ -525,12 +522,9 @@ export default function Header() { {showTypeahead && !mobileSearchOpen ? ( void; onSelectItem: (item: TypeaheadItem) => void; - onTabChange: (tab: TypeaheadTab) => void; pluginItems: TypeaheadItem[]; query: string; skillItems: TypeaheadItem[]; @@ -812,80 +797,17 @@ function SearchTypeahead({ const hasSkillMatches = skillItems.some((item) => item.kind === "skill"); const hasPluginMatches = pluginItems.some((item) => item.kind === "plugin"); const hasMatches = hasSkillMatches || hasPluginMatches; - const activeTabHasItems = items.length > 0; - const emptyTabLabel = activeTab === "skills" ? "skills" : "plugins"; - const skillsTabRef = useRef(null); - const pluginsTabRef = useRef(null); - - const handleTabKeyDown = (event: React.KeyboardEvent) => { - if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; - event.preventDefault(); - const nextTab = activeTab === "skills" ? "plugins" : "skills"; - onTabChange(nextTab); - (nextTab === "skills" ? skillsTabRef : pluginsTabRef).current?.focus(); - }; + const pluginStartIndex = skillItems.length; return (