diff --git a/src/components/Tooltip/Tooltip.test.tsx b/src/components/Tooltip/Tooltip.test.tsx index 45520d14..d517232a 100644 --- a/src/components/Tooltip/Tooltip.test.tsx +++ b/src/components/Tooltip/Tooltip.test.tsx @@ -1,5 +1,5 @@ /* -Copyright 2023 New Vector Ltd +Copyright 2023-2024 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React, { act } from "react"; import * as stories from "./Tooltip.stories"; import { composeStories, composeStory } from "@storybook/react"; @@ -37,6 +37,19 @@ const { Descriptive, } = composeStories(stories); +/** + * Patches an element to always match :focus-visible whenever it's in focus. + * JSDOM doesn't seem to support this selector on its own. + */ +function mockFocusVisible(e: Element): void { + const originalMatches = e.matches.bind(e); + vi.spyOn(e, "matches").mockImplementation( + (selectors) => + originalMatches(selectors) || + (selectors === ":focus-visible" && e === document.activeElement), + ); +} + describe("Tooltip", () => { it("renders open by default", () => { render(); @@ -69,6 +82,7 @@ describe("Tooltip", () => { it("opens tooltip on focus", async () => { const user = userEvent.setup(); render(); + mockFocusVisible(screen.getByRole("link")); expect(screen.queryByRole("tooltip")).toBe(null); await user.tab(); // trigger focused, tooltip shown @@ -79,6 +93,7 @@ describe("Tooltip", () => { it("opens tooltip on focus where trigger is non interactive", async () => { const user = userEvent.setup(); render(); + mockFocusVisible(screen.getByText("Just some text").parentElement!); expect(screen.queryByRole("tooltip")).toBe(null); await user.tab(); // trigger focused, tooltip shown @@ -86,6 +101,28 @@ describe("Tooltip", () => { screen.getByRole("tooltip"); }); + it("opens tooltip on long press", async () => { + vi.useFakeTimers(); + try { + render(); + expect(screen.queryByRole("tooltip")).toBe(null); + // Press + fireEvent.touchStart(screen.getByRole("link")); + expect(screen.queryByRole("tooltip")).toBe(null); + // And hold + await act(() => vi.advanceTimersByTimeAsync(1000)); + screen.getByRole("tooltip"); + // And release + fireEvent.touchEnd(screen.getByRole("link")); + // Tooltip should remain visible for some time + screen.getByRole("tooltip"); + await act(() => vi.advanceTimersByTimeAsync(2000)); + expect(screen.queryByRole("tooltip")).toBe(null); + } finally { + vi.useRealTimers(); + } + }); + it("overrides default tab index for non interactive triggers", async () => { const user = userEvent.setup(); const Component = composeStory( diff --git a/src/components/Tooltip/useTooltip.ts b/src/components/Tooltip/useTooltip.ts index 18367fc1..0a783f93 100644 --- a/src/components/Tooltip/useTooltip.ts +++ b/src/components/Tooltip/useTooltip.ts @@ -33,7 +33,14 @@ import { useInteractions, useRole, } from "@floating-ui/react"; -import { useMemo, useRef, useState, JSX, AriaAttributes } from "react"; +import { + useMemo, + useRef, + useState, + JSX, + AriaAttributes, + useEffect, +} from "react"; import { hoverDelay } from "./TooltipProvider"; export interface CommonUseTooltipProps { @@ -168,11 +175,42 @@ export function useTooltip({ enabled: controlledOpen === undefined, // Show tooltip after a delay when trigger is interactive delay: isTriggerInteractive ? delay : {}, + mouseOnly: true, }); + const focus = useFocus(context, { enabled: controlledOpen === undefined, - visibleOnly: false, }); + + // On touch screens, show the tooltip on a long press + const pressTimer = useRef(); + useEffect(() => () => window.clearTimeout(pressTimer.current), []); + const press = useMemo(() => { + const onTouchEnd = () => { + if (pressTimer.current === undefined) + pressTimer.current = window.setTimeout(() => { + setOpen(false); + pressTimer.current = undefined; + }, 1500); + else window.clearTimeout(pressTimer.current); + }; + return { + // Set these props on the anchor element + reference: { + onTouchStart: () => { + if (pressTimer.current !== undefined) + window.clearTimeout(pressTimer.current); + pressTimer.current = window.setTimeout(() => { + setOpen(true); + pressTimer.current = undefined; + }, 500); + }, + onTouchEnd, + onTouchCancel: onTouchEnd, + }, + }; + }, []); + const dismiss = useDismiss(context); const purpose = "label" in props ? "label" : "description"; @@ -181,6 +219,7 @@ export function useTooltip({ enabled: purpose === "description", role: "tooltip", }); + // A label tooltip should set aria-labelledby with no role regardless of // whether the tooltip is visible. // (Source: https://zoebijl.github.io/apg-tooltip/#tooltip-main-label) @@ -189,7 +228,7 @@ export function useTooltip({ () => purpose === "label" ? { - // The props we want to set on the anchor element + // Set these props on the anchor element reference: { "aria-labelledby": labelId, "aria-describedby": caption ? captionId : undefined, @@ -199,7 +238,14 @@ export function useTooltip({ [purpose, labelId, captionId], ); - const interactions = useInteractions([hover, focus, dismiss, role, label]); + const interactions = useInteractions([ + hover, + focus, + press, + dismiss, + role, + label, + ]); return useMemo( () => ({