Refactor & standardize modal tabs (#3864)

## Description:

Refactors tab handling out of the individual modal components and into
the base o-modal component. Tabs are now declared by passing tabs,
activeTab, and onTabChange props, and a new named header slot pins
consumer-supplied content above the tabs. This standardizes the modal
tab look.

<img width="1089" height="290" alt="Screenshot 2026-05-06 at 12 17
33 PM"
src="https://github.com/user-attachments/assets/08d5a039-0aef-4aa7-b972-1e43b8723685"
/>

## 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
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

evan
This commit is contained in:
Evan
2026-05-06 12:47:11 -06:00
committed by GitHub
parent 94bab78d24
commit df84ee023e
8 changed files with 220 additions and 326 deletions
+43 -74
View File
@@ -60,8 +60,20 @@ export class ClanModal extends BaseModal {
} | null = null;
render() {
const content = this.renderInner();
if (this.inline) return content;
const onListView = this.view === "list" && !this.selectedClanTag;
const tabs = onListView
? [
{ key: "my-clans", label: translateText("clan_modal.my_clans") },
{ key: "browse", label: translateText("clan_modal.browse") },
]
: [];
const header = onListView
? modalHeader({
title: translateText("clan_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})
: null;
return html`
<o-modal
id="clan-modal"
@@ -69,12 +81,26 @@ export class ClanModal extends BaseModal {
?hideCloseButton=${true}
?inline=${this.inline}
hideHeader
.tabs=${tabs}
.activeTab=${this.activeTab}
.onTabChange=${(key: string) => this.handleTabChange(key as Tab)}
>
${content}
${header ? html`<div slot="header">${header}</div>` : ""}
${this.renderInner()}
</o-modal>
`;
}
private handleTabChange(tab: Tab) {
this.activeTab = tab;
this.view = "list";
this.selectedClan = null;
this.selectedClanTag = "";
if (tab === "my-clans") {
this.loadMyClans();
}
}
protected onOpen(): void {
this.loadMyClans();
}
@@ -131,16 +157,7 @@ export class ClanModal extends BaseModal {
private renderInner() {
if (this.loading) {
return html`
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
${this.renderLoadingSpinner()}
</div>
`;
return this.renderLoadingSpinner();
}
if (this.view === "my-requests") {
@@ -289,30 +306,20 @@ export class ClanModal extends BaseModal {
></clan-detail-view>`;
}
// List view (tabs + my clans / browse)
// List view (my clans / browse) — header + tabs are rendered by o-modal
return html`
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
${this.renderTabs()}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1">
${this.activeTab === "my-clans"
? this.renderMyClans()
: html`<clan-browse-view
.myClanRoles=${this.myClanRoles}
.myPendingRequests=${this.myPendingRequests}
.cachedState=${this.browseCache}
@browse-updated=${(e: CustomEvent<BrowseState>) => {
this.browseCache = e.detail;
}}
@clan-select=${(e: CustomEvent<{ tag: string }>) =>
this.openDetail(e.detail.tag)}
></clan-browse-view>`}
</div>
</div>
${this.activeTab === "my-clans"
? this.renderMyClans()
: html`<clan-browse-view
.myClanRoles=${this.myClanRoles}
.myPendingRequests=${this.myPendingRequests}
.cachedState=${this.browseCache}
@browse-updated=${(e: CustomEvent<BrowseState>) => {
this.browseCache = e.detail;
}}
@clan-select=${(e: CustomEvent<{ tag: string }>) =>
this.openDetail(e.detail.tag)}
></clan-browse-view>`}
`;
}
@@ -321,44 +328,6 @@ export class ClanModal extends BaseModal {
this.view = "detail";
}
private renderTabs() {
const tabs: { key: Tab; label: string }[] = [
{ key: "my-clans", label: translateText("clan_modal.my_clans") },
{ key: "browse", label: translateText("clan_modal.browse") },
];
return html`
<div class="flex border-b border-white/10 px-4 lg:px-6 gap-1">
${tabs.map(
(tab) => html`
<button
@click=${() => {
this.activeTab = tab.key;
this.view = "list";
this.selectedClan = null;
this.selectedClanTag = "";
if (tab.key === "my-clans") {
this.loadMyClans();
}
}}
class="px-4 py-3 text-sm font-bold uppercase tracking-wider transition-all relative
${this.activeTab === tab.key
? "text-aquarius"
: "text-white/40 hover:text-white/70"}"
>
${tab.label}
${this.activeTab === tab.key
? html`<div
class="absolute bottom-0 left-0 right-0 h-0.5 bg-malibu-blue"
></div>`
: ""}
</button>
`,
)}
</div>
`;
}
private renderMyClans() {
const hasClans = this.myClans.length > 0;
const hasRequests = this.myPendingRequests.length > 0;
-1
View File
@@ -210,7 +210,6 @@ export class LangSelector extends LitElement {
"join-lobby-modal",
"emoji-table",
"leader-board",
"leaderboard-tabs",
"leaderboard-player-list",
"leaderboard-clan-table",
"build-menu",
+39 -42
View File
@@ -5,7 +5,6 @@ import "./components/leaderboard/LeaderboardClanTable";
import type { LeaderboardClanTable } from "./components/leaderboard/LeaderboardClanTable";
import "./components/leaderboard/LeaderboardPlayerList";
import type { LeaderboardPlayerList } from "./components/leaderboard/LeaderboardPlayerList";
import "./components/leaderboard/LeaderboardTabs";
import { modalHeader } from "./components/ui/ModalHeader";
import { translateText } from "./Utils";
@@ -81,46 +80,13 @@ export class LeaderboardModal extends BaseModal {
>(${translateText("leaderboard_modal.refresh_time")})</span
>`;
const content = html`
<div class="${this.modalContainerClass}">
${modalHeader({
titleContent: html`
<div class="flex flex-wrap items-center gap-2">
<span
class="text-white text-xl sm:text-2xl font-bold uppercase tracking-widest"
>
${translateText("leaderboard_modal.title")}
</span>
${this.activeTab === "clans" ? dateRange : ""}
${this.activeTab === "players" ? refreshTime : ""}
</div>
`,
onBack: () => this.close(),
ariaLabel: translateText("common.close"),
})}
<div class="flex-1 flex flex-col min-h-0">
<leaderboard-tabs
.activeTab=${this.activeTab}
@tab-change=${(event: CustomEvent<"players" | "clans">) =>
this.handleTabChange(event.detail)}
></leaderboard-tabs>
<div class="flex-1 min-h-0">
<leaderboard-player-list
class=${this.activeTab === "players" ? "h-full" : "hidden"}
></leaderboard-player-list>
<leaderboard-clan-table
class=${this.activeTab === "clans" ? "h-full" : "hidden"}
@date-range-change=${(
event: CustomEvent<{ start: string; end: string }>,
) => this.handleClanDateRangeChange(event)}
></leaderboard-clan-table>
</div>
</div>
</div>
`;
if (this.inline) return content;
const tabs = [
{
key: "players",
label: translateText("leaderboard_modal.ranked_tab"),
},
{ key: "clans", label: translateText("leaderboard_modal.clans_tab") },
];
return html`
<o-modal
@@ -128,8 +94,39 @@ export class LeaderboardModal extends BaseModal {
?inline=${this.inline}
hideCloseButton
hideHeader
.tabs=${tabs}
.activeTab=${this.activeTab}
.onTabChange=${(key: string) =>
this.handleTabChange(key as "players" | "clans")}
>
${content}
<div slot="header">
${modalHeader({
titleContent: html`
<div class="flex flex-wrap items-center gap-2">
<span
class="text-white text-xl sm:text-2xl font-bold uppercase tracking-widest"
>
${translateText("leaderboard_modal.title")}
</span>
${this.activeTab === "clans" ? dateRange : ""}
${this.activeTab === "players" ? refreshTime : ""}
</div>
`,
onBack: () => this.close(),
ariaLabel: translateText("common.close"),
})}
</div>
<div class="flex-1 min-h-0 h-full">
<leaderboard-player-list
class=${this.activeTab === "players" ? "h-full" : "hidden"}
></leaderboard-player-list>
<leaderboard-clan-table
class=${this.activeTab === "clans" ? "h-full" : "hidden"}
@date-range-change=${(
event: CustomEvent<{ start: string; end: string }>,
) => this.handleClanDateRangeChange(event)}
></leaderboard-clan-table>
</div>
</o-modal>
`;
}
+23 -53
View File
@@ -41,43 +41,12 @@ export class StoreModal extends BaseModal {
}
private renderHeader(): TemplateResult {
return html`
${modalHeader({
title: translateText("store.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
})}
<div class="flex items-center gap-2 justify-center pt-2">
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "packs"
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "packs")}
>
${translateText("store.packs")}
</button>
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "patterns"
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "patterns")}
>
${translateText("store.patterns")}
</button>
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "flags"
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "flags")}
>
${translateText("store.flags")}
</button>
</div>
`;
return modalHeader({
title: translateText("store.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
});
}
private renderPatternGrid(): TemplateResult {
@@ -188,22 +157,18 @@ export class StoreModal extends BaseModal {
render() {
if (!this.isActive && !this.inline) return html``;
const content = html`
<div class="${this.modalContainerClass}">
${this.renderHeader()}
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
${this.activeTab === "patterns"
? this.renderPatternGrid()
: this.activeTab === "flags"
? this.renderFlagGrid()
: this.renderPackGrid()}
</div>
</div>
`;
const tabs = [
{ key: "packs", label: translateText("store.packs") },
{ key: "patterns", label: translateText("store.patterns") },
{ key: "flags", label: translateText("store.flags") },
];
if (this.inline) {
return content;
}
const grid =
this.activeTab === "patterns"
? this.renderPatternGrid()
: this.activeTab === "flags"
? this.renderFlagGrid()
: this.renderPackGrid();
return html`
<o-modal
@@ -212,8 +177,13 @@ export class StoreModal extends BaseModal {
?inline=${this.inline}
?hideHeader=${true}
?hideCloseButton=${true}
.tabs=${tabs}
.activeTab=${this.activeTab}
.onTabChange=${(key: string) =>
(this.activeTab = key as "patterns" | "flags" | "packs")}
>
${content}
<div slot="header">${this.renderHeader()}</div>
${grid}
</o-modal>
`;
}
+17 -46
View File
@@ -319,51 +319,10 @@ export class UserSettingModal extends BaseModal {
? this.renderBasicSettings()
: this.renderKeybindSettings();
const content = html`
<div class="${this.modalContainerClass}">
<div
class="relative flex flex-col border-b border-white/10 lg:pb-4 shrink-0"
>
${modalHeader({
title: translateText("user_setting.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
showDivider: true,
})}
<div class="hidden lg:flex items-center gap-2 justify-center mt-4">
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "basic"
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "basic")}
>
${translateText("user_setting.tab_basic")}
</button>
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "keybinds"
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "keybinds")}
>
${translateText("user_setting.tab_keybinds")}
</button>
</div>
</div>
<div
class="pt-6 flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent px-6 pb-6 mr-1"
>
<div class="flex flex-col gap-2">${activeContent}</div>
</div>
</div>
`;
if (this.inline) {
return content;
}
const tabs = [
{ key: "basic", label: translateText("user_setting.tab_basic") },
{ key: "keybinds", label: translateText("user_setting.tab_keybinds") },
];
return html`
<o-modal
@@ -371,8 +330,20 @@ export class UserSettingModal extends BaseModal {
?inline=${this.inline}
hideCloseButton
hideHeader
.tabs=${tabs}
.activeTab=${this.activeTab}
.onTabChange=${(key: string) =>
(this.activeTab = key as "basic" | "keybinds")}
>
${content}
<div slot="header">
${modalHeader({
title: translateText("user_setting.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
showDivider: true,
})}
</div>
<div class="flex flex-col gap-2">${activeContent}</div>
</o-modal>
`;
}
+89 -34
View File
@@ -2,6 +2,8 @@ import { LitElement, html, unsafeCSS } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import tailwindStyles from "../../styles.css?inline";
export type OModalTab = { key: string; label: string };
@customElement("o-modal")
export class OModal extends LitElement {
static styles = [unsafeCSS(tailwindStyles)];
@@ -28,6 +30,15 @@ export class OModal extends LitElement {
@property({ type: String })
public maxWidth = "";
@property({ type: Array })
public tabs: OModalTab[] = [];
@property({ type: String })
public activeTab = "";
@property({ attribute: false })
public onTabChange?: (key: string) => void;
public onClose?: () => void;
public open() {
@@ -60,7 +71,48 @@ export class OModal extends LitElement {
super.disconnectedCallback();
}
private handleTabClick(key: string) {
this.onTabChange?.(key);
}
private renderTabs() {
return html`
<div
role="tablist"
class="flex justify-center border-b border-white/10 px-4 lg:px-6 gap-1 shrink-0"
>
${this.tabs.map((tab) => {
const active = this.activeTab === tab.key;
return html`
<button
type="button"
role="tab"
data-key=${tab.key}
aria-selected=${active}
class="px-4 py-3 text-sm font-bold uppercase tracking-wider transition-all relative cursor-pointer ${active
? "text-aquarius"
: "text-white/40 hover:text-white/70"}"
@click=${() => this.handleTabClick(tab.key)}
>
${tab.label}
${active
? html`<div
class="absolute bottom-0 left-0 right-0 h-0.5 bg-malibu-blue"
></div>`
: ""}
</button>
`;
})}
</div>
`;
}
render() {
const shouldRender = this.isModalOpen || this.inline;
if (!shouldRender) {
return html``;
}
const backdropClass = this.inline
? "relative z-10 w-full h-full flex items-stretch bg-transparent"
: "fixed inset-0 z-[9999] bg-black/60 flex items-center justify-center overflow-hidden";
@@ -73,42 +125,45 @@ export class OModal extends LitElement {
const wrapperStyle =
!this.inline && this.maxWidth ? `max-width: ${this.maxWidth};` : "";
const hasTabs = this.tabs.length > 0;
const sectionClass = hasTabs
? "relative flex-1 min-h-0 flex flex-col text-white bg-black/70 backdrop-blur-xl lg:rounded-2xl lg:border border-white/10 overflow-hidden"
: "relative flex-1 min-h-0 flex flex-col text-white bg-[#23232382] backdrop-blur-md lg:rounded-lg overflow-hidden";
return html`
${this.isModalOpen
? html`
<aside
class="${backdropClass}"
@click=${this.inline ? null : () => this.close()}
>
<div
@click=${(e: Event) => e.stopPropagation()}
class="${wrapperClass}"
style="${wrapperStyle}"
<aside
class="${backdropClass}"
@click=${this.inline ? null : () => this.close()}
>
<div
@click=${(e: Event) => e.stopPropagation()}
class="${wrapperClass}"
style="${wrapperStyle}"
>
${this.inline || this.hideCloseButton
? html``
: html`<div
class="absolute top-5 right-5 z-10 text-white cursor-pointer"
@click=${() => this.close()}
>
${this.inline || this.hideCloseButton
? html``
: html`<div
class="absolute top-5 right-5 z-10 text-white cursor-pointer"
@click=${() => this.close()}
>
</div>`}
${!this.hideHeader && this.title
? html`<div
class="px-[1.4rem] py-[1rem] text-2xl font-bold text-white"
>
${this.title}
</div>`
: html``}
<section
class="relative flex-1 min-h-0 p-0 lg:p-[1.4rem] text-white bg-[#23232382] backdrop-blur-md lg:rounded-lg overflow-y-auto"
>
<slot></slot>
</section>
</div>
</aside>
`
: html``}
</div>`}
${!this.hideHeader && this.title
? html`<div
class="px-[1.4rem] py-[1rem] text-2xl font-bold text-white"
>
${this.title}
</div>`
: html``}
<section class="${sectionClass}">
<slot name="header"></slot>
${hasTabs ? this.renderTabs() : html``}
<div class="flex-1 min-h-0 overflow-y-auto p-0 lg:p-[1.4rem]">
<slot></slot>
</div>
</section>
</div>
</aside>
`;
}
}
@@ -1,75 +0,0 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../../Utils";
export type LeaderboardTab = "players" | "clans";
@customElement("leaderboard-tabs")
export class LeaderboardTabs extends LitElement {
@property({ type: String }) activeTab: LeaderboardTab = "players";
createRenderRoot() {
return this;
}
private baseTabClass =
"px-6 py-2 rounded-full text-sm font-bold uppercase tracking-wider transition-all cursor-pointer select-none";
private activeTabClass = "bg-blue-600 text-white";
private inactiveTabClass =
"text-white/40 hover:text-white/60 hover:bg-white/5";
private getTabClass(active: boolean) {
return [
this.baseTabClass,
active ? this.activeTabClass : this.inactiveTabClass,
].join(" ");
}
@state()
private playerClass = this.getTabClass(this.activeTab === "players");
@state()
private clanClass = this.getTabClass(this.activeTab === "clans");
private handleTabChange(tab: LeaderboardTab) {
this.dispatchEvent(
new CustomEvent<LeaderboardTab>("tab-change", {
detail: tab,
bubbles: true,
composed: true,
}),
);
this.playerClass = this.getTabClass(tab === "players");
this.clanClass = this.getTabClass(tab === "clans");
}
render() {
return html`
<div
role="tablist"
class="flex gap-2 p-1 bg-white/5 rounded-full border border-white/10 mb-4 w-fit mx-auto mt-4"
>
<button
type="button"
role="tab"
class="${this.playerClass}"
@click=${() => this.handleTabChange("players")}
id="player-leaderboard-tab"
aria-selected=${this.activeTab === "players"}
>
${translateText("leaderboard_modal.ranked_tab")}
</button>
<button
type="button"
role="tab"
class="${this.clanClass}"
@click=${() => this.handleTabChange("clans")}
id="clan-leaderboard-tab"
aria-selected=${this.activeTab === "clans"}
>
${translateText("leaderboard_modal.clans_tab")}
</button>
</div>
`;
}
}