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
13 changes: 13 additions & 0 deletions demo/src/app/modal-button/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ export default function ModalButtonPage() {
<p>Category: Workshops</p>
<p>Date: Any</p>
</ModalButton>

<h2 className="mt-3">
Disabled with a reason (hover to see the tooltip)
</h2>
<ModalButton
title="Remove survey"
className="btn btn-outline-danger"
messages={{ cancel: "Cancel", submit: "Remove" }}
disabled
disabledReason="In order to remove this survey, remove all of its language versions first."
>
<p>Are you sure you want to remove the survey?</p>
</ModalButton>
</div>
);
}
45 changes: 45 additions & 0 deletions src/components/ModalButton.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ModalButton
title="Remove survey"
messages={messages}
disabled
disabledReason="Remove all language versions first."
/>,
);

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(
<ModalButton
title="Remove survey"
messages={messages}
disabledReason="Remove all language versions first."
/>,
);

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();
});
});
42 changes: 32 additions & 10 deletions src/components/ModalButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -42,6 +47,7 @@ export default function ModalButton({
action,
messages,
disabled,
disabledReason,
className = "btn btn-link p-0 link-subtle",
submitButtonVariant = "primary",
confirmLabel,
Expand All @@ -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 = (
<button
type="button"
className={showDisabledReason ? `${className} pe-none` : className}
onClick={open}
title={showDisabledReason ? undefined : (labelTitle ?? title)}
disabled={disabled}
>
{label ?? `${title}…`}
</button>
);

/// TODO Change manual button to React Bootstrap one.
/// Add a tooltip to the button if it is disabled to tell us why.
return (
<>
<button
type="button"
className={className}
onClick={open}
title={labelTitle ?? title}
disabled={disabled}
>
{label ?? `${title}…`}
</button>
{showDisabledReason ? (
<OverlayTrigger
placement="bottom"
overlay={<Tooltip>{disabledReason}</Tooltip>}
>
<span className="d-inline-block">{button}</span>
</OverlayTrigger>
) : (
button
)}
{!disabled &&
typeof document !== "undefined" &&
createPortal(
Expand Down
Loading