mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-17 19:49:20 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user