diff --git a/demo/src/app/modal-button/page.tsx b/demo/src/app/modal-button/page.tsx
index 641b4d9..d96bbe9 100644
--- a/demo/src/app/modal-button/page.tsx
+++ b/demo/src/app/modal-button/page.tsx
@@ -38,6 +38,19 @@ export default function ModalButtonPage() {
Category: Workshops
Date: Any
+
+
+ Disabled with a reason (hover to see the tooltip)
+
+
+ Are you sure you want to remove the survey?
+
);
}
diff --git a/src/components/ModalButton.test.tsx b/src/components/ModalButton.test.tsx
new file mode 100644
index 0000000..18837cc
--- /dev/null
+++ b/src/components/ModalButton.test.tsx
@@ -0,0 +1,45 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import ModalButton from "./ModalButton";
+
+const messages = { cancel: "Cancel", submit: "Remove" };
+
+describe("ModalButton", () => {
+ it("shows the disabled reason as a tooltip when disabled", async () => {
+ render(
+ ,
+ );
+
+ const button = screen.getByRole("button", { name: "Remove survey…" });
+ expect(button).toBeDisabled();
+ expect(button).not.toHaveAttribute("title");
+
+ fireEvent.mouseOver(button.parentElement!);
+ expect(
+ await screen.findByText("Remove all language versions first."),
+ ).toBeInTheDocument();
+ });
+
+ it("does not show the disabled reason when enabled", () => {
+ render(
+ ,
+ );
+
+ const button = screen.getByRole("button", { name: "Remove survey…" });
+ expect(button).toBeEnabled();
+ expect(button).toHaveAttribute("title", "Remove survey");
+ fireEvent.mouseOver(button);
+ expect(
+ screen.queryByText("Remove all language versions first."),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/ModalButton.tsx b/src/components/ModalButton.tsx
index 8143c78..47ef48c 100644
--- a/src/components/ModalButton.tsx
+++ b/src/components/ModalButton.tsx
@@ -3,6 +3,8 @@
import { ReactNode, MouseEvent, useCallback, useState } from "react";
import Button from "react-bootstrap/Button";
import Modal from "react-bootstrap/Modal";
+import OverlayTrigger from "react-bootstrap/OverlayTrigger";
+import Tooltip from "react-bootstrap/Tooltip";
import { createPortal } from "react-dom";
interface Messages {
@@ -22,6 +24,9 @@ interface Props {
/// `messages` prop rather than an inlined locale table.
messages: Messages;
disabled?: boolean;
+ /// Shown as a tooltip over the button while it is disabled, so the user learns
+ /// what to do before they can proceed. Ignored while the button is enabled.
+ disabledReason?: ReactNode;
className?: string;
submitButtonVariant?: "primary" | "danger" | "success";
/// When set (and no `action` is given), the modal gets a primary button with this
@@ -42,6 +47,7 @@ export default function ModalButton({
action,
messages,
disabled,
+ disabledReason,
className = "btn btn-link p-0 link-subtle",
submitButtonVariant = "primary",
confirmLabel,
@@ -54,19 +60,35 @@ export default function ModalButton({
setIsVisible(true);
}, []);
+ const showDisabledReason = Boolean(disabled && disabledReason);
+
+ // A disabled button fires no mouse events, so the tooltip is triggered by a
+ // wrapper span and the button itself must not swallow the pointer.
+ const button = (
+
+ );
+
/// TODO Change manual button to React Bootstrap one.
- /// Add a tooltip to the button if it is disabled to tell us why.
return (
<>
-
+ {showDisabledReason ? (
+ {disabledReason}}
+ >
+ {button}
+
+ ) : (
+ button
+ )}
{!disabled &&
typeof document !== "undefined" &&
createPortal(