mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +00:00
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:
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
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("all", translateText("map.all"))}
|
||||
${this.renderTabButton("favorites", translateText("map.favorites"))}
|
||||
</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("all", translateText("map.all"))}
|
||||
${this.renderTabButton(
|
||||
"favorites",
|
||||
translateText("map.favorites"),
|
||||
)}
|
||||
</div>`}
|
||||
</div>
|
||||
${this.renderActiveTab()}
|
||||
${isSearching ? this.renderSearchResults() : this.renderActiveTab()}
|
||||
<div
|
||||
class="w-full ${this.randomMapDivider
|
||||
? "pt-4 border-t border-white/5"
|
||||
|
||||
Reference in New Issue
Block a user