diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.test.ts b/app/features/tournament-bracket/components/BracketMapListDialog.test.ts new file mode 100644 index 000000000..21222d2aa --- /dev/null +++ b/app/features/tournament-bracket/components/BracketMapListDialog.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vitest"; +import { mapSearchFilter } from "./BracketMapListDialog"; + +const contains = (string: string, substring: string) => + string.toLowerCase().includes(substring.toLowerCase()); + +const filter = (textValue: string, inputValue: string) => + mapSearchFilter(textValue, inputValue, contains); + +describe("mapSearchFilter", () => { + test("matches everything for empty input", () => { + expect(filter("SZ Hagglefish Market", "")).toBe(true); + expect(filter("TC Crableg Capital", " ")).toBe(true); + }); + + test("matches against stage name when no mode prefix is given", () => { + expect(filter("SZ Crableg Capital", "crab")).toBe(true); + expect(filter("TC Crableg Capital", "crab")).toBe(true); + expect(filter("SZ Hagglefish Market", "crab")).toBe(false); + }); + + test("mode prefix alone matches all maps of that mode", () => { + expect(filter("SZ Hagglefish Market", "sz")).toBe(true); + expect(filter("SZ Crableg Capital", "sz")).toBe(true); + expect(filter("TC Crableg Capital", "sz")).toBe(false); + }); + + test("mode prefix is case-insensitive", () => { + expect(filter("SZ Hagglefish Market", "Sz")).toBe(true); + expect(filter("SZ Hagglefish Market", "SZ")).toBe(true); + }); + + test("trailing whitespace after a mode prefix still matches all of that mode", () => { + expect(filter("SZ Hagglefish Market", "sz ")).toBe(true); + expect(filter("TC Hagglefish Market", "sz ")).toBe(false); + }); + + test("mode prefix combined with a query filters within that mode", () => { + expect(filter("SZ Crableg Capital", "sz crab")).toBe(true); + expect(filter("SZ Hagglefish Market", "sz crab")).toBe(false); + expect(filter("TC Crableg Capital", "sz crab")).toBe(false); + }); + + test("query after mode prefix is matched against the stage name", () => { + expect(filter("RM Museum d'Alfonsino", "rm museum")).toBe(true); + expect(filter("RM Museum d'Alfonsino", "rm market")).toBe(false); + }); + + test("non-mode first word falls back to stage name search", () => { + expect(filter("SZ Hagglefish Market", "hagg")).toBe(true); + expect(filter("SZ Hagglefish Market", "market")).toBe(true); + }); +}); diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index 36d61a6fe..34ba9da6a 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -8,6 +8,7 @@ import { Unlink, } from "lucide-react"; import * as React from "react"; +import { useFilter } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { type FetcherWithComponents, Link, useFetcher } from "react-router"; import { SendouDialog } from "~/components/elements/Dialog"; @@ -1072,6 +1073,7 @@ function MapListRow({ onMapChange: (map: NonNullable[number]) => void; }) { const { t } = useTranslation(["common", "game-misc"]); + const { contains } = useFilter({ sensitivity: "base" }); const tournament = useTournament(); const items = modesShort.flatMap((mode) => { @@ -1118,13 +1120,20 @@ function MapListRow({ search={{ placeholder: t("common:forms.stageSearch.search.placeholder"), }} + filter={(textValue, inputValue) => + mapSearchFilter(textValue, inputValue, contains) + } className={styles.mapRowSelect} popoverClassName={styles.mapRowSelectPopover} > {(group) => ( {group.maps.map((m) => ( - +
boolean, +) { + const separatorIndex = textValue.indexOf(" "); + const mode = textValue.slice(0, separatorIndex); + const stageName = textValue.slice(separatorIndex + 1); + + const trimmedInput = inputValue.trim(); + const firstSpaceIndex = trimmedInput.indexOf(" "); + const firstWord = + firstSpaceIndex === -1 + ? trimmedInput + : trimmedInput.slice(0, firstSpaceIndex); + + const matchedMode = modesShort.find( + (m) => m.toLowerCase() === firstWord.toLowerCase(), + ); + + if (!matchedMode) { + return contains(stageName, trimmedInput); + } + + if (mode !== matchedMode) return false; + + const restQuery = + firstSpaceIndex === -1 + ? "" + : trimmedInput.slice(firstSpaceIndex + 1).trim(); + + if (restQuery === "") return true; + + return contains(stageName, restQuery); +} + function MysteryRow({ number, isCounterpicks,