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); - }}> +
+ ); }