Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions src/client/HostLobbyModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,11 +531,9 @@ export class HostLobbyModal extends BaseModal {
// Clear clipboard so the host doesn't accidentally share a dead link
void navigator.clipboard.writeText("").catch(() => {});
});
if (this.modalEl) {
this.modalEl.onClose = () => {
this.close();
};
}
// BaseModal.firstUpdated() owns modalEl.onClose so the o-modal close path
// (backdrop / close button) runs confirmBeforeClose(). Don't override it
// here — doing so would bypass the leave-lobby confirmation.
this.loadNationCount();
}

Expand All @@ -552,8 +550,8 @@ export class HostLobbyModal extends BaseModal {
);
}

public confirmBeforeClose(): boolean {
return confirm(translateText("host_modal.leave_confirmation"));
public confirmBeforeClose(): boolean | Promise<boolean> {
return this.confirmClose(translateText("host_modal.leave_confirmation"));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

protected onClose(): void {
Expand Down
4 changes: 2 additions & 2 deletions src/client/JoinLobbyModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,9 +336,9 @@ export class JoinLobbyModal extends BaseModal {
);
}

public confirmBeforeClose(): boolean {
public confirmBeforeClose(): boolean | Promise<boolean> {
if (!this.currentLobbyId) return true;
return confirm(translateText("host_modal.leave_confirmation"));
return this.confirmClose(translateText("host_modal.leave_confirmation"));
}

protected onClose(): void {
Expand Down
25 changes: 21 additions & 4 deletions src/client/Navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,30 @@ export function initNavigation() {
window.showPage = showPage;

// Use event delegation for navigation items (they may be inside Lit components)
document.addEventListener("click", (e) => {
document.addEventListener("click", async (e) => {
const target = (e.target as HTMLElement).closest(
".nav-menu-item[data-page]",
);
if (target) {
const pageId = (target as HTMLElement).dataset.page;
if (pageId) showPage(pageId);
if (!pageId) return;

// showPage() closes the currently visible modal, so respect its
// close-confirmation guard first (e.g. the leave-lobby prompt).
const visibleModal = document.querySelector(
".page-content:not(.hidden)",
) as any;
if (
visibleModal &&
typeof visibleModal.isOpen === "function" &&
visibleModal.isOpen() &&
typeof visibleModal.confirmBeforeClose === "function" &&
!(await visibleModal.confirmBeforeClose())
) {
return;
}

showPage(pageId);
}
});

Expand All @@ -95,7 +112,7 @@ export function initNavigation() {
const mainEl = document.querySelector("main");

if (mainEl) {
mainEl.addEventListener("click", (e: Event) => {
mainEl.addEventListener("click", async (e: Event) => {
const target = e.target as HTMLElement;
const isPlayPageHidden = document
.getElementById("page-play")
Expand All @@ -118,7 +135,7 @@ export function initNavigation() {
// Check confirmation guard before closing
if (
typeof openModal.confirmBeforeClose === "function" &&
!openModal.confirmBeforeClose()
!(await openModal.confirmBeforeClose())
) {
return;
}
Expand Down
55 changes: 49 additions & 6 deletions src/client/components/BaseModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { property, query, state } from "lit/decorators.js";
import { modalRouter } from "../ModalRouter";
import "./baseComponents/Modal";
import type { OModalTab } from "./baseComponents/Modal";
import "./ConfirmDialog";

/**
* Static-ish configuration for the <o-modal> shell.
Expand Down Expand Up @@ -39,6 +40,12 @@ export abstract class BaseModal extends LitElement {
@state() protected activeTab = "";
@property({ type: Boolean }) inline = false;

// Pending close confirmation; when set, renders the inline confirm dialog.
@state() private closeConfirm: {
message: string;
resolve: (ok: boolean) => void;
} | null = null;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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.
Expand Down Expand Up @@ -91,12 +98,30 @@ export abstract class BaseModal extends LitElement {

/**
* Guard called before closing via Escape key or click-outside.
* Return false to prevent the modal from closing.
* Return false (or a promise resolving false) to prevent closing.
*/
public confirmBeforeClose(): boolean {
public confirmBeforeClose(): boolean | Promise<boolean> {
return true;
}

/**
* Show a styled confirm dialog and resolve to the user's choice. Call from
* confirmBeforeClose() to gate closing behind a confirm/cancel prompt.
*/
protected confirmClose(message: string): Promise<boolean> {
// Don't stack a second prompt if one is already open.
if (this.closeConfirm) return Promise.resolve(false);
return new Promise((resolve) => {
this.closeConfirm = { message, resolve };
});
}

private settleCloseConfirm(ok: boolean) {
const pending = this.closeConfirm;
this.closeConfirm = null;
pending?.resolve(ok);
}

// ---- Rendering ----

createRenderRoot() {
Expand Down Expand Up @@ -133,6 +158,14 @@ export abstract class BaseModal extends LitElement {
${headerSlot ? html`<div slot="header">${headerSlot}</div>` : null}
${body}
</o-modal>
${this.closeConfirm
? html`<confirm-dialog
.message=${this.closeConfirm.message}
variant="warning"
@confirm=${() => this.settleCloseConfirm(true)}
@cancel=${() => this.settleCloseConfirm(false)}
></confirm-dialog>`
: null}
`;
}

Expand Down Expand Up @@ -199,6 +232,9 @@ export abstract class BaseModal extends LitElement {
}

public close(args?: Record<string, unknown>): void {
// If closing was triggered elsewhere while a confirm prompt is pending,
// dismiss it (removes the dialog and resolves the awaiting guard as false).
this.settleCloseConfirm(false);
this.unregisterEscapeHandler();
this.onClose(args);

Expand Down Expand Up @@ -234,9 +270,13 @@ export abstract class BaseModal extends LitElement {

protected firstUpdated(): void {
if (this.modalEl) {
this.modalEl.onClose = () => {
this.modalEl.onClose = async () => {
if (this.isModalOpen) {
if (!this.confirmBeforeClose()) {
const confirmed = await this.confirmBeforeClose();
// Bail if a parallel close() settled things while we awaited —
// otherwise we'd re-open an already-closed modal.
if (!this.isModalOpen) return;
if (!confirmed) {
// Re-open the underlying o-modal since it already closed itself
this.modalEl?.open();
return;
Expand All @@ -248,14 +288,17 @@ export abstract class BaseModal extends LitElement {
}

disconnectedCallback() {
this.settleCloseConfirm(false);
this.unregisterEscapeHandler();
super.disconnectedCallback();
}

private handleKeyDown = (e: KeyboardEvent) => {
private handleKeyDown = async (e: KeyboardEvent) => {
if (e.key === "Escape" && this.isModalOpen) {
e.preventDefault();
if (!this.confirmBeforeClose()) {
const confirmed = await this.confirmBeforeClose();
// Bail if a parallel close() already closed us while we awaited.
if (!confirmed || !this.isModalOpen) {
return;
}
this.close();
Expand Down
5 changes: 4 additions & 1 deletion tests/client/LeaderboardModal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ describe("LeaderboardModal", () => {
expect(modal.tagName.toLowerCase()).toBe("leaderboard-modal");
});

it("should close on Escape when open", () => {
it("should close on Escape when open", async () => {
const mockModalEl = { open: vi.fn(), close: vi.fn() };
Object.defineProperty(modal, "modalEl", {
get: () => mockModalEl,
Expand All @@ -317,6 +317,9 @@ describe("LeaderboardModal", () => {
);

window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
// handleKeyDown awaits confirmBeforeClose() before closing, so the close
// is deferred to a later microtask — flush it before asserting.
await new Promise((resolve) => setTimeout(resolve, 0));
expect((modal as unknown as { isModalOpen: boolean }).isModalOpen).toBe(
false,
);
Expand Down
Loading