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(
() => ({