Refactor modal system: BaseModal renders shell, unified open(args) API (#3923)

## Description

Refactors the modal system so that `BaseModal` owns the `<o-modal>`
shell rendering, tab state, and lifecycle. Modal subclasses now provide
content via small hook methods (`renderHeaderSlot()`, `renderBody(tab)`,
`modalConfig()`) instead of each rebuilding the `<o-modal>` template and
inline-mode branching.

This sets up the foundation for a future modal URL router (e.g.
`#modal=store&tab=flags`), which will be a follow-up PR.

### What changed

**`BaseModal`** — `src/client/components/BaseModal.ts`
- Now renders the `<o-modal>` shell itself; subclasses no longer
duplicate it
- Owns `activeTab` state and dispatches per-tab rendering via
`renderBody(tab)`
- Single `modalConfig()` method returns `{ title?, tabs?, hideHeader?,
hideCloseButton?, alwaysMaximized?, maxWidth? }`
- Uniform `open(args?)` / `close(args?)` interface; subclasses interpret
args in `onOpen(args)` / `onClose(args)`
- Tabbed modals can lazy-load via `onTabEnter(tab)` lifecycle hook
- Re-entrancy guard on `open()` so `showPage()` re-invocations don't
clobber state set by the outer call
- Initial tab defaults to first entry in `modalConfig().tabs` so the
active tab is highlighted on first open

**17 modals migrated** to the new shape:
- Tabbed: Store, UserSetting, Leaderboard, Clan
- Non-tabbed: FlagInput, Account, TokenLogin, News, TerritoryPatterns,
Troubleshooting, SinglePlayer, Matchmaking, RankedModal, Help, Language
- Lobby: JoinLobbyModal, HostLobbyModal (kept their `confirmBeforeClose`
/ `closeAndLeave` / `closeWithoutLeaving` methods)

Per-modal diffs are mostly mechanical:
- Drop the `<o-modal>` wrapper template and the `if (this.inline) return
content` branch
- Drop the inner `<div class="${this.modalContainerClass}">` wrapper
(shell styling now lives on `<o-modal>`)
- Move header content into `renderHeaderSlot()` so it lives in the
sticky header area
- Convert `super.open()`/`super.close()` overrides into
`onOpen(args)`/`onClose(args)` hooks
- For tabbed modals: drop subclass `@state activeTab`, manual
`handleTabChange`, and the `render()` switch — all owned by BaseModal
now

**Other changes:**
- `Store`: in affiliate mode (`#affiliate=X`), tabs are hidden and a
single combined grid of purchasable affiliate items is shown
- `Main.ts`: `joinModal.open(lobbyId, lobbyInfo)` callsites converted to
the new `open({ lobbyId, lobbyInfo })` shape

### Follow-up

Modal URL router (`#modal=X&tab=Y&...`) is a separate PR on top of this
foundation.

## Please complete the following:

- [x] I have added screenshots for all UI updates _(no visual changes;
smoke-tested in dev)_
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file _(no new user-visible strings)_
- [ ] I have added relevant tests to the test directory _(no test
coverage; tested in browser)_
- [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-14 15:33:41 -07:00
committed by GitHub
parent e0f73598d6
commit bbe727cc84
19 changed files with 850 additions and 1001 deletions
+185 -91
View File
@@ -1,29 +1,47 @@
import { html, LitElement, TemplateResult } from "lit";
import { property, query, state } from "lit/decorators.js";
import "./baseComponents/Modal";
import type { OModalTab } from "./baseComponents/Modal";
/**
* Base class for modal components that provides unified Escape key handling and common modal patterns.
* Static-ish configuration for the <o-modal> shell.
* Subclasses return a fresh object from modalConfig(); avoid heavy work — it's
* read on every render() and during open()/setActiveTab().
*/
export interface ModalConfig {
title?: string;
tabs?: OModalTab[];
hideHeader?: boolean;
hideCloseButton?: boolean;
alwaysMaximized?: boolean;
maxWidth?: string;
}
/**
* Base class for modal components.
*
* Features:
* - Visibility tracking with isModalOpen state
* - Escape key handler with visibility check and target validation
* - Automatic listener lifecycle management
* - Common inline/modal element handling
* - Shared open/close logic with hooks for custom behavior
* - Standardized loading spinner UI
* - Consistent modal container styling
* BaseModal renders the <o-modal> shell itself — subclasses provide content
* via renderContent() (or renderTab() for tabbed modals) and declare
* configuration via modalConfig().
*
* Lifecycle:
* open(args?) → onOpen(args) hook → shell visible
* close(args?) → onClose(args) hook → shell hidden
*
* Tabs (optional):
* Return a non-empty tabs[] from modalConfig(). BaseModal owns activeTab
* state and dispatches rendering to renderTab(key). Subclasses can opt in
* to onTabEnter(key) for per-tab lifecycle (e.g. lazy load).
*/
export abstract class BaseModal extends LitElement {
@state() protected isModalOpen = false;
@state() protected activeTab = "";
@property({ type: Boolean }) inline = false;
/**
* Standard modal container class string.
* Provides consistent dark glassmorphic styling across all modals.
* No rounding on mobile for full-screen appearance.
*/
protected readonly modalContainerClass =
"h-full flex flex-col overflow-hidden bg-black/70 backdrop-blur-xl lg:rounded-2xl lg:border border-white/10";
// Re-entrancy guard: showPage() (for inline modals) re-invokes .open()
// with no args after we call it. We must not re-run onOpen(undefined)
// from that nested call, which would clobber state set by the outer call.
private opening = false;
@query("o-modal") protected modalEl?: HTMLElement & {
open: () => void;
@@ -31,14 +49,165 @@ export abstract class BaseModal extends LitElement {
onClose?: () => void;
};
// ---- Subclass configuration ----
// Override modalConfig() to configure the rendered <o-modal>. Defaults match
// the most common shape (custom in-content header, no built-in close button).
protected modalConfig(): ModalConfig {
return {};
}
/** Render slot="header" content. Default: no header slot. */
protected renderHeaderSlot(): TemplateResult | null {
return null;
}
/**
* Render the modal body. For tabbed modals, switch on `tab` to render the
* appropriate panel. Modals without tabs can ignore the argument.
*/
protected renderBody(_tab: string): TemplateResult {
return html``;
}
// ---- Lifecycle hooks ----
/** Called when the modal opens. Receives router args / direct-caller args. */
protected onOpen(_args?: Record<string, unknown>): void {}
/** Called when the modal closes. */
protected onClose(_args?: Record<string, unknown>): void {}
/** Called when the active tab changes (including initial set on open). */
protected onTabEnter(_key: string): void {}
/**
* Guard called before closing via Escape key or click-outside.
* Return false to prevent the modal from closing.
*/
public confirmBeforeClose(): boolean {
return true;
}
// ---- Rendering ----
createRenderRoot() {
return this;
}
protected willUpdate(): void {
// Default the active tab so the highlight is correct on first render,
// before open() runs (matters for inline modals rendered on page mount).
const tabs = this.modalConfig().tabs ?? [];
if (tabs.length && this.activeTab === "") {
this.activeTab = tabs[0].key;
}
}
render(): TemplateResult {
const cfg = this.modalConfig();
const tabs = cfg.tabs ?? [];
const body = this.renderBody(this.activeTab);
const headerSlot = this.renderHeaderSlot();
return html`
<o-modal
title=${cfg.title ?? ""}
?inline=${this.inline}
?hideHeader=${cfg.hideHeader ?? true}
?hideCloseButton=${cfg.hideCloseButton ?? true}
?alwaysMaximized=${cfg.alwaysMaximized ?? false}
maxWidth=${cfg.maxWidth ?? ""}
.tabs=${tabs}
.activeTab=${this.activeTab}
.onTabChange=${(key: string) => this.setActiveTab(key)}
>
${headerSlot ? html`<div slot="header">${headerSlot}</div>` : null}
${body}
</o-modal>
`;
}
// ---- Open / close ----
public isOpen(): boolean {
return this.isModalOpen;
}
/**
* Open the modal. `args` is a loose bag forwarded to onOpen(). The router
* passes parsed URL params; direct callers can pass whatever they want.
*
* Recognized keys:
* - tab: string — sets active tab (validated against modalTabs)
*/
public open(args?: Record<string, unknown>): void {
if (this.opening) return;
this.opening = true;
try {
const tabs = this.modalConfig().tabs ?? [];
if (tabs.length && this.activeTab === "") {
this.activeTab = tabs[0].key;
}
if (
typeof args?.tab === "string" &&
tabs.some((t) => t.key === args.tab)
) {
this.activeTab = args.tab;
}
const wasOpen = this.isModalOpen;
if (!wasOpen) {
this.registerEscapeHandler();
}
this.onOpen(args);
if (this.activeTab) this.onTabEnter(this.activeTab);
if (wasOpen) return;
if (this.inline) {
const needsShow =
this.classList.contains("hidden") || this.style.display === "none";
if (needsShow && window.showPage) {
const pageId = this.id || this.tagName.toLowerCase();
window.showPage?.(pageId);
}
this.style.pointerEvents = "auto";
} else {
this.modalEl?.open();
}
} finally {
this.opening = false;
}
}
public close(args?: Record<string, unknown>): void {
this.unregisterEscapeHandler();
this.onClose(args);
if (this.inline) {
this.style.pointerEvents = "none";
if (window.showPage) {
window.showPage?.("page-play");
}
} else {
this.modalEl?.close();
}
}
// ---- Tab management ----
/** Programmatically change the active tab. Triggers onTabEnter. */
public setActiveTab(key: string): void {
const tabs = this.modalConfig().tabs ?? [];
if (!tabs.some((t) => t.key === key)) return;
if (this.activeTab === key) return;
this.activeTab = key;
this.onTabEnter(key);
}
// ---- Internals ----
protected firstUpdated(): void {
if (this.modalEl) {
this.modalEl.onClose = () => {
@@ -59,10 +228,6 @@ export abstract class BaseModal extends LitElement {
super.disconnectedCallback();
}
/**
* Handle Escape key press to close the modal.
* Only closes if the modal is open.
*/
private handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && this.isModalOpen) {
e.preventDefault();
@@ -73,87 +238,16 @@ export abstract class BaseModal extends LitElement {
}
};
/**
* Register the Escape key handler and mark modal as open.
*/
protected registerEscapeHandler() {
this.isModalOpen = true;
window.addEventListener("keydown", this.handleKeyDown);
}
/**
* Unregister the Escape key handler and mark modal as closed.
*/
protected unregisterEscapeHandler() {
this.isModalOpen = false;
window.removeEventListener("keydown", this.handleKeyDown);
}
/**
* Hook for custom logic when modal opens.
* Override this in subclasses to add custom open behavior.
*/
protected onOpen(): void {
// Default implementation does nothing
}
/**
* Hook for custom logic when modal closes.
* Override this in subclasses to add custom close behavior.
*/
protected onClose(): void {
// Default implementation does nothing
}
/**
* Guard called before closing via Escape key or click-outside.
* Override in subclasses to show a confirmation dialog.
* Return false to prevent the modal from closing.
*/
public confirmBeforeClose(): boolean {
return true;
}
/**
* Open the modal. Handles both inline and modal element modes.
* Subclasses can override onOpen() for custom behavior.
*/
public open(): void {
if (this.isModalOpen) return;
this.registerEscapeHandler();
this.onOpen();
if (this.inline) {
const needsShow =
this.classList.contains("hidden") || this.style.display === "none";
if (needsShow && window.showPage) {
const pageId = this.id || this.tagName.toLowerCase();
window.showPage?.(pageId);
}
this.style.pointerEvents = "auto";
} else {
this.modalEl?.open();
}
}
/**
* Close the modal. Handles both inline and modal element modes.
* Subclasses can override onClose() for custom behavior.
*/
public close(): void {
this.unregisterEscapeHandler();
this.onClose();
if (this.inline) {
this.style.pointerEvents = "none";
if (window.showPage) {
window.showPage?.("page-play");
}
} else {
this.modalEl?.close();
}
}
protected renderLoadingSpinner(
message?: string,
spinnerColor: "blue" | "green" | "yellow" | "white" = "blue",
+32 -41
View File
@@ -78,50 +78,41 @@ export class RankedModal extends BaseModal {
return this;
}
render() {
const content = html`
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("mode_selector.ranked_title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
<div class="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
${this.renderCard(
translateText("mode_selector.ranked_1v1_title"),
this.errorMessage ??
(hasLinkedAccount(this.userMeResponse)
? translateText("matchmaking_modal.elo", { elo: this.elo })
: translateText("mode_selector.ranked_title")),
() => this.handleRanked(),
)}
${this.renderDisabledCard(
translateText("mode_selector.ranked_2v2_title"),
translateText("mode_selector.coming_soon"),
)}
${this.renderDisabledCard(
translateText("mode_selector.coming_soon"),
"",
)}
${this.renderDisabledCard(
translateText("mode_selector.coming_soon"),
"",
)}
</div>
protected renderHeaderSlot() {
return modalHeader({
title: translateText("mode_selector.ranked_title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
});
}
protected renderBody() {
return html`
<div class="custom-scrollbar p-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
${this.renderCard(
translateText("mode_selector.ranked_1v1_title"),
this.errorMessage ??
(hasLinkedAccount(this.userMeResponse)
? translateText("matchmaking_modal.elo", { elo: this.elo })
: translateText("mode_selector.ranked_title")),
() => this.handleRanked(),
)}
${this.renderDisabledCard(
translateText("mode_selector.ranked_2v2_title"),
translateText("mode_selector.coming_soon"),
)}
${this.renderDisabledCard(
translateText("mode_selector.coming_soon"),
"",
)}
${this.renderDisabledCard(
translateText("mode_selector.coming_soon"),
"",
)}
</div>
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal ?hideHeader=${true} ?hideCloseButton=${true}>
${content}
</o-modal>
`;
}
private renderCard(title: string, subtitle: string, onClick: () => void) {