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(