From a4a292464e511ab842f0fdfcaee5d0474536f83b Mon Sep 17 00:00:00 2001 From: norendren Date: Sat, 7 Nov 2026 12:31:09 -0700 Subject: [PATCH] Add a game search to the navbar Finding a specific game took several clicks through Explore. Add a magnifying glass to the navbar on desktop and mobile that searches the catalog by title or designer and opens the game page. Reuse the game picker's filtering, with title matches ranked first. --- CHANGELOG.md | 3 +- docs/subsystems/explore.md | 4 + public/locales/en/apfront.json | 4 + src/components/GameSearch.js | 160 ++++++++++++++++++++++++++++++ src/components/GameSearch.test.js | 125 +++++++++++++++++++++++ src/components/Navbar.js | 5 + src/index.css | 65 +++++++++++- src/lib/gameOptions.js | 33 +++++- src/lib/gameOptions.test.js | 33 ++++++ 9 files changed, 427 insertions(+), 5 deletions(-) create mode 100644 src/components/GameSearch.js create mode 100644 src/components/GameSearch.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index fbbf08f5..095d3e1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,11 @@ Since the `1.0.0-beta` release, the version in `package.json` has stayed at `1.0 **Monthly batching (agents):** Use **one** `## [1.0.0-ci] - YYYY-MM-DD` header per calendar month. Before editing, search this file for `- YYYY-MM-` (today’s year-month); if a section exists, append bullets under `### Added` / `### Changed` / `### Fixed` there and set that header’s date to today — do **not** add a second `##` for the same month. A new ship day is not a new section. -## [1.0.0-ci] - 2026-11-06 +## [1.0.0-ci] - 2026-11-07 ### Added +- **Navbar game search:** a magnifying glass in the navbar finds games by title (or designer) from any page and opens the game page, with keyboard navigation. - **Game wishlist:** anyone signed in can **add a missing cover image** on a wishlist entry’s detail page; **admins** can **replace** the single cover image. (Requires matching `feedback_update` rules on the API.) - **Explore (all games):** quick **filter by tag** dropdown plus **Advanced filters** modal (combine goals, mechanics, board, and other tags with any/all/none; optional OR groups; global exclusions). Tag filters can be bookmarked via URL query parameters (`t`, `tf`, and related keys). Name, designer, star, and recommended filters are unchanged. - **Site statistics (games tab):** game statistics and pie rule tables include a **Min. records** control to hide rows below a chosen record count (remembered per browser). diff --git a/docs/subsystems/explore.md b/docs/subsystems/explore.md index 0eceea9d..07e701d0 100644 --- a/docs/subsystems/explore.md +++ b/docs/subsystems/explore.md @@ -13,6 +13,10 @@ The explore page lists available games and meta-games. Legacy bookmarks to `/games/:mode` where `:mode` is a catalog view slug redirect to `/explore/:mode`. +## Navbar game search + +The magnifying glass in the navbar ([`GameSearch.js`](../src/components/GameSearch.js)) is the quick route to a game page. It filters the public catalog with `filterGameOptions` (the matching used by the game picker) and orders results with `rankGameSearchResults`, so title matches come first. Selecting a result navigates to `/games/:metaGame`. + ## Key files | File | Role | diff --git a/public/locales/en/apfront.json b/public/locales/en/apfront.json index 6fcd12be..c8d1ed40 100644 --- a/public/locales/en/apfront.json +++ b/public/locales/en/apfront.json @@ -1245,6 +1245,7 @@ "toggleDarkMode": "Toggle dark mode", "toggleLightMode": "Toggle light mode", "notifications": "Notifications", + "searchGames": "Search games", "moveUp": "move up", "moveDown": "move down" }, @@ -2392,6 +2393,9 @@ "recent": "Recent opponents" } }, + "gameSearch": { + "placeholder": "Search games…" + }, "gamePicker": { "allGames": "All games", "choose": "Choose a game…", diff --git a/src/components/GameSearch.js b/src/components/GameSearch.js new file mode 100644 index 00000000..eef19da6 --- /dev/null +++ b/src/components/GameSearch.js @@ -0,0 +1,160 @@ +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate } from "react-router-dom"; +import NavDropdownPanel from "./NavDropdownPanel"; +import { + buildGameBrowseEntries, + filterGameOptions, + rankGameSearchResults, +} from "../lib/gameOptions"; + +/** Navbar magnifying glass: find a game by title and jump to its page. */ +function GameSearch({ closeBurger }) { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + // The navbar renders one instance for desktop and one for mobile. + const listId = useId(); + const triggerRef = useRef(null); + const listRef = useRef(null); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + + const games = useMemo( + () => (open ? buildGameBrowseEntries({ locale: i18n.language }) : []), + [open, i18n.language] + ); + const results = useMemo( + () => rankGameSearchResults(filterGameOptions(games, { query }), query), + [games, query] + ); + const hasQuery = query.trim() !== ""; + + useEffect(() => { + listRef.current?.children[activeIndex]?.scrollIntoView?.({ + block: "nearest", + }); + }, [activeIndex]); + + const close = useCallback(() => { + setOpen(false); + setQuery(""); + setActiveIndex(0); + }, []); + + const toggle = () => { + if (open) { + close(); + } else { + closeBurger(); + setOpen(true); + } + }; + + const handleKeyDown = (event) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (results.length > 0) { + const step = event.key === "ArrowDown" ? 1 : -1; + setActiveIndex( + (index) => (index + step + results.length) % results.length + ); + } + } else if (event.key === "Enter" && results[activeIndex]) { + event.preventDefault(); + navigate(`/games/${results[activeIndex].id}`); + close(); + } else if (event.key === "Escape") { + close(); + triggerRef.current?.focus(); + } + }; + + return ( + + + {open ? ( +
+ 0} + aria-controls={listId} + aria-activedescendant={ + results[activeIndex] ? `${listId}-${activeIndex}` : undefined + } + autoComplete="off" + autoFocus + placeholder={t("gameSearch.placeholder")} + value={query} + onChange={(event) => { + setQuery(event.target.value); + setActiveIndex(0); + }} + onKeyDown={handleKeyDown} + /> +
    + {results.map((game, index) => ( +
  • + setActiveIndex(index)} + > + {game.name} + {game.designers ? ( + + {game.designers} + + ) : null} + +
  • + ))} +
+ {hasQuery && results.length === 0 ? ( +

{t("gamePicker.noResults")}

+ ) : null} +
+ ) : null} +
+ ); +} + +export default GameSearch; diff --git a/src/components/GameSearch.test.js b/src/components/GameSearch.test.js new file mode 100644 index 00000000..277f7c09 --- /dev/null +++ b/src/components/GameSearch.test.js @@ -0,0 +1,125 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import GameSearch from "./GameSearch"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ + t: (key) => key, + i18n: { language: "en" }, + }), +})); + +vi.mock("../lib/gameOptions", async (importOriginal) => ({ + ...(await importOriginal()), + buildGameBrowseEntries: () => [ + { id: "cargo", name: "Cargo", designers: "Ann" }, + { id: "go", name: "Go", designers: "" }, + { id: "gorgon", name: "Gorgon", designers: "Bea" }, + ], +})); + +function CurrentPath() { + return {useLocation().pathname}; +} + +function setup(closeBurger = vi.fn()) { + render( + + + + + ); + fireEvent.click(screen.getByRole("button", { name: "a11y.searchGames" })); + return { input: screen.getByRole("combobox"), closeBurger }; +} + +const path = () => screen.getByTestId("path").textContent; +const type = (input, value) => fireEvent.change(input, { target: { value } }); + +describe("GameSearch", () => { + it("opens a focused search box and closes the burger menu", () => { + const { input, closeBurger } = setup(); + + expect(input).toHaveFocus(); + expect(closeBurger).toHaveBeenCalled(); + expect(screen.queryAllByRole("option")).toHaveLength(0); + }); + + it("lists matching games as links, best title match first", () => { + const { input } = setup(); + + type(input, "go"); + + const options = screen.getAllByRole("option"); + expect(options.map((o) => o.getAttribute("href"))).toEqual([ + "/games/go", + "/games/gorgon", + "/games/cargo", + ]); + }); + + it("navigates to the top result on Enter and closes", () => { + const { input } = setup(); + + type(input, "go"); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(path()).toBe("/games/go"); + expect(screen.queryByRole("combobox")).toBeNull(); + }); + + it("moves the selection with the arrow keys", () => { + const { input } = setup(); + + type(input, "go"); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(path()).toBe("/games/gorgon"); + }); + + it("wraps from the first result to the last", () => { + const { input } = setup(); + + type(input, "go"); + fireEvent.keyDown(input, { key: "ArrowUp" }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(path()).toBe("/games/cargo"); + }); + + it("navigates when a result is clicked", () => { + const { input } = setup(); + + type(input, "gorgon"); + fireEvent.click(screen.getByRole("option", { name: /Gorgon/ })); + + expect(path()).toBe("/games/gorgon"); + expect(screen.queryByRole("combobox")).toBeNull(); + }); + + it("shows an empty state and ignores Enter when nothing matches", () => { + const { input } = setup(); + + type(input, "zzz"); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(screen.getByText("gamePicker.noResults")).toBeInTheDocument(); + expect(path()).toBe("/"); + }); + + it("closes on Escape, clears the query and refocuses the button", () => { + const { input } = setup(); + const button = screen.getByRole("button", { name: "a11y.searchGames" }); + + type(input, "go"); + fireEvent.keyDown(input, { key: "Escape" }); + + expect(screen.queryByRole("combobox")).toBeNull(); + expect(button).toHaveFocus(); + fireEvent.click(button); + expect(screen.getByRole("combobox")).toHaveValue(""); + }); +}); diff --git a/src/components/Navbar.js b/src/components/Navbar.js index 01fdb9ad..e975468b 100644 --- a/src/components/Navbar.js +++ b/src/components/Navbar.js @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import logoLight from "../assets/AbstractPlayLogo-light.svg"; import logoDark from "../assets/AbstractPlayLogo-dark.svg"; +import GameSearch from "./GameSearch"; import ProfileMenu from "./ProfileMenu"; import NotificationBell from "./NotificationBell"; import { useStorageState } from "react-use-storage-state"; @@ -82,6 +83,7 @@ function Navbar() {
+ {loggedin && globalMe !== null ? ( ) : null} @@ -246,6 +248,9 @@ function Navbar() {
+
+ +
{loggedin ? ( <>
diff --git a/src/index.css b/src/index.css index 3b01e0db..b91041b9 100644 --- a/src/index.css +++ b/src/index.css @@ -233,6 +233,13 @@ a.navbar-item, width: auto; } +/* Fits the logged-in icons beside the logo on the narrowest phones. */ +@media screen and (max-width: 369px) { + .navbar-brand .navbar-item img { + height: 2rem; + } +} + @media screen and (max-width: 1219px) { .navbar-brand { max-width: 100%; @@ -255,7 +262,8 @@ a.navbar-item, .navbar-icon-cluster-desktop, .navbar-dark-mode-desktop, - .navbar-login-desktop { + .navbar-login-desktop, + .navbar-search-desktop { display: none !important; } @@ -277,6 +285,10 @@ a.navbar-item, } } +.navbar-search-desktop { + padding: 0; +} + .nav-dropdown-root { position: relative; display: inline-flex; @@ -619,14 +631,16 @@ a.navbar-item, transform: translate(25%, -25%); } -.notification-panel { +.notification-panel, +.game-search-panel { max-width: min(100vw - 2rem, 22rem); padding: 0; width: 22rem; } @media screen and (max-width: 1219px) { - .notification-panel.nav-dropdown-panel { + .notification-panel.nav-dropdown-panel, + .game-search-panel.nav-dropdown-panel { left: 1rem; margin-top: 0; max-width: none; @@ -637,6 +651,51 @@ a.navbar-item, } } +.game-search-panel .input { + border-radius: 4px 4px 0 0; + border-width: 0 0 1px; + box-shadow: none; +} + +.game-search-results { + list-style: none; + margin: 0; + max-height: 60vh; + overflow-y: auto; + padding: 0; +} + +.game-search-result { + color: var(--main-font-color); + display: block; + padding: 0.5rem 0.75rem; + text-decoration: none; +} + +.game-search-result:hover, +.game-search-result:focus-visible, +.game-search-result.is-active { + background: var(--tag-background-color); + color: var(--main-font-color); +} + +.game-search-result-name { + display: block; + font-weight: 600; +} + +.game-search-result-designers { + color: var(--secondary-font-color); + display: block; + font-size: 0.75rem; +} + +.game-search-empty { + color: var(--secondary-font-color); + margin: 0; + padding: 0.75rem; +} + .notification-panel-news-callout { background: var(--secondary-color-3-bg); color: var(--main-font-color); diff --git a/src/lib/gameOptions.js b/src/lib/gameOptions.js index b948db70..9e1cf3fa 100644 --- a/src/lib/gameOptions.js +++ b/src/lib/gameOptions.js @@ -9,7 +9,7 @@ import { compareStrings } from "./compareStrings"; import { isLabSupportedGame } from "./Lab/buildGame"; import { isProductionMode } from "./realMode"; import { tournamentPlaySupported } from "./tournamentGame"; -import { queryMatchesHaystack } from "./searchQuery"; +import { normalizeSearchQuery, queryMatchesHaystack } from "./searchQuery"; /** * @param {{ flags?: string[] } | null | undefined} info @@ -298,3 +298,34 @@ export function filterGameOptions( return true; }); } + +function titleMatchRank(name, normalizedQuery) { + const lower = name.toLowerCase(); + if (lower === normalizedQuery) return 0; + if (lower.startsWith(normalizedQuery)) return 1; + if (lower.split(/[\s\p{P}]+/u).some((word) => word.startsWith(normalizedQuery))) { + return 2; + } + return lower.includes(normalizedQuery) ? 3 : 4; +} + +/** + * Order search matches so title hits come first: exact, then prefix, then word + * prefix, then substring, then anything else (e.g. designer-only matches). + * The sort is stable, so ties keep the incoming order. An empty query has no + * results. + * @template {{ name: string }} T + * @param {T[]} games + * @param {string} query + * @returns {T[]} + */ +export function rankGameSearchResults(games, query) { + const normalizedQuery = normalizeSearchQuery(query); + if (!normalizedQuery) { + return []; + } + return games + .map((game) => ({ game, rank: titleMatchRank(game.name, normalizedQuery) })) + .sort((a, b) => a.rank - b.rank) + .map(({ game }) => game); +} diff --git a/src/lib/gameOptions.test.js b/src/lib/gameOptions.test.js index 44ccf258..f1917370 100644 --- a/src/lib/gameOptions.test.js +++ b/src/lib/gameOptions.test.js @@ -12,6 +12,7 @@ import { isPublicCatalogGame, isBoardRootCategory, pickRandomGameOption, + rankGameSearchResults, resolveMetaGameUid, sortCategoryKeys, } from "./gameOptions"; @@ -283,3 +284,35 @@ describe("filterGameOptions", () => { ]); }); }); + +describe("rankGameSearchResults", () => { + const games = [ + { name: "Cargo" }, + { name: "Designed by Go fan" }, + { name: "Go" }, + { name: "Gorgon" }, + { name: "Mad-Go" }, + { name: "Zebra", designers: "Hugo" }, + ]; + + it("orders exact, prefix, word prefix, substring, then other matches", () => { + expect(rankGameSearchResults(games, " GO ").map((g) => g.name)).toEqual([ + "Go", + "Gorgon", + "Designed by Go fan", + "Mad-Go", + "Cargo", + "Zebra", + ]); + }); + + it("returns nothing for a blank query", () => { + expect(rankGameSearchResults(games, " ")).toEqual([]); + }); + + it("does not mutate the input", () => { + const input = [...games]; + rankGameSearchResults(input, "go"); + expect(input).toEqual(games); + }); +});