diff --git a/__tests__/components/common/TooltipIconButton.test.tsx b/__tests__/components/common/TooltipIconButton.test.tsx
deleted file mode 100644
index 9097cd0be3..0000000000
--- a/__tests__/components/common/TooltipIconButton.test.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { render, screen, fireEvent } from '@testing-library/react';
-import TooltipIconButton from '../../../components/common/TooltipIconButton';
-import { faCheck } from '@fortawesome/free-solid-svg-icons';
-
-jest.mock('@fortawesome/react-fontawesome', () => ({
- FontAwesomeIcon: (props: any) => ,
-}));
-
-describe('TooltipIconButton', () => {
- it('shows tooltip on hover and hides on mouse leave', () => {
- render();
- const wrapper = screen.getByTestId('icon').parentElement as HTMLElement;
- expect(screen.queryByText('info')).not.toBeInTheDocument();
-
- fireEvent.mouseEnter(wrapper);
- const tooltip = screen.getByText('info');
- expect(tooltip).toBeInTheDocument();
- expect(tooltip.className).toContain('tw-bottom-6');
-
- fireEvent.mouseLeave(wrapper);
- expect(screen.queryByText('info')).not.toBeInTheDocument();
- });
-
- it('applies bottom position classes', () => {
- render(
-
- );
- const wrapper = screen.getByTestId('icon').parentElement as HTMLElement;
- fireEvent.mouseEnter(wrapper);
- const tooltip = screen.getByText('info');
- expect(tooltip.className).toContain('tw-top-6');
- });
-
- it('calls onClick and stops propagation', () => {
- const onClick = jest.fn();
- const parentClick = jest.fn();
- render(
-
-
-
- );
- const wrapper = screen.getByTestId('icon').parentElement as HTMLElement;
- fireEvent.click(wrapper);
- expect(onClick).toHaveBeenCalled();
- expect(parentClick).not.toHaveBeenCalled();
- });
-});
diff --git a/components/common/TooltipIconButton.test.tsx b/components/common/TooltipIconButton.test.tsx
new file mode 100644
index 0000000000..6b532ce4b9
--- /dev/null
+++ b/components/common/TooltipIconButton.test.tsx
@@ -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) => ,
+}));
+
+describe("TooltipIconButton", () => {
+ it("shows tooltip on hover and hides on mouse leave", () => {
+ render(
+
+ );
+ 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(
+
+ );
+ 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(
+ <>
+
+
+ >
+ );
+
+ 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(
+
+ );
+ 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(
+
+
+
+ );
+ const button = screen.getByRole("button");
+ fireEvent.click(button);
+ expect(onClick).toHaveBeenCalled();
+ expect(parentClick).not.toHaveBeenCalled();
+ });
+});
diff --git a/components/common/TooltipIconButton.tsx b/components/common/TooltipIconButton.tsx
index de992dfc2c..38315cb320 100644
--- a/components/common/TooltipIconButton.tsx
+++ b/components/common/TooltipIconButton.tsx
@@ -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, "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({
@@ -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) {
@@ -36,22 +56,52 @@ export default function TooltipIconButton({
}
};
+ const handleMouseEnter = (event: MouseEvent) => {
+ setIsHovered(true);
+ onMouseEnter?.(event);
+ };
+
+ const handleMouseLeave = (event: MouseEvent) => {
+ setIsHovered(false);
+ onMouseLeave?.(event);
+ };
+
+ const handleFocus = (event: FocusEvent) => {
+ setIsFocused(true);
+ onFocus?.(event);
+ };
+
+ const handleBlur = (event: FocusEvent) => {
+ setIsFocused(false);
+ onBlur?.(event);
+ };
+
+ const handleClick = (event: MouseEvent) => {
+ event.stopPropagation();
+ onClick?.(event);
+ };
+
return (
- setShowTooltip(true)}
- onMouseLeave={() => setShowTooltip(false)}
- onClick={(e) => {
- e.stopPropagation();
- onClick?.(e);
- }}>
+
+
);
}