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} + /> + + {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); + }); +});