diff --git a/package.json b/package.json
index a5d984f..cf5f13f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openstack-uicore-foundation",
- "version": "5.0.7",
+ "version": "5.0.8-beta.3",
"description": "ui reactjs components for openstack marketing site",
"main": "lib/openstack-uicore-foundation.js",
"scripts": {
diff --git a/src/components/index.js b/src/components/index.js
index 9bcf931..cf94988 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -63,6 +63,7 @@ export {default as MuiChipList} from './mui/chip-list'
export {default as MuiChipNotify} from './mui/chip-notify'
export {default as MuiChipSelectInput} from './mui/chip-select-input'
export {default as MuiConfirmDialog} from './mui/confirm-dialog'
+export {GlobalConfirmDialog} from './mui/showConfirmDialog'
export {default as MuiCustomAlert} from './mui/custom-alert'
export {default as MuiDropdownCheckbox} from './mui/dropdown-checkbox'
export {default as MuiMenuButton} from './mui/menu-button'
diff --git a/src/components/mui/__tests__/global-confirm-dialog.test.js b/src/components/mui/__tests__/global-confirm-dialog.test.js
new file mode 100644
index 0000000..6e8d9b7
--- /dev/null
+++ b/src/components/mui/__tests__/global-confirm-dialog.test.js
@@ -0,0 +1,144 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * */
+
+import React from "react";
+import { render, screen, waitFor, act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom";
+import showConfirmDialog, { GlobalConfirmDialog } from "../showConfirmDialog";
+
+describe("GlobalConfirmDialog", () => {
+ test("renders nothing when no dialog is active", () => {
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ test("shows dialog when showConfirmDialog is called", async () => {
+ render();
+
+ let promise;
+ await act(async () => {
+ promise = showConfirmDialog({
+ title: "Confirm Action",
+ text: "Are you sure?"
+ });
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Confirm Action")).toBeInTheDocument();
+ expect(screen.getByText("Are you sure?")).toBeInTheDocument();
+ });
+
+ let resolved = false;
+ promise.then(() => { resolved = true; });
+ await new Promise((r) => setTimeout(r, 10));
+ expect(resolved).toBe(false);
+ });
+
+ test("resolves true when confirm button is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ let promise;
+ await act(async () => {
+ promise = showConfirmDialog({
+ title: "Delete Item",
+ text: "This cannot be undone",
+ confirmButtonText: "Delete"
+ });
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Delete Item")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+
+ const result = await promise;
+ expect(result).toBe(true);
+
+ await waitFor(() => {
+ expect(screen.queryByText("Delete Item")).not.toBeInTheDocument();
+ });
+ });
+
+ test("resolves false when cancel button is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ let promise;
+ await act(async () => {
+ promise = showConfirmDialog({
+ title: "Cancel Operation",
+ text: "Do you want to cancel?",
+ cancelButtonText: "No"
+ });
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Cancel Operation")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole("button", { name: "No" }));
+
+ const result = await promise;
+ expect(result).toBe(false);
+
+ await waitFor(() => {
+ expect(screen.queryByText("Cancel Operation")).not.toBeInTheDocument();
+ });
+ });
+
+ test("handles multiple sequential dialogs", async () => {
+ const user = userEvent.setup();
+ render();
+
+ let promise1;
+ await act(async () => {
+ promise1 = showConfirmDialog({ title: "First Dialog", text: "First message" });
+ });
+
+ await waitFor(() => { expect(screen.getByText("First Dialog")).toBeInTheDocument(); });
+ await user.click(screen.getByRole("button", { name: "Confirm" }));
+ expect(await promise1).toBe(true);
+
+ let promise2;
+ await act(async () => {
+ promise2 = showConfirmDialog({ title: "Second Dialog", text: "Second message" });
+ });
+
+ await waitFor(() => { expect(screen.getByText("Second Dialog")).toBeInTheDocument(); });
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(await promise2).toBe(false);
+ });
+
+ test("passes custom button colors and text", async () => {
+ render();
+
+ await act(async () => {
+ showConfirmDialog({
+ title: "Custom Dialog",
+ text: "Custom buttons",
+ confirmButtonText: "Yes, Do It",
+ cancelButtonText: "No, Stop",
+ confirmButtonColor: "error",
+ cancelButtonColor: "secondary"
+ });
+ });
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Yes, Do It" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "No, Stop" })).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/mui/__tests__/show-confirm-dialog.test.js b/src/components/mui/__tests__/show-confirm-dialog.test.js
index 1e71daa..555c950 100644
--- a/src/components/mui/__tests__/show-confirm-dialog.test.js
+++ b/src/components/mui/__tests__/show-confirm-dialog.test.js
@@ -11,53 +11,17 @@
* limitations under the License.
* */
-jest.mock("react-dom", () => ({
- render: jest.fn(),
- unmountComponentAtNode: jest.fn()
-}));
-
-
-jest.mock("../confirm-dialog", () => {
- const React = require("react");
- return { __esModule: true, default: () =>
};
-});
-
import showConfirmDialog from "../showConfirmDialog";
-import ReactDOM from "react-dom";
describe("showConfirmDialog", () => {
- beforeEach(() => jest.clearAllMocks());
-
- test("returns a Promise", () => {
- const result = showConfirmDialog({ title: "Test", text: "Body" });
- expect(result).toBeInstanceOf(Promise);
- });
-
- test("calls ReactDOM.render to mount the dialog", async () => {
- showConfirmDialog({ title: "Test", text: "Body" });
- await Promise.resolve();
- expect(ReactDOM.render).toHaveBeenCalledTimes(1);
- });
-
- test("appends a container div to the document body", async () => {
- const initialChildCount = document.body.children.length;
- showConfirmDialog({ title: "Test", text: "Body" });
- await Promise.resolve();
- expect(document.body.children.length).toBeGreaterThan(initialChildCount);
+ test("regression: does not reference react-dom/client", () => {
+ const moduleCode = showConfirmDialog.toString();
+ expect(moduleCode).not.toContain("react-dom/client");
});
- test("passes title and text to ConfirmDialog", async () => {
- showConfirmDialog({
- title: "My Title",
- text: "My Text",
- confirmButtonText: "Yes",
- cancelButtonText: "No"
- });
- await Promise.resolve();
- const [element] = ReactDOM.render.mock.calls[0];
- expect(element.props.title).toBe("My Title");
- expect(element.props.text).toBe("My Text");
- expect(element.props.confirmButtonText).toBe("Yes");
- expect(element.props.cancelButtonText).toBe("No");
+ test("throws when GlobalConfirmDialog is not mounted", () => {
+ expect(() => {
+ showConfirmDialog({ title: "Test", text: "Body" });
+ }).toThrow("");
});
});
diff --git a/src/components/mui/showConfirmDialog.js b/src/components/mui/showConfirmDialog.js
index 673a244..d13339a 100644
--- a/src/components/mui/showConfirmDialog.js
+++ b/src/components/mui/showConfirmDialog.js
@@ -11,27 +11,54 @@
* limitations under the License.
* */
-import ReactDOM from "react-dom";
-import React from "react";
+import React, { useState, useEffect } from "react";
import ConfirmDialog from "./confirm-dialog";
-// Lazy-loaded createRoot for React 18+.
-// Cached after first call so the dynamic import only runs once.
-let createRootFn = undefined; // undefined = not yet checked
+/**
+ * Imperative confirm dialog API.
+ *
+ * SETUP (required):
+ * Place at the root of your app:
+ *
+ * import { GlobalConfirmDialog } from
+ * 'openstack-uicore-foundation/lib/components/mui/show-confirm-dialog';
+ *
+ * function App() {
+ * return (
+ * <>
+ *
+ *
+ * >
+ * );
+ * }
+ *
+ * USAGE (works from any file — the bridge is shared via globalThis):
+ * import showConfirmDialog from
+ * 'openstack-uicore-foundation/lib/components/mui/show-confirm-dialog';
+ *
+ * const confirmed = await showConfirmDialog({
+ * title: 'Delete Item?',
+ * text: 'This cannot be undone'
+ * });
+ */
-async function getCreateRoot() {
- if (createRootFn !== undefined) return createRootFn;
- try {
- // webpackIgnore prevents webpack from resolving this at build time,
- // so consuming projects on React 16/17 won't get a "Module not found" error.
- const mod = await import(/* webpackIgnore: true */ "react-dom/client");
- createRootFn = mod.createRoot || null;
- } catch (_) {
- createRootFn = null;
- }
- return createRootFn;
-}
+// Shared bridge reference stored on globalThis so that all webpack bundles
+// (table, sortable-table, show-confirm-dialog, index, etc.) read/write the
+// same callback. A module-level variable would be duplicated per bundle.
+const BRIDGE_KEY = "__oif_confirm_dialog_bridge__";
+const _global = typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : {};
+/**
+ * @param param0
+ * @param param0.title
+ * @param param0.text
+ * @param param0.iconType
+ * @param param0.confirmButtonText
+ * @param param0.cancelButtonText
+ * @param param0.confirmButtonColor
+ * @param param0.cancelButtonColor
+ * @returns {Promise}
+ */
const showConfirmDialog = ({
title,
text,
@@ -40,49 +67,73 @@ const showConfirmDialog = ({
cancelButtonText = "Cancel",
confirmButtonColor = "primary",
cancelButtonColor = "primary"
-}) =>
- new Promise((resolve) => {
- const container = document.createElement("div");
- document.body.appendChild(container);
+}) => {
+ if (!_global[BRIDGE_KEY]) {
+ throw new Error(
+ "[openstack-uicore-foundation] showConfirmDialog: is not mounted. " +
+ "Add to the root of your app."
+ );
+ }
- let root = null;
+ return _global[BRIDGE_KEY]({
+ title,
+ text,
+ iconType,
+ confirmButtonText,
+ cancelButtonText,
+ confirmButtonColor,
+ cancelButtonColor
+ });
+};
- const close = (answer) => {
- if (root) {
- root.unmount();
- } else {
- ReactDOM.unmountComponentAtNode(container);
- }
- container.remove();
- resolve(answer);
+/**
+ * Global confirm dialog component. Place at the root of your app:
+ *
+ *
+ * ...
+ *
+ *
+ *
+ * Then call showConfirmDialog() anywhere.
+ */
+export const GlobalConfirmDialog = () => {
+ const [dialogState, setDialogState] = useState(null);
+
+ useEffect(() => {
+ _global[BRIDGE_KEY] = (options) => {
+ return new Promise((resolve) => {
+ setDialogState({ ...options, open: true, onResolve: resolve });
+ });
};
+ return () => { _global[BRIDGE_KEY] = null; };
+ }, []);
- const handleConfirm = () => close(true);
- const handleCancel = () => close(false);
+ const handleConfirm = () => {
+ if (dialogState?.onResolve) dialogState.onResolve(true);
+ setDialogState(null);
+ };
- const element = (
-
- );
+ const handleCancel = () => {
+ if (dialogState?.onResolve) dialogState.onResolve(false);
+ setDialogState(null);
+ };
- getCreateRoot().then((createRoot) => {
- if (createRoot) {
- root = createRoot(container);
- root.render(element);
- } else {
- ReactDOM.render(element, container);
- }
- });
- });
+ if (!dialogState) return null;
+
+ return (
+
+ );
+};
export default showConfirmDialog;