Skip to content
Closed
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
47 changes: 0 additions & 47 deletions __tests__/components/common/TooltipIconButton.test.tsx

This file was deleted.

99 changes: 99 additions & 0 deletions components/common/TooltipIconButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import TooltipIconButton from "./TooltipIconButton";
import { faCheck } from "@fortawesome/free-solid-svg-icons";

type FontAwesomeProps = {
readonly [key: string]: unknown;
};

jest.mock("@fortawesome/react-fontawesome", () => ({
FontAwesomeIcon: (props: FontAwesomeProps) => <svg data-testid="icon" {...props} />,
}));

describe("TooltipIconButton", () => {
it("shows tooltip on hover and hides on mouse leave", () => {
render(
<TooltipIconButton
icon={faCheck}
tooltipText="info"
aria-describedby="external-id"
/>
);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("type", "button");
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();

fireEvent.mouseEnter(button);
const tooltip = screen.getByRole("tooltip");
expect(tooltip).toHaveTextContent("info");
expect(tooltip.className).toContain("tw-bottom-6");

const describedBy = button.getAttribute("aria-describedby") ?? "";
expect(describedBy).toContain("external-id");
expect(describedBy).toContain(tooltip.getAttribute("id") ?? "");

fireEvent.mouseLeave(button);
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});

it("applies bottom position classes", () => {
render(
<TooltipIconButton icon={faCheck} tooltipText="info" tooltipPosition="bottom" />
);
const button = screen.getByRole("button");
fireEvent.mouseEnter(button);
const tooltip = screen.getByRole("tooltip");
expect(tooltip.className).toContain("tw-top-6");
});

it("shows tooltip on focus and hides on blur triggered by keyboard navigation", async () => {
const user = userEvent.setup();
const handleFocus = jest.fn();
const handleBlur = jest.fn();

render(
<>
<TooltipIconButton
icon={faCheck}
tooltipText="info"
onFocus={handleFocus}
onBlur={handleBlur}
/>
<button>Next</button>
</>
);

await user.tab();
const tooltip = screen.getByRole("tooltip");
expect(tooltip).toBeInTheDocument();
expect(handleFocus).toHaveBeenCalledTimes(1);

await user.tab();
expect(handleBlur).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});

it("forwards tabIndex to the button", () => {
render(
<TooltipIconButton icon={faCheck} tooltipText="info" tabIndex={-1} />
);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("tabindex", "-1");
});

it("calls onClick and stops propagation", () => {
const onClick = jest.fn();
const parentClick = jest.fn();

render(
<div onClick={parentClick}>
<TooltipIconButton icon={faCheck} tooltipText="info" onClick={onClick} />
</div>
);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(onClick).toHaveBeenCalled();
expect(parentClick).not.toHaveBeenCalled();
});
});
78 changes: 64 additions & 14 deletions components/common/TooltipIconButton.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
"use client";

import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { useState } from "react";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { useId, useState } from "react";
import type { ComponentPropsWithoutRef, FocusEvent, MouseEvent } from "react";

interface TooltipIconButtonProps {
interface TooltipIconButtonProps
extends Omit<ComponentPropsWithoutRef<"button">, "children"> {
readonly icon: IconDefinition;
readonly tooltipText: string;
readonly iconClassName?: string;
readonly tooltipWidth?: string;
readonly tooltipPosition?: "top" | "bottom" | "left" | "right";
readonly onClick?: (e: React.MouseEvent) => void;
}

export default function TooltipIconButton({
Expand All @@ -19,9 +20,28 @@ export default function TooltipIconButton({
iconClassName = "tw-text-iron-400 tw-size-4",
tooltipWidth = "tw-w-72",
tooltipPosition = "top",
type = "button",
tabIndex = 0,
className,
onClick,
onFocus,
onBlur,
onMouseEnter,
onMouseLeave,
"aria-describedby": ariaDescribedByProp,
...rest
}: TooltipIconButtonProps) {
const [showTooltip, setShowTooltip] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const tooltipId = useId();
const tooltipElementId = `${tooltipId}-tooltip`;
const ariaDescribedBy = [ariaDescribedByProp, tooltipElementId]
.filter(Boolean)
.join(" ");
const buttonClassName = className
? `tw-relative tw-cursor-pointer tw-bg-transparent tw-border-none tw-p-0 ${className}`
: "tw-relative tw-cursor-pointer tw-bg-transparent tw-border-none tw-p-0";
const showTooltip = isHovered || isFocused;

const getPositionClasses = () => {
switch (tooltipPosition) {
Expand All @@ -36,22 +56,52 @@ export default function TooltipIconButton({
}
};

const handleMouseEnter = (event: MouseEvent<HTMLButtonElement>) => {
setIsHovered(true);
onMouseEnter?.(event);
};

const handleMouseLeave = (event: MouseEvent<HTMLButtonElement>) => {
setIsHovered(false);
onMouseLeave?.(event);
};

const handleFocus = (event: FocusEvent<HTMLButtonElement>) => {
setIsFocused(true);
onFocus?.(event);
};

const handleBlur = (event: FocusEvent<HTMLButtonElement>) => {
setIsFocused(false);
onBlur?.(event);
};

const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onClick?.(event);
};

return (
<div
className="tw-relative tw-cursor-pointer"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}>
<button
type={type}
className={buttonClassName}
tabIndex={tabIndex}
aria-describedby={ariaDescribedBy}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleFocus}
onBlur={handleBlur}
onClick={handleClick}
{...rest}>
<FontAwesomeIcon icon={icon} className={iconClassName} />
{showTooltip && (
<div
id={tooltipElementId}
role="tooltip"
className={`tw-absolute ${getPositionClasses()} ${tooltipWidth} tw-p-3 tw-bg-iron-900 tw-text-iron-100 tw-text-xs tw-rounded-lg tw-shadow-lg tw-z-10`}>
{tooltipText}
</div>
)}
</div>
</button>
);
}