Add map search 🔍 (#4283)

## Description:

Add a search input field to the map picker section header, allowing
users to quickly filter maps by name.

- Place transparent search input on the right side of the "Maps" section
header
- Filter maps by translated name and map ID as the user types
- Hide Featured/All/Favourites tab buttons while search is active
- Show filtered results with a count heading, or a "no results" empty
state
- Clear button appears when search input has text

<img width="857" height="463" alt="Screenshot 2026-06-15 001415"
src="https://github.com/user-attachments/assets/35e1101a-177e-4923-bb1d-34eb683c6f80"
/>

No search results:

<img width="855" height="454" alt="Screenshot 2026-06-15 001433"
src="https://github.com/user-attachments/assets/bf27211d-5891-4739-a92f-0fc44b3c9c61"
/>

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

FloPinguin
This commit is contained in:
FloPinguin
2026-06-15 02:54:43 +02:00
committed by GitHub
parent 6c8ce958b2
commit b997099dfe
3 changed files with 124 additions and 12 deletions
+3
View File
@@ -944,6 +944,9 @@
"favorite": "Add to favourites",
"favorites_empty": "Click the star on any map to favourite it",
"loading": "Loading...",
"no_results": "No maps found",
"search_maps": "Search maps...",
"search_results": "Search Results",
"unfavorite": "Remove from favourites"
},
"matchmaking_button": {
+61 -2
View File
@@ -6,7 +6,7 @@ import {
nothing,
svg,
} from "lit";
import { customElement, property } from "lit/decorators.js";
import { customElement, property, state } from "lit/decorators.js";
import {
Difficulty,
Duos,
@@ -80,10 +80,18 @@ function renderSection(
titleKey: string,
content: TemplateResult | TemplateResult[],
sectionClass = "space-y-6",
headerAction?: TemplateResult,
): TemplateResult {
return html`
<section class=${sectionClass}>
${renderSectionHeader(iconSvg, colorClass, bgClass, titleKey)} ${content}
${renderSectionHeader(
iconSvg,
colorClass,
bgClass,
titleKey,
headerAction,
)}
${content}
</section>
`;
}
@@ -139,6 +147,7 @@ function renderSectionHeader(
colorClass: string,
bgClass: string,
titleKey: string,
headerAction?: TemplateResult,
): TemplateResult {
return html`
<div class="flex items-center gap-4 pb-2 border-b border-white/10">
@@ -157,6 +166,7 @@ function renderSectionHeader(
<h3 class="text-lg font-bold text-white uppercase tracking-wider">
${translateText(titleKey)}
</h3>
${headerAction ? html`<div class="ml-auto">${headerAction}</div>` : null}
</div>
`;
}
@@ -218,6 +228,7 @@ export interface GameConfigSettingsData {
export class GameConfigSettings extends LitElement {
@property({ attribute: false }) settings?: GameConfigSettingsData;
@property({ attribute: false }) sectionGapClass = "space-y-6";
@state() private mapSearchQuery = "";
createRenderRoot() {
return this;
@@ -233,6 +244,15 @@ export class GameConfigSettings extends LitElement {
);
}
private handleMapSearchInput = (event: Event) => {
const input = event.target as HTMLInputElement;
this.mapSearchQuery = input.value;
};
private clearMapSearch = () => {
this.mapSearchQuery = "";
};
private handleSelectMap = (map: GameMapType) => {
this.emit("map-selected", { map });
};
@@ -309,6 +329,42 @@ export class GameConfigSettings extends LitElement {
});
}
private renderMapSearchInput(): TemplateResult {
return html`<div class="relative">
<input
type="text"
placeholder="${translateText("map_component.search_maps")}"
.value=${this.mapSearchQuery}
@input=${this.handleMapSearchInput}
class="w-48 px-3 py-1.5 pl-8 pr-7 rounded-lg text-sm bg-transparent border border-white/10 text-white placeholder-white/40 focus:outline-none focus:border-malibu-blue/50 transition-all"
/>
<svg
class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
${this.mapSearchQuery
? html`<button
type="button"
@click=${this.clearMapSearch}
class="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 flex items-center justify-center rounded-full text-white/40 hover:text-white hover:bg-white/10 transition-all"
>
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>`
: null}
</div>`;
}
render() {
if (!this.settings) return nothing;
const settings = this.settings;
@@ -328,7 +384,10 @@ export class GameConfigSettings extends LitElement {
.mapWins=${settings.map.mapWins ?? new Map()}
.onSelectMap=${this.handleSelectMap}
.onSelectRandom=${this.handleSelectRandom}
.searchQuery=${this.mapSearchQuery}
></map-picker>`,
undefined,
this.renderMapSearchInput(),
)}
${renderSection(
DIFFICULTY_ICON,
+55 -5
View File
@@ -36,6 +36,7 @@ export class MapPicker extends LitElement {
@property({ type: Boolean }) useRandomMap = false;
@property({ type: Boolean }) showMedals = false;
@property({ type: Boolean }) randomMapDivider = false;
@property({ type: String }) searchQuery = "";
@property({ attribute: false }) mapWins: Map<GameMapType, Set<Difficulty>> =
new Map();
@property({ attribute: false }) onSelectMap?: (map: GameMapType) => void;
@@ -74,6 +75,16 @@ export class MapPicker extends LitElement {
event.preventDefault();
}
private get filteredMaps(): MapInfo[] {
if (!this.searchQuery.trim()) return [];
const query = this.searchQuery.trim().toLowerCase();
return maps.filter((m) => {
const name = translateText(m.translationKey).toLowerCase();
const id = m.id.toLowerCase();
return name.includes(query) || id.includes(query);
});
}
private getWins(mapValue: GameMapType): Set<Difficulty> {
return this.mapWins?.get(mapValue) ?? new Set();
}
@@ -211,6 +222,36 @@ export class MapPicker extends LitElement {
}
}
private renderSearchResults() {
const results = this.filteredMaps;
if (results.length === 0) {
return html`<div
class="w-full flex flex-col items-center justify-center gap-3 py-12 px-4 text-center rounded-xl border border-dashed border-white/10 bg-black/20"
>
<svg
class="w-8 h-8 text-white/30"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
<p class="text-sm text-white/50 leading-relaxed max-w-xs">
${translateText("map_component.no_results")}
</p>
</div>`;
}
return html`<div class="w-full">
${this.renderSectionHeading(
`${translateText("map_component.search_results")} (${results.length})`,
)}
${this.renderMapGrid(results)}
</div>`;
}
private renderTabButton(tab: MapTab, label: string) {
const isActive = this.activeTab === tab;
return html`<button
@@ -227,20 +268,29 @@ export class MapPicker extends LitElement {
}
render() {
const isSearching = this.searchQuery.trim().length > 0;
return html`
<div class="space-y-8">
<div class="w-full">
<div
${isSearching
? null
: html`<div
role="tablist"
aria-label="${translateText("map.map")}"
class="grid grid-cols-3 gap-2 rounded-xl border border-white/10 bg-black/20 p-1"
>
${this.renderTabButton("featured", translateText("map.featured"))}
${this.renderTabButton(
"featured",
translateText("map.featured"),
)}
${this.renderTabButton("all", translateText("map.all"))}
${this.renderTabButton("favorites", translateText("map.favorites"))}
${this.renderTabButton(
"favorites",
translateText("map.favorites"),
)}
</div>`}
</div>
</div>
${this.renderActiveTab()}
${isSearching ? this.renderSearchResults() : this.renderActiveTab()}
<div
class="w-full ${this.randomMapDivider
? "pt-4 border-t border-white/5"