Skip to content

Commit

Permalink
Merge pull request #272 from element-hq/mobile-tooltips
Browse files Browse the repository at this point in the history
Make tooltips more touchscreen-friendly
  • Loading branch information
robintown authored Nov 11, 2024
2 parents e45ff44 + 9b0ec47 commit ad0ee71
Show file tree
Hide file tree
Showing 2 changed files with 91 additions and 8 deletions.
45 changes: 41 additions & 4 deletions src/components/Tooltip/Tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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";
Expand All @@ -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(<ForcedOpen />);
Expand Down Expand Up @@ -69,6 +82,7 @@ describe("Tooltip", () => {
it("opens tooltip on focus", async () => {
const user = userEvent.setup();
render(<InteractiveTrigger />);
mockFocusVisible(screen.getByRole("link"));
expect(screen.queryByRole("tooltip")).toBe(null);
await user.tab();
// trigger focused, tooltip shown
Expand All @@ -79,13 +93,36 @@ describe("Tooltip", () => {
it("opens tooltip on focus where trigger is non interactive", async () => {
const user = userEvent.setup();
render(<NonInteractiveTrigger />);
mockFocusVisible(screen.getByText("Just some text").parentElement!);
expect(screen.queryByRole("tooltip")).toBe(null);
await user.tab();
// trigger focused, tooltip shown
expect(screen.getByText("Just some text").parentElement).toHaveFocus();
screen.getByRole("tooltip");
});

it("opens tooltip on long press", async () => {
vi.useFakeTimers();
try {
render(<InteractiveTrigger />);
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(
Expand Down
54 changes: 50 additions & 4 deletions src/components/Tooltip/useTooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<number>();
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";
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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(
() => ({
Expand Down

0 comments on commit ad0ee71

Please sign in to comment.