Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make tooltips more touchscreen-friendly #272

Merged
merged 1 commit into from
Nov 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading