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
+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>
`;
}
}