Skip to content

Commit

Permalink
Merge pull request #130 from vector-im/kerry/280/tooltip-delay
Browse files Browse the repository at this point in the history
Tooltip: handle non-interactive tooltip triggers
  • Loading branch information
robintown authored Dec 6, 2023
2 parents 964280e + 9f2f6d5 commit be8822e
Show file tree
Hide file tree
Showing 4 changed files with 438 additions and 40 deletions.
107 changes: 105 additions & 2 deletions src/components/Tooltip/Tooltip.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,57 @@ export default {
title: "Tooltip",
component: TooltipComponent,
tags: ["autodocs"],
argTypes: {},
args: {},
controls: {
include: [
"side",
"align",
"open",
"label",
"caption",
"isTriggerInteractive",
],
},
argTypes: {
side: {
control: "inline-radio",
options: ["left", "right", "top", "bottom"],
},
align: {
control: "inline-radio",
options: ["center", "start", "end"],
},
open: {
control: "boolean",
},
isTriggerInteractive: {
control: "boolean",
},
label: {
control: "string",
},
caption: {
control: "string",
},
},
args: {
side: "left",
align: "center",
open: undefined,
label: "@bob:example.org",
caption: undefined,
children: (
<IconButton data-testid="testbutton">
<UserIcon />
</IconButton>
),
},
decorators: [
(Story: StoryFn) => (
<div style={{ padding: 100 }}>
<Story />
</div>
),
],
} as Meta<typeof TooltipComponent>;

const TemplateSide: StoryFn<typeof TooltipComponent> = () => (
Expand Down Expand Up @@ -104,3 +153,57 @@ const TemplateAlign: StoryFn<typeof TooltipComponent> = () => (

export const Align = TemplateAlign.bind({});
Align.args = {};

export const Default = {
args: {
// unset to test defaults
side: undefined,
align: undefined,
},
};

export const WithCaption = {
args: {
...Default.args,
label: "Copy",
caption: "⌘ + C",
},
};

export const ForcedOpen = {
args: {
...Default.args,
open: true,
label: "I'm always open",
},
};

export const ForcedClose = {
args: {
...Default.args,
open: false,
label: "You can't see me",
children: <span>No tooltip to see here</span>,
},
};

export const InteractiveTrigger = {
args: {
...Default.args,
isTriggerInteractive: true,
label: "Shown with delay",
},
};

export const NonInteractiveTrigger = {
args: {
...Default.args,
isTriggerInteractive: false,
label: "Shown without delay",
children: (
<IconButton data-testid="testbutton" disabled>
<UserIcon />
</IconButton>
),
},
};
98 changes: 80 additions & 18 deletions src/components/Tooltip/Tooltip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,97 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { describe, it, expect, beforeAll } from "vitest";
import { render } from "@testing-library/react";
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import React from "react";

import { Tooltip } from "./Tooltip";
import { IconButton } from "../Button";
import * as stories from "./Tooltip.stories";
import { composeStories, composeStory } from "@storybook/react";

const {
Default,
WithCaption,
ForcedOpen,
ForcedClose,
InteractiveTrigger,
NonInteractiveTrigger,
} = composeStories(stories);

describe("Tooltip", () => {
beforeAll(() => {
global.ResizeObserver = require("resize-observer-polyfill");
});

afterEach(cleanup);

it("renders open by default", () => {
const { asFragment } = render(
<Tooltip label="Hello world 👋" caption="⌘ + C" open={true}>
<IconButton>
<svg />
</IconButton>
</Tooltip>,
);
const { asFragment } = render(<ForcedOpen />);
// trigger rendered
expect(asFragment()).toMatchSnapshot();
// tooltip shown
expect(screen.getByRole("tooltip")).toMatchSnapshot();
});
it("renders", () => {
const { asFragment } = render(
<Tooltip label="Hello world 👋">
<IconButton>
<svg />
</IconButton>
</Tooltip>,

it("renders closed by default", () => {
const { asFragment } = render(<ForcedClose />);
expect(asFragment()).toMatchSnapshot();
// no tooltip
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});

it("renders default tooltip", async () => {
render(<Default />);
const trigger = screen.getByTestId("testbutton");

fireEvent.focus(trigger);
// tooltip shown
expect(await screen.findByRole("tooltip")).toMatchSnapshot();
});

it("opens tooltip on focus", async () => {
render(<InteractiveTrigger />);
const trigger = screen.getByTestId("testbutton");

expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
fireEvent.focus(trigger);
// tooltip shown
expect(await screen.findByRole("tooltip")).toMatchSnapshot();
});

it("opens tooltip on focus where trigger is non interactive", async () => {
const { container } = render(<NonInteractiveTrigger />);

expect(container).toMatchSnapshot();
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
const trigger = screen.getByTestId("testbutton");
fireEvent.focus(trigger);
// tooltip shown
expect(await screen.findByRole("tooltip")).toMatchSnapshot();
});

it("overrides default tab index for non interactive triggers", async () => {
const Component = composeStory(
{
...stories.NonInteractiveTrigger,
args: {
...stories.NonInteractiveTrigger.args,
nonInteractiveTriggerTabIndex: -1,
},
},
stories.default,
);
const { container } = render(<Component />);

expect(container).toMatchSnapshot();
});

it("renders with caption", async () => {
const { asFragment } = render(<WithCaption />);
expect(asFragment()).toMatchSnapshot();
const trigger = screen.getByTestId("testbutton");

fireEvent.focus(trigger);
// tooltip shown
expect(await screen.findByRole("tooltip")).toMatchSnapshot();
});
});
30 changes: 28 additions & 2 deletions src/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,29 @@ type TooltipProps = {
>["onPointerDownOutside"];
/**
* The controlled open state of the tooltip.
* When true, the tooltip is always open. When false, the tooltip is always hidden.
* When undefined, the tooltip will manage its own open state.
* You will mostly want to omit this property. Will be used the vast majority
* of the time during development.
* @default undefined
*/
open?: boolean;
/**
* Whether the trigger element is interactive.
* When trigger is interactive:
* - tooltip will be shown after a 300ms delay.
* When trigger is not interactive:
* - tooltip will be shown instantly when pointer enters trigger.
* - trigger will be wrapped in a span with a tab index from prop nonInteractiveTriggerTabIndex
* @default true
*/
isTriggerInteractive?: boolean;
/**
* Tab index to apply to the span wrapping non interactive tooltip triggers.
* Only used when `isTriggerInteractive` is false.
* @default 0
*/
nonInteractiveTriggerTabIndex?: number;
};

/**
Expand All @@ -83,12 +101,20 @@ export const Tooltip = ({
align = "center",
onEscapeKeyDown,
onPointerDownOutside,
isTriggerInteractive = true,
nonInteractiveTriggerTabIndex = 0,
open,
}: PropsWithChildren<TooltipProps>): JSX.Element => {
return (
<Provider>
<Root open={open} delayDuration={300}>
<Trigger asChild>{children}</Trigger>
<Root open={open} delayDuration={isTriggerInteractive ? 300 : 0}>
<Trigger asChild>
{isTriggerInteractive ? (
children
) : (
<span tabIndex={nonInteractiveTriggerTabIndex}>{children}</span>
)}
</Trigger>
<Portal>
<Content
side={side}
Expand Down
Loading

0 comments on commit be8822e

Please sign in to comment.